@josephyan/qingflow-cli 0.2.0-beta.55

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