@qingflow-tech/qingflow-cli 1.1.60 → 1.1.61-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-cli/SKILL.md +120 -8
  5. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +1 -1
  6. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  7. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
  8. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  9. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
  10. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +4 -4
  11. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  12. package/skills/qingflow-cli/reference/data/README.md +20 -0
  13. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +7 -1
  14. package/skills/qingflow-common/SKILL.md +5 -2
  15. package/src/qingflow_mcp/app_form_compat.py +3 -0
  16. package/src/qingflow_mcp/app_form_validator.py +69 -17
  17. package/src/qingflow_mcp/backend_client.py +33 -8
  18. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  45. package/src/qingflow_mcp/builder_facade/service.py +39 -1
  46. package/src/qingflow_mcp/cli/commands/data.py +13 -3
  47. package/src/qingflow_mcp/cli/formatters.py +7 -0
  48. package/src/qingflow_mcp/cli/main.py +34 -1
  49. package/src/qingflow_mcp/cloud_context.py +16 -1
  50. package/src/qingflow_mcp/public_surface.py +67 -8
  51. package/src/qingflow_mcp/response_trim.py +2 -1
  52. package/src/qingflow_mcp/server.py +13 -7
  53. package/src/qingflow_mcp/server_app_builder.py +9 -5
  54. package/src/qingflow_mcp/server_app_user.py +10 -6
  55. package/src/qingflow_mcp/server_cloud.py +43 -7
  56. package/src/qingflow_mcp/tool_catalog.py +387 -0
  57. package/src/qingflow_mcp/tool_catalog_i18n.py +185 -0
  58. package/src/qingflow_mcp/tool_policy.py +197 -0
  59. package/src/qingflow_mcp/tools/data_files.py +174 -0
  60. package/src/qingflow_mcp/tools/data_tools.py +42 -13
  61. package/src/qingflow_mcp/tools/record_tools.py +14 -72
  62. package/src/qingflow_mcp/tools/storage_download.py +89 -0
@@ -11,9 +11,15 @@ from .app_form_compat import normalize_app_form_declaration
11
11
  from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
12
12
 
13
13
 
14
+ #: code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl
15
+ #: 原样落库),运行时 DataSvcImpl.getAnswerValueByString:选项/数字/日期有专门
16
+ #: 分支;attachment 按拆分 URL 写入(专门分支);subtable 整表组装(专门分支);
17
+ #: rich_text/data_relation 走 Question.canImport 兜底按字符串写入(不报错)。
18
+ #: 四者为存量合法配置(提取读得出、apply 必须写回,两闸门同口径)。
14
19
  _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
15
20
  "text",
16
21
  "long_text",
22
+ "rich_text",
17
23
  "number",
18
24
  "amount",
19
25
  "date",
@@ -21,6 +27,9 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
21
27
  "single_choice",
22
28
  "single_select",
23
29
  "multi_select",
30
+ "attachment",
31
+ "data_relation",
32
+ "subtable",
24
33
  }
25
34
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
26
35
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -218,19 +227,32 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
218
227
  widths = ["width" in field for field in fields if isinstance(field, dict)]
219
228
  if widths and any(widths) and not all(widths):
220
229
  errors.append((row_path, "width must be present on every field in the row or omitted from all"))
221
- if widths and all(widths) and sum(field.get("width", 0) for field in fields if isinstance(field, dict)) != 100:
222
- errors.append((row_path, "field widths must total 100"))
230
+ # 行宽契约(与后端语义对齐):任意 1-100 整数 + 全有或全无。
231
+ # 后端 html_width 无取值/总和校验,存量存在 33/34 三等分与字段删除后
232
+ # 留白(如 3×25=75)的行;get 读出的原布局必须能原样过校验
233
+ # (apply(get(S)) ≡ S 命题的必要条件)。
234
+ if widths and all(widths):
235
+ bad = [f.get("width") for f in fields if isinstance(f, dict) and not (isinstance(f.get("width"), int) and 1 <= f["width"] <= 100)]
236
+ if bad:
237
+ errors.append((row_path, "field widths must be an integer between 1 and 100"))
223
238
  for field_index, field in enumerate(fields):
224
239
  if isinstance(field, dict):
225
240
  top_fields.append((f"{row_path}/{field_index}", field))
226
241
  def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
227
- """Resolve against the declared body, preserving backend-dependent ID-only cases."""
242
+ """Resolve against the declared body, preserving backend-dependent ID-only cases.
243
+
244
+ 返回态:resolved(唯一定位)/ invalid(定位失败)/ ambiguous(方案 A:name-only
245
+ 命中多个同名字段,调用方须报 AMBIGUOUS_FIELD_SELECTOR)/ unknown(存在未命名
246
+ 字段,后端侧再判)/ schema(结构不完整,交给 Schema 校验)。
247
+ """
228
248
  if not isinstance(selector, dict):
229
249
  return "schema", None
230
250
  raw_que_id = selector.get("queId")
231
251
  que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
232
252
  raw_name = selector.get("name")
233
- name = raw_name if isinstance(raw_name, str) and raw_name else ""
253
+ # name 两侧 strip:存量字段名带前导/尾随空格,queId 已同时给出不构成歧义
254
+ # (与 compiler 侧三个 resolve 函数同口径)。
255
+ name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else ""
234
256
  if raw_que_id is not None and que_id is None:
235
257
  return "schema", None
236
258
  if raw_name is not None and not name:
@@ -239,26 +261,52 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
239
261
  matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
240
262
  if len(matches) != 1:
241
263
  return "invalid", None
242
- declared_name = str(matches[0][1].get("name") or "")
264
+ declared_name = str(matches[0][1].get("name") or "").strip()
243
265
  if name and declared_name and name != declared_name:
244
266
  return "invalid", None
245
267
  return "resolved", matches[0]
246
268
  if name:
247
- matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
269
+ matches = [(path, field) for path, field in top_fields if str(field.get("name") or "").strip() == name]
248
270
  if len(matches) == 1:
249
271
  return "resolved", matches[0]
250
272
  if len(matches) > 1:
251
- return "invalid", None
273
+ return "ambiguous", None
252
274
  if any(not str(field.get("name") or "") for _, field in top_fields):
253
275
  return "unknown", None
254
276
  return "invalid", None
255
277
  return "schema", None
256
278
 
279
+ def ambiguity_message(selector: Any, subject: str) -> str:
280
+ name = str((selector or {}).get("name") or "").strip()
281
+ candidate_ids = sorted(
282
+ {
283
+ field.get("queId")
284
+ for _, field in top_fields
285
+ if str(field.get("name") or "").strip() == name
286
+ and isinstance(field.get("queId"), int)
287
+ and not isinstance(field.get("queId"), bool)
288
+ }
289
+ )
290
+ return (
291
+ f"AMBIGUOUS_FIELD_SELECTOR: {subject} name {name!r} matches multiple fields "
292
+ f"(candidate queIds: {candidate_ids}); duplicate names are allowed but "
293
+ "selectors must use queId to disambiguate"
294
+ )
295
+
257
296
  title_selector = spec.get("dataTitleField")
258
297
  if top_fields:
259
298
  if "dataTitleField" not in spec and "appKey" in payload:
260
299
  pass
261
- elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
300
+ elif title_selector is None:
301
+ # dataTitleField=null 仅在带 appKey 的更新/round-trip 场景放行:后端写链
302
+ # FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个可用
303
+ # 字段,get 读面在后端未设置时原样输出 null。创建场景(无 appKey)仍需
304
+ # 显式 selector。
305
+ if "appKey" not in payload:
306
+ errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
307
+ elif resolve_local_selector(title_selector)[0] == "ambiguous":
308
+ errors.append(("/spec/dataTitleField", ambiguity_message(title_selector, "dataTitleField")))
309
+ elif resolve_local_selector(title_selector)[0] == "invalid":
262
310
  errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
263
311
  elif title_selector is not None:
264
312
  errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
@@ -266,11 +314,15 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
266
314
  cover_selector = spec.get("dataCoverField")
267
315
  if cover_selector is not None:
268
316
  cover_resolution, cover = resolve_local_selector(cover_selector)
269
- if cover_resolution == "invalid":
317
+ if cover_resolution == "ambiguous":
318
+ errors.append(("/spec/dataCoverField", ambiguity_message(cover_selector, "dataCoverField")))
319
+ elif cover_resolution == "invalid":
270
320
  errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
271
321
  elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
272
322
  errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
273
- names: dict[str, str] = {}
323
+ # 方案 A(duplicate-field-name):同父级允许同名(后端以 queId 为身份,40013
324
+ # 实测 1624 组),不再报 duplicate name;歧义在 selector 解析处以
325
+ # AMBIGUOUS_FIELD_SELECTOR 显式拒绝。
274
326
  fields_by_id = {
275
327
  field.get("queId"): field
276
328
  for _, field in top_fields
@@ -278,12 +330,6 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
278
330
  }
279
331
  output_targets: dict[str, tuple[str, str]] = {}
280
332
  for path, field in top_fields:
281
- name = str(field.get("name") or "")
282
- if name:
283
- if name in names:
284
- errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
285
- else:
286
- names[name] = f"{path}/name"
287
333
  field_type = _field_type(field)
288
334
  if field_type == "data_relation":
289
335
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
@@ -329,6 +375,9 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
329
375
  selector = item.get("field") if isinstance(item.get("field"), dict) else {}
330
376
  input_path = f"{path}/config/{binding_name}/inputs/{index}"
331
377
  resolution, selected = resolve_local_selector(selector)
378
+ if resolution == "ambiguous":
379
+ errors.append((f"{input_path}/field", ambiguity_message(selector, f"{binding_name}.inputs[{index}].field")))
380
+ continue
332
381
  if resolution == "invalid":
333
382
  errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
334
383
  continue
@@ -380,13 +429,16 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
380
429
  )
381
430
  )
382
431
 
383
- producer = name or str(field.get("queId") or field_type)
432
+ producer = str(field.get("name") or "") or str(field.get("queId") or field_type)
384
433
  for index, item in enumerate(binding.get("outputs") or []):
385
434
  if not isinstance(item, dict):
386
435
  continue
387
436
  selector = item.get("targetField")
388
437
  target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
389
438
  resolution, selected = resolve_local_selector(selector)
439
+ if resolution == "ambiguous":
440
+ errors.append((target_path, ambiguity_message(selector, f"{binding_name}.outputs[{index}].targetField")))
441
+ continue
390
442
  if resolution == "invalid":
391
443
  errors.append((target_path, "targetField must resolve to exactly one top-level field"))
392
444
  continue
@@ -304,18 +304,40 @@ class BackendClient:
304
304
  "body": body,
305
305
  }
306
306
 
307
- def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
307
+ def _get_binary_response(
308
+ self, url: str, *, headers: dict[str, str] | None = None,
309
+ follow_redirects: bool | None = None, max_bytes: int | None = None,
310
+ ) -> tuple[httpx.Response, bytes]:
311
+ options: dict[str, Any] = {"headers": headers or None}
312
+ if follow_redirects is not None:
313
+ options["follow_redirects"] = follow_redirects
314
+ if max_bytes is None:
315
+ response = self._client.get(url, **options)
316
+ return response, response.content
317
+ with self._client.stream("GET", url, **options) as response:
318
+ content = bytearray()
319
+ for chunk in response.iter_bytes():
320
+ if len(content) + len(chunk) > max_bytes:
321
+ raise QingflowApiError(
322
+ category="file_size_limit", message="附件超过单文件或总大小预算",
323
+ )
324
+ content.extend(chunk)
325
+ return response, bytes(content)
326
+
327
+ def download_binary(
328
+ self, url: str, *, headers: dict[str, str] | None = None, max_bytes: int | None = None,
329
+ ) -> bytes:
308
330
  try:
309
- response = self._client.get(url, headers=headers or None)
331
+ response, content = self._get_binary_response(url, headers=headers, max_bytes=max_bytes)
310
332
  except httpx.RequestError as exc:
311
333
  raise QingflowApiError(category="network", message=str(exc))
312
334
  if response.status_code >= 400:
313
335
  raise QingflowApiError(
314
336
  category="http",
315
- message=self._extract_message(response.text) or f"HTTP {response.status_code}",
337
+ message=self._extract_message(content.decode(response.encoding or "utf-8", errors="replace")) or f"HTTP {response.status_code}",
316
338
  http_status=response.status_code,
317
339
  )
318
- return response.content
340
+ return content
319
341
 
320
342
  def download_binary_with_cookie(
321
343
  self,
@@ -324,6 +346,7 @@ class BackendClient:
324
346
  *,
325
347
  cookie_name: str,
326
348
  headers: dict[str, str] | None = None,
349
+ max_bytes: int | None = None,
327
350
  ) -> BackendBinaryResponse:
328
351
  qf_version, _source = self._resolve_qf_version(context.qf_version)
329
352
  request_headers = {
@@ -337,7 +360,9 @@ class BackendClient:
337
360
  cookie_parts.append(f"qfVersion={qf_version}")
338
361
  request_headers["Cookie"] = "; ".join(cookie_parts)
339
362
  try:
340
- response = self._client.get(url, headers=request_headers, follow_redirects=False)
363
+ response, content = self._get_binary_response(
364
+ url, headers=request_headers, follow_redirects=False, max_bytes=max_bytes,
365
+ )
341
366
  except httpx.RequestError as exc:
342
367
  raise QingflowApiError(category="network", message=str(exc))
343
368
  redirected = 300 <= response.status_code < 400 and bool(response.headers.get("location"))
@@ -345,17 +370,17 @@ class BackendClient:
345
370
  redirect_headers = {key: value for key, value in request_headers.items() if key.lower() != "cookie"}
346
371
  redirect_url = urljoin(str(response.url), response.headers["location"])
347
372
  try:
348
- response = self._client.get(redirect_url, headers=redirect_headers)
373
+ response, content = self._get_binary_response(redirect_url, headers=redirect_headers, max_bytes=max_bytes)
349
374
  except httpx.RequestError as exc:
350
375
  raise QingflowApiError(category="network", message=str(exc))
351
376
  if response.status_code >= 400:
352
377
  raise QingflowApiError(
353
378
  category="http",
354
- message=self._extract_message(response.text) or f"HTTP {response.status_code}",
379
+ message=self._extract_message(content.decode(response.encoding or "utf-8", errors="replace")) or f"HTTP {response.status_code}",
355
380
  http_status=response.status_code,
356
381
  )
357
382
  return BackendBinaryResponse(
358
- content=response.content,
383
+ content=content,
359
384
  headers=dict(response.headers),
360
385
  http_status=response.status_code,
361
386
  final_url=str(response.url),