@josephyan/qingflow-app-builder-mcp 0.2.0-beta.7 → 0.2.0-beta.71

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