@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,777 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
8
+ from ..errors import QingflowApiError, raise_tool_error
9
+ from ..json_types import JSONObject, JSONValue
10
+ from .base import tool_cn_name
11
+ from .record_tools import (
12
+ ATTACHMENT_QUE_TYPES,
13
+ DEPARTMENT_QUE_TYPES,
14
+ MEMBER_QUE_TYPES,
15
+ MULTI_SELECT_QUE_TYPES,
16
+ RELATION_QUE_TYPES,
17
+ SINGLE_SELECT_QUE_TYPES,
18
+ SUBTABLE_QUE_TYPES,
19
+ FieldIndex,
20
+ FormField,
21
+ RecordTools,
22
+ _coerce_count,
23
+ _collect_question_relations,
24
+ _field_ref_payload,
25
+ _normalize_optional_text,
26
+ _relation_ids_from_answer,
27
+ _stringify_json,
28
+ )
29
+
30
+
31
+ CODE_BLOCK_QUE_TYPE = 26
32
+ CODE_BLOCK_RELATION_TYPE = 3
33
+ SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
34
+
35
+
36
+ class CodeBlockTools(RecordTools):
37
+ """代码块工具(中文名:代码块运行与映射)。
38
+
39
+ 类型:记录增强工具。
40
+ 主要职责:
41
+ 1. 读取代码块字段配置与关联关系;
42
+ 2. 执行代码块并处理返回别名;
43
+ 3. 按规则回写目标字段并输出执行摘要。
44
+ """
45
+
46
+ def _get_code_block_relation_schema(
47
+ self,
48
+ profile: str,
49
+ context, # type: ignore[no-untyped-def]
50
+ app_key: str,
51
+ *,
52
+ force_refresh: bool,
53
+ ) -> JSONObject:
54
+ """执行内部辅助逻辑。"""
55
+ cache_key = (profile, app_key, "code_block_relation_form", 1)
56
+ if not force_refresh and cache_key in self._form_cache:
57
+ return self._form_cache[cache_key]
58
+ schema = self.backend.request(
59
+ "GET",
60
+ context,
61
+ f"/app/{app_key}/form",
62
+ params={"type": 1},
63
+ )
64
+ normalized = schema if isinstance(schema, dict) else {}
65
+ self._form_cache[cache_key] = normalized
66
+ return normalized
67
+
68
+ def register(self, mcp: FastMCP) -> None:
69
+ """注册当前工具到 MCP 服务。"""
70
+ super().register(mcp)
71
+
72
+ @mcp.tool()
73
+ def record_code_block_schema_get(
74
+ app_key: str = "",
75
+ output_profile: str = "normal",
76
+ ) -> JSONObject:
77
+ return self.record_code_block_schema_get_public(
78
+ profile=DEFAULT_PROFILE,
79
+ app_key=app_key,
80
+ output_profile=output_profile,
81
+ )
82
+
83
+ @mcp.tool(
84
+ description=(
85
+ "Run a form code-block field against the current record data, parse alias results, and optionally "
86
+ "reuse Qingflow's existing relation-calculation chain to compute bound outputs and write them back. "
87
+ "Use record_code_block_schema_get first and choose an exact code-block field selector. "
88
+ "For safe debugging, pass apply_writeback=false to inspect parsed results without writing back."
89
+ )
90
+ )
91
+ def record_code_block_run(
92
+ profile: str = DEFAULT_PROFILE,
93
+ app_key: str = "",
94
+ record_id: str = "",
95
+ code_block_field: str = "",
96
+ role: int = 1,
97
+ workflow_node_id: int | None = None,
98
+ answers: list[JSONObject] | None = None,
99
+ fields: JSONObject | None = None,
100
+ manual: bool = True,
101
+ apply_writeback: bool = True,
102
+ verify_writeback: bool = True,
103
+ force_refresh_form: bool = False,
104
+ output_profile: str = "normal",
105
+ ) -> JSONObject:
106
+ return self.record_code_block_run(
107
+ profile=profile,
108
+ app_key=app_key,
109
+ record_id=record_id,
110
+ code_block_field=code_block_field,
111
+ role=role,
112
+ workflow_node_id=workflow_node_id,
113
+ answers=answers or [],
114
+ fields=fields or {},
115
+ manual=manual,
116
+ apply_writeback=apply_writeback,
117
+ verify_writeback=verify_writeback,
118
+ force_refresh_form=force_refresh_form,
119
+ output_profile=output_profile,
120
+ )
121
+
122
+ @tool_cn_name("代码块 Schema")
123
+ def record_code_block_schema_get_public(
124
+ self,
125
+ *,
126
+ profile: str = DEFAULT_PROFILE,
127
+ app_key: str,
128
+ output_profile: str = "normal",
129
+ ) -> JSONObject:
130
+ """执行记录相关逻辑。"""
131
+ if not app_key:
132
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
133
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
134
+
135
+ def runner(session_profile, context):
136
+ relation_schema = self._get_code_block_relation_schema(profile, context, app_key, force_refresh=False)
137
+ index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
138
+ input_fields = [
139
+ self._ready_schema_field_payload(
140
+ profile,
141
+ context,
142
+ field,
143
+ ws_id=session_profile.selected_ws_id,
144
+ required_override=None,
145
+ )
146
+ for field in index.by_id.values()
147
+ if field.que_type != CODE_BLOCK_QUE_TYPE and bool(self._schema_write_hints(field).get("writable"))
148
+ ]
149
+ code_block_fields: list[JSONObject] = []
150
+ for field in index.by_id.values():
151
+ if field.que_type != CODE_BLOCK_QUE_TYPE:
152
+ continue
153
+ targets = _collect_code_block_relation_targets(
154
+ _collect_question_relations(relation_schema),
155
+ code_block_que_id=field.que_id,
156
+ )
157
+ bound_output_fields = [
158
+ target_field.que_title
159
+ for target in targets
160
+ for target_field in [index.by_id.get(str(_coerce_count(target.get("que_id")) or -1))]
161
+ if target_field is not None and isinstance(target_field.que_title, str)
162
+ ]
163
+ code_block_fields.append(
164
+ {
165
+ "title": field.que_title,
166
+ "selector": field.que_title,
167
+ "bound_output_fields": bound_output_fields,
168
+ "configured_aliases": _extract_code_block_configured_aliases(field),
169
+ }
170
+ )
171
+ response: JSONObject = {
172
+ "profile": profile,
173
+ "ws_id": session_profile.selected_ws_id,
174
+ "ok": True,
175
+ "status": "success",
176
+ "request_route": self._request_route_payload(context),
177
+ "warnings": [],
178
+ "app_key": app_key,
179
+ "schema_scope": "code_block_ready",
180
+ "code_block_fields": code_block_fields,
181
+ "input_fields": input_fields,
182
+ }
183
+ if normalized_output_profile == "verbose":
184
+ response["legacy_schema"] = self.record_schema_get(
185
+ profile=profile,
186
+ app_key=app_key,
187
+ schema_mode="applicant",
188
+ output_profile="verbose",
189
+ )
190
+ return response
191
+
192
+ return self._run_record_tool(profile, runner)
193
+
194
+ @tool_cn_name("运行代码块")
195
+ def record_code_block_run(
196
+ self,
197
+ *,
198
+ profile: str,
199
+ app_key: str,
200
+ record_id: int | str,
201
+ code_block_field: str,
202
+ role: int = 1,
203
+ workflow_node_id: int | None = None,
204
+ answers: list[JSONObject] | None = None,
205
+ fields: JSONObject | None = None,
206
+ manual: bool = True,
207
+ apply_writeback: bool = True,
208
+ verify_writeback: bool = True,
209
+ force_refresh_form: bool = False,
210
+ output_profile: str = "normal",
211
+ ) -> JSONObject:
212
+ """执行记录相关逻辑。"""
213
+ normalized_record_id = self._validate_app_and_record(app_key, record_id)
214
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
215
+ if role not in SUPPORTED_CODE_BLOCK_ROLES:
216
+ raise_tool_error(QingflowApiError.config_error("role must be one of 1, 2, 3, or 5"))
217
+ if role == 3 and (workflow_node_id is None or workflow_node_id <= 0):
218
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id is required when role=3"))
219
+ if not code_block_field:
220
+ raise_tool_error(QingflowApiError.config_error("code_block_field is required"))
221
+
222
+ def runner(session_profile, context):
223
+ relation_schema = self._get_code_block_relation_schema(
224
+ profile,
225
+ context,
226
+ app_key,
227
+ force_refresh=force_refresh_form,
228
+ )
229
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
230
+ code_block = self._resolve_field_selector(code_block_field, index, location="code_block_field")
231
+ if code_block.que_type != CODE_BLOCK_QUE_TYPE:
232
+ raise_tool_error(
233
+ QingflowApiError(
234
+ category="config",
235
+ message=f"field '{code_block.que_title}' is not a code-block field",
236
+ backend_code="CODE_BLOCK_FIELD_REQUIRED",
237
+ details={
238
+ "error_code": "CODE_BLOCK_FIELD_REQUIRED",
239
+ "field": _field_ref_payload(code_block),
240
+ "expected_que_type": CODE_BLOCK_QUE_TYPE,
241
+ },
242
+ )
243
+ )
244
+
245
+ current_answers = self._load_record_answers_for_code_block(
246
+ context,
247
+ app_key=app_key,
248
+ apply_id=normalized_record_id,
249
+ role=role,
250
+ audit_node_id=workflow_node_id,
251
+ )
252
+ override_answers = (
253
+ self._resolve_answers(
254
+ profile,
255
+ context,
256
+ app_key,
257
+ answers=answers or [],
258
+ fields=fields or {},
259
+ force_refresh_form=force_refresh_form,
260
+ )
261
+ if answers or fields
262
+ else []
263
+ )
264
+ merged_answers = self._merge_record_answers(current_answers, override_answers) if override_answers else current_answers
265
+ key_que_values = self._answers_to_open_match_values(merged_answers, index)
266
+ run_body: JSONObject = {
267
+ "role": role,
268
+ "manual": bool(manual),
269
+ "applyId": normalized_record_id,
270
+ "appKey": app_key,
271
+ "queryQuestions": [{"queId": code_block.que_id, "ordinal": None}],
272
+ "keyQueValues": key_que_values,
273
+ }
274
+ if workflow_node_id is not None:
275
+ run_body["auditNodeId"] = workflow_node_id
276
+ run_result = self.backend.request(
277
+ "POST",
278
+ context,
279
+ f"/data/{app_key}/codeBlock/working",
280
+ json_body=run_body,
281
+ )
282
+ alias_results = _normalize_code_block_alias_results(run_result)
283
+ configured_aliases = _extract_code_block_configured_aliases(code_block)
284
+ relation_target_fields = _collect_code_block_relation_targets(
285
+ _collect_question_relations(relation_schema),
286
+ code_block_que_id=code_block.que_id,
287
+ )
288
+ relation_errors: list[JSONObject] = []
289
+ relation_items: list[JSONObject] = []
290
+ calculated_answers: list[JSONObject] = []
291
+ relation_result: JSONObject | None = None
292
+ if relation_target_fields:
293
+ relation_body: JSONObject = {
294
+ "role": role,
295
+ "manual": bool(manual),
296
+ "applyId": normalized_record_id,
297
+ "appKey": app_key,
298
+ "queryQuestions": [{"queId": target["que_id"], "ordinal": None} for target in relation_target_fields],
299
+ "keyQueValues": key_que_values,
300
+ "codeBlockValues": [{"queId": code_block.que_id, "values": alias_results}],
301
+ }
302
+ if workflow_node_id is not None:
303
+ relation_body["auditNodeId"] = workflow_node_id
304
+ relation_route = "/data/que/actuator"
305
+ try:
306
+ relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
307
+ relation_items = _relation_result_items(relation_result)
308
+ except QingflowApiError as exc:
309
+ if exc.http_status != 404:
310
+ raise
311
+ relation_route = "/que/actuator"
312
+ relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
313
+ relation_items = _relation_result_items(relation_result)
314
+ if not relation_items:
315
+ # Keep compatibility with legacy runtime deployments and lightweight test doubles
316
+ # that still stub the older relation-calculation route only.
317
+ relation_route = "/que/actuator"
318
+ relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
319
+ relation_items = _relation_result_items(relation_result)
320
+ relation_errors = _relation_result_errors(relation_items)
321
+ calculated_answers = _relation_result_answers(relation_items)
322
+ write_result: JSONObject | None = None
323
+ verification: JSONObject | None = None
324
+ writeback_attempted = False
325
+ writeback_applied = False
326
+ status = "completed"
327
+ ok = True
328
+ if relation_errors:
329
+ status = "relation_failed"
330
+ ok = False
331
+ elif not apply_writeback:
332
+ status = "debug_completed"
333
+ elif relation_target_fields and calculated_answers:
334
+ write_body: JSONObject = {"role": role, "answers": calculated_answers}
335
+ if workflow_node_id is not None:
336
+ write_body["auditNodeId"] = workflow_node_id
337
+ writeback_attempted = True
338
+ write_result = cast(
339
+ JSONObject,
340
+ self.backend.request(
341
+ "POST",
342
+ context,
343
+ f"/app/{app_key}/apply/{normalized_record_id}",
344
+ json_body=write_body,
345
+ ),
346
+ )
347
+ writeback_applied = True
348
+ if verify_writeback:
349
+ verification = self._verify_code_block_writeback_result(
350
+ context,
351
+ app_key=app_key,
352
+ apply_id=normalized_record_id,
353
+ expected_answers=calculated_answers,
354
+ index=index,
355
+ role=role,
356
+ audit_node_id=workflow_node_id,
357
+ )
358
+ if not bool(verification.get("verified")):
359
+ status = "verification_failed"
360
+ ok = False
361
+ else:
362
+ status = "no_writeback"
363
+ response: JSONObject = {
364
+ "profile": profile,
365
+ "ws_id": session_profile.selected_ws_id,
366
+ "request_route": self._request_route_payload(context),
367
+ "app_key": app_key,
368
+ "record_id": normalized_record_id,
369
+ "apply_id": normalized_record_id,
370
+ "status": status,
371
+ "ok": ok,
372
+ "code_block_field": _field_ref_payload(code_block),
373
+ "execution": {
374
+ "executed": True,
375
+ "role": role,
376
+ "workflow_node_id": workflow_node_id,
377
+ "manual": bool(manual),
378
+ "apply_writeback": bool(apply_writeback),
379
+ "result_count": len(alias_results),
380
+ },
381
+ "outputs": {
382
+ "configured_aliases": configured_aliases,
383
+ "alias_results": alias_results,
384
+ "alias_map": _build_alias_result_map(alias_results),
385
+ },
386
+ "relation": {
387
+ "target_fields": relation_target_fields,
388
+ "result_item_count": len(relation_items),
389
+ "calculated_answer_count": len(calculated_answers),
390
+ "calculated_answers_preview": calculated_answers,
391
+ "errors": relation_errors,
392
+ },
393
+ "writeback": {
394
+ "enabled": bool(apply_writeback),
395
+ "attempted": writeback_attempted,
396
+ "applied": writeback_applied,
397
+ "skipped_reason": "apply_writeback_disabled" if not apply_writeback else None,
398
+ "verify_writeback": verify_writeback,
399
+ "write_verified": bool(verification.get("verified")) if verification is not None else None,
400
+ "result": write_result,
401
+ "verification": verification,
402
+ },
403
+ "resource": {"apply_id": normalized_record_id},
404
+ }
405
+ if normalized_output_profile == "verbose":
406
+ response["debug"] = {
407
+ "run_body": run_body,
408
+ "relation_route": relation_route if relation_target_fields else None,
409
+ "relation_result": relation_result,
410
+ "calculated_answers": calculated_answers,
411
+ "merged_answers": merged_answers,
412
+ "key_que_values": key_que_values,
413
+ }
414
+ return response
415
+
416
+ return self._run_record_tool(profile, runner)
417
+
418
+ def _load_record_answers_for_code_block(
419
+ self,
420
+ context, # type: ignore[no-untyped-def]
421
+ *,
422
+ app_key: str,
423
+ apply_id: int,
424
+ role: int,
425
+ audit_node_id: int | None,
426
+ ) -> list[JSONObject]:
427
+ """执行内部辅助逻辑。"""
428
+ last_error: QingflowApiError | None = None
429
+ for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
430
+ params: JSONObject = {"role": role, "listType": list_type}
431
+ if audit_node_id is not None:
432
+ params["auditNodeId"] = audit_node_id
433
+ try:
434
+ record = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}", params=params)
435
+ answers = record.get("answers") if isinstance(record, dict) else None
436
+ return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
437
+ except QingflowApiError as exc:
438
+ last_error = exc
439
+ if exc.backend_code == 40002:
440
+ continue
441
+ raise
442
+ if last_error is not None:
443
+ raise last_error
444
+ raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
445
+
446
+ def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
447
+ """执行内部辅助逻辑。"""
448
+ values: list[JSONObject] = []
449
+ for answer in answers:
450
+ if not isinstance(answer, dict):
451
+ continue
452
+ open_match = self._answer_to_open_match_value(answer, index)
453
+ if open_match is None:
454
+ continue
455
+ values.append(open_match)
456
+ return values
457
+
458
+ def _answer_to_open_match_value(self, answer: JSONObject, index: FieldIndex) -> JSONObject | None:
459
+ """执行内部辅助逻辑。"""
460
+ que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
461
+ if que_id is None or que_id <= 0:
462
+ return None
463
+ field = index.by_id.get(str(que_id))
464
+ if field is None:
465
+ return None
466
+ ordinal = _coerce_count(answer.get("ordinal"))
467
+ if field.que_type in SUBTABLE_QUE_TYPES:
468
+ rows = answer.get("tableValues")
469
+ row_values: list[list[JSONObject]] = []
470
+ subtable_index = self._subtable_field_index_optional(field)
471
+ if isinstance(rows, list) and subtable_index is not None:
472
+ for row in rows:
473
+ if not isinstance(row, list):
474
+ continue
475
+ normalized_row: list[JSONObject] = []
476
+ for cell in row:
477
+ if not isinstance(cell, dict):
478
+ continue
479
+ converted = self._answer_to_open_match_value(cell, subtable_index)
480
+ if converted is not None:
481
+ normalized_row.append(converted)
482
+ row_values.append(normalized_row)
483
+ payload: JSONObject = {"keyQueId": field.que_id, "ordinal": ordinal, "values": [], "tableValues": row_values}
484
+ return payload
485
+ return {
486
+ "keyQueId": field.que_id,
487
+ "ordinal": ordinal,
488
+ "values": self._answer_values_to_code_block_values(answer, field),
489
+ }
490
+
491
+ def _answer_values_to_code_block_values(self, answer: JSONObject, field: FormField) -> list[str]:
492
+ """执行内部辅助逻辑。"""
493
+ if field.que_type in RELATION_QUE_TYPES:
494
+ return _relation_ids_from_answer(answer)
495
+ raw_values = answer.get("values")
496
+ if not isinstance(raw_values, list):
497
+ return []
498
+ normalized: list[str] = []
499
+ for item in raw_values:
500
+ normalized_value = _normalize_code_block_value_item(item, field)
501
+ if normalized_value is None:
502
+ continue
503
+ normalized.append(normalized_value)
504
+ return normalized
505
+
506
+ def _verify_code_block_writeback_result(
507
+ self,
508
+ context, # type: ignore[no-untyped-def]
509
+ *,
510
+ app_key: str,
511
+ apply_id: int,
512
+ expected_answers: list[JSONObject],
513
+ index: FieldIndex,
514
+ role: int,
515
+ audit_node_id: int | None,
516
+ ) -> JSONObject:
517
+ """执行内部辅助逻辑。"""
518
+ if role == 1 and audit_node_id is None:
519
+ return self._verify_record_write_result(
520
+ context,
521
+ app_key=app_key,
522
+ apply_id=apply_id,
523
+ normalized_answers=expected_answers,
524
+ index=index,
525
+ verify_list_type=DEFAULT_RECORD_LIST_TYPE,
526
+ )
527
+ actual_answers = self._load_record_answers_for_code_block(
528
+ context,
529
+ app_key=app_key,
530
+ apply_id=apply_id,
531
+ role=role,
532
+ audit_node_id=audit_node_id,
533
+ )
534
+ actual_by_id = {
535
+ que_id: item
536
+ for item in actual_answers
537
+ if isinstance(item, dict) and (que_id := _coerce_count(item.get("queId"))) is not None
538
+ }
539
+ missing_fields: list[JSONObject] = []
540
+ empty_fields: list[JSONObject] = []
541
+ count_mismatches: list[JSONObject] = []
542
+ for answer in expected_answers:
543
+ que_id = _coerce_count(answer.get("queId"))
544
+ if que_id is None or que_id <= 0:
545
+ continue
546
+ actual = actual_by_id.get(que_id)
547
+ field = index.by_id.get(str(que_id))
548
+ field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
549
+ if actual is None:
550
+ missing_fields.append(field_payload)
551
+ continue
552
+ expected_rows = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
553
+ if expected_rows:
554
+ actual_rows = actual.get("tableValues") if isinstance(actual.get("tableValues"), list) else []
555
+ self._verify_subtable_write_result(
556
+ field=field,
557
+ expected_rows=expected_rows,
558
+ actual_rows=actual_rows,
559
+ missing_fields=missing_fields,
560
+ empty_fields=empty_fields,
561
+ count_mismatches=count_mismatches,
562
+ )
563
+ continue
564
+ if field is not None and field.que_type in RELATION_QUE_TYPES:
565
+ expected_relation_ids = _relation_ids_from_answer(answer)
566
+ actual_relation_ids = _relation_ids_from_answer(actual)
567
+ if expected_relation_ids and not actual_relation_ids:
568
+ empty_fields.append(field_payload)
569
+ continue
570
+ if expected_relation_ids:
571
+ actual_id_set = set(actual_relation_ids)
572
+ missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
573
+ if missing_ids:
574
+ count_mismatches.append(
575
+ {
576
+ **field_payload,
577
+ "expected_ids": expected_relation_ids,
578
+ "actual_ids": actual_relation_ids,
579
+ "missing_ids": missing_ids,
580
+ }
581
+ )
582
+ continue
583
+ actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
584
+ if not actual_values:
585
+ empty_fields.append(field_payload)
586
+ continue
587
+ expected_values = answer.get("values") if isinstance(answer.get("values"), list) else []
588
+ if expected_values and len(actual_values) < len(expected_values):
589
+ count_mismatches.append(
590
+ {
591
+ **field_payload,
592
+ "expected_count": len(expected_values),
593
+ "actual_count": len(actual_values),
594
+ }
595
+ )
596
+ return {
597
+ "verified": not missing_fields and not empty_fields and not count_mismatches,
598
+ "verification_mode": "role_record_view",
599
+ "field_level_verified": True,
600
+ "missing_fields": missing_fields,
601
+ "empty_fields": empty_fields,
602
+ "count_mismatches": count_mismatches,
603
+ }
604
+
605
+
606
+ def _normalize_code_block_value_item(value: JSONValue, field: FormField) -> str | None:
607
+ if field.que_type in SINGLE_SELECT_QUE_TYPES | MULTI_SELECT_QUE_TYPES:
608
+ return _selector_numeric_or_text(value, ("optionId", "optId", "id"), allow_text=False)
609
+ if field.que_type in MEMBER_QUE_TYPES:
610
+ return _selector_numeric_or_text(value, ("id", "uid"), allow_text=False)
611
+ if field.que_type in DEPARTMENT_QUE_TYPES:
612
+ return _selector_numeric_or_text(value, ("id", "deptId"), allow_text=False)
613
+ if field.que_type in ATTACHMENT_QUE_TYPES:
614
+ return _selector_numeric_or_text(value, ("value", "url", "otherInfo", "name", "fileName"), allow_text=True)
615
+ if isinstance(value, dict):
616
+ scalar = value.get("value")
617
+ return _stringify_json(scalar) if scalar is not None else None
618
+ text = _normalize_optional_text(value)
619
+ return text if text is not None else None
620
+
621
+
622
+ def _selector_numeric_or_text(value: JSONValue, keys: tuple[str, ...], *, allow_text: bool) -> str | None:
623
+ numeric = _coerce_count(value)
624
+ if numeric is not None:
625
+ return str(numeric)
626
+ if not isinstance(value, dict):
627
+ if not allow_text:
628
+ return None
629
+ text = _normalize_optional_text(value)
630
+ return text if text is not None else None
631
+ for key in keys:
632
+ if key not in value:
633
+ continue
634
+ candidate = value.get(key)
635
+ if candidate is None:
636
+ continue
637
+ numeric = _coerce_count(candidate)
638
+ if numeric is not None:
639
+ return str(numeric)
640
+ if allow_text:
641
+ text = _normalize_optional_text(candidate)
642
+ if text is not None:
643
+ return text
644
+ return _normalize_optional_text(value.get("value")) if allow_text and isinstance(value.get("value"), (str, int, float)) else None
645
+
646
+
647
+ def _normalize_code_block_alias_results(payload: JSONValue) -> list[JSONObject]:
648
+ if not isinstance(payload, dict):
649
+ return []
650
+ raw_results = payload.get("result")
651
+ if not isinstance(raw_results, list):
652
+ return []
653
+ results: list[JSONObject] = []
654
+ for item in raw_results:
655
+ if not isinstance(item, dict):
656
+ continue
657
+ values = item.get("value")
658
+ result: JSONObject = {
659
+ "parentAliasId": _coerce_count(item.get("parentAliasId")),
660
+ "parentAlias": _normalize_optional_text(item.get("parentAlias")),
661
+ "aliasId": _coerce_count(item.get("aliasId")),
662
+ "alias": _normalize_optional_text(item.get("alias")),
663
+ "value": [_stringify_json(entry) for entry in values] if isinstance(values, list) else [],
664
+ }
665
+ results.append(result)
666
+ return results
667
+
668
+
669
+ def _extract_code_block_configured_aliases(field: FormField) -> list[JSONObject]:
670
+ raw_config = field.raw.get("codeBlockConfig")
671
+ if not isinstance(raw_config, dict):
672
+ return []
673
+ raw_aliases = raw_config.get("resultAliasPath")
674
+ if not isinstance(raw_aliases, list):
675
+ return []
676
+ return [item for item in (_normalize_code_block_alias_item(alias) for alias in raw_aliases) if item is not None]
677
+
678
+
679
+ def _normalize_code_block_alias_item(value: JSONValue) -> JSONObject | None:
680
+ if not isinstance(value, dict):
681
+ return None
682
+ alias_name = _normalize_optional_text(value.get("aliasName", value.get("alias_name")))
683
+ alias_path = _normalize_optional_text(value.get("aliasPath", value.get("alias_path")))
684
+ if alias_name is None and alias_path is None:
685
+ return None
686
+ raw_sub_alias = value.get("subAlias", value.get("sub_alias"))
687
+ sub_alias = (
688
+ [item for item in (_normalize_code_block_alias_item(entry) for entry in raw_sub_alias) if item is not None]
689
+ if isinstance(raw_sub_alias, list)
690
+ else []
691
+ )
692
+ return {
693
+ "alias_id": _coerce_count(value.get("aliasId", value.get("alias_id"))),
694
+ "alias_name": alias_name,
695
+ "alias_path": alias_path,
696
+ "alias_type": _coerce_count(value.get("aliasType", value.get("alias_type"))) or 1,
697
+ "sub_alias": sub_alias,
698
+ }
699
+
700
+
701
+ def _build_alias_result_map(alias_results: list[JSONObject]) -> JSONObject:
702
+ alias_map: JSONObject = {}
703
+ for item in alias_results:
704
+ alias = _normalize_optional_text(item.get("alias"))
705
+ if alias is None:
706
+ continue
707
+ parent_alias = _normalize_optional_text(item.get("parentAlias"))
708
+ key = f"{parent_alias}.{alias}" if parent_alias else alias
709
+ values = item.get("value")
710
+ alias_map[key] = values if isinstance(values, list) else []
711
+ return alias_map
712
+
713
+
714
+ def _collect_code_block_relation_targets(question_relations: list[JSONObject], *, code_block_que_id: int) -> list[JSONObject]:
715
+ targets: list[JSONObject] = []
716
+ seen: set[int] = set()
717
+ for relation in question_relations:
718
+ if _coerce_count(relation.get("relationType")) != CODE_BLOCK_RELATION_TYPE:
719
+ continue
720
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
721
+ relation_code_block_que_id = _coerce_count(relation.get("qlinkerQueId"))
722
+ if relation_code_block_que_id is None:
723
+ relation_code_block_que_id = _coerce_count(alias_config.get("queId"))
724
+ if relation_code_block_que_id != code_block_que_id:
725
+ continue
726
+ target_id = _coerce_count(
727
+ relation.get("queId", relation.get("targetQueId", relation.get("displayedQueId")))
728
+ )
729
+ if target_id is None or target_id in seen:
730
+ continue
731
+ seen.add(target_id)
732
+ targets.append(
733
+ {
734
+ "que_id": target_id,
735
+ "alias_id": _coerce_count(relation.get("aliasId")) or _coerce_count(alias_config.get("aliasId")),
736
+ "alias_name": _normalize_optional_text(relation.get("qlinkerAlias"))
737
+ or _normalize_optional_text(alias_config.get("qlinkerAlias")),
738
+ "qlinker_que_id": relation_code_block_que_id,
739
+ }
740
+ )
741
+ return targets
742
+
743
+
744
+ def _relation_result_items(payload: JSONValue) -> list[JSONObject]:
745
+ if not isinstance(payload, dict):
746
+ return []
747
+ result = payload.get("result")
748
+ return [item for item in result if isinstance(item, dict)] if isinstance(result, list) else []
749
+
750
+
751
+ def _relation_result_answers(items: list[JSONObject]) -> list[JSONObject]:
752
+ answers: list[JSONObject] = []
753
+ for item in items:
754
+ raw_answers = item.get("answers")
755
+ if not isinstance(raw_answers, list):
756
+ continue
757
+ for answer in raw_answers:
758
+ if isinstance(answer, dict):
759
+ answers.append(answer)
760
+ return answers
761
+
762
+
763
+ def _relation_result_errors(items: list[JSONObject]) -> list[JSONObject]:
764
+ errors: list[JSONObject] = []
765
+ for item in items:
766
+ message = _normalize_optional_text(item.get("errorMsg"))
767
+ if message is None:
768
+ continue
769
+ errors.append(
770
+ {
771
+ "que_id": _coerce_count(item.get("queId")),
772
+ "que_title": _normalize_optional_text(item.get("queTitle")),
773
+ "ordinal": _coerce_count(item.get("ordinal")),
774
+ "message": message,
775
+ }
776
+ )
777
+ return errors