@josephyan/qingflow-cli 1.0.11 → 1.1.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 (67) hide show
  1. package/README.md +3 -3
  2. package/npm/bin/qingflow.mjs +40 -2
  3. package/npm/lib/runtime.mjs +386 -15
  4. package/npm/scripts/postinstall.mjs +7 -2
  5. package/package.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/skills/qingflow-cli/SKILL.md +440 -0
  8. package/skills/qingflow-cli/manifest.yaml +10 -0
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +94 -0
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +485 -0
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +237 -0
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_MATCH_RULES.md +137 -0
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +263 -0
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +304 -0
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md +41 -0
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +139 -0
  17. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +84 -0
  18. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +129 -0
  19. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +195 -0
  20. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +159 -0
  21. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +20 -0
  22. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md +176 -0
  23. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +163 -0
  24. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +107 -0
  25. package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +151 -0
  26. package/skills/qingflow-cli/reference/_batch_schema_complex.json +18 -0
  27. package/skills/qingflow-cli/reference/_batch_schema_scalar.json +17 -0
  28. package/skills/qingflow-cli/reference/charts_remove.example.json +1 -0
  29. package/skills/qingflow-cli/reference/charts_reorder.example.json +1 -0
  30. package/skills/qingflow-cli/reference/charts_upsert_bar.example.json +8 -0
  31. package/skills/qingflow-cli/reference/charts_upsert_dashboard_starter.example.json +37 -0
  32. package/skills/qingflow-cli/reference/charts_upsert_minimal.example.json +13 -0
  33. package/skills/qingflow-cli/reference/portal_sections_all_types.example.json +131 -0
  34. package/skills/qingflow-cli/reference/portal_sections_five_types.example.json +126 -0
  35. package/skills/qingflow-cli/reference/portal_sections_standard_workbench.example.json +128 -0
  36. package/skills/qingflow-cli/reference/schema_add_fields_minimal.example.json +7 -0
  37. package/skills/qingflow-cli/reference/schema_apply_add_fields_all_types.json +78 -0
  38. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +7 -0
  39. package/skills/qingflow-cli/scripts/builder-package-from-app-list.py +140 -0
  40. package/skills/qingflow-cli/scripts/find-app-by-keyword.py +132 -0
  41. package/skills/qingflow-cli/scripts/validate_qingflow_output_files.py +87 -0
  42. package/src/qingflow_mcp/__init__.py +1 -1
  43. package/src/qingflow_mcp/builder_facade/models.py +532 -48
  44. package/src/qingflow_mcp/builder_facade/service.py +9194 -2384
  45. package/src/qingflow_mcp/builder_facade/workflow_spec.py +111 -0
  46. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  47. package/src/qingflow_mcp/cli/commands/builder.py +354 -56
  48. package/src/qingflow_mcp/cli/commands/record.py +89 -2
  49. package/src/qingflow_mcp/cli/formatters.py +32 -1
  50. package/src/qingflow_mcp/cli/main.py +245 -3
  51. package/src/qingflow_mcp/public_surface.py +11 -8
  52. package/src/qingflow_mcp/response_trim.py +143 -14
  53. package/src/qingflow_mcp/server.py +15 -12
  54. package/src/qingflow_mcp/server_app_builder.py +108 -30
  55. package/src/qingflow_mcp/server_app_user.py +17 -18
  56. package/src/qingflow_mcp/solution/compiler/__init__.py +1 -3
  57. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  58. package/src/qingflow_mcp/solution/executor.py +3 -133
  59. package/src/qingflow_mcp/tools/ai_builder_tools.py +2617 -440
  60. package/src/qingflow_mcp/tools/app_tools.py +53 -8
  61. package/src/qingflow_mcp/tools/package_tools.py +16 -2
  62. package/src/qingflow_mcp/tools/record_tools.py +2095 -176
  63. package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
  64. package/src/qingflow_mcp/tools/solution_tools.py +30 -2
  65. package/src/qingflow_mcp/tools/workflow_tools.py +3 -31
  66. package/src/qingflow_mcp/version.py +110 -0
  67. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +0 -173
@@ -45,6 +45,11 @@ RECORD_ACCESS_UNBOUNDED_ROW_THRESHOLD = 50_000
45
45
  RECORD_ACCESS_TIME_BUDGET_SECONDS = 55.0
46
46
  RECORD_ACCESS_MIN_REMAINING_SECONDS = 8.0
47
47
  RECORD_GET_DETAIL_LOG_PAGE_SIZE = 10
48
+ RECORD_LOGS_PAGE_SIZE = 200
49
+ RECORD_LOGS_PREVIEW_LIMIT = 10
50
+ RECORD_LOGS_MAX_ITEMS = 20_000
51
+ RECORD_LOGS_TIME_BUDGET_SECONDS = 55.0
52
+ RECORD_LOGS_MIN_REMAINING_SECONDS = 8.0
48
53
  RECORD_GET_MEDIA_MAX_IMAGES = 30
49
54
  RECORD_GET_MEDIA_MAX_IMAGE_BYTES = 20 * 1024 * 1024
50
55
  RECORD_GET_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024
@@ -269,6 +274,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
269
274
  FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
270
275
 
271
276
 
277
+ def _pick_route_payload(payload: JSONObject) -> JSONObject:
278
+ return {
279
+ key: payload[key]
280
+ for key in (
281
+ "route_type",
282
+ "endpoint_kind",
283
+ "status",
284
+ "role",
285
+ "task_id",
286
+ "workflow_node_id",
287
+ "view_id",
288
+ "view_key",
289
+ "view_name",
290
+ "error_code",
291
+ "backend_code",
292
+ "http_status",
293
+ "request_id",
294
+ "message",
295
+ "reason",
296
+ )
297
+ if key in payload and payload[key] not in (None, "", [], {})
298
+ }
299
+
300
+
272
301
  class RecordTools(ToolBase):
273
302
  """记录工具(中文名:记录读写与分析)。
274
303
 
@@ -442,6 +471,20 @@ class RecordTools(ToolBase):
442
471
  output_profile=output_profile,
443
472
  )
444
473
 
474
+ @mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. This tool hides pagination and returns file paths plus completeness metadata.")
475
+ def record_logs_get(
476
+ profile: str = DEFAULT_PROFILE,
477
+ app_key: str = "",
478
+ record_id: str = "",
479
+ view_id: str | None = None,
480
+ ) -> JSONObject:
481
+ return self.record_logs_get(
482
+ profile=profile,
483
+ app_key=app_key,
484
+ record_id=record_id,
485
+ view_id=view_id,
486
+ )
487
+
445
488
  @mcp.tool()
446
489
  def record_browse_schema_get(
447
490
  app_key: str = "",
@@ -470,21 +513,22 @@ class RecordTools(ToolBase):
470
513
 
471
514
  @mcp.tool(
472
515
  description=(
473
- "Insert one Qingflow record using an applicant-node field map. "
516
+ "Insert Qingflow records using applicant-node field maps. "
474
517
  "Use record_insert_schema_get first. "
475
- "This tool performs internal preflight validation before any write is applied."
518
+ "Prefer items=[{'fields': {...}}]; a single insert is one item. "
519
+ "Each item performs internal preflight validation before that item is written."
476
520
  )
477
521
  )
478
522
  def record_insert(
479
523
  app_key: str = "",
480
- fields: JSONObject | None = None,
524
+ items: list[JSONObject] | None = None,
481
525
  verify_write: bool = True,
482
526
  output_profile: str = "normal",
483
527
  ) -> JSONObject:
484
528
  return self.record_insert_public(
485
529
  profile=DEFAULT_PROFILE,
486
530
  app_key=app_key,
487
- fields=fields or {},
531
+ items=items,
488
532
  verify_write=verify_write,
489
533
  output_profile=output_profile,
490
534
  )
@@ -492,8 +536,9 @@ class RecordTools(ToolBase):
492
536
  @mcp.tool(
493
537
  description=(
494
538
  "Update one Qingflow record using a field map. "
495
- "Use record_update_schema_get first. "
496
- "This tool automatically probes accessible views in order and uses the first safe match."
539
+ "For simple field changes, call this tool directly after the target record is clear. "
540
+ "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
541
+ "Use record_update_schema_get for diagnostics or complex field-scope inspection."
497
542
  )
498
543
  )
499
544
  def record_update(
@@ -994,15 +1039,59 @@ class RecordTools(ToolBase):
994
1039
  item["title"]: self._ready_schema_template_value(item)
995
1040
  for item in writable_fields
996
1041
  },
1042
+ "available_update_routes": self._record_update_schema_available_routes(matched_probes),
1043
+ "recommended_update_route": {
1044
+ "route_type": "auto",
1045
+ "order": ["admin_direct", "view_edit", "task_save_only"],
1046
+ "message": "record_update will try data-manager direct edit first, then a matching custom-view edit route, then a unique current-user todo save-only route when the target fields are editable on that workflow node.",
1047
+ },
997
1048
  }
998
1049
  if normalized_output_profile == "verbose":
999
1050
  response["view_probe_summary"] = probe_summary
1000
1051
  response["record_context_probe"] = probe_summary
1001
1052
  response["ambiguous_fields"] = ambiguous_fields
1053
+ response["route_probe_summary"] = probe_summary
1002
1054
  return response
1003
1055
 
1004
1056
  return self._run_record_tool(profile, runner)
1005
1057
 
1058
+ def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
1059
+ routes: list[JSONObject] = [
1060
+ {
1061
+ "route_type": "admin_direct",
1062
+ "endpoint_kind": "app_apply_update",
1063
+ "role": 1,
1064
+ "availability": "attempted_on_update",
1065
+ "message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
1066
+ }
1067
+ ]
1068
+ for probe in matched_probes:
1069
+ if probe.route.kind != "custom":
1070
+ continue
1071
+ view_key = self._route_view_key(probe.route)
1072
+ if not view_key:
1073
+ continue
1074
+ routes.append(
1075
+ {
1076
+ "route_type": "view_edit",
1077
+ "endpoint_kind": "view_apply_update",
1078
+ "view_id": probe.route.view_id,
1079
+ "view_key": view_key,
1080
+ "view_name": probe.route.name,
1081
+ "availability": "candidate",
1082
+ "message": "Uses the same custom-view detail edit route as the frontend.",
1083
+ }
1084
+ )
1085
+ routes.append(
1086
+ {
1087
+ "route_type": "task_save_only",
1088
+ "endpoint_kind": "workflow_node_save_only",
1089
+ "availability": "auto_probe_on_update",
1090
+ "message": "record_update probes the current user's todo list and uses save-only only when exactly one matching task exists and the requested fields are editable on that workflow node.",
1091
+ }
1092
+ )
1093
+ return routes
1094
+
1006
1095
  def _record_update_schema_blocked_response(
1007
1096
  self,
1008
1097
  *,
@@ -1064,11 +1153,16 @@ class RecordTools(ToolBase):
1064
1153
  required = bool(required_override) if required_override is not None else bool(field.required or any(item.get("required") for item in row_fields))
1065
1154
  else:
1066
1155
  required = bool(required_override) if required_override is not None else bool(field.required)
1156
+ write_format = _write_format_for_field(field)
1067
1157
  payload: JSONObject = {
1068
1158
  "title": field.que_title,
1069
1159
  "kind": kind,
1070
1160
  "required": required,
1161
+ "format_hint": _ready_schema_format_hint(kind, write_format),
1071
1162
  }
1163
+ example_value = _ready_schema_example_value(kind, field, write_format, row_fields=row_fields)
1164
+ if example_value is not None:
1165
+ payload["example_value"] = example_value
1072
1166
  if include_field_id:
1073
1167
  payload["field_id"] = field.que_id
1074
1168
  if kind in {"single_select", "multi_select"} and field.options:
@@ -2169,6 +2263,130 @@ class RecordTools(ToolBase):
2169
2263
 
2170
2264
  return self._run_record_tool(profile, runner)
2171
2265
 
2266
+ @tool_cn_name("记录全量日志")
2267
+ def record_logs_get(
2268
+ self,
2269
+ *,
2270
+ profile: str,
2271
+ app_key: str,
2272
+ record_id: Any,
2273
+ view_id: str | None = None,
2274
+ ) -> JSONObject:
2275
+ """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2276
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2277
+
2278
+ def runner(session_profile, context):
2279
+ resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
2280
+ profile,
2281
+ context,
2282
+ app_key,
2283
+ view_id=view_id,
2284
+ list_type=None,
2285
+ view_key=None,
2286
+ view_name=None,
2287
+ allow_default=True,
2288
+ )
2289
+ warnings: list[JSONObject] = []
2290
+ warnings.extend(compatibility_warnings)
2291
+ warnings.extend(_view_filter_trust_warnings(resolved_view))
2292
+ unavailable_context: list[JSONObject] = []
2293
+
2294
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2295
+ index = _build_top_level_field_index(schema)
2296
+ audit_info = self._record_get_audit_info(
2297
+ context,
2298
+ app_key=app_key,
2299
+ record_id=record_id_int,
2300
+ resolved_view=resolved_view,
2301
+ )
2302
+ audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2303
+ detail_result, used_list_type, used_role = self._record_get_apply_detail(
2304
+ context,
2305
+ app_key=app_key,
2306
+ record_id=record_id_int,
2307
+ resolved_view=resolved_view,
2308
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2309
+ )
2310
+ answer_list = _record_detail_answers(detail_result)
2311
+ selected_fields = list(index.by_id.values())
2312
+ fields = [
2313
+ _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
2314
+ for field in selected_fields
2315
+ ]
2316
+ app_name = self._record_get_detail_app_name(
2317
+ profile,
2318
+ context,
2319
+ app_key=app_key,
2320
+ schema=schema,
2321
+ used_list_type=used_list_type,
2322
+ )
2323
+ view_payload = _accessible_view_payload(resolved_view)
2324
+ record_payload = _record_detail_record_payload(
2325
+ app_key=app_key,
2326
+ record_id=record_id_int,
2327
+ detail=detail_result,
2328
+ answer_list=cast(list[JSONValue], answer_list),
2329
+ fields=fields,
2330
+ )
2331
+ log_visibility = self._record_get_log_visibility_context(
2332
+ context,
2333
+ app_key=app_key,
2334
+ record_id=record_id_int,
2335
+ resolved_view=resolved_view,
2336
+ role=used_role,
2337
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2338
+ unavailable_context=unavailable_context,
2339
+ )
2340
+ run_dir = _record_logs_run_dir()
2341
+ run_dir.mkdir(parents=True, exist_ok=True)
2342
+ deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
2343
+ data_logs = self._record_get_full_data_logs_context(
2344
+ context,
2345
+ app_key=app_key,
2346
+ record_id=record_id_int,
2347
+ role=used_role,
2348
+ log_visibility=log_visibility,
2349
+ unavailable_context=unavailable_context,
2350
+ run_dir=run_dir,
2351
+ deadline=deadline,
2352
+ )
2353
+ workflow_logs = self._record_get_full_workflow_logs_context(
2354
+ context,
2355
+ app_key=app_key,
2356
+ record_id=record_id_int,
2357
+ resolved_view=resolved_view,
2358
+ role=used_role,
2359
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2360
+ log_visibility=log_visibility,
2361
+ unavailable_context=unavailable_context,
2362
+ run_dir=run_dir,
2363
+ deadline=deadline,
2364
+ )
2365
+ status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
2366
+ context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
2367
+ payload: JSONObject = {
2368
+ "ok": True,
2369
+ "status": status,
2370
+ "output_profile": "record_logs",
2371
+ "app": {"app_key": app_key, "app_name": app_name},
2372
+ "view": view_payload,
2373
+ "record": record_payload,
2374
+ "local_dir": str(run_dir),
2375
+ "data_logs": data_logs,
2376
+ "workflow_logs": workflow_logs,
2377
+ "warnings": warnings,
2378
+ "unavailable_context": unavailable_context,
2379
+ "context_integrity": context_integrity,
2380
+ }
2381
+ summary_path = run_dir / "summary.json"
2382
+ summary_payload = deepcopy(payload)
2383
+ summary_payload.pop("request_route", None)
2384
+ summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
2385
+ payload["summary_path"] = str(summary_path)
2386
+ return payload
2387
+
2388
+ return self._run_record_tool(profile, runner)
2389
+
2172
2390
  def _record_get_detail_context(
2173
2391
  self,
2174
2392
  *,
@@ -2818,6 +3036,108 @@ class RecordTools(ToolBase):
2818
3036
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
2819
3037
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
2820
3038
 
3039
+ def _record_get_full_data_logs_context(
3040
+ self,
3041
+ context, # type: ignore[no-untyped-def]
3042
+ *,
3043
+ app_key: str,
3044
+ record_id: int,
3045
+ role: int,
3046
+ log_visibility: JSONObject,
3047
+ unavailable_context: list[JSONObject],
3048
+ run_dir: Path,
3049
+ deadline: float,
3050
+ ) -> JSONObject:
3051
+ """读取全量数据日志并写入 JSONL。"""
3052
+ if log_visibility.get("status") == "unavailable":
3053
+ return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
3054
+ if log_visibility.get("data_log_visible") is False:
3055
+ return _record_logs_hidden_payload("data_logs")
3056
+
3057
+ def fetch_page(page_num: int) -> JSONValue:
3058
+ return self.backend.request(
3059
+ "POST",
3060
+ context,
3061
+ f"/worksheet/data/log/{app_key}/{record_id}/page",
3062
+ json_body={
3063
+ "viewChannel": log_visibility.get("channel"),
3064
+ "role": role,
3065
+ "pageNum": page_num,
3066
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3067
+ },
3068
+ )
3069
+
3070
+ try:
3071
+ return _record_logs_fetch_all_to_jsonl(
3072
+ fetch_page=fetch_page,
3073
+ normalizer=_record_detail_data_log_item,
3074
+ source="data_logs",
3075
+ file_path=run_dir / "data-logs.jsonl",
3076
+ deadline=deadline,
3077
+ )
3078
+ except QingflowApiError as exc:
3079
+ unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3080
+ return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
3081
+
3082
+ def _record_get_full_workflow_logs_context(
3083
+ self,
3084
+ context, # type: ignore[no-untyped-def]
3085
+ *,
3086
+ app_key: str,
3087
+ record_id: int,
3088
+ resolved_view: AccessibleViewRoute,
3089
+ role: int,
3090
+ audit_node_id: int | None,
3091
+ log_visibility: JSONObject,
3092
+ unavailable_context: list[JSONObject],
3093
+ run_dir: Path,
3094
+ deadline: float,
3095
+ ) -> JSONObject:
3096
+ """读取全量流程日志并写入 JSONL。"""
3097
+ if log_visibility.get("status") == "unavailable":
3098
+ return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
3099
+ if log_visibility.get("workflow_log_visible") is False:
3100
+ return _record_logs_hidden_payload("workflow_logs")
3101
+
3102
+ def fetch_page(page_num: int) -> JSONValue:
3103
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3104
+ return self.backend.request(
3105
+ "POST",
3106
+ context,
3107
+ f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
3108
+ json_body={
3109
+ "key": resolved_view.view_selection.view_key,
3110
+ "rowRecordId": str(record_id),
3111
+ "pageNum": page_num,
3112
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3113
+ },
3114
+ )
3115
+ return self.backend.request(
3116
+ "POST",
3117
+ context,
3118
+ "/application/workflow/node/record",
3119
+ json_body={
3120
+ "key": app_key,
3121
+ "rowRecordId": str(record_id),
3122
+ "nodeId": audit_node_id,
3123
+ "role": role,
3124
+ "pageNum": page_num,
3125
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3126
+ },
3127
+ )
3128
+
3129
+ try:
3130
+ return _record_logs_fetch_all_to_jsonl(
3131
+ fetch_page=fetch_page,
3132
+ normalizer=_record_detail_workflow_log_item,
3133
+ source="workflow_logs",
3134
+ file_path=run_dir / "workflow-logs.jsonl",
3135
+ deadline=deadline,
3136
+ )
3137
+ except QingflowApiError as exc:
3138
+ unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3139
+ return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3140
+
2821
3141
  def _record_get_associated_resources(
2822
3142
  self,
2823
3143
  context, # type: ignore[no-untyped-def]
@@ -2995,86 +3315,7 @@ class RecordTools(ToolBase):
2995
3315
  profile: str = DEFAULT_PROFILE,
2996
3316
  app_key: str,
2997
3317
  fields: JSONObject | None = None,
2998
- verify_write: bool = True,
2999
- output_profile: str = "normal",
3000
- ) -> JSONObject:
3001
- """执行记录相关逻辑。"""
3002
- normalized_output_profile = self._normalize_public_output_profile(output_profile)
3003
- if not app_key:
3004
- raise_tool_error(QingflowApiError.config_error("app_key is required"))
3005
- if fields is not None and not isinstance(fields, dict):
3006
- raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3007
- submit_type_value = self._normalize_record_write_submit_type("submit")
3008
- raw_preflight = self._preflight_record_write(
3009
- profile=profile,
3010
- operation="create",
3011
- app_key=app_key,
3012
- apply_id=None,
3013
- answers=[],
3014
- fields=cast(JSONObject, fields or {}),
3015
- force_refresh_form=False,
3016
- view_id=None,
3017
- list_type=None,
3018
- view_key=None,
3019
- view_name=None,
3020
- )
3021
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3022
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3023
- normalized_payload: JSONObject = self._record_write_normalized_payload(
3024
- operation="insert",
3025
- record_id=None,
3026
- record_ids=[],
3027
- normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3028
- submit_type=submit_type_value,
3029
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3030
- )
3031
- if preflight_data.get("blockers"):
3032
- return self._record_write_blocked_response(
3033
- raw_preflight,
3034
- operation="insert",
3035
- normalized_payload=normalized_payload,
3036
- output_profile=normalized_output_profile,
3037
- human_review=False,
3038
- target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
3039
- )
3040
- try:
3041
- raw_apply = self.record_create(
3042
- profile=profile,
3043
- app_key=app_key,
3044
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3045
- fields={},
3046
- submit_type=submit_type_value,
3047
- verify_write=verify_write,
3048
- force_refresh_form=preflight_used_force_refresh,
3049
- )
3050
- except QingflowApiError as exc:
3051
- self._raise_record_write_permission_error(
3052
- exc,
3053
- operation="insert",
3054
- app_key=app_key,
3055
- record_id=None,
3056
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3057
- )
3058
- raise
3059
- return self._record_write_apply_response(
3060
- raw_apply,
3061
- operation="insert",
3062
- normalized_payload=normalized_payload,
3063
- output_profile=normalized_output_profile,
3064
- human_review=False,
3065
- preflight=raw_preflight,
3066
- )
3067
-
3068
- @tool_cn_name("更新记录")
3069
- def record_update_public(
3070
- self,
3071
- *,
3072
- profile: str = DEFAULT_PROFILE,
3073
- app_key: str,
3074
- record_id: Any | None,
3075
- fields: JSONObject | None = None,
3076
3318
  items: list[JSONObject] | None = None,
3077
- dry_run: bool = False,
3078
3319
  verify_write: bool = True,
3079
3320
  output_profile: str = "normal",
3080
3321
  ) -> JSONObject:
@@ -3083,102 +3324,1240 @@ class RecordTools(ToolBase):
3083
3324
  if not app_key:
3084
3325
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
3085
3326
  if items is not None:
3086
- if dry_run not in {True, False}:
3087
- raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
3088
- normalized_items = self._normalize_public_record_update_batch_items(
3089
- record_id=record_id,
3090
- fields=fields,
3091
- items=items,
3092
- )
3093
- return self._record_update_public_batch(
3327
+ normalized_items = self._normalize_public_record_insert_batch_items(fields=fields, items=items)
3328
+ return self._record_insert_public_batch(
3094
3329
  profile=profile,
3095
3330
  app_key=app_key,
3096
3331
  items=normalized_items,
3097
- dry_run=dry_run,
3098
3332
  verify_write=verify_write,
3099
3333
  output_profile=normalized_output_profile,
3100
3334
  )
3101
- if dry_run:
3102
- raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3103
- if record_id is None:
3104
- raise_tool_error(QingflowApiError.config_error("record_id is required"))
3105
- record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3106
3335
  if fields is not None and not isinstance(fields, dict):
3107
3336
  raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3108
- return self._record_update_public_single(
3337
+ return self._record_insert_public_single(
3109
3338
  profile=profile,
3110
3339
  app_key=app_key,
3111
- record_id=record_id_int,
3112
3340
  fields=cast(JSONObject, fields or {}),
3113
3341
  verify_write=verify_write,
3114
3342
  output_profile=normalized_output_profile,
3343
+ capture_exceptions=False,
3115
3344
  )
3116
3345
 
3117
- def _record_update_public_single(
3346
+ def _record_insert_public_single(
3347
+ self,
3348
+ *,
3349
+ profile: str,
3350
+ app_key: str,
3351
+ fields: JSONObject,
3352
+ verify_write: bool,
3353
+ output_profile: str,
3354
+ capture_exceptions: bool,
3355
+ ) -> JSONObject:
3356
+ """执行内部辅助逻辑。"""
3357
+ submit_type_value = self._normalize_record_write_submit_type("submit")
3358
+ write_attempted = False
3359
+ try:
3360
+ raw_preflight = self._preflight_record_write(
3361
+ profile=profile,
3362
+ operation="create",
3363
+ app_key=app_key,
3364
+ apply_id=None,
3365
+ answers=[],
3366
+ fields=fields,
3367
+ force_refresh_form=False,
3368
+ view_id=None,
3369
+ list_type=None,
3370
+ view_key=None,
3371
+ view_name=None,
3372
+ )
3373
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3374
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3375
+ normalized_payload: JSONObject = self._record_write_normalized_payload(
3376
+ operation="insert",
3377
+ record_id=None,
3378
+ record_ids=[],
3379
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3380
+ submit_type=submit_type_value,
3381
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3382
+ )
3383
+ if preflight_data.get("blockers"):
3384
+ return self._record_write_blocked_response(
3385
+ raw_preflight,
3386
+ operation="insert",
3387
+ normalized_payload=normalized_payload,
3388
+ output_profile=output_profile,
3389
+ human_review=False,
3390
+ target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
3391
+ )
3392
+ try:
3393
+ write_attempted = True
3394
+ raw_apply = self.record_create(
3395
+ profile=profile,
3396
+ app_key=app_key,
3397
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3398
+ fields={},
3399
+ submit_type=submit_type_value,
3400
+ verify_write=verify_write,
3401
+ force_refresh_form=preflight_used_force_refresh,
3402
+ )
3403
+ except QingflowApiError as exc:
3404
+ self._raise_record_write_permission_error(
3405
+ exc,
3406
+ operation="insert",
3407
+ app_key=app_key,
3408
+ record_id=None,
3409
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3410
+ )
3411
+ raise
3412
+ return self._record_write_apply_response(
3413
+ raw_apply,
3414
+ operation="insert",
3415
+ normalized_payload=normalized_payload,
3416
+ output_profile=output_profile,
3417
+ human_review=False,
3418
+ preflight=raw_preflight,
3419
+ )
3420
+ except (QingflowApiError, RuntimeError) as exc:
3421
+ if not capture_exceptions:
3422
+ raise
3423
+ return self._record_write_exception_response(
3424
+ exc,
3425
+ operation="insert",
3426
+ profile=profile,
3427
+ app_key=app_key,
3428
+ record_id=None,
3429
+ output_profile=output_profile,
3430
+ human_review=False,
3431
+ write_executed=write_attempted,
3432
+ )
3433
+
3434
+ def _normalize_public_record_insert_batch_items(
3435
+ self,
3436
+ *,
3437
+ fields: JSONObject | None,
3438
+ items: list[JSONObject] | None,
3439
+ ) -> list[JSONObject]:
3440
+ """执行内部辅助逻辑。"""
3441
+ if fields is not None:
3442
+ raise_tool_error(QingflowApiError.config_error("record_insert batch mode does not accept fields"))
3443
+ if not isinstance(items, list) or not items:
3444
+ raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
3445
+ normalized_items: list[JSONObject] = []
3446
+ for index, item in enumerate(items):
3447
+ if not isinstance(item, dict):
3448
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
3449
+ item_fields = item.get("fields")
3450
+ if not isinstance(item_fields, dict):
3451
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
3452
+ normalized_items.append({"fields": cast(JSONObject, item_fields)})
3453
+ return normalized_items
3454
+
3455
+ def _record_insert_public_batch(
3456
+ self,
3457
+ *,
3458
+ profile: str,
3459
+ app_key: str,
3460
+ items: list[JSONObject],
3461
+ verify_write: bool,
3462
+ output_profile: str,
3463
+ ) -> JSONObject:
3464
+ """执行内部辅助逻辑。"""
3465
+ responses: list[JSONObject] = []
3466
+ for item in items:
3467
+ responses.append(
3468
+ self._record_insert_public_single(
3469
+ profile=profile,
3470
+ app_key=app_key,
3471
+ fields=cast(JSONObject, item["fields"]),
3472
+ verify_write=verify_write,
3473
+ output_profile=output_profile,
3474
+ capture_exceptions=True,
3475
+ )
3476
+ )
3477
+ return self._record_insert_batch_response(
3478
+ profile=profile,
3479
+ app_key=app_key,
3480
+ responses=responses,
3481
+ output_profile=output_profile,
3482
+ )
3483
+
3484
+ def _record_insert_batch_response(
3485
+ self,
3486
+ *,
3487
+ profile: str,
3488
+ app_key: str,
3489
+ responses: list[JSONObject],
3490
+ output_profile: str,
3491
+ ) -> JSONObject:
3492
+ """执行内部辅助逻辑。"""
3493
+ items = [
3494
+ self._record_insert_batch_item_from_response(index=index, response=response, output_profile=output_profile)
3495
+ for index, response in enumerate(responses)
3496
+ ]
3497
+ summary = self._record_insert_batch_summary(items)
3498
+ status, ok, message = self._record_insert_batch_envelope_status(summary=summary)
3499
+ first_response = responses[0] if responses else {}
3500
+ created_record_ids = [
3501
+ cast(str, item["record_id"])
3502
+ for item in items
3503
+ if isinstance(item.get("record_id"), str) and item.get("record_id")
3504
+ ]
3505
+ write_executed = any(bool(item.get("write_executed")) for item in items)
3506
+ verification_status = self._record_insert_batch_verification_status(items)
3507
+ return {
3508
+ "profile": first_response.get("profile", profile),
3509
+ "ws_id": first_response.get("ws_id"),
3510
+ "ok": ok,
3511
+ "status": status,
3512
+ "mode": "batch",
3513
+ "total": summary["total"],
3514
+ "succeeded": summary["succeeded"],
3515
+ "failed": summary["failed"],
3516
+ "created_record_ids": created_record_ids,
3517
+ "write_executed": write_executed,
3518
+ "verification_status": verification_status,
3519
+ "safe_to_retry": not write_executed,
3520
+ "request_route": first_response.get("request_route"),
3521
+ "warnings": [],
3522
+ "output_profile": output_profile,
3523
+ "items": items,
3524
+ "data": {
3525
+ "app_key": app_key,
3526
+ "mode": "batch",
3527
+ "summary": summary,
3528
+ "created_record_ids": created_record_ids,
3529
+ "items": items,
3530
+ },
3531
+ "message": message,
3532
+ }
3533
+
3534
+ def _record_insert_batch_summary(self, items: list[JSONObject]) -> JSONObject:
3535
+ """执行内部辅助逻辑。"""
3536
+ created = [item for item in items if isinstance(item.get("record_id"), str) and item.get("record_id")]
3537
+ failed = [item for item in items if item.get("status") not in {"success", "verification_failed"}]
3538
+ return {
3539
+ "total": len(items),
3540
+ "succeeded": len(created),
3541
+ "failed": len(failed),
3542
+ "created_count": len(created),
3543
+ "blocked_count": sum(1 for item in items if item.get("status") == "blocked"),
3544
+ "confirmation_count": sum(1 for item in items if item.get("status") == "needs_confirmation"),
3545
+ "verification_failed_count": sum(1 for item in items if item.get("status") == "verification_failed"),
3546
+ }
3547
+
3548
+ def _record_insert_batch_envelope_status(self, *, summary: JSONObject) -> tuple[str, bool, str]:
3549
+ """执行内部辅助逻辑。"""
3550
+ succeeded = int(summary["succeeded"])
3551
+ failed = int(summary["failed"])
3552
+ if succeeded and failed:
3553
+ return "partial_success", False, "batch insert completed with partial failures"
3554
+ if succeeded and int(summary["verification_failed_count"]):
3555
+ return "verification_failed", True, "batch insert completed but verification failed for some created records"
3556
+ if succeeded:
3557
+ return "success", True, "batch insert completed"
3558
+ if int(summary["confirmation_count"]):
3559
+ return "needs_confirmation", False, "batch insert requires confirmation before retrying failed rows"
3560
+ if int(summary["blocked_count"]):
3561
+ return "blocked", False, "batch insert preflight blocked all rows"
3562
+ return "failed", False, "batch insert failed"
3563
+
3564
+ def _record_insert_batch_verification_status(self, items: list[JSONObject]) -> str:
3565
+ """执行内部辅助逻辑。"""
3566
+ statuses = {str(item.get("verification_status") or "not_requested") for item in items}
3567
+ if "failed" in statuses:
3568
+ return "failed"
3569
+ if "verified" in statuses:
3570
+ return "verified"
3571
+ return "not_requested"
3572
+
3573
+ def _record_insert_batch_item_from_response(
3574
+ self,
3575
+ *,
3576
+ index: int,
3577
+ response: JSONObject,
3578
+ output_profile: str,
3579
+ ) -> JSONObject:
3580
+ """执行内部辅助逻辑。"""
3581
+ data = cast(JSONObject, response.get("data", {})) if isinstance(response.get("data"), dict) else {}
3582
+ resource = _public_record_resource(data.get("resource"))
3583
+ record_id = _public_record_id_text(response.get("record_id"))
3584
+ apply_id = _public_record_id_text(response.get("apply_id"))
3585
+ if record_id is None and isinstance(resource, dict):
3586
+ record_id = _public_record_id_text(resource.get("record_id"))
3587
+ if apply_id is None and isinstance(resource, dict):
3588
+ apply_id = _public_record_id_text(resource.get("apply_id"))
3589
+ item: JSONObject = {
3590
+ "index": index,
3591
+ "row_number": index + 1,
3592
+ "status": response.get("status"),
3593
+ "write_executed": bool(response.get("write_executed")),
3594
+ "verification_status": response.get("verification_status", "not_requested"),
3595
+ "safe_to_retry": bool(response.get("safe_to_retry", True)),
3596
+ }
3597
+ if record_id is not None:
3598
+ item["record_id"] = record_id
3599
+ if apply_id is not None:
3600
+ item["apply_id"] = apply_id
3601
+ if resource:
3602
+ item["resource"] = resource
3603
+ verification = data.get("verification")
3604
+ if isinstance(verification, dict):
3605
+ compact_verification = {
3606
+ key: verification[key]
3607
+ for key in ("verified", "verification_mode", "field_level_verified")
3608
+ if key in verification
3609
+ }
3610
+ if compact_verification:
3611
+ item["verification"] = compact_verification
3612
+ field_errors = cast(list[JSONObject], data.get("field_errors", [])) if isinstance(data.get("field_errors"), list) else []
3613
+ confirmation_requests = cast(list[JSONObject], data.get("confirmation_requests", [])) if isinstance(data.get("confirmation_requests"), list) else []
3614
+ failed_fields = self._record_write_failed_fields(field_errors=field_errors, confirmation_requests=confirmation_requests)
3615
+ if failed_fields:
3616
+ item["failed_fields"] = failed_fields
3617
+ if confirmation_requests:
3618
+ item["confirmation_requests"] = [
3619
+ self._record_write_semantic_confirmation_request(request)
3620
+ for request in confirmation_requests
3621
+ if isinstance(request, dict)
3622
+ ]
3623
+ blockers = data.get("blockers")
3624
+ if isinstance(blockers, list) and blockers:
3625
+ item["blockers"] = blockers
3626
+ warnings = response.get("warnings")
3627
+ if isinstance(warnings, list) and warnings:
3628
+ item["warnings"] = warnings
3629
+ error = data.get("error")
3630
+ if isinstance(error, dict):
3631
+ item["error"] = error
3632
+ if output_profile == "verbose" and isinstance(data.get("debug"), dict):
3633
+ item["debug"] = data.get("debug")
3634
+ return item
3635
+
3636
+ def _record_write_failed_fields(
3637
+ self,
3638
+ *,
3639
+ field_errors: list[JSONObject],
3640
+ confirmation_requests: list[JSONObject],
3641
+ ) -> list[JSONObject]:
3642
+ """执行内部辅助逻辑。"""
3643
+ failed_fields = [
3644
+ self._record_write_semantic_field_error(error)
3645
+ for error in field_errors
3646
+ if isinstance(error, dict)
3647
+ ]
3648
+ failed_fields.extend(
3649
+ self._record_write_failed_field_from_confirmation(request)
3650
+ for request in confirmation_requests
3651
+ if isinstance(request, dict)
3652
+ )
3653
+ return failed_fields
3654
+
3655
+ def _record_write_semantic_field_error(self, error: JSONObject) -> JSONObject:
3656
+ """执行内部辅助逻辑。"""
3657
+ field = error.get("field")
3658
+ field_payload = cast(JSONObject, field) if isinstance(field, dict) else {}
3659
+ error_code = _normalize_optional_text(error.get("error_code")) or "INVALID_FIELD_VALUE"
3660
+ title = (
3661
+ _normalize_optional_text(field_payload.get("que_title"))
3662
+ or _normalize_optional_text(field_payload.get("title"))
3663
+ or _normalize_optional_text(error.get("location"))
3664
+ or "unknown field"
3665
+ )
3666
+ field_id = (
3667
+ field_payload.get("que_id")
3668
+ if field_payload.get("que_id") is not None
3669
+ else field_payload.get("field_id")
3670
+ )
3671
+ expected_format = error.get("expected_format") if isinstance(error.get("expected_format"), dict) else None
3672
+ if expected_format is None:
3673
+ expected_format = self._record_write_expected_format_from_field_payload(field_payload)
3674
+ payload: JSONObject = {
3675
+ "title": title,
3676
+ "field_id": field_id,
3677
+ "error_code": error_code,
3678
+ "message": self._record_write_semantic_error_message(error_code, error.get("message")),
3679
+ "next_action": self._record_write_next_action_for_error(error_code),
3680
+ }
3681
+ if expected_format is not None:
3682
+ payload["expected_format"] = expected_format
3683
+ payload["example_value"] = self._record_write_example_value_for_format(expected_format, field_payload)
3684
+ if error.get("received_value") is not None:
3685
+ payload["received_value"] = error.get("received_value")
3686
+ if error.get("fix_hint") is not None:
3687
+ payload["fix_hint"] = error.get("fix_hint")
3688
+ if error.get("details") is not None:
3689
+ payload["details"] = error.get("details")
3690
+ return payload
3691
+
3692
+ def _record_write_semantic_confirmation_request(self, request: JSONObject) -> JSONObject:
3693
+ """执行内部辅助逻辑。"""
3694
+ field_ref = request.get("field_ref")
3695
+ field_payload = cast(JSONObject, field_ref) if isinstance(field_ref, dict) else {}
3696
+ payload: JSONObject = {
3697
+ "field": request.get("field"),
3698
+ "title": _normalize_optional_text(request.get("field")) or _normalize_optional_text(field_payload.get("que_title")),
3699
+ "field_id": field_payload.get("que_id"),
3700
+ "kind": request.get("kind"),
3701
+ "input": request.get("input"),
3702
+ "candidates": request.get("candidates", []),
3703
+ "next_action": "让用户确认候选,或用显式 id/object 只重试本行。",
3704
+ }
3705
+ if request.get("parent_field") is not None:
3706
+ payload["parent_field"] = request.get("parent_field")
3707
+ if request.get("row_ordinal") is not None:
3708
+ payload["row_ordinal"] = request.get("row_ordinal")
3709
+ return payload
3710
+
3711
+ def _record_write_failed_field_from_confirmation(self, request: JSONObject) -> JSONObject:
3712
+ """执行内部辅助逻辑。"""
3713
+ semantic = self._record_write_semantic_confirmation_request(request)
3714
+ return {
3715
+ "title": semantic.get("title") or semantic.get("field"),
3716
+ "field_id": semantic.get("field_id"),
3717
+ "error_code": "LOOKUP_NEEDS_CONFIRMATION",
3718
+ "message": "候选不唯一,需要用户确认。",
3719
+ "kind": semantic.get("kind"),
3720
+ "input": semantic.get("input"),
3721
+ "candidates": semantic.get("candidates", []),
3722
+ "next_action": semantic.get("next_action"),
3723
+ }
3724
+
3725
+ def _record_write_expected_format_from_field_payload(self, field_payload: JSONObject) -> JSONObject | None:
3726
+ """执行内部辅助逻辑。"""
3727
+ que_type = _coerce_count(field_payload.get("que_type"))
3728
+ if que_type is None:
3729
+ return None
3730
+ synthetic_field = FormField(
3731
+ que_id=_coerce_count(field_payload.get("que_id")) or 0,
3732
+ que_title=_normalize_optional_text(field_payload.get("que_title")) or _normalize_optional_text(field_payload.get("title")) or "",
3733
+ que_type=que_type,
3734
+ required=False,
3735
+ readonly=False,
3736
+ system=False,
3737
+ options=[],
3738
+ aliases=[],
3739
+ target_app_key=None,
3740
+ target_app_name_hint=None,
3741
+ member_select_scope_type=None,
3742
+ member_select_scope=None,
3743
+ dept_select_scope_type=None,
3744
+ dept_select_scope=None,
3745
+ raw={},
3746
+ )
3747
+ return _write_format_for_field(synthetic_field)
3748
+
3749
+ def _record_write_example_value_for_format(self, expected_format: JSONObject, field_payload: JSONObject) -> JSONValue:
3750
+ """执行内部辅助逻辑。"""
3751
+ examples = expected_format.get("examples")
3752
+ if isinstance(examples, list) and examples:
3753
+ return cast(JSONValue, examples[0])
3754
+ kind = _normalize_optional_text(expected_format.get("kind"))
3755
+ if kind == "member_list":
3756
+ return "张三"
3757
+ if kind == "department_list":
3758
+ return "直销部"
3759
+ if kind == "relation_record":
3760
+ return {"apply_id": "5001"}
3761
+ if kind == "attachment_list":
3762
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
3763
+ if kind == "subtable_rows":
3764
+ return {"rows": [{"子字段": "值"}]}
3765
+ if kind == "date_string":
3766
+ return "2026-03-13 10:00:00"
3767
+ if kind == "boolean_label":
3768
+ return "是"
3769
+ if kind in {"single_select", "multi_select"}:
3770
+ options = expected_format.get("options")
3771
+ if isinstance(options, list) and options:
3772
+ return cast(JSONValue, options[0])
3773
+ que_type = _coerce_count(field_payload.get("que_type"))
3774
+ if que_type in NUMBER_QUE_TYPES:
3775
+ return 100
3776
+ return "文本"
3777
+
3778
+ def _record_write_semantic_error_message(self, error_code: str, fallback: JSONValue) -> str:
3779
+ """执行内部辅助逻辑。"""
3780
+ if error_code == "MISSING_REQUIRED_FIELD":
3781
+ return "缺少必填字段。"
3782
+ if error_code == "FIELD_NOT_FOUND":
3783
+ return "字段不存在或字段标题不匹配。"
3784
+ if error_code == "AMBIGUOUS_FIELD":
3785
+ return "字段标题存在歧义。"
3786
+ if error_code in {"INVALID_FIELD_VALUE", "INVALID_MEMBER_VALUE", "INVALID_DEPARTMENT_VALUE", "INVALID_RELATION_VALUE"}:
3787
+ return _normalize_optional_text(fallback) or "字段值格式不正确。"
3788
+ return _normalize_optional_text(fallback) or "字段写入失败。"
3789
+
3790
+ def _record_write_next_action_for_error(self, error_code: str) -> str:
3791
+ """执行内部辅助逻辑。"""
3792
+ if error_code == "MISSING_REQUIRED_FIELD":
3793
+ return "补充该字段后只重试本行。"
3794
+ if error_code in {"FIELD_NOT_FOUND", "AMBIGUOUS_FIELD"}:
3795
+ return "重新调用 schema 工具确认字段标题或 field_id 后,只重试本行。"
3796
+ return "修正该字段值后只重试本行。"
3797
+
3798
+ @tool_cn_name("更新记录")
3799
+ def record_update_public(
3800
+ self,
3801
+ *,
3802
+ profile: str = DEFAULT_PROFILE,
3803
+ app_key: str,
3804
+ record_id: Any | None,
3805
+ fields: JSONObject | None = None,
3806
+ items: list[JSONObject] | None = None,
3807
+ dry_run: bool = False,
3808
+ verify_write: bool = True,
3809
+ output_profile: str = "normal",
3810
+ ) -> JSONObject:
3811
+ """执行记录相关逻辑。"""
3812
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
3813
+ if not app_key:
3814
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
3815
+ if items is not None:
3816
+ if dry_run not in {True, False}:
3817
+ raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
3818
+ normalized_items = self._normalize_public_record_update_batch_items(
3819
+ record_id=record_id,
3820
+ fields=fields,
3821
+ items=items,
3822
+ )
3823
+ return self._record_update_public_batch(
3824
+ profile=profile,
3825
+ app_key=app_key,
3826
+ items=normalized_items,
3827
+ dry_run=dry_run,
3828
+ verify_write=verify_write,
3829
+ output_profile=normalized_output_profile,
3830
+ )
3831
+ if dry_run:
3832
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3833
+ if record_id is None:
3834
+ raise_tool_error(QingflowApiError.config_error("record_id is required"))
3835
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3836
+ if fields is not None and not isinstance(fields, dict):
3837
+ raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3838
+ return self._record_update_public_single(
3839
+ profile=profile,
3840
+ app_key=app_key,
3841
+ record_id=record_id_int,
3842
+ fields=cast(JSONObject, fields or {}),
3843
+ verify_write=verify_write,
3844
+ output_profile=normalized_output_profile,
3845
+ )
3846
+
3847
+ def _record_update_public_single(
3848
+ self,
3849
+ *,
3850
+ profile: str,
3851
+ app_key: str,
3852
+ record_id: int,
3853
+ fields: JSONObject,
3854
+ verify_write: bool,
3855
+ output_profile: str,
3856
+ ) -> JSONObject:
3857
+ """执行内部辅助逻辑。"""
3858
+ raw_preflight = self._preflight_record_update_with_auto_view(
3859
+ profile=profile,
3860
+ app_key=app_key,
3861
+ record_id=record_id,
3862
+ fields=fields,
3863
+ force_refresh_form=False,
3864
+ )
3865
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3866
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3867
+ normalized_payload = self._record_write_normalized_payload(
3868
+ operation="update",
3869
+ record_id=record_id,
3870
+ record_ids=[],
3871
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3872
+ submit_type=1,
3873
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3874
+ )
3875
+ if preflight_data.get("blockers"):
3876
+ return self._record_write_blocked_response(
3877
+ raw_preflight,
3878
+ operation="update",
3879
+ normalized_payload=normalized_payload,
3880
+ output_profile=output_profile,
3881
+ human_review=True,
3882
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
3883
+ )
3884
+ route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
3885
+ profile=profile,
3886
+ app_key=app_key,
3887
+ record_id=record_id,
3888
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3889
+ preflight_data=preflight_data,
3890
+ verify_write=verify_write,
3891
+ force_refresh_form=preflight_used_force_refresh,
3892
+ )
3893
+ if route_blocker is not None:
3894
+ return self._record_update_route_blocked_response(
3895
+ raw_preflight=raw_preflight,
3896
+ operation="update",
3897
+ normalized_payload=normalized_payload,
3898
+ output_profile=output_profile,
3899
+ human_review=True,
3900
+ app_key=app_key,
3901
+ record_id=record_id,
3902
+ tried_routes=tried_routes,
3903
+ route_blocker=route_blocker,
3904
+ )
3905
+ raw_apply = cast(JSONObject, route_apply)
3906
+ return self._record_write_apply_response(
3907
+ raw_apply,
3908
+ operation="update",
3909
+ normalized_payload=normalized_payload,
3910
+ output_profile=output_profile,
3911
+ human_review=True,
3912
+ preflight=raw_preflight,
3913
+ )
3914
+
3915
+ def _record_update_apply_with_auto_route(
3916
+ self,
3917
+ *,
3918
+ profile: str,
3919
+ app_key: str,
3920
+ record_id: int,
3921
+ normalized_answers: list[JSONObject],
3922
+ preflight_data: JSONObject,
3923
+ verify_write: bool,
3924
+ force_refresh_form: bool,
3925
+ ) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
3926
+ """Try record update routes in the same order a frontend user would expect."""
3927
+ tried_routes: list[JSONObject] = []
3928
+ admin_attempt = self._record_update_route_attempt(
3929
+ route_type="admin_direct",
3930
+ endpoint_kind="app_apply_update",
3931
+ role=1,
3932
+ reason="try data-manager direct edit first",
3933
+ )
3934
+ try:
3935
+ raw_apply = self.record_update(
3936
+ profile=profile,
3937
+ app_key=app_key,
3938
+ apply_id=record_id,
3939
+ answers=normalized_answers,
3940
+ fields={},
3941
+ role=1,
3942
+ verify_write=verify_write,
3943
+ force_refresh_form=force_refresh_form,
3944
+ )
3945
+ admin_attempt["status"] = "success"
3946
+ raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
3947
+ raw_apply["tried_routes"] = [admin_attempt]
3948
+ return raw_apply, [admin_attempt], None
3949
+ except (QingflowApiError, RuntimeError) as exc:
3950
+ api_error = self._record_update_extract_api_error(exc)
3951
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3952
+ raise
3953
+ admin_attempt.update(self._record_update_route_error_payload(
3954
+ api_error,
3955
+ status="denied",
3956
+ error_code="ADMIN_UPDATE_PERMISSION_DENIED",
3957
+ ))
3958
+ tried_routes.append(admin_attempt)
3959
+
3960
+ view_route = self._record_update_selected_custom_view_route(preflight_data)
3961
+ if view_route is None:
3962
+ tried_routes.append(
3963
+ self._record_update_route_attempt(
3964
+ route_type="view_edit",
3965
+ endpoint_kind="view_apply_update",
3966
+ status="skipped",
3967
+ error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
3968
+ reason="preflight did not select a single custom view route for this payload",
3969
+ )
3970
+ )
3971
+ else:
3972
+ view_attempt = self._record_update_route_attempt(
3973
+ route_type="view_edit",
3974
+ endpoint_kind="view_apply_update",
3975
+ view_id=cast(str, view_route.get("view_id")),
3976
+ view_key=cast(str, view_route.get("view_key")),
3977
+ view_name=_normalize_optional_text(view_route.get("name")),
3978
+ reason="fallback to frontend custom-view detail edit route",
3979
+ )
3980
+ try:
3981
+ raw_apply = self._record_update_via_custom_view(
3982
+ profile=profile,
3983
+ app_key=app_key,
3984
+ apply_id=record_id,
3985
+ view_key=cast(str, view_route["view_key"]),
3986
+ answers=normalized_answers,
3987
+ verify_write=verify_write,
3988
+ force_refresh_form=force_refresh_form,
3989
+ )
3990
+ view_attempt["status"] = "success"
3991
+ tried_routes.append(view_attempt)
3992
+ raw_apply["update_route"] = self._record_update_route_public(view_attempt)
3993
+ raw_apply["tried_routes"] = tried_routes
3994
+ return raw_apply, tried_routes, None
3995
+ except (QingflowApiError, RuntimeError) as exc:
3996
+ api_error = self._record_update_extract_api_error(exc)
3997
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3998
+ raise
3999
+ view_attempt.update(self._record_update_route_error_payload(
4000
+ api_error,
4001
+ status="denied",
4002
+ error_code="VIEW_UPDATE_PERMISSION_DENIED",
4003
+ ))
4004
+ tried_routes.append(view_attempt)
4005
+
4006
+ task_route = self._record_update_task_save_only_candidate(
4007
+ profile=profile,
4008
+ app_key=app_key,
4009
+ record_id=record_id,
4010
+ normalized_answers=normalized_answers,
4011
+ )
4012
+ task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
4013
+ if not task_route.get("available"):
4014
+ tried_routes.append(task_attempt or self._record_update_route_attempt(
4015
+ route_type="task_save_only",
4016
+ endpoint_kind="workflow_node_save_only",
4017
+ status="skipped",
4018
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4019
+ reason="no unique current-user todo task can edit the requested fields",
4020
+ ))
4021
+ else:
4022
+ task_attempt = self._record_update_route_attempt(
4023
+ route_type="task_save_only",
4024
+ endpoint_kind="workflow_node_save_only",
4025
+ role=3,
4026
+ task_id=_normalize_optional_text(task_route.get("task_id")),
4027
+ workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
4028
+ reason="fallback to current-user workflow todo save-only route",
4029
+ )
4030
+ try:
4031
+ raw_apply = self._record_update_via_task_save_only(
4032
+ profile=profile,
4033
+ app_key=app_key,
4034
+ apply_id=record_id,
4035
+ workflow_node_id=cast(int, task_route["workflow_node_id"]),
4036
+ answers=normalized_answers,
4037
+ verify_write=verify_write,
4038
+ force_refresh_form=force_refresh_form,
4039
+ )
4040
+ task_attempt["status"] = "success"
4041
+ tried_routes.append(task_attempt)
4042
+ raw_apply["update_route"] = self._record_update_route_public(task_attempt)
4043
+ raw_apply["tried_routes"] = tried_routes
4044
+ return raw_apply, tried_routes, None
4045
+ except (QingflowApiError, RuntimeError) as exc:
4046
+ api_error = self._record_update_extract_api_error(exc)
4047
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4048
+ raise
4049
+ task_attempt.update(self._record_update_route_error_payload(
4050
+ api_error,
4051
+ status="denied",
4052
+ error_code="TASK_UPDATE_PERMISSION_DENIED",
4053
+ ))
4054
+ tried_routes.append(task_attempt)
4055
+ return None, tried_routes, {
4056
+ "error_code": "NO_AVAILABLE_UPDATE_ROUTE",
4057
+ "message": "No available record update route could execute this payload for the current user.",
4058
+ "recommended_next_actions": [
4059
+ "If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
4060
+ "If the record is editable from a specific table view in the UI, make sure the target fields are visible and editable in that view.",
4061
+ "If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
4062
+ ],
4063
+ }
4064
+
4065
+ def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
4066
+ selection = preflight_data.get("selection")
4067
+ if not isinstance(selection, dict):
4068
+ return None
4069
+ view = selection.get("view")
4070
+ if not isinstance(view, dict):
4071
+ return None
4072
+ view_id = _normalize_optional_text(view.get("view_id"))
4073
+ if not view_id or not view_id.startswith("custom:"):
4074
+ return None
4075
+ view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
4076
+ if not view_key:
4077
+ return None
4078
+ return {
4079
+ "view_id": view_id,
4080
+ "view_key": view_key,
4081
+ "name": view.get("name"),
4082
+ }
4083
+
4084
+ def _record_update_route_attempt(
4085
+ self,
4086
+ *,
4087
+ route_type: str,
4088
+ endpoint_kind: str,
4089
+ status: str = "attempted",
4090
+ role: int | None = None,
4091
+ task_id: str | None = None,
4092
+ workflow_node_id: int | None = None,
4093
+ view_id: str | None = None,
4094
+ view_key: str | None = None,
4095
+ view_name: str | None = None,
4096
+ error_code: str | None = None,
4097
+ reason: str | None = None,
4098
+ ) -> JSONObject:
4099
+ payload: JSONObject = {
4100
+ "route_type": route_type,
4101
+ "endpoint_kind": endpoint_kind,
4102
+ "status": status,
4103
+ }
4104
+ if role is not None:
4105
+ payload["role"] = role
4106
+ if task_id:
4107
+ payload["task_id"] = task_id
4108
+ if workflow_node_id is not None:
4109
+ payload["workflow_node_id"] = workflow_node_id
4110
+ if view_id:
4111
+ payload["view_id"] = view_id
4112
+ if view_key:
4113
+ payload["view_key"] = view_key
4114
+ if view_name:
4115
+ payload["view_name"] = view_name
4116
+ if error_code:
4117
+ payload["error_code"] = error_code
4118
+ if reason:
4119
+ payload["reason"] = reason
4120
+ return payload
4121
+
4122
+ def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
4123
+ return _pick_route_payload(attempt)
4124
+
4125
+ def _record_update_route_error_payload(
4126
+ self,
4127
+ exc: QingflowApiError,
4128
+ *,
4129
+ status: str,
4130
+ error_code: str,
4131
+ ) -> JSONObject:
4132
+ payload: JSONObject = {
4133
+ "status": status,
4134
+ "error_code": error_code,
4135
+ "message": exc.message,
4136
+ }
4137
+ if exc.backend_code is not None:
4138
+ payload["backend_code"] = exc.backend_code
4139
+ if exc.http_status is not None:
4140
+ payload["http_status"] = exc.http_status
4141
+ if exc.request_id is not None:
4142
+ payload["request_id"] = exc.request_id
4143
+ return payload
4144
+
4145
+ def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
4146
+ if isinstance(exc, QingflowApiError):
4147
+ return exc
4148
+ try:
4149
+ payload = json.loads(str(exc))
4150
+ except json.JSONDecodeError:
4151
+ return None
4152
+ if not isinstance(payload, dict):
4153
+ return None
4154
+ return QingflowApiError(
4155
+ category=str(payload.get("category") or "backend"),
4156
+ message=str(payload.get("message") or exc),
4157
+ backend_code=payload.get("backend_code"),
4158
+ request_id=_normalize_optional_text(payload.get("request_id")),
4159
+ http_status=_coerce_count(payload.get("http_status")),
4160
+ details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
4161
+ )
4162
+
4163
+ def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4164
+ if exc.backend_code in {40002, 40027, 40038, 404}:
4165
+ return True
4166
+ if exc.http_status == 404:
4167
+ return True
4168
+ return False
4169
+
4170
+ def _record_update_route_blocked_response(
4171
+ self,
4172
+ *,
4173
+ raw_preflight: JSONObject,
4174
+ operation: str,
4175
+ normalized_payload: JSONObject,
4176
+ output_profile: str,
4177
+ human_review: bool,
4178
+ app_key: str,
4179
+ record_id: int,
4180
+ tried_routes: list[JSONObject],
4181
+ route_blocker: JSONObject,
4182
+ ) -> JSONObject:
4183
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
4184
+ validation = cast(JSONObject, plan_data.get("validation", {}))
4185
+ warnings_payload = validation.get("warnings", [])
4186
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
4187
+ warnings.append(
4188
+ {
4189
+ "code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
4190
+ "message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
4191
+ }
4192
+ )
4193
+ recommended = list(route_blocker.get("recommended_next_actions") or [])
4194
+ response: JSONObject = {
4195
+ "profile": raw_preflight.get("profile"),
4196
+ "ws_id": raw_preflight.get("ws_id"),
4197
+ "ok": False,
4198
+ "status": "blocked",
4199
+ "write_executed": False,
4200
+ "verification_status": "not_requested",
4201
+ "safe_to_retry": True,
4202
+ "request_route": raw_preflight.get("request_route"),
4203
+ "warnings": warnings,
4204
+ "output_profile": output_profile,
4205
+ "update_route": None,
4206
+ "tried_routes": tried_routes,
4207
+ "error_code": route_blocker.get("error_code"),
4208
+ "data": {
4209
+ "action": {"operation": operation, "executed": False},
4210
+ "resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
4211
+ "verification": None,
4212
+ "normalized_payload": normalized_payload,
4213
+ "blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
4214
+ "field_errors": [],
4215
+ "confirmation_requests": [],
4216
+ "resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
4217
+ "recommended_next_actions": recommended,
4218
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
4219
+ "error": route_blocker,
4220
+ "update_route": None,
4221
+ "tried_routes": tried_routes,
4222
+ },
4223
+ }
4224
+ if output_profile == "verbose":
4225
+ response["data"]["debug"] = {"preflight": plan_data}
4226
+ return response
4227
+
4228
+ def _record_update_task_save_only_candidate(
4229
+ self,
4230
+ *,
4231
+ profile: str,
4232
+ app_key: str,
4233
+ record_id: int,
4234
+ normalized_answers: list[JSONObject],
4235
+ ) -> JSONObject:
4236
+ requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
4237
+
4238
+ def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
4239
+ attempt = self._record_update_route_attempt(
4240
+ route_type="task_save_only",
4241
+ endpoint_kind="workflow_node_save_only",
4242
+ status=status,
4243
+ error_code=error_code,
4244
+ reason=reason,
4245
+ )
4246
+ if extra:
4247
+ attempt.update(extra)
4248
+ return {"available": False, "attempt": attempt}
4249
+
4250
+ def runner(session_profile, context):
4251
+ matches: list[JSONObject] = []
4252
+ pages_scanned = 0
4253
+ for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
4254
+ try:
4255
+ task_page = self.backend.request(
4256
+ "POST",
4257
+ context,
4258
+ "/task/dynamic/page",
4259
+ json_body={
4260
+ "type": 1,
4261
+ "processStatus": 1,
4262
+ "appKey": app_key,
4263
+ "pageNum": page_num,
4264
+ "pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
4265
+ },
4266
+ )
4267
+ except QingflowApiError as exc:
4268
+ return unavailable(
4269
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4270
+ reason="current-user todo task list is unavailable",
4271
+ extra=self._record_update_route_error_payload(
4272
+ exc,
4273
+ status="skipped",
4274
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4275
+ ),
4276
+ )
4277
+ pages_scanned += 1
4278
+ rows = task_page.get("list") if isinstance(task_page, dict) else None
4279
+ items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
4280
+ for item in items:
4281
+ candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
4282
+ if candidate_record_id == record_id:
4283
+ matches.append(dict(item))
4284
+ if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
4285
+ break
4286
+
4287
+ if not matches:
4288
+ return unavailable(
4289
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4290
+ reason="no current-user todo task was found for this record",
4291
+ extra={"pages_scanned": pages_scanned},
4292
+ )
4293
+ if len(matches) > 1:
4294
+ return unavailable(
4295
+ error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
4296
+ reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
4297
+ extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
4298
+ )
4299
+
4300
+ task = matches[0]
4301
+ workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
4302
+ if workflow_node_id is None:
4303
+ return unavailable(
4304
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4305
+ reason="matched todo task does not expose a workflow node id",
4306
+ extra={"matched_task": self._record_update_compact_task_match(task)},
4307
+ )
4308
+ try:
4309
+ editable_payload = self.backend.request(
4310
+ "GET",
4311
+ context,
4312
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4313
+ )
4314
+ except QingflowApiError as exc:
4315
+ return unavailable(
4316
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4317
+ reason="workflow node editable field list is unavailable; record_update will not guess task editability",
4318
+ extra=self._record_update_route_error_payload(
4319
+ exc,
4320
+ status="skipped",
4321
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4322
+ ),
4323
+ )
4324
+ editable_question_ids = self._record_update_extract_question_ids(editable_payload)
4325
+ if not editable_question_ids:
4326
+ return unavailable(
4327
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4328
+ reason="workflow node editable field list is empty",
4329
+ extra={
4330
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4331
+ "workflow_node_id": workflow_node_id,
4332
+ },
4333
+ )
4334
+ effective_editable_question_ids = self._record_update_effective_task_editable_ids(
4335
+ editable_question_ids,
4336
+ normalized_answers=normalized_answers,
4337
+ )
4338
+ non_editable = sorted(
4339
+ question_id for question_id in requested_question_ids
4340
+ if question_id not in effective_editable_question_ids
4341
+ )
4342
+ if non_editable:
4343
+ return unavailable(
4344
+ status="denied",
4345
+ error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
4346
+ reason="one or more requested fields are not editable on the current workflow node",
4347
+ extra={
4348
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4349
+ "workflow_node_id": workflow_node_id,
4350
+ "non_editable_question_ids": non_editable,
4351
+ },
4352
+ )
4353
+ return {
4354
+ "available": True,
4355
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4356
+ "workflow_node_id": workflow_node_id,
4357
+ "matched_task": self._record_update_compact_task_match(task),
4358
+ "editable_question_ids": sorted(editable_question_ids),
4359
+ "effective_editable_question_ids": sorted(effective_editable_question_ids),
4360
+ }
4361
+
4362
+ return self._run_record_tool(profile, runner)
4363
+
4364
+ def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
4365
+ return {
4366
+ key: value
4367
+ for key, value in {
4368
+ "task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
4369
+ "record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
4370
+ "workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
4371
+ "workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
4372
+ }.items()
4373
+ if value not in (None, "", [], {})
4374
+ }
4375
+
4376
+ def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
4377
+ question_ids: set[int] = set()
4378
+ for answer in answers:
4379
+ if not isinstance(answer, dict):
4380
+ continue
4381
+ que_id = _coerce_count(answer.get("queId"))
4382
+ if que_id is not None and que_id > 0:
4383
+ question_ids.add(que_id)
4384
+ table_values = answer.get("tableValues")
4385
+ if not isinstance(table_values, list):
4386
+ continue
4387
+ for row in table_values:
4388
+ if not isinstance(row, list):
4389
+ continue
4390
+ for cell in row:
4391
+ if not isinstance(cell, dict):
4392
+ continue
4393
+ cell_que_id = _coerce_count(cell.get("queId"))
4394
+ if cell_que_id is not None and cell_que_id > 0:
4395
+ question_ids.add(cell_que_id)
4396
+ return question_ids
4397
+
4398
+ def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
4399
+ candidates: list[Any] = []
4400
+ if isinstance(payload, list):
4401
+ candidates = payload
4402
+ elif isinstance(payload, dict):
4403
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
4404
+ value = payload.get(key)
4405
+ if isinstance(value, list):
4406
+ candidates = value
4407
+ break
4408
+ question_ids: set[int] = set()
4409
+ for item in candidates:
4410
+ value: Any = item
4411
+ if isinstance(item, dict):
4412
+ value = item.get("queId", item.get("questionId", item.get("id")))
4413
+ que_id = _coerce_count(value)
4414
+ if que_id is not None and que_id > 0:
4415
+ question_ids.add(que_id)
4416
+ return question_ids
4417
+
4418
+ def _record_update_effective_task_editable_ids(
4419
+ self,
4420
+ editable_question_ids: set[int],
4421
+ *,
4422
+ normalized_answers: list[JSONObject],
4423
+ ) -> set[int]:
4424
+ effective_editable_ids = set(editable_question_ids)
4425
+ for answer in normalized_answers:
4426
+ if not isinstance(answer, dict):
4427
+ continue
4428
+ parent_que_id = _coerce_count(answer.get("queId"))
4429
+ if parent_que_id is None or parent_que_id <= 0:
4430
+ continue
4431
+ table_values = answer.get("tableValues")
4432
+ if not isinstance(table_values, list) or not table_values:
4433
+ continue
4434
+ row_subfield_ids: set[int] = set()
4435
+ for row in table_values:
4436
+ if not isinstance(row, list):
4437
+ continue
4438
+ for cell in row:
4439
+ if not isinstance(cell, dict):
4440
+ continue
4441
+ cell_que_id = _coerce_count(cell.get("queId"))
4442
+ if cell_que_id is not None and cell_que_id > 0:
4443
+ row_subfield_ids.add(cell_que_id)
4444
+ if row_subfield_ids & editable_question_ids:
4445
+ effective_editable_ids.add(parent_que_id)
4446
+ return effective_editable_ids
4447
+
4448
+ def _record_update_via_custom_view(
4449
+ self,
4450
+ *,
4451
+ profile: str,
4452
+ app_key: str,
4453
+ apply_id: int,
4454
+ view_key: str,
4455
+ answers: list[JSONObject],
4456
+ verify_write: bool,
4457
+ force_refresh_form: bool,
4458
+ ) -> JSONObject:
4459
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4460
+ normalized_view_key = view_key.strip()
4461
+ if not normalized_view_key:
4462
+ raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4463
+
4464
+ def runner(session_profile, context):
4465
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4466
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4467
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4468
+ result = self.backend.request(
4469
+ "POST",
4470
+ context,
4471
+ f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
4472
+ json_body={"answers": normalized_answers},
4473
+ )
4474
+ verification = self._verify_record_write_result(
4475
+ context,
4476
+ app_key=app_key,
4477
+ apply_id=normalized_apply_id,
4478
+ normalized_answers=normalized_answers,
4479
+ index=cast(FieldIndex, index),
4480
+ verify_view_key=normalized_view_key,
4481
+ ) if verify_write and index is not None else None
4482
+ verified = True if verification is None else bool(verification.get("verified"))
4483
+ return self._attach_human_review_notice(
4484
+ {
4485
+ "profile": profile,
4486
+ "ws_id": session_profile.selected_ws_id,
4487
+ "request_route": self._request_route_payload(context),
4488
+ "app_key": app_key,
4489
+ "apply_id": normalized_apply_id,
4490
+ "record_id": normalized_apply_id,
4491
+ "result": result,
4492
+ "normalized_answers": normalized_answers,
4493
+ "status": "completed" if verified else "verification_failed",
4494
+ "ok": True,
4495
+ "verify_write": verify_write,
4496
+ "write_verified": verified if verify_write else None,
4497
+ "verification": verification,
4498
+ "resource": _record_resource_payload(normalized_apply_id),
4499
+ },
4500
+ operation="update",
4501
+ target="record data",
4502
+ )
4503
+
4504
+ return self._run_record_tool(profile, runner)
4505
+
4506
+ def _record_update_via_task_save_only(
3118
4507
  self,
3119
4508
  *,
3120
4509
  profile: str,
3121
4510
  app_key: str,
3122
- record_id: int,
3123
- fields: JSONObject,
4511
+ apply_id: int,
4512
+ workflow_node_id: int,
4513
+ answers: list[JSONObject],
3124
4514
  verify_write: bool,
3125
- output_profile: str,
4515
+ force_refresh_form: bool,
3126
4516
  ) -> JSONObject:
3127
- """执行内部辅助逻辑。"""
3128
- raw_preflight = self._preflight_record_update_with_auto_view(
3129
- profile=profile,
3130
- app_key=app_key,
3131
- record_id=record_id,
3132
- fields=fields,
3133
- force_refresh_form=False,
3134
- )
3135
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3136
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3137
- normalized_payload = self._record_write_normalized_payload(
3138
- operation="update",
3139
- record_id=record_id,
3140
- record_ids=[],
3141
- normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3142
- submit_type=1,
3143
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3144
- )
3145
- if preflight_data.get("blockers"):
3146
- return self._record_write_blocked_response(
3147
- raw_preflight,
3148
- operation="update",
3149
- normalized_payload=normalized_payload,
3150
- output_profile=output_profile,
3151
- human_review=True,
3152
- target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
4517
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4518
+ if workflow_node_id <= 0:
4519
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
4520
+
4521
+ def runner(session_profile, context):
4522
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4523
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4524
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4525
+ result = self.backend.request(
4526
+ "POST",
4527
+ context,
4528
+ f"/app/{app_key}/apply/{normalized_apply_id}",
4529
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
3153
4530
  )
3154
- try:
3155
- raw_apply = self.record_update(
3156
- profile=profile,
4531
+ verification = self._verify_record_write_result(
4532
+ context,
3157
4533
  app_key=app_key,
3158
- apply_id=record_id,
3159
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3160
- fields={},
3161
- role=1,
3162
- verify_write=verify_write,
3163
- force_refresh_form=preflight_used_force_refresh,
3164
- )
3165
- except QingflowApiError as exc:
3166
- self._raise_record_write_permission_error(
3167
- exc,
4534
+ apply_id=normalized_apply_id,
4535
+ normalized_answers=normalized_answers,
4536
+ index=cast(FieldIndex, index),
4537
+ ) if verify_write and index is not None else None
4538
+ verified = True if verification is None else bool(verification.get("verified"))
4539
+ return self._attach_human_review_notice(
4540
+ {
4541
+ "profile": profile,
4542
+ "ws_id": session_profile.selected_ws_id,
4543
+ "request_route": self._request_route_payload(context),
4544
+ "app_key": app_key,
4545
+ "apply_id": normalized_apply_id,
4546
+ "record_id": normalized_apply_id,
4547
+ "result": result,
4548
+ "normalized_answers": normalized_answers,
4549
+ "status": "completed" if verified else "verification_failed",
4550
+ "ok": True,
4551
+ "verify_write": verify_write,
4552
+ "write_verified": verified if verify_write else None,
4553
+ "verification": verification,
4554
+ "resource": _record_resource_payload(normalized_apply_id),
4555
+ },
3168
4556
  operation="update",
3169
- app_key=app_key,
3170
- record_id=record_id,
3171
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
4557
+ target="record data",
3172
4558
  )
3173
- raise
3174
- return self._record_write_apply_response(
3175
- raw_apply,
3176
- operation="update",
3177
- normalized_payload=normalized_payload,
3178
- output_profile=output_profile,
3179
- human_review=True,
3180
- preflight=raw_preflight,
3181
- )
4559
+
4560
+ return self._run_record_tool(profile, runner)
3182
4561
 
3183
4562
  def _record_update_public_batch(
3184
4563
  self,
@@ -3341,13 +4720,44 @@ class RecordTools(ToolBase):
3341
4720
  """执行内部辅助逻辑。"""
3342
4721
  summary = self._record_update_batch_summary(responses)
3343
4722
  batch_items = [self._record_update_batch_item_from_response(response, output_profile=output_profile) for response in responses]
4723
+ public_items = [self._record_update_public_batch_item(item, index=index) for index, item in enumerate(batch_items)]
3344
4724
  status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
3345
4725
  first_response = responses[0] if responses else {}
4726
+ applied_count = int(summary.get("applied_count") or 0)
4727
+ ready_count = int(summary.get("ready_count") or 0)
4728
+ verified_count = int(summary.get("verified_count") or 0)
4729
+ field_level_verified_count = int(summary.get("field_level_verified_count") or 0)
4730
+ confirmation_count = int(summary.get("confirmation_count") or 0)
4731
+ blocked_count = int(summary.get("blocked_count") or 0)
4732
+ failed_count = int(summary.get("failed_count") or 0)
4733
+ write_executed = applied_count > 0
4734
+ verification_status = "not_requested"
4735
+ if write_executed:
4736
+ verification_status = "verified" if verified_count == applied_count else "failed"
4737
+ updated_record_ids = [
4738
+ str(item.get("record_id"))
4739
+ for item in public_items
4740
+ if item.get("record_id") not in (None, "") and str(item.get("status") or "").lower() == "success"
4741
+ ]
3346
4742
  return {
3347
4743
  "profile": first_response.get("profile", profile),
3348
4744
  "ws_id": first_response.get("ws_id"),
3349
4745
  "ok": ok,
3350
4746
  "status": status,
4747
+ "mode": "batch",
4748
+ "dry_run": dry_run,
4749
+ "app_key": app_key,
4750
+ "total": int(summary.get("total") or 0),
4751
+ "succeeded": ready_count if dry_run else applied_count,
4752
+ "failed": blocked_count + failed_count,
4753
+ "needs_confirmation": confirmation_count,
4754
+ "updated_record_ids": updated_record_ids,
4755
+ "write_executed": write_executed,
4756
+ "safe_to_retry": not write_executed,
4757
+ "verification_status": verification_status,
4758
+ "field_level_verified_count": field_level_verified_count,
4759
+ "summary": summary,
4760
+ "items": public_items,
3351
4761
  "request_route": first_response.get("request_route"),
3352
4762
  "warnings": [],
3353
4763
  "output_profile": output_profile,
@@ -3361,6 +4771,31 @@ class RecordTools(ToolBase):
3361
4771
  "message": message,
3362
4772
  }
3363
4773
 
4774
+ def _record_update_public_batch_item(self, item: JSONObject, *, index: int) -> JSONObject:
4775
+ """执行内部辅助逻辑。"""
4776
+ public = dict(item)
4777
+ public.setdefault("index", index)
4778
+ public.setdefault("row_number", index + 1)
4779
+ resource = public.get("resource")
4780
+ if isinstance(resource, dict):
4781
+ record_id = resource.get("record_id")
4782
+ apply_id = resource.get("apply_id")
4783
+ if record_id not in (None, ""):
4784
+ public["record_id"] = str(record_id)
4785
+ if apply_id not in (None, ""):
4786
+ public["apply_id"] = str(apply_id)
4787
+ status = str(public.get("status") or "").lower()
4788
+ verification = public.get("verification")
4789
+ if isinstance(verification, dict):
4790
+ if bool(verification.get("verified")):
4791
+ public.setdefault("verification_status", "verified")
4792
+ elif status == "success":
4793
+ public.setdefault("verification_status", "failed")
4794
+ public.setdefault("write_executed", status == "success")
4795
+ public.setdefault("safe_to_retry", not bool(public.get("write_executed")))
4796
+ public.setdefault("verification_status", "not_requested")
4797
+ return public
4798
+
3364
4799
  def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
3365
4800
  """执行内部辅助逻辑。"""
3366
4801
  summary: JSONObject = {
@@ -3421,6 +4856,12 @@ class RecordTools(ToolBase):
3421
4856
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
3422
4857
  "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
3423
4858
  }
4859
+ update_route = response.get("update_route")
4860
+ if isinstance(update_route, dict):
4861
+ item["update_route"] = update_route
4862
+ tried_routes = response.get("tried_routes")
4863
+ if isinstance(tried_routes, list):
4864
+ item["tried_routes"] = tried_routes
3424
4865
  blockers = data.get("blockers")
3425
4866
  if isinstance(blockers, list) and blockers:
3426
4867
  item["blockers"] = blockers
@@ -4286,6 +5727,11 @@ class RecordTools(ToolBase):
4286
5727
  delete_ids = [normalize_positive_id_int(record_id, field_name="record_id")]
4287
5728
  if not delete_ids:
4288
5729
  raise_tool_error(QingflowApiError.config_error("record_id or record_ids is required"))
5730
+ seen_delete_ids: set[int] = set()
5731
+ for item in delete_ids:
5732
+ if item in seen_delete_ids:
5733
+ raise_tool_error(QingflowApiError.config_error(f"duplicate record id in delete payload: {stringify_backend_id(item)}"))
5734
+ seen_delete_ids.add(item)
4289
5735
  normalized_payload = {
4290
5736
  "operation": "delete",
4291
5737
  "record_id": stringify_backend_id(record_id) if record_id is not None else None,
@@ -4293,16 +5739,134 @@ class RecordTools(ToolBase):
4293
5739
  "answers": [],
4294
5740
  "submit_type": 1,
4295
5741
  }
4296
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
4297
- return self._record_write_apply_response(
4298
- raw_apply,
4299
- operation="delete",
5742
+ return self._record_delete_public_batch(
5743
+ profile=profile,
5744
+ app_key=app_key,
5745
+ delete_ids=delete_ids,
4300
5746
  normalized_payload=normalized_payload,
4301
5747
  output_profile=normalized_output_profile,
4302
- human_review=True,
4303
- preflight=None,
4304
5748
  )
4305
5749
 
5750
+ def _record_delete_public_batch(
5751
+ self,
5752
+ *,
5753
+ profile: str,
5754
+ app_key: str,
5755
+ delete_ids: list[int],
5756
+ normalized_payload: JSONObject,
5757
+ output_profile: str,
5758
+ ) -> JSONObject:
5759
+ items: list[JSONObject] = []
5760
+ request_route: JSONObject | None = None
5761
+ ws_id: object = None
5762
+ for index, delete_id in enumerate(delete_ids):
5763
+ record_id_text = stringify_backend_id(delete_id)
5764
+ try:
5765
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
5766
+ request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
5767
+ ws_id = raw_apply.get("ws_id", ws_id)
5768
+ single_payload = {
5769
+ "operation": "delete",
5770
+ "record_id": record_id_text,
5771
+ "record_ids": [record_id_text],
5772
+ "answers": [],
5773
+ "submit_type": 1,
5774
+ }
5775
+ single_response = self._record_write_apply_response(
5776
+ raw_apply,
5777
+ operation="delete",
5778
+ normalized_payload=single_payload,
5779
+ output_profile=output_profile,
5780
+ human_review=True,
5781
+ preflight=None,
5782
+ )
5783
+ item_status = str(single_response.get("status") or "success")
5784
+ item: JSONObject = {
5785
+ "index": index,
5786
+ "row_number": index + 1,
5787
+ "record_id": record_id_text,
5788
+ "status": item_status,
5789
+ "write_executed": bool(single_response.get("write_executed")),
5790
+ "verification_status": single_response.get("verification_status", "not_requested"),
5791
+ "safe_to_retry": bool(single_response.get("safe_to_retry", False)),
5792
+ }
5793
+ if item_status != "success":
5794
+ item["error"] = (single_response.get("data") or {}).get("error") if isinstance(single_response.get("data"), dict) else None
5795
+ items.append(item)
5796
+ except (QingflowApiError, RuntimeError) as exc:
5797
+ error_response = self._record_write_exception_response(
5798
+ exc,
5799
+ operation="delete",
5800
+ profile=profile,
5801
+ app_key=app_key,
5802
+ record_id=record_id_text,
5803
+ output_profile=output_profile,
5804
+ human_review=True,
5805
+ write_executed=False,
5806
+ )
5807
+ request_route = cast(JSONObject, error_response.get("request_route")) if isinstance(error_response.get("request_route"), dict) else request_route
5808
+ item = {
5809
+ "index": index,
5810
+ "row_number": index + 1,
5811
+ "record_id": record_id_text,
5812
+ "status": "failed",
5813
+ "write_executed": False,
5814
+ "verification_status": "not_requested",
5815
+ "safe_to_retry": True,
5816
+ "error": (error_response.get("data") or {}).get("error") if isinstance(error_response.get("data"), dict) else {"message": str(exc)},
5817
+ }
5818
+ items.append(item)
5819
+ deleted_ids = [
5820
+ str(item["record_id"])
5821
+ for item in items
5822
+ if str(item.get("status") or "") == "success"
5823
+ ]
5824
+ failed_ids = [
5825
+ str(item["record_id"])
5826
+ for item in items
5827
+ if str(item.get("status") or "") != "success"
5828
+ ]
5829
+ total = len(items)
5830
+ succeeded = len(deleted_ids)
5831
+ failed = len(failed_ids)
5832
+ if succeeded and failed:
5833
+ status = "partial_success"
5834
+ ok = False
5835
+ elif succeeded:
5836
+ status = "success"
5837
+ ok = True
5838
+ else:
5839
+ status = "failed"
5840
+ ok = False
5841
+ write_executed = any(bool(item.get("write_executed")) for item in items)
5842
+ return {
5843
+ "profile": profile,
5844
+ "ws_id": ws_id,
5845
+ "ok": ok,
5846
+ "status": status,
5847
+ "mode": "batch",
5848
+ "total": total,
5849
+ "succeeded": succeeded,
5850
+ "failed": failed,
5851
+ "deleted_ids": deleted_ids,
5852
+ "failed_ids": failed_ids,
5853
+ "write_executed": write_executed,
5854
+ "verification_status": "not_requested",
5855
+ "safe_to_retry": False if write_executed else True,
5856
+ "request_route": request_route,
5857
+ "warnings": [],
5858
+ "output_profile": output_profile,
5859
+ "items": items,
5860
+ "data": {
5861
+ "action": {"operation": "delete", "executed": write_executed},
5862
+ "resource": {"type": "record", "app_key": app_key, "record_id": None, "record_ids": [stringify_backend_id(item) for item in delete_ids]},
5863
+ "normalized_payload": normalized_payload,
5864
+ "deleted_ids": deleted_ids,
5865
+ "failed_ids": failed_ids,
5866
+ "items": items,
5867
+ },
5868
+ }
5869
+
4306
5870
  @tool_cn_name("写入记录")
4307
5871
  def record_write(
4308
5872
  self,
@@ -6861,6 +8425,7 @@ class RecordTools(ToolBase):
6861
8425
  field_index_override=index,
6862
8426
  )
6863
8427
  except RecordInputError as error:
8428
+ normalized_answers = list(lookup_resolution.normalized_answers)
6864
8429
  invalid_fields.append(
6865
8430
  {
6866
8431
  "location": _stringify_json(error.details.get("location") if error.details else None),
@@ -7357,7 +8922,7 @@ class RecordTools(ToolBase):
7357
8922
  "result": result,
7358
8923
  "normalized_answers": normalized_answers,
7359
8924
  "status": "completed" if verified else "verification_failed",
7360
- "ok": verified,
8925
+ "ok": True,
7361
8926
  "apply_id": apply_id,
7362
8927
  "record_id": apply_id,
7363
8928
  "verify_write": verify_write,
@@ -7561,7 +9126,7 @@ class RecordTools(ToolBase):
7561
9126
  "result": result,
7562
9127
  "normalized_answers": normalized_answers,
7563
9128
  "status": "completed" if verified else "verification_failed",
7564
- "ok": verified,
9129
+ "ok": True,
7565
9130
  "verify_write": verify_write,
7566
9131
  "write_verified": verified if verify_write else None,
7567
9132
  "verification": verification,
@@ -10100,6 +11665,9 @@ class RecordTools(ToolBase):
10100
11665
  "ws_id": raw_preflight.get("ws_id"),
10101
11666
  "ok": False,
10102
11667
  "status": status,
11668
+ "write_executed": False,
11669
+ "verification_status": "not_requested",
11670
+ "safe_to_retry": True,
10103
11671
  "request_route": raw_preflight.get("request_route"),
10104
11672
  "warnings": warnings,
10105
11673
  "output_profile": output_profile,
@@ -10143,6 +11711,9 @@ class RecordTools(ToolBase):
10143
11711
  "ws_id": raw_preflight.get("ws_id"),
10144
11712
  "ok": True,
10145
11713
  "status": "ready",
11714
+ "write_executed": False,
11715
+ "verification_status": "not_requested",
11716
+ "safe_to_retry": True,
10146
11717
  "request_route": raw_preflight.get("request_route"),
10147
11718
  "warnings": warnings,
10148
11719
  "output_profile": output_profile,
@@ -10185,17 +11756,49 @@ class RecordTools(ToolBase):
10185
11756
  resolved_fields = cast(list[JSONObject], preflight_data.get("lookup_resolved_fields", []))
10186
11757
  if isinstance(verification_warnings, list):
10187
11758
  warnings.extend(cast(list[JSONObject], [item for item in verification_warnings if isinstance(item, dict)]))
11759
+ resource = _public_record_resource(raw_apply.get("resource"))
11760
+ record_id = _public_record_id_text(resource.get("record_id")) if isinstance(resource, dict) else None
11761
+ apply_id = _public_record_id_text(resource.get("apply_id")) if isinstance(resource, dict) else None
11762
+ if record_id is None:
11763
+ record_id = _public_record_id_text(raw_apply.get("record_id"))
11764
+ if apply_id is None:
11765
+ apply_id = _public_record_id_text(raw_apply.get("apply_id"))
11766
+ if apply_id is None:
11767
+ apply_id = record_id
11768
+ if record_id is None:
11769
+ record_id = apply_id
11770
+ write_executed = True
11771
+ verification_requested = (
11772
+ raw_apply.get("verify_write") is True
11773
+ or raw_apply.get("write_verified") is not None
11774
+ or isinstance(raw_apply.get("verification"), dict)
11775
+ )
11776
+ if verification_requested:
11777
+ verification_status = "verified" if bool(verification.get("verified")) else "failed"
11778
+ else:
11779
+ verification_status = "not_requested"
11780
+ raw_status = _normalize_optional_text(raw_apply.get("status"))
11781
+ response_status = "verification_failed" if verification_status == "failed" else "success"
11782
+ if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
11783
+ response_status = raw_status or "failed"
11784
+ update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
11785
+ tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
10188
11786
  response: JSONObject = {
10189
11787
  "profile": raw_apply.get("profile"),
10190
11788
  "ws_id": raw_apply.get("ws_id"),
10191
- "ok": bool(raw_apply.get("ok", True)),
10192
- "status": "success" if bool(raw_apply.get("ok", True)) else _normalize_optional_text(raw_apply.get("status")) or "failed",
11789
+ "ok": True if verification_status == "failed" and write_executed else bool(raw_apply.get("ok", True)),
11790
+ "status": response_status,
11791
+ "write_executed": write_executed,
11792
+ "verification_status": verification_status,
11793
+ "safe_to_retry": False,
10193
11794
  "request_route": raw_apply.get("request_route"),
10194
11795
  "warnings": warnings,
10195
11796
  "output_profile": output_profile,
11797
+ "update_route": update_route,
11798
+ "tried_routes": tried_routes,
10196
11799
  "data": {
10197
11800
  "action": {"operation": operation, "executed": True},
10198
- "resource": _public_record_resource(raw_apply.get("resource")),
11801
+ "resource": resource,
10199
11802
  "verification": raw_apply.get("verification"),
10200
11803
  "normalized_payload": normalized_payload,
10201
11804
  "blockers": [],
@@ -10203,8 +11806,14 @@ class RecordTools(ToolBase):
10203
11806
  "confirmation_requests": [],
10204
11807
  "resolved_fields": resolved_fields,
10205
11808
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
11809
+ "update_route": update_route,
11810
+ "tried_routes": tried_routes,
10206
11811
  },
10207
11812
  }
11813
+ if record_id is not None:
11814
+ response["record_id"] = record_id
11815
+ if apply_id is not None:
11816
+ response["apply_id"] = apply_id
10208
11817
  if output_profile == "verbose":
10209
11818
  debug: JSONObject = {
10210
11819
  "legacy_result": raw_apply.get("result"),
@@ -10223,9 +11832,10 @@ class RecordTools(ToolBase):
10223
11832
  operation: str,
10224
11833
  profile: str,
10225
11834
  app_key: str,
10226
- record_id: int,
11835
+ record_id: Any | None,
10227
11836
  output_profile: str,
10228
11837
  human_review: bool,
11838
+ write_executed: bool = True,
10229
11839
  ) -> JSONObject:
10230
11840
  """执行内部辅助逻辑。"""
10231
11841
  error_payload: JSONObject = {
@@ -10266,11 +11876,14 @@ class RecordTools(ToolBase):
10266
11876
  "ws_id": None,
10267
11877
  "ok": False,
10268
11878
  "status": "failed",
11879
+ "write_executed": write_executed,
11880
+ "verification_status": "failed" if write_executed else "not_requested",
11881
+ "safe_to_retry": not write_executed,
10269
11882
  "request_route": request_route,
10270
11883
  "warnings": [],
10271
11884
  "output_profile": output_profile,
10272
11885
  "data": {
10273
- "action": {"operation": operation, "executed": True},
11886
+ "action": {"operation": operation, "executed": write_executed},
10274
11887
  "resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
10275
11888
  "verification": None,
10276
11889
  "normalized_payload": None,
@@ -11766,6 +13379,7 @@ class RecordTools(ToolBase):
11766
13379
  normalized_answers: list[JSONObject],
11767
13380
  index: FieldIndex,
11768
13381
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13382
+ verify_view_key: str | None = None,
11769
13383
  ) -> JSONObject:
11770
13384
  """执行内部辅助逻辑。"""
11771
13385
  if apply_id is None:
@@ -11777,13 +13391,36 @@ class RecordTools(ToolBase):
11777
13391
  "count_mismatches": [],
11778
13392
  }
11779
13393
  try:
11780
- record = self.backend.request(
11781
- "GET",
11782
- context,
11783
- f"/app/{app_key}/apply/{apply_id}",
11784
- params={"role": 1, "listType": verify_list_type},
11785
- )
13394
+ if verify_view_key:
13395
+ record = self.backend.request(
13396
+ "GET",
13397
+ context,
13398
+ f"/view/{verify_view_key}/apply/{apply_id}",
13399
+ )
13400
+ else:
13401
+ record = self.backend.request(
13402
+ "GET",
13403
+ context,
13404
+ f"/app/{app_key}/apply/{apply_id}",
13405
+ params={"role": 1, "listType": verify_list_type},
13406
+ )
11786
13407
  except QingflowApiError as exc:
13408
+ if verify_view_key:
13409
+ return {
13410
+ "verified": False,
13411
+ "verification_mode": "custom_view_record_detail",
13412
+ "field_level_verified": False,
13413
+ "error": "custom_view_readback_failed",
13414
+ "missing_fields": [],
13415
+ "empty_fields": [],
13416
+ "count_mismatches": [],
13417
+ "warnings": [{
13418
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13419
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13420
+ "backend_code": exc.backend_code,
13421
+ "http_status": exc.http_status,
13422
+ }],
13423
+ }
11787
13424
  if exc.backend_code != 40002:
11788
13425
  raise
11789
13426
  return self._verify_record_write_result_via_initiated_tasks(
@@ -11847,7 +13484,7 @@ class RecordTools(ToolBase):
11847
13484
  )
11848
13485
  return {
11849
13486
  "verified": not missing_fields and not empty_fields and not count_mismatches,
11850
- "verification_mode": "initiated_record_view",
13487
+ "verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
11851
13488
  "field_level_verified": True,
11852
13489
  "missing_fields": missing_fields,
11853
13490
  "empty_fields": empty_fields,
@@ -12759,6 +14396,13 @@ def _record_access_run_dir() -> Path:
12759
14396
  return base_dir / run_id
12760
14397
 
12761
14398
 
14399
+ def _record_logs_run_dir() -> Path:
14400
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
14401
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
14402
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
14403
+ return base_dir / run_id
14404
+
14405
+
12762
14406
  def _record_access_field_payload(field: FormField) -> JSONObject:
12763
14407
  return {
12764
14408
  "field_id": field.que_id,
@@ -13527,6 +15171,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
13527
15171
  }
13528
15172
 
13529
15173
 
15174
+ def _record_logs_hidden_payload(source: str) -> JSONObject:
15175
+ return {
15176
+ "status": "hidden",
15177
+ "visible": False,
15178
+ "source": source,
15179
+ "complete": False,
15180
+ "items_count": 0,
15181
+ "pages_fetched": 0,
15182
+ "reported_total": None,
15183
+ "local_path": None,
15184
+ "preview_items": [],
15185
+ "warnings": [],
15186
+ }
15187
+
15188
+
15189
+ def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
15190
+ return {
15191
+ "status": "unavailable",
15192
+ "visible": None,
15193
+ "source": source,
15194
+ "reason": reason,
15195
+ "complete": False,
15196
+ "items_count": 0,
15197
+ "pages_fetched": 0,
15198
+ "reported_total": None,
15199
+ "local_path": None,
15200
+ "preview_items": [],
15201
+ "warnings": [],
15202
+ }
15203
+
15204
+
15205
+ def _record_logs_fetch_all_to_jsonl(
15206
+ *,
15207
+ fetch_page,
15208
+ normalizer,
15209
+ source: str,
15210
+ file_path: Path,
15211
+ deadline: float,
15212
+ ) -> JSONObject: # type: ignore[no-untyped-def]
15213
+ file_path.parent.mkdir(parents=True, exist_ok=True)
15214
+ page_num = 1
15215
+ pages_fetched = 0
15216
+ items_count = 0
15217
+ reported_total: int | None = None
15218
+ preview_items: list[JSONObject] = []
15219
+ warnings: list[JSONObject] = []
15220
+ stopped_reason: str | None = None
15221
+ complete = True
15222
+
15223
+ with file_path.open("w", encoding="utf-8") as handle:
15224
+ while True:
15225
+ if _record_logs_time_budget_exceeded(deadline=deadline):
15226
+ complete = False
15227
+ stopped_reason = "time_budget_exceeded"
15228
+ warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
15229
+ break
15230
+ payload = fetch_page(page_num)
15231
+ pages_fetched += 1
15232
+ items = _record_detail_page_items(payload)
15233
+ if reported_total is None:
15234
+ reported_total = _record_detail_page_total(payload)
15235
+ if not items:
15236
+ break
15237
+ for item in items:
15238
+ normalized = normalizer(item)
15239
+ handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
15240
+ items_count += 1
15241
+ if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
15242
+ preview_items.append(normalized)
15243
+ if items_count >= RECORD_LOGS_MAX_ITEMS:
15244
+ complete = False
15245
+ stopped_reason = "item_limit_exceeded"
15246
+ warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
15247
+ break
15248
+ if stopped_reason:
15249
+ break
15250
+ if reported_total is not None and items_count >= reported_total:
15251
+ break
15252
+ if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
15253
+ break
15254
+ page_num += 1
15255
+
15256
+ return {
15257
+ "status": "ok" if complete else "partial",
15258
+ "visible": True,
15259
+ "source": source,
15260
+ "complete": complete,
15261
+ "items_count": items_count,
15262
+ "pages_fetched": pages_fetched,
15263
+ "page_size": RECORD_LOGS_PAGE_SIZE,
15264
+ "reported_total": reported_total,
15265
+ "local_path": str(file_path),
15266
+ "preview_items": preview_items,
15267
+ "warnings": warnings,
15268
+ "stopped_reason": stopped_reason,
15269
+ }
15270
+
15271
+
15272
+ def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
15273
+ return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
15274
+
15275
+
15276
+ def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
15277
+ return {
15278
+ "code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
15279
+ "source": source,
15280
+ "message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
15281
+ "pages_fetched": pages_fetched,
15282
+ "items_count": items_count,
15283
+ }
15284
+
15285
+
15286
+ def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
15287
+ return {
15288
+ "code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
15289
+ "source": source,
15290
+ "message": f"record_logs_get stopped after the internal {item_limit} item limit.",
15291
+ "item_limit": item_limit,
15292
+ }
15293
+
15294
+
15295
+ def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
15296
+ statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
15297
+ if statuses == {"unavailable"}:
15298
+ return "unavailable"
15299
+ if "partial" in statuses or "unavailable" in statuses:
15300
+ return "partial"
15301
+ return "success"
15302
+
15303
+
15304
+ def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
15305
+ data_integrity = _record_logs_section_integrity(data_logs)
15306
+ workflow_integrity = _record_logs_section_integrity(workflow_logs)
15307
+ return {
15308
+ "data_logs": data_integrity,
15309
+ "workflow_logs": workflow_integrity,
15310
+ "safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
15311
+ }
15312
+
15313
+
15314
+ def _record_logs_section_integrity(section: JSONObject) -> str:
15315
+ status = str(section.get("status") or "")
15316
+ if status == "ok" and section.get("complete") is True:
15317
+ return "full"
15318
+ if status == "hidden":
15319
+ return "hidden"
15320
+ if status == "partial":
15321
+ return "partial"
15322
+ if status == "unavailable":
15323
+ return "unavailable"
15324
+ return "unknown"
15325
+
15326
+
13530
15327
  def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
13531
15328
  items = _record_detail_page_items(payload)
13532
15329
  total = _record_detail_page_total(payload)
@@ -13658,11 +15455,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
13658
15455
  view_key = _normalize_optional_text(raw.get("viewKey", raw.get("viewgraphKey")))
13659
15456
  chart_key = _normalize_optional_text(raw.get("chartKey", raw.get("chartId")))
13660
15457
  is_view = bool(view_key) or graph_type.endswith("view") or graph_type == "view"
15458
+ if is_view and not view_key and chart_key:
15459
+ view_key = chart_key
15460
+ chart_key = None
13661
15461
  resource_type = "view" if is_view else "report"
13662
15462
  view_type = _normalize_optional_text(raw.get("viewType", raw.get("viewgraphType", raw.get("graphType"))))
13663
15463
  data_access = _record_detail_resource_data_access(resource_type=resource_type, view_type=view_type)
13664
15464
  return {
13665
15465
  "type": resource_type,
15466
+ "resource_type": resource_type,
13666
15467
  "name": _normalize_optional_text(raw.get("viewName", raw.get("chartName", raw.get("name", raw.get("title"))))),
13667
15468
  "app_key": _normalize_optional_text(raw.get("appKey", raw.get("targetAppKey"))),
13668
15469
  "app_name": _normalize_optional_text(raw.get("formTitle", raw.get("appName", raw.get("targetAppName")))),
@@ -13670,10 +15471,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
13670
15471
  "chart_key": chart_key,
13671
15472
  "view_type": view_type,
13672
15473
  "graph_type": raw.get("graphType"),
15474
+ "report_source": _record_detail_report_source(raw.get("sourceType")) if resource_type == "report" else None,
13673
15475
  "data_access": data_access,
13674
15476
  }
13675
15477
 
13676
15478
 
15479
+ def _record_detail_report_source(source_type: Any) -> str:
15480
+ return "dataset" if str(source_type or "").strip().upper() == "BI_DATASET" else "app"
15481
+
15482
+
13677
15483
  def _record_detail_resource_data_access(*, resource_type: str, view_type: str | None) -> JSONObject:
13678
15484
  if resource_type == "report":
13679
15485
  return {
@@ -18313,9 +20119,122 @@ def _write_format_for_field(field: FormField) -> JSONObject:
18313
20119
  return _write_support_payload(support_level="full", kind="boolean_label", examples=["是", "否"])
18314
20120
  if field.que_type in DATE_QUE_TYPES:
18315
20121
  return _write_support_payload(support_level="full", kind="date_string", examples=["2026-03-13 10:00:00"])
20122
+ if field.que_type == 8:
20123
+ allow_decimal = bool((field.raw or {}).get("canDecimal"))
20124
+ payload = _write_support_payload(
20125
+ support_level="full",
20126
+ kind="amount_number",
20127
+ examples=[100.5 if allow_decimal else 100],
20128
+ )
20129
+ payload["allow_decimal"] = allow_decimal
20130
+ return payload
18316
20131
  return _write_support_payload(support_level="full", kind="scalar_text")
18317
20132
 
18318
20133
 
20134
+ def _ready_schema_format_hint(kind: str, write_format: JSONObject) -> str:
20135
+ if kind == "member":
20136
+ return "可直接填成员姓名;唯一匹配会自动解析,重名时会返回候选确认。也可传成员 id/value 对象。"
20137
+ if kind == "department":
20138
+ return "可直接填部门名称;唯一匹配会自动解析,重名时会返回候选确认。也可传部门 id/value 对象。"
20139
+ if kind == "relation":
20140
+ return "可传目标记录 apply_id/record_id 对象,也可填目标记录的可搜索文本;多候选时会返回确认。"
20141
+ if kind == "attachment":
20142
+ return "先调用 file_upload_local 上传文件,再写入上传返回的附件对象或 value/name。"
20143
+ if kind == "subtable":
20144
+ return "传 {'rows': [{...}]} 或直接传行对象数组;每行 key 使用子字段标题。"
20145
+ if kind == "address":
20146
+ return "传省/市/区/详细地址对象、地址明细字符串,或后端地址 parts 数组。"
20147
+ if kind == "single_select":
20148
+ return "传 options 中的一个选项文本。"
20149
+ if kind == "multi_select":
20150
+ return "传 options 中的多个选项文本数组。"
20151
+ if kind == "boolean":
20152
+ return "传 '是' 或 '否'。"
20153
+ if kind == "date":
20154
+ return "推荐传 'YYYY-MM-DD HH:MM:SS';只有日期时可传 'YYYY-MM-DD'。"
20155
+ if kind == "number":
20156
+ write_kind = _normalize_optional_text(write_format.get("kind"))
20157
+ if write_kind == "amount_number":
20158
+ if bool(write_format.get("allow_decimal")):
20159
+ return "传数字或数字字符串,支持小数。"
20160
+ return "传整数或整数字符串;该字段后端不接受小数。"
20161
+ return "传数字或数字字符串。"
20162
+ if kind == "unsupported":
20163
+ reason = _normalize_optional_text(write_format.get("reason"))
20164
+ return reason or "该字段不支持直接写入。"
20165
+ return "传文本值。"
20166
+
20167
+
20168
+ def _ready_schema_example_value(
20169
+ kind: str,
20170
+ field: FormField,
20171
+ write_format: JSONObject,
20172
+ *,
20173
+ row_fields: list[JSONObject],
20174
+ ) -> JSONValue:
20175
+ if kind == "member":
20176
+ return "张三"
20177
+ if kind == "department":
20178
+ return "直销部"
20179
+ if kind == "relation":
20180
+ return {"apply_id": "5001"}
20181
+ if kind == "attachment":
20182
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
20183
+ if kind == "subtable":
20184
+ row: JSONObject = {}
20185
+ for item in row_fields:
20186
+ if not isinstance(item, dict):
20187
+ continue
20188
+ title = _normalize_optional_text(item.get("title"))
20189
+ if not title:
20190
+ continue
20191
+ row[title] = item.get("example_value", _ready_schema_template_scalar(item.get("kind")))
20192
+ if not row:
20193
+ row = {"子字段": "值"}
20194
+ return {"rows": [row]}
20195
+ if kind == "address":
20196
+ examples = write_format.get("examples")
20197
+ if isinstance(examples, list) and examples:
20198
+ return deepcopy(cast(JSONValue, examples[0]))
20199
+ return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
20200
+ if kind == "single_select":
20201
+ return field.options[0] if field.options else "选项A"
20202
+ if kind == "multi_select":
20203
+ return [field.options[0]] if field.options else ["选项A"]
20204
+ if kind == "boolean":
20205
+ return "是"
20206
+ if kind == "date":
20207
+ return "2026-03-13 10:00:00"
20208
+ if kind == "number":
20209
+ return 100
20210
+ if kind == "unsupported":
20211
+ return None
20212
+ return "示例文本"
20213
+
20214
+
20215
+ def _ready_schema_template_scalar(kind: Any) -> JSONValue:
20216
+ normalized = _normalize_optional_text(kind)
20217
+ if normalized == "number":
20218
+ return 100
20219
+ if normalized == "date":
20220
+ return "2026-03-13 10:00:00"
20221
+ if normalized == "boolean":
20222
+ return "是"
20223
+ if normalized == "member":
20224
+ return "张三"
20225
+ if normalized == "department":
20226
+ return "直销部"
20227
+ if normalized == "relation":
20228
+ return {"apply_id": "5001"}
20229
+ if normalized == "multi_select":
20230
+ return ["选项A"]
20231
+ if normalized == "attachment":
20232
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
20233
+ if normalized == "address":
20234
+ return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
20235
+ return "值"
20236
+
20237
+
18319
20238
  def _summarize_write_support(resolved_fields: list[JSONObject]) -> JSONObject:
18320
20239
  summary: JSONObject = {
18321
20240
  "full": [],