@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,2986 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from ..backend_client import BackendRequestContext
11
+ from ..config import DEFAULT_PROFILE
12
+ from ..errors import QingflowApiError, raise_tool_error
13
+ from ..id_utils import ids_equal, normalize_positive_id_int, normalize_positive_id_text, stringify_backend_id
14
+ from ..json_types import JSONObject
15
+ from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
16
+ from .base import ToolBase, tool_cn_name
17
+ from .qingbi_report_tools import _qingbi_base_url
18
+ from .record_tools import (
19
+ FieldIndex,
20
+ LAYOUT_ONLY_QUE_TYPES,
21
+ SUBTABLE_QUE_TYPES,
22
+ RecordTools,
23
+ _build_answer_backed_field_index,
24
+ _build_applicant_hidden_linked_top_level_field_index,
25
+ _build_applicant_top_level_field_index,
26
+ _build_static_schema_linkage_payloads,
27
+ _canonical_value_is_empty,
28
+ _canonicalize_answer_value_for_compare,
29
+ _clone_form_field,
30
+ _coerce_count,
31
+ _collect_linked_required_field_ids,
32
+ _collect_option_linked_field_ids,
33
+ _collect_question_relations,
34
+ _field_ref_payload,
35
+ _merge_field_indexes,
36
+ _subtable_descendant_ids,
37
+ )
38
+ from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
39
+
40
+
41
+ class TaskContextTools(ToolBase):
42
+ """任务上下文工具(中文名:任务上下文与审批执行)。
43
+
44
+ 类型:任务深度上下文工具。
45
+ 主要职责:
46
+ 1. 聚合任务详情、候选人、关联报表与流程日志;
47
+ 2. 执行审批动作(通过、驳回、转交等);
48
+ 3. 为任务处理过程提供可执行上下文而非仅列表数据。
49
+ """
50
+
51
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
52
+ """执行内部辅助逻辑。"""
53
+ super().__init__(sessions, backend)
54
+ self._task_tools = TaskTools(sessions, backend)
55
+ self._approval_tools = ApprovalTools(sessions, backend)
56
+ self._record_tools = RecordTools(sessions, backend)
57
+ self._app_name_cache: dict[str, str | None] = {}
58
+
59
+ def register(self, mcp: FastMCP) -> None:
60
+ """注册当前工具到 MCP 服务。"""
61
+ @mcp.tool(
62
+ description=(
63
+ "List workflow tasks. `query` first uses backend task search; if the backend returns zero rows, "
64
+ "public task_list falls back to local matching on app_name, workflow_node_name, app_key, and record_id."
65
+ )
66
+ )
67
+ def task_list(
68
+ profile: str = DEFAULT_PROFILE,
69
+ task_box: str = "todo",
70
+ flow_status: str = "all",
71
+ app_key: str | None = None,
72
+ workflow_node_id: int | None = None,
73
+ query: str | None = None,
74
+ page: int = 1,
75
+ page_size: int = 20,
76
+ ) -> dict[str, Any]:
77
+ return self.task_list(
78
+ profile=profile,
79
+ task_box=task_box,
80
+ flow_status=flow_status,
81
+ app_key=app_key,
82
+ workflow_node_id=workflow_node_id,
83
+ query=query,
84
+ page=page,
85
+ page_size=page_size,
86
+ )
87
+
88
+ @mcp.tool()
89
+ def task_get(
90
+ profile: str = DEFAULT_PROFILE,
91
+ task_id: str = "",
92
+ app_key: str = "",
93
+ record_id: str = "",
94
+ workflow_node_id: int = 0,
95
+ include_candidates: bool = True,
96
+ include_associated_reports: bool = True,
97
+ ) -> dict[str, Any]:
98
+ return self.task_get(
99
+ profile=profile,
100
+ task_id=task_id,
101
+ app_key=app_key,
102
+ record_id=record_id,
103
+ workflow_node_id=workflow_node_id,
104
+ include_candidates=include_candidates,
105
+ include_associated_reports=include_associated_reports,
106
+ )
107
+
108
+ @mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
109
+ def task_action_execute(
110
+ profile: str = DEFAULT_PROFILE,
111
+ task_id: str = "",
112
+ app_key: str = "",
113
+ record_id: str = "",
114
+ workflow_node_id: int = 0,
115
+ action: str = "",
116
+ payload: dict[str, Any] | None = None,
117
+ fields: dict[str, Any] | None = None,
118
+ ) -> dict[str, Any]:
119
+ return self.task_action_execute(
120
+ profile=profile,
121
+ task_id=task_id,
122
+ app_key=app_key,
123
+ record_id=record_id,
124
+ workflow_node_id=workflow_node_id,
125
+ action=action,
126
+ payload=payload or {},
127
+ fields=fields or {},
128
+ )
129
+
130
+ @mcp.tool()
131
+ def task_associated_report_detail_get(
132
+ profile: str = DEFAULT_PROFILE,
133
+ task_id: str = "",
134
+ app_key: str = "",
135
+ record_id: str = "",
136
+ workflow_node_id: int = 0,
137
+ report_id: int = 0,
138
+ page: int = 1,
139
+ page_size: int = 20,
140
+ ) -> dict[str, Any]:
141
+ return self.task_associated_report_detail_get(
142
+ profile=profile,
143
+ task_id=task_id,
144
+ app_key=app_key,
145
+ record_id=record_id,
146
+ workflow_node_id=workflow_node_id,
147
+ report_id=report_id,
148
+ page=page,
149
+ page_size=page_size,
150
+ )
151
+
152
+ @mcp.tool()
153
+ def task_workflow_log_get(
154
+ profile: str = DEFAULT_PROFILE,
155
+ task_id: str = "",
156
+ app_key: str = "",
157
+ record_id: str = "",
158
+ workflow_node_id: int = 0,
159
+ ) -> dict[str, Any]:
160
+ return self.task_workflow_log_get(
161
+ profile=profile,
162
+ task_id=task_id,
163
+ app_key=app_key,
164
+ record_id=record_id,
165
+ workflow_node_id=workflow_node_id,
166
+ )
167
+
168
+ @tool_cn_name("任务上下文列表")
169
+ def task_list(
170
+ self,
171
+ *,
172
+ profile: str,
173
+ task_box: str,
174
+ flow_status: str,
175
+ app_key: str | None,
176
+ workflow_node_id: int | None,
177
+ query: str | None,
178
+ page: int,
179
+ page_size: int,
180
+ ) -> dict[str, Any]:
181
+ """执行任务相关逻辑。"""
182
+ response = self._list_normalized_task_items(
183
+ profile=profile,
184
+ task_box=task_box,
185
+ flow_status=flow_status,
186
+ app_key=app_key,
187
+ workflow_node_id=workflow_node_id,
188
+ query=query,
189
+ page=page,
190
+ page_size=page_size,
191
+ )
192
+ warnings: list[dict[str, Any]] = []
193
+ items = response["items"] if isinstance(response.get("items"), list) else []
194
+ page_amount = response.get("page_amount")
195
+ reported_total = response.get("reported_total")
196
+ if query and not items:
197
+ fallback = self._task_list_local_query_fallback(
198
+ profile=profile,
199
+ task_box=task_box,
200
+ flow_status=flow_status,
201
+ app_key=app_key,
202
+ workflow_node_id=workflow_node_id,
203
+ query=query,
204
+ page=page,
205
+ page_size=page_size,
206
+ )
207
+ if fallback is not None:
208
+ items = fallback["items"]
209
+ returned_items = len(items)
210
+ page_amount = fallback["page_amount"]
211
+ reported_total = fallback["reported_total"]
212
+ warnings.append(
213
+ {
214
+ "code": "TASK_LIST_QUERY_FALLBACK_APPLIED",
215
+ "message": (
216
+ "backend searchKey returned zero tasks; task_list fell back to local matching on "
217
+ "app_name, workflow_node_name, app_key, and record_id."
218
+ ),
219
+ }
220
+ )
221
+ public_items = [self._public_task_item(item) for item in items]
222
+ return {
223
+ "profile": profile,
224
+ "ws_id": response.get("raw", {}).get("ws_id") if isinstance(response.get("raw"), dict) else None,
225
+ "ok": True,
226
+ "request_route": response.get("raw", {}).get("request_route") if isinstance(response.get("raw"), dict) else None,
227
+ "warnings": warnings,
228
+ "output_profile": "normal",
229
+ "data": {
230
+ "items": public_items,
231
+ "pagination": {
232
+ "page": page,
233
+ "page_size": page_size,
234
+ "returned_items": len(public_items),
235
+ "page_amount": page_amount,
236
+ "reported_total": reported_total,
237
+ },
238
+ "selection": {
239
+ "app_key": app_key,
240
+ "workflow_node_id": workflow_node_id,
241
+ "query": query,
242
+ },
243
+ },
244
+ }
245
+
246
+ @tool_cn_name("任务上下文详情")
247
+ def task_get(
248
+ self,
249
+ *,
250
+ profile: str,
251
+ task_id: Any = None,
252
+ app_key: str = "",
253
+ record_id: Any = "",
254
+ workflow_node_id: int = 0,
255
+ include_candidates: bool = True,
256
+ include_associated_reports: bool = True,
257
+ ) -> dict[str, Any]:
258
+ """执行任务相关逻辑。"""
259
+ if task_id in (None, ""):
260
+ normalize_positive_id_int(record_id, field_name="record_id")
261
+
262
+ def runner(session_profile, context):
263
+ locator = self._resolve_task_locator_input(
264
+ profile=profile,
265
+ task_id=task_id,
266
+ app_key=app_key,
267
+ record_id=record_id,
268
+ workflow_node_id=workflow_node_id,
269
+ )
270
+ task_id_text = locator["task_id"]
271
+ resolved_app_key = str(locator["app_key"])
272
+ resolved_record_id = int(locator["record_id"])
273
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
274
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
275
+ data = self._build_task_context(
276
+ profile=profile,
277
+ context=context,
278
+ app_key=resolved_app_key,
279
+ record_id=resolved_record_id,
280
+ workflow_node_id=resolved_workflow_node_id,
281
+ include_candidates=include_candidates,
282
+ include_associated_reports=include_associated_reports,
283
+ current_uid=session_profile.uid,
284
+ )
285
+ data = self._compact_task_get_context(data)
286
+ task_payload = data.get("task")
287
+ if isinstance(task_payload, dict) and task_id_text is not None:
288
+ task_payload["task_id"] = task_id_text
289
+ return {
290
+ "profile": profile,
291
+ "ws_id": session_profile.selected_ws_id,
292
+ "ok": True,
293
+ "request_route": self._request_route_payload(context),
294
+ "warnings": [],
295
+ "output_profile": "normal",
296
+ "data": data,
297
+ }
298
+
299
+ return self._run(profile, runner)
300
+
301
+ @tool_cn_name("任务仅保存")
302
+ def task_save_only(
303
+ self,
304
+ *,
305
+ profile: str,
306
+ app_key: str,
307
+ record_id: Any,
308
+ workflow_node_id: int,
309
+ fields: dict[str, Any] | None = None,
310
+ ) -> dict[str, Any]:
311
+ """执行任务相关逻辑。"""
312
+ field_updates = dict(fields or {})
313
+ if not field_updates:
314
+ raise_tool_error(QingflowApiError.config_error("fields is required and must be non-empty for task_save_only"))
315
+ return self.task_action_execute(
316
+ profile=profile,
317
+ app_key=app_key,
318
+ record_id=record_id,
319
+ workflow_node_id=workflow_node_id,
320
+ action="save_only",
321
+ payload={},
322
+ fields=field_updates,
323
+ )
324
+
325
+ @tool_cn_name("执行任务动作")
326
+ def task_action_execute(
327
+ self,
328
+ *,
329
+ profile: str,
330
+ task_id: Any = None,
331
+ app_key: str = "",
332
+ record_id: Any = "",
333
+ workflow_node_id: int = 0,
334
+ action: str,
335
+ payload: dict[str, Any],
336
+ fields: dict[str, Any] | None = None,
337
+ ) -> dict[str, Any]:
338
+ """执行任务相关逻辑。"""
339
+ if task_id in (None, ""):
340
+ normalize_positive_id_int(record_id, field_name="record_id")
341
+
342
+ normalized_action = (action or "").strip().lower()
343
+ if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
344
+ raise_tool_error(
345
+ QingflowApiError.not_supported(
346
+ "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, urge, or save_only"
347
+ )
348
+ )
349
+ body = dict(payload or {})
350
+ field_updates = dict(fields or {})
351
+ if field_updates and body.get("answers") is not None:
352
+ raise_tool_error(
353
+ QingflowApiError.config_error(
354
+ "task actions must not provide payload.answers and fields at the same time; pass field changes through fields only"
355
+ )
356
+ )
357
+
358
+ def runner(session_profile, context):
359
+ locator = self._resolve_task_locator_input(
360
+ profile=profile,
361
+ task_id=task_id,
362
+ app_key=app_key,
363
+ record_id=record_id,
364
+ workflow_node_id=workflow_node_id,
365
+ )
366
+ task_id_text = locator["task_id"]
367
+ resolved_app_key = str(locator["app_key"])
368
+ resolved_record_id = int(locator["record_id"])
369
+ resolved_record_id_text = str(locator["record_id_text"] or "")
370
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
371
+ record_id_text = resolved_record_id_text
372
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
373
+ try:
374
+ task_context = self._build_task_context(
375
+ profile=profile,
376
+ context=context,
377
+ app_key=resolved_app_key,
378
+ record_id=resolved_record_id,
379
+ workflow_node_id=resolved_workflow_node_id,
380
+ include_candidates=False,
381
+ include_associated_reports=False,
382
+ current_uid=session_profile.uid,
383
+ )
384
+ except QingflowApiError as error:
385
+ if error.backend_code == 46001:
386
+ return self._task_action_visibility_unverified_response(
387
+ profile=profile,
388
+ session_profile=session_profile,
389
+ context=context,
390
+ app_key=resolved_app_key,
391
+ record_id=resolved_record_id,
392
+ workflow_node_id=resolved_workflow_node_id,
393
+ action=normalized_action,
394
+ source_error=error,
395
+ before_apply_status=None,
396
+ task_id=task_id_text,
397
+ )
398
+ raise
399
+ if normalized_action == "save_only" and not field_updates:
400
+ raise_tool_error(
401
+ QingflowApiError.config_error("fields is required and must be non-empty for action 'save_only'")
402
+ )
403
+ if normalized_action == "transfer":
404
+ target_member_id = self._extract_positive_int(body, "target_member_id", aliases=("uid", "targetMemberId"))
405
+ if target_member_id == session_profile.uid:
406
+ raise_tool_error(
407
+ QingflowApiError.config_error(
408
+ "task transfer does not support transferring to the current user; choose another transfer member"
409
+ )
410
+ )
411
+ capabilities = task_context.get("capabilities") or {}
412
+ available_actions = capabilities.get("available_actions") or []
413
+ if normalized_action not in available_actions:
414
+ if normalized_action == "save_only":
415
+ capability_warnings = capabilities.get("warnings") or []
416
+ message = (
417
+ "task action 'save_only' is not currently available for the current node; "
418
+ "MCP only exposes save_only when backend editableQueIds returns a non-empty result"
419
+ )
420
+ if capability_warnings:
421
+ message += "; backend editableQueIds is unavailable or empty for this task context"
422
+ raise_tool_error(QingflowApiError.config_error(message))
423
+ raise_tool_error(
424
+ QingflowApiError.config_error(
425
+ f"task action '{normalized_action}' is not currently available for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
426
+ )
427
+ )
428
+ feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
429
+ if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
430
+ raise_tool_error(
431
+ QingflowApiError.config_error(
432
+ f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
433
+ )
434
+ )
435
+ if normalized_action == "urge" and field_updates:
436
+ raise_tool_error(
437
+ QingflowApiError.not_supported(
438
+ "TASK_ACTION_FIELDS_NOT_SUPPORTED: action 'urge' does not support fields because the downstream route does not accept task answers"
439
+ )
440
+ )
441
+ prepared_fields = None
442
+ if field_updates:
443
+ prepared_fields = self._prepare_task_field_update(
444
+ profile=profile,
445
+ context=context,
446
+ app_key=resolved_app_key,
447
+ record_id=resolved_record_id,
448
+ workflow_node_id=resolved_workflow_node_id,
449
+ task_context=task_context,
450
+ fields=field_updates,
451
+ )
452
+ before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
453
+ runtime_baseline = None
454
+ if normalized_action != "save_only":
455
+ runtime_baseline = self._capture_task_runtime_baseline(
456
+ profile=profile,
457
+ context=context,
458
+ app_key=resolved_app_key,
459
+ record_id=resolved_record_id,
460
+ workflow_node_id=resolved_workflow_node_id,
461
+ )
462
+ try:
463
+ raw = self._execute_task_action(
464
+ profile=profile,
465
+ app_key=resolved_app_key,
466
+ record_id=resolved_record_id,
467
+ workflow_node_id=resolved_workflow_node_id,
468
+ normalized_action=normalized_action,
469
+ payload=body,
470
+ prepared_fields=prepared_fields,
471
+ )
472
+ except QingflowApiError as error:
473
+ if error.backend_code == 46001:
474
+ return self._task_action_visibility_unverified_response(
475
+ profile=profile,
476
+ session_profile=session_profile,
477
+ context=context,
478
+ app_key=resolved_app_key,
479
+ record_id=resolved_record_id,
480
+ workflow_node_id=resolved_workflow_node_id,
481
+ action=normalized_action,
482
+ source_error=error,
483
+ before_apply_status=before_apply_status,
484
+ task_id=task_id_text,
485
+ )
486
+ raise
487
+
488
+ if normalized_action == "save_only":
489
+ verification, warnings = self._verify_task_save_only(
490
+ context=context,
491
+ app_key=resolved_app_key,
492
+ record_id=resolved_record_id,
493
+ workflow_node_id=resolved_workflow_node_id,
494
+ before_apply_status=before_apply_status,
495
+ expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
496
+ task_context=task_context,
497
+ )
498
+ save_verified = bool(verification.get("fields_saved_verified")) and bool(verification.get("task_still_actionable"))
499
+ status = "success" if save_verified else "failed"
500
+ error_code = None if save_verified else "TASK_SAVE_ONLY_VERIFICATION_FAILED"
501
+ else:
502
+ verification, warnings = self._verify_task_action_runtime(
503
+ profile=profile,
504
+ context=context,
505
+ app_key=resolved_app_key,
506
+ record_id=resolved_record_id,
507
+ workflow_node_id=resolved_workflow_node_id,
508
+ action=normalized_action,
509
+ before_apply_status=before_apply_status,
510
+ runtime_baseline=runtime_baseline,
511
+ )
512
+ runtime_verified = bool(verification.get("runtime_continuation_verified"))
513
+ status = "success" if runtime_verified else "partial_success"
514
+ error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
515
+ result = {
516
+ "profile": raw.get("profile", profile),
517
+ "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
518
+ "ok": bool(raw.get("ok", True)) and status != "failed",
519
+ "status": status,
520
+ "error_code": error_code,
521
+ "request_route": raw.get("request_route") or self._request_route_payload(context),
522
+ "warnings": warnings,
523
+ "verification": verification,
524
+ "output_profile": "normal",
525
+ "data": {
526
+ "action": normalized_action,
527
+ "resource": {
528
+ "app_key": resolved_app_key,
529
+ "record_id": record_id_text,
530
+ "workflow_node_id": resolved_workflow_node_id,
531
+ },
532
+ "selection": {"action": normalized_action},
533
+ "result": raw.get("result"),
534
+ "human_review": True,
535
+ "field_update_applied": bool(field_updates),
536
+ },
537
+ }
538
+ if task_id_text is not None:
539
+ resource = result["data"].get("resource")
540
+ if isinstance(resource, dict):
541
+ resource["task_id"] = task_id_text
542
+ return result
543
+
544
+ return self._run(profile, runner)
545
+
546
+ def _execute_task_action(
547
+ self,
548
+ *,
549
+ profile: str,
550
+ app_key: str,
551
+ record_id: Any,
552
+ workflow_node_id: int,
553
+ normalized_action: str,
554
+ payload: dict[str, Any],
555
+ prepared_fields: dict[str, Any] | None,
556
+ ) -> dict[str, Any]:
557
+ """执行内部辅助逻辑。"""
558
+ merged_answers = None
559
+ normalized_answers = None
560
+ if isinstance(prepared_fields, dict):
561
+ candidate_answers = prepared_fields.get("merged_answers")
562
+ if isinstance(candidate_answers, list):
563
+ merged_answers = candidate_answers
564
+ candidate_normalized = prepared_fields.get("normalized_answers")
565
+ if isinstance(candidate_normalized, list):
566
+ normalized_answers = candidate_normalized
567
+ if normalized_action == "approve":
568
+ action_payload = dict(payload)
569
+ action_payload["nodeId"] = workflow_node_id
570
+ if merged_answers is not None:
571
+ action_payload["answers"] = merged_answers
572
+ return self._approval_tools.record_approve(
573
+ profile=profile,
574
+ app_key=app_key,
575
+ apply_id=record_id,
576
+ payload=action_payload,
577
+ )
578
+ if normalized_action == "reject":
579
+ action_payload = dict(payload)
580
+ action_payload["nodeId"] = workflow_node_id
581
+ if merged_answers is not None:
582
+ action_payload["answers"] = merged_answers
583
+ if not self._extract_audit_feedback(action_payload):
584
+ raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
585
+ return self._approval_tools.record_reject(
586
+ profile=profile,
587
+ app_key=app_key,
588
+ apply_id=record_id,
589
+ payload=action_payload,
590
+ )
591
+ if normalized_action == "rollback":
592
+ target_node_id = self._extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
593
+ action_payload: JSONObject = {
594
+ "auditNodeId": workflow_node_id,
595
+ "targetAuditNodeId": target_node_id,
596
+ }
597
+ audit_feedback = self._extract_audit_feedback(payload)
598
+ if audit_feedback:
599
+ action_payload["auditFeedback"] = audit_feedback
600
+ if merged_answers is not None:
601
+ action_payload["answers"] = merged_answers
602
+ return self._approval_tools.record_rollback(
603
+ profile=profile,
604
+ app_key=app_key,
605
+ apply_id=record_id,
606
+ payload=action_payload,
607
+ )
608
+ if normalized_action == "transfer":
609
+ target_member_id = self._extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId"))
610
+ action_payload = {
611
+ "auditNodeId": workflow_node_id,
612
+ "uid": target_member_id,
613
+ }
614
+ audit_feedback = self._extract_audit_feedback(payload)
615
+ if audit_feedback:
616
+ action_payload["auditFeedback"] = audit_feedback
617
+ if merged_answers is not None:
618
+ action_payload["answers"] = merged_answers
619
+ return self._approval_tools.record_transfer(
620
+ profile=profile,
621
+ app_key=app_key,
622
+ apply_id=record_id,
623
+ payload=action_payload,
624
+ )
625
+ if normalized_action == "save_only":
626
+ if normalized_answers is None:
627
+ raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
628
+ return self._task_save_only(
629
+ profile=profile,
630
+ app_key=app_key,
631
+ record_id=record_id,
632
+ workflow_node_id=workflow_node_id,
633
+ apply_answers=normalized_answers,
634
+ )
635
+ return self._task_tools.task_urge(
636
+ profile=profile,
637
+ app_key=app_key,
638
+ row_record_id=record_id,
639
+ )
640
+
641
+ def _verify_task_action_runtime(
642
+ self,
643
+ *,
644
+ profile: str,
645
+ context: BackendRequestContext,
646
+ app_key: str,
647
+ record_id: int,
648
+ workflow_node_id: int,
649
+ action: str,
650
+ before_apply_status: Any,
651
+ runtime_baseline: dict[str, Any] | None = None,
652
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
653
+ """执行内部辅助逻辑。"""
654
+ verification: dict[str, Any] = {
655
+ "action_executed": True,
656
+ "runtime_continuation_verified": action == "urge",
657
+ "scope": "workflow_runtime",
658
+ "task_context_visibility_verified": True,
659
+ }
660
+ warnings: list[dict[str, Any]] = []
661
+ if action == "urge":
662
+ return verification, warnings
663
+
664
+ state_after: dict[str, Any] | None = None
665
+ try:
666
+ state_after = self.backend.request(
667
+ "GET",
668
+ context,
669
+ f"/app/{app_key}/apply/{record_id}",
670
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
671
+ )
672
+ verification["record_state_readable"] = True
673
+ verification["before_apply_status"] = before_apply_status
674
+ verification["after_apply_status"] = state_after.get("applyStatus") if isinstance(state_after, dict) else None
675
+ verification["record_state_changed"] = verification["after_apply_status"] != before_apply_status
676
+ except QingflowApiError as error:
677
+ verification["record_state_readable"] = False
678
+ verification["record_state_changed"] = False
679
+ verification["record_state_error"] = {
680
+ "http_status": error.http_status,
681
+ "backend_code": error.backend_code,
682
+ "category": error.category,
683
+ }
684
+
685
+ log_items: list[dict[str, Any]] = []
686
+ try:
687
+ log_page = self.backend.request(
688
+ "POST",
689
+ context,
690
+ "/application/workflow/node/record",
691
+ json_body={
692
+ "key": app_key,
693
+ "rowRecordId": record_id,
694
+ "nodeId": workflow_node_id,
695
+ "role": 3,
696
+ "pageNum": 1,
697
+ "pageSize": 50,
698
+ },
699
+ )
700
+ log_items = self._normalize_workflow_logs(log_page)
701
+ verification["workflow_log_visible"] = True
702
+ verification["workflow_log_count"] = len(log_items)
703
+ except QingflowApiError as error:
704
+ verification["workflow_log_visible"] = False
705
+ verification["workflow_log_count"] = None
706
+ verification["workflow_log_error"] = {
707
+ "http_status": error.http_status,
708
+ "backend_code": error.backend_code,
709
+ "category": error.category,
710
+ }
711
+
712
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
713
+ initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
714
+ downstream_todo_detected = any(
715
+ ids_equal(item.get("record_id"), record_id) and int(item.get("workflow_node_id") or 0) != workflow_node_id
716
+ for item in todo_items
717
+ if isinstance(item, dict)
718
+ )
719
+ initiated_visible = any(
720
+ ids_equal(item.get("record_id"), record_id)
721
+ for item in initiated_items
722
+ if isinstance(item, dict)
723
+ )
724
+ verification["downstream_todo_detected"] = downstream_todo_detected
725
+ verification["initiated_task_visible"] = initiated_visible
726
+ baseline_downstream_nodes = set()
727
+ baseline_log_count = None
728
+ baseline_log_digest = None
729
+ if isinstance(runtime_baseline, dict):
730
+ baseline_downstream_nodes = set(runtime_baseline.get("downstream_todo_nodes") or [])
731
+ baseline_log_count = runtime_baseline.get("workflow_log_count")
732
+ baseline_log_digest = runtime_baseline.get("workflow_log_digest")
733
+ current_downstream_nodes = {
734
+ int(item.get("workflow_node_id") or 0)
735
+ for item in todo_items
736
+ if isinstance(item, dict)
737
+ and ids_equal(item.get("record_id"), record_id)
738
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
739
+ }
740
+ workflow_log_digest = self._workflow_log_digest(log_items)
741
+ verification["downstream_todo_nodes"] = sorted(node_id for node_id in current_downstream_nodes if node_id > 0)
742
+ verification["downstream_todo_changed"] = current_downstream_nodes != baseline_downstream_nodes
743
+ verification["workflow_log_advanced"] = bool(
744
+ verification.get("workflow_log_visible")
745
+ and (
746
+ (isinstance(baseline_log_count, int) and len(log_items) > baseline_log_count)
747
+ or (baseline_log_digest is not None and workflow_log_digest is not None and workflow_log_digest != baseline_log_digest)
748
+ )
749
+ )
750
+ runtime_verified = bool(
751
+ verification.get("record_state_changed")
752
+ or verification.get("downstream_todo_changed")
753
+ or verification.get("workflow_log_advanced")
754
+ )
755
+ record_state_error = verification.get("record_state_error")
756
+ runtime_consumed_after_action = bool(
757
+ runtime_verified
758
+ and isinstance(record_state_error, dict)
759
+ and record_state_error.get("backend_code") == 46001
760
+ )
761
+ if runtime_consumed_after_action:
762
+ verification["record_state_scope"] = "current_node_runtime"
763
+ verification["record_state_unavailable_reason"] = "runtime_consumed_after_action"
764
+ verification["record_state_unavailability_expected"] = True
765
+ warnings.append(
766
+ {
767
+ "code": "TASK_RUNTIME_CONSUMED_AFTER_ACTION",
768
+ "message": (
769
+ "the current workflow node runtime is no longer readable after the action (backend 46001), "
770
+ "which usually means the node has been consumed and the workflow has already continued."
771
+ ),
772
+ }
773
+ )
774
+ verification["runtime_continuation_verified"] = runtime_verified
775
+ if not runtime_verified:
776
+ warnings.append(
777
+ {
778
+ "code": "WORKFLOW_CONTINUATION_UNVERIFIED",
779
+ "message": "task action executed, but MCP could not verify downstream workflow continuation from record state, workflow logs, or downstream todo tasks.",
780
+ }
781
+ )
782
+ return verification, warnings
783
+
784
+ def _verify_task_save_only(
785
+ self,
786
+ *,
787
+ context: BackendRequestContext,
788
+ app_key: str,
789
+ record_id: int,
790
+ workflow_node_id: int,
791
+ before_apply_status: Any,
792
+ expected_answers: list[dict[str, Any]],
793
+ task_context: dict[str, Any],
794
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
795
+ """执行内部辅助逻辑。"""
796
+ verification: dict[str, Any] = {
797
+ "action_executed": True,
798
+ "scope": "task_field_save",
799
+ "runtime_continuation_verified": False,
800
+ "task_context_visibility_verified": True,
801
+ "fields_saved_verified": False,
802
+ "task_still_actionable": False,
803
+ "workflow_not_advanced": False,
804
+ "before_apply_status": before_apply_status,
805
+ }
806
+ warnings: list[dict[str, Any]] = []
807
+ try:
808
+ detail = self.backend.request(
809
+ "GET",
810
+ context,
811
+ f"/app/{app_key}/apply/{record_id}",
812
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
813
+ )
814
+ except QingflowApiError as error:
815
+ verification["record_state_readable"] = False
816
+ verification["task_context_visibility_verified"] = False
817
+ verification["transport_error"] = {
818
+ "http_status": error.http_status,
819
+ "backend_code": error.backend_code,
820
+ "category": error.category,
821
+ }
822
+ warnings.append(
823
+ {
824
+ "code": "TASK_SAVE_ONLY_UNVERIFIED",
825
+ "message": "save_only write was sent, but MCP could not re-read the current task context to verify that the node remains actionable and fields were saved.",
826
+ }
827
+ )
828
+ return verification, warnings
829
+
830
+ verification["record_state_readable"] = True
831
+ verification["task_still_actionable"] = True
832
+ after_apply_status = detail.get("applyStatus") if isinstance(detail, dict) else None
833
+ verification["after_apply_status"] = after_apply_status
834
+ verification["workflow_not_advanced"] = after_apply_status == before_apply_status
835
+ current_record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
836
+ actual_answers = detail.get("answers") if isinstance(detail, dict) and isinstance(detail.get("answers"), list) else []
837
+ expected_by_id = {
838
+ que_id: answer
839
+ for answer in expected_answers
840
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
841
+ }
842
+ actual_by_id = {
843
+ que_id: answer
844
+ for answer in actual_answers
845
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
846
+ }
847
+ update_schema = task_context.get("update_schema") if isinstance(task_context.get("update_schema"), dict) else {}
848
+ writable_titles = {
849
+ item.get("title"): item
850
+ for item in (update_schema.get("writable_fields") or [])
851
+ if isinstance(item, dict) and item.get("title")
852
+ }
853
+ missing_fields: list[dict[str, Any]] = []
854
+ mismatched_fields: list[dict[str, Any]] = []
855
+ for que_id, expected in expected_by_id.items():
856
+ actual = actual_by_id.get(que_id)
857
+ title = None
858
+ if isinstance(current_record, dict):
859
+ for answer in (current_record.get("answers") or []):
860
+ if isinstance(answer, dict) and _coerce_count(answer.get("queId")) == que_id:
861
+ title = answer.get("queTitle")
862
+ break
863
+ if title is None:
864
+ title = next((key for key, item in writable_titles.items() if isinstance(item, dict) and item.get("field_id") == que_id), None)
865
+ field_payload = {"que_id": que_id, "que_title": title}
866
+ if actual is None:
867
+ missing_fields.append(field_payload)
868
+ continue
869
+ expected_value = _canonicalize_answer_value_for_compare(expected, None)
870
+ actual_value = _canonicalize_answer_value_for_compare(actual, None)
871
+ if _canonical_value_is_empty(expected_value):
872
+ continue
873
+ if actual_value != expected_value:
874
+ mismatched_fields.append(
875
+ {
876
+ **field_payload,
877
+ "expected": expected_value,
878
+ "actual": actual_value,
879
+ }
880
+ )
881
+ verification["missing_fields"] = missing_fields
882
+ verification["mismatched_fields"] = mismatched_fields
883
+ verification["fields_saved_verified"] = not missing_fields and not mismatched_fields
884
+ if not verification["workflow_not_advanced"]:
885
+ warnings.append(
886
+ {
887
+ "code": "TASK_SAVE_ONLY_ADVANCED_WORKFLOW",
888
+ "message": "save_only unexpectedly changed the workflow runtime state; the task should have remained on the current node.",
889
+ }
890
+ )
891
+ if not verification["fields_saved_verified"]:
892
+ warnings.append(
893
+ {
894
+ "code": "TASK_SAVE_ONLY_FIELD_VERIFICATION_FAILED",
895
+ "message": "save_only completed, but MCP could not verify that all requested field changes were persisted on the current task node.",
896
+ }
897
+ )
898
+ return verification, warnings
899
+
900
+ def _capture_task_runtime_baseline(
901
+ self,
902
+ *,
903
+ profile: str,
904
+ context: BackendRequestContext,
905
+ app_key: str,
906
+ record_id: int,
907
+ workflow_node_id: int,
908
+ ) -> dict[str, Any]:
909
+ """执行内部辅助逻辑。"""
910
+ baseline: dict[str, Any] = {
911
+ "workflow_log_visible": False,
912
+ "workflow_log_count": None,
913
+ "workflow_log_digest": None,
914
+ "downstream_todo_nodes": [],
915
+ }
916
+ try:
917
+ log_page = self.backend.request(
918
+ "POST",
919
+ context,
920
+ "/application/workflow/node/record",
921
+ json_body={
922
+ "key": app_key,
923
+ "rowRecordId": record_id,
924
+ "nodeId": workflow_node_id,
925
+ "role": 3,
926
+ "pageNum": 1,
927
+ "pageSize": 50,
928
+ },
929
+ )
930
+ log_items = self._normalize_workflow_logs(log_page)
931
+ baseline["workflow_log_visible"] = True
932
+ baseline["workflow_log_count"] = len(log_items)
933
+ baseline["workflow_log_digest"] = self._workflow_log_digest(log_items)
934
+ except QingflowApiError:
935
+ pass
936
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
937
+ baseline["downstream_todo_nodes"] = sorted(
938
+ {
939
+ int(item.get("workflow_node_id") or 0)
940
+ for item in todo_items
941
+ if isinstance(item, dict)
942
+ and ids_equal(item.get("record_id"), record_id)
943
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
944
+ }
945
+ )
946
+ return baseline
947
+
948
+ def _task_action_visibility_unverified_response(
949
+ self,
950
+ *,
951
+ profile: str,
952
+ session_profile,
953
+ context: BackendRequestContext,
954
+ app_key: str,
955
+ record_id: int,
956
+ workflow_node_id: int,
957
+ action: str,
958
+ source_error: QingflowApiError,
959
+ before_apply_status: Any,
960
+ task_id: str | None = None,
961
+ ) -> dict[str, Any]:
962
+ """执行内部辅助逻辑。"""
963
+ record_id_text = stringify_backend_id(record_id)
964
+ verification, warnings = self._verify_task_action_runtime(
965
+ profile=profile,
966
+ context=context,
967
+ app_key=app_key,
968
+ record_id=record_id,
969
+ workflow_node_id=workflow_node_id,
970
+ action=action,
971
+ before_apply_status=before_apply_status,
972
+ )
973
+ verification["action_executed"] = False
974
+ verification["task_context_visibility_verified"] = bool(verification.get("runtime_continuation_verified"))
975
+ if verification["task_context_visibility_verified"]:
976
+ warnings.append(
977
+ {
978
+ "code": "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR",
979
+ "message": "the task is no longer actionable in the current context; MCP found downstream workflow evidence and treats it as already processed by another actor.",
980
+ }
981
+ )
982
+ result = {
983
+ "profile": profile,
984
+ "ws_id": session_profile.selected_ws_id,
985
+ "ok": True,
986
+ "status": "partial_success",
987
+ "error_code": "TASK_ALREADY_PROCESSED",
988
+ "request_route": self._request_route_payload(context),
989
+ "warnings": warnings,
990
+ "verification": verification,
991
+ "output_profile": "normal",
992
+ "data": {
993
+ "action": action,
994
+ "resource": {
995
+ "app_key": app_key,
996
+ "record_id": record_id_text,
997
+ "workflow_node_id": workflow_node_id,
998
+ },
999
+ "selection": {"action": action},
1000
+ "result": None,
1001
+ "human_review": True,
1002
+ },
1003
+ }
1004
+ if task_id is not None:
1005
+ resource = result["data"].get("resource")
1006
+ if isinstance(resource, dict):
1007
+ resource["task_id"] = task_id
1008
+ return result
1009
+ warnings.append(
1010
+ {
1011
+ "code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
1012
+ "message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
1013
+ }
1014
+ )
1015
+ result = {
1016
+ "profile": profile,
1017
+ "ws_id": session_profile.selected_ws_id,
1018
+ "ok": False,
1019
+ "status": "failed",
1020
+ "error_code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
1021
+ "request_route": self._request_route_payload(context),
1022
+ "warnings": warnings,
1023
+ "verification": verification,
1024
+ "output_profile": "normal",
1025
+ "data": {
1026
+ "action": action,
1027
+ "resource": {
1028
+ "app_key": app_key,
1029
+ "record_id": record_id_text,
1030
+ "workflow_node_id": workflow_node_id,
1031
+ },
1032
+ "selection": {"action": action},
1033
+ "result": None,
1034
+ "human_review": True,
1035
+ "transport_error": {
1036
+ "http_status": source_error.http_status,
1037
+ "backend_code": source_error.backend_code,
1038
+ "category": source_error.category,
1039
+ },
1040
+ },
1041
+ }
1042
+ if task_id is not None:
1043
+ resource = result["data"].get("resource")
1044
+ if isinstance(resource, dict):
1045
+ resource["task_id"] = task_id
1046
+ return result
1047
+
1048
+ def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
1049
+ """执行内部辅助逻辑。"""
1050
+ try:
1051
+ response = self._list_normalized_task_items(
1052
+ profile=profile,
1053
+ task_box=task_box,
1054
+ flow_status="all",
1055
+ app_key=app_key,
1056
+ workflow_node_id=None,
1057
+ query=None,
1058
+ page=1,
1059
+ page_size=50,
1060
+ )
1061
+ except QingflowApiError:
1062
+ return []
1063
+ items = response.get("items") if isinstance(response, dict) else None
1064
+ if not isinstance(items, list):
1065
+ return []
1066
+ return [item for item in items if isinstance(item, dict)]
1067
+
1068
+ def _list_normalized_task_items(
1069
+ self,
1070
+ *,
1071
+ profile: str,
1072
+ task_box: str,
1073
+ flow_status: str,
1074
+ app_key: str | None,
1075
+ workflow_node_id: int | None,
1076
+ query: str | None,
1077
+ page: int,
1078
+ page_size: int,
1079
+ ) -> dict[str, Any]:
1080
+ normalized_type = self._task_tools._task_box_to_type(task_box)
1081
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
1082
+ raw = self._task_tools.task_list(
1083
+ profile=profile,
1084
+ type=normalized_type,
1085
+ process_status=normalized_status,
1086
+ app_key=app_key,
1087
+ node_id=workflow_node_id,
1088
+ search_key=query,
1089
+ page_num=page,
1090
+ page_size=page_size,
1091
+ create_time_asc=None,
1092
+ )
1093
+ task_page = raw.get("page", {})
1094
+ return {
1095
+ "raw": raw,
1096
+ "items": [self._normalize_task_item(item) for item in _task_page_items(task_page) if isinstance(item, dict)],
1097
+ "page_amount": _task_page_amount(task_page),
1098
+ "reported_total": _task_page_total(task_page),
1099
+ }
1100
+
1101
+ def _task_list_local_query_fallback(
1102
+ self,
1103
+ *,
1104
+ profile: str,
1105
+ task_box: str,
1106
+ flow_status: str,
1107
+ app_key: str | None,
1108
+ workflow_node_id: int | None,
1109
+ query: str,
1110
+ page: int,
1111
+ page_size: int,
1112
+ ) -> dict[str, Any] | None:
1113
+ scan_page_size = max(page_size, 100)
1114
+ scan_page = 1
1115
+ page_amount: int | None = None
1116
+ matched_items: list[dict[str, Any]] = []
1117
+ while True:
1118
+ response = self._list_normalized_task_items(
1119
+ profile=profile,
1120
+ task_box=task_box,
1121
+ flow_status=flow_status,
1122
+ app_key=app_key,
1123
+ workflow_node_id=workflow_node_id,
1124
+ query=None,
1125
+ page=scan_page,
1126
+ page_size=scan_page_size,
1127
+ )
1128
+ normalized_items = response.get("items") if isinstance(response.get("items"), list) else []
1129
+ matched_items.extend(item for item in normalized_items if self._task_item_matches_query(item, query))
1130
+ if page_amount is None:
1131
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1132
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1133
+ page_amount = coerced_page_amount
1134
+ if page_amount is not None and scan_page >= page_amount:
1135
+ break
1136
+ if not normalized_items or len(normalized_items) < scan_page_size:
1137
+ break
1138
+ scan_page += 1
1139
+ if not matched_items:
1140
+ return None
1141
+ start = max(page - 1, 0) * page_size
1142
+ end = start + page_size
1143
+ matched_total = len(matched_items)
1144
+ matched_page_amount = (matched_total + page_size - 1) // page_size if page_size > 0 else 0
1145
+ return {
1146
+ "items": matched_items[start:end],
1147
+ "page_amount": matched_page_amount,
1148
+ "reported_total": matched_total,
1149
+ }
1150
+
1151
+ def _resolve_task_locator_by_task_id(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1152
+ task_id_text = normalize_positive_id_text(task_id, field_name="task_id")
1153
+ searched_task_boxes = ("todo", "initiated", "cc", "done")
1154
+ incomplete_task_boxes: list[str] = []
1155
+ page_size = 100
1156
+ for task_box in searched_task_boxes:
1157
+ page = 1
1158
+ page_amount: int | None = None
1159
+ while True:
1160
+ response = self._list_normalized_task_items(
1161
+ profile=profile,
1162
+ task_box=task_box,
1163
+ flow_status="all",
1164
+ app_key=None,
1165
+ workflow_node_id=None,
1166
+ query=None,
1167
+ page=page,
1168
+ page_size=page_size,
1169
+ )
1170
+ items = response.get("items") if isinstance(response.get("items"), list) else []
1171
+ for item in items:
1172
+ if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1173
+ continue
1174
+ app_key = str(item.get("app_key") or "").strip()
1175
+ record_id = stringify_backend_id(item.get("record_id"))
1176
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
1177
+ if not app_key or record_id is None or workflow_node_id <= 0:
1178
+ incomplete_task_boxes.append(task_box)
1179
+ continue
1180
+ return {
1181
+ "task_id": task_id_text,
1182
+ "task_box": task_box,
1183
+ "app_key": app_key,
1184
+ "record_id": record_id,
1185
+ "workflow_node_id": workflow_node_id,
1186
+ }
1187
+ if page_amount is None:
1188
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1189
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1190
+ page_amount = coerced_page_amount
1191
+ if page_amount is not None and page >= page_amount:
1192
+ break
1193
+ if not items or len(items) < page_size:
1194
+ break
1195
+ page += 1
1196
+ if incomplete_task_boxes:
1197
+ searched = ", ".join(incomplete_task_boxes)
1198
+ raise_tool_error(
1199
+ QingflowApiError.config_error(
1200
+ f"task_id={task_id_text} resolved to an incomplete task locator in task_box={searched}; please refresh the task list and retry"
1201
+ )
1202
+ )
1203
+ raise_tool_error(
1204
+ QingflowApiError.config_error(
1205
+ f"task_id={task_id_text} was not found in the current visible task boxes (todo, initiated, cc, done)"
1206
+ )
1207
+ )
1208
+
1209
+ def _resolve_task_locator_input(
1210
+ self,
1211
+ *,
1212
+ profile: str,
1213
+ task_id: Any = None,
1214
+ app_key: str = "",
1215
+ record_id: Any = "",
1216
+ workflow_node_id: int = 0,
1217
+ ) -> dict[str, Any]:
1218
+ task_id_text = normalize_positive_id_text(task_id, field_name="task_id") if task_id not in (None, "") else None
1219
+ resolved_app_key = (app_key or "").strip()
1220
+ resolved_record_id: int
1221
+ resolved_workflow_node_id: int
1222
+ if task_id_text is not None:
1223
+ locator = self._resolve_task_locator_by_task_id(profile=profile, task_id=task_id_text)
1224
+ resolved_app_key = str(locator["app_key"])
1225
+ resolved_record_id = normalize_positive_id_int(locator["record_id"], field_name="record_id")
1226
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1227
+ explicit_app_key = (app_key or "").strip()
1228
+ if explicit_app_key and explicit_app_key != resolved_app_key:
1229
+ raise_tool_error(
1230
+ QingflowApiError.config_error(
1231
+ f"task_id={task_id_text} resolved to app_key='{resolved_app_key}', which does not match app_key='{explicit_app_key}'"
1232
+ )
1233
+ )
1234
+ if record_id not in (None, ""):
1235
+ explicit_record_id = normalize_positive_id_text(record_id, field_name="record_id")
1236
+ if explicit_record_id != stringify_backend_id(resolved_record_id):
1237
+ raise_tool_error(
1238
+ QingflowApiError.config_error(
1239
+ f"task_id={task_id_text} resolved to record_id={resolved_record_id}, which does not match record_id={explicit_record_id}"
1240
+ )
1241
+ )
1242
+ if workflow_node_id not in (None, 0) and int(workflow_node_id) != resolved_workflow_node_id:
1243
+ raise_tool_error(
1244
+ QingflowApiError.config_error(
1245
+ f"task_id={task_id_text} resolved to workflow_node_id={resolved_workflow_node_id}, which does not match workflow_node_id={workflow_node_id}"
1246
+ )
1247
+ )
1248
+ else:
1249
+ resolved_record_id = normalize_positive_id_int(record_id, field_name="record_id")
1250
+ resolved_workflow_node_id = int(workflow_node_id)
1251
+ return {
1252
+ "task_id": task_id_text,
1253
+ "app_key": resolved_app_key,
1254
+ "record_id": resolved_record_id,
1255
+ "record_id_text": stringify_backend_id(resolved_record_id),
1256
+ "workflow_node_id": resolved_workflow_node_id,
1257
+ }
1258
+
1259
+ def _task_item_matches_query(self, item: dict[str, Any], query: str) -> bool:
1260
+ needle = str(query or "").strip().casefold()
1261
+ if not needle:
1262
+ return False
1263
+ for candidate in (
1264
+ item.get("app_name"),
1265
+ item.get("workflow_node_name"),
1266
+ item.get("app_key"),
1267
+ item.get("record_id"),
1268
+ ):
1269
+ if candidate in (None, ""):
1270
+ continue
1271
+ if needle in str(candidate).casefold():
1272
+ return True
1273
+ return False
1274
+
1275
+ @tool_cn_name("任务关联报表详情")
1276
+ def task_associated_report_detail_get(
1277
+ self,
1278
+ *,
1279
+ profile: str,
1280
+ task_id: Any = None,
1281
+ app_key: str = "",
1282
+ record_id: Any = "",
1283
+ workflow_node_id: int = 0,
1284
+ report_id: int,
1285
+ page: int,
1286
+ page_size: int,
1287
+ ) -> dict[str, Any]:
1288
+ """执行任务相关逻辑。"""
1289
+ if task_id in (None, ""):
1290
+ normalize_positive_id_int(record_id, field_name="record_id")
1291
+
1292
+ if report_id <= 0:
1293
+ raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
1294
+ if page <= 0 or page_size <= 0:
1295
+ raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
1296
+
1297
+ def runner(session_profile, context):
1298
+ locator = self._resolve_task_locator_input(
1299
+ profile=profile,
1300
+ task_id=task_id,
1301
+ app_key=app_key,
1302
+ record_id=record_id,
1303
+ workflow_node_id=workflow_node_id,
1304
+ )
1305
+ task_id_text = locator["task_id"]
1306
+ resolved_app_key = str(locator["app_key"])
1307
+ resolved_record_id = int(locator["record_id"])
1308
+ record_id_text = str(locator["record_id_text"] or "")
1309
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1310
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
1311
+ task_context = self._build_task_context(
1312
+ profile=profile,
1313
+ context=context,
1314
+ app_key=resolved_app_key,
1315
+ record_id=resolved_record_id,
1316
+ workflow_node_id=resolved_workflow_node_id,
1317
+ include_candidates=False,
1318
+ include_associated_reports=True,
1319
+ current_uid=session_profile.uid,
1320
+ )
1321
+ report_item = self._find_associated_report(task_context, report_id)
1322
+ if report_item is None:
1323
+ raise_tool_error(
1324
+ QingflowApiError.config_error(
1325
+ f"report_id={report_id} is not visible for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
1326
+ )
1327
+ )
1328
+ association_query = self._build_association_query(
1329
+ report_item["raw"],
1330
+ task_context.get("record", {}).get("answers") or [],
1331
+ )
1332
+ selection = {
1333
+ "app_key": resolved_app_key,
1334
+ "record_id": record_id_text,
1335
+ "workflow_node_id": resolved_workflow_node_id,
1336
+ "report_id": report_id,
1337
+ "target_app_key": report_item.get("target_app_key"),
1338
+ "target_app_name": report_item.get("target_app_name"),
1339
+ "chart_key": report_item.get("chart_key"),
1340
+ "chart_name": report_item.get("chart_name"),
1341
+ }
1342
+ if task_id_text is not None:
1343
+ selection["task_id"] = task_id_text
1344
+ context_payload = {
1345
+ "match_rules": report_item.get("match_rules") or [],
1346
+ "resolved_filters": association_query.get("keyQueValues") or [],
1347
+ }
1348
+
1349
+ if report_item.get("graph_type") == "view":
1350
+ viewgraph_key = str(report_item.get("chart_key") or "")
1351
+ body = {
1352
+ "filter": {},
1353
+ "viewgraphKey": viewgraph_key,
1354
+ "equipmentType": 0,
1355
+ "associationQuery": association_query,
1356
+ }
1357
+ result = self.backend.request(
1358
+ "POST",
1359
+ context,
1360
+ f"/view/{viewgraph_key}/apply/filter",
1361
+ json_body=body,
1362
+ )
1363
+ items = _task_page_items(result)
1364
+ return {
1365
+ "profile": profile,
1366
+ "ws_id": session_profile.selected_ws_id,
1367
+ "ok": True,
1368
+ "request_route": self._request_route_payload(context),
1369
+ "warnings": [],
1370
+ "output_profile": "normal",
1371
+ "data": {
1372
+ "result_type": "view_list",
1373
+ "result": {
1374
+ "items": items,
1375
+ "pagination": {
1376
+ "page": page,
1377
+ "page_size": page_size,
1378
+ "returned_items": len(items),
1379
+ "page_amount": _task_page_amount(result),
1380
+ "reported_total": _task_page_total(result),
1381
+ },
1382
+ },
1383
+ "selection": selection,
1384
+ "context": context_payload,
1385
+ },
1386
+ }
1387
+
1388
+ chart_key = str(report_item.get("chart_key") or "")
1389
+ source_type = report_item.get("source_type")
1390
+ if source_type == "qingbi":
1391
+ qingbi_context = BackendRequestContext(
1392
+ base_url=_qingbi_base_url(context.base_url),
1393
+ token=context.token,
1394
+ ws_id=context.ws_id,
1395
+ qf_request_id=context.qf_request_id,
1396
+ qf_version=context.qf_version,
1397
+ qf_version_source=context.qf_version_source,
1398
+ )
1399
+ chart_result = self.backend.request(
1400
+ "POST",
1401
+ qingbi_context,
1402
+ f"/qingbi/charts/data/{chart_key}",
1403
+ params={
1404
+ "qfUUID": uuid4().hex,
1405
+ "pageNum": page,
1406
+ "pageSize": page_size,
1407
+ },
1408
+ json_body={
1409
+ "asosChartId": report_id,
1410
+ "keyQueValues": association_query.get("keyQueValues") or [],
1411
+ },
1412
+ )
1413
+ request_route = {
1414
+ "base_url": qingbi_context.base_url,
1415
+ "qf_version": qingbi_context.qf_version,
1416
+ "qf_version_source": qingbi_context.qf_version_source or "context",
1417
+ }
1418
+ elif self._qingflow_chart_uses_apply_filter(context, chart_key):
1419
+ chart_result = self.backend.request(
1420
+ "POST",
1421
+ context,
1422
+ f"/chart/{chart_key}/apply/filter",
1423
+ json_body={
1424
+ "filter": {
1425
+ "pageNum": page,
1426
+ "pageSize": page_size,
1427
+ },
1428
+ "asosChartId": report_id,
1429
+ "keyQueValues": association_query.get("keyQueValues") or [],
1430
+ },
1431
+ )
1432
+ items = _task_page_items(chart_result)
1433
+ return {
1434
+ "profile": profile,
1435
+ "ws_id": session_profile.selected_ws_id,
1436
+ "ok": True,
1437
+ "request_route": self._request_route_payload(context),
1438
+ "warnings": [],
1439
+ "output_profile": "normal",
1440
+ "data": {
1441
+ "result_type": "view_list",
1442
+ "result": {
1443
+ "items": items,
1444
+ "pagination": {
1445
+ "page": page,
1446
+ "page_size": page_size,
1447
+ "returned_items": len(items),
1448
+ "page_amount": _task_page_amount(chart_result),
1449
+ "reported_total": _task_page_total(chart_result),
1450
+ },
1451
+ },
1452
+ "selection": selection,
1453
+ "context": context_payload,
1454
+ },
1455
+ }
1456
+ else:
1457
+ chart_result = self.backend.request(
1458
+ "POST",
1459
+ context,
1460
+ f"/chart/{chart_key}/chartData",
1461
+ json_body={
1462
+ "asosChartId": report_id,
1463
+ "keyQueValues": association_query.get("keyQueValues") or [],
1464
+ },
1465
+ )
1466
+ request_route = self._request_route_payload(context)
1467
+
1468
+ return {
1469
+ "profile": profile,
1470
+ "ws_id": session_profile.selected_ws_id,
1471
+ "ok": True,
1472
+ "request_route": request_route,
1473
+ "warnings": [],
1474
+ "output_profile": "normal",
1475
+ "data": {
1476
+ "result_type": "chart_data",
1477
+ "result": self._normalize_chart_result(chart_result),
1478
+ "selection": selection,
1479
+ "context": context_payload,
1480
+ },
1481
+ }
1482
+
1483
+ return self._run(profile, runner)
1484
+
1485
+ @tool_cn_name("任务流程日志")
1486
+ def task_workflow_log_get(
1487
+ self,
1488
+ *,
1489
+ profile: str,
1490
+ task_id: Any = None,
1491
+ app_key: str = "",
1492
+ record_id: Any = "",
1493
+ workflow_node_id: int = 0,
1494
+ ) -> dict[str, Any]:
1495
+ """执行任务相关逻辑。"""
1496
+ if task_id in (None, ""):
1497
+ normalize_positive_id_int(record_id, field_name="record_id")
1498
+
1499
+ def runner(session_profile, context):
1500
+ locator = self._resolve_task_locator_input(
1501
+ profile=profile,
1502
+ task_id=task_id,
1503
+ app_key=app_key,
1504
+ record_id=record_id,
1505
+ workflow_node_id=workflow_node_id,
1506
+ )
1507
+ task_id_text = locator["task_id"]
1508
+ resolved_app_key = str(locator["app_key"])
1509
+ resolved_record_id = int(locator["record_id"])
1510
+ record_id_text = str(locator["record_id_text"] or "")
1511
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1512
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
1513
+ task_context = self._build_task_context(
1514
+ profile=profile,
1515
+ context=context,
1516
+ app_key=resolved_app_key,
1517
+ record_id=resolved_record_id,
1518
+ workflow_node_id=resolved_workflow_node_id,
1519
+ include_candidates=False,
1520
+ include_associated_reports=False,
1521
+ current_uid=session_profile.uid,
1522
+ )
1523
+ visibility = task_context.get("visibility") or {}
1524
+ if not visibility.get("audit_record_visible"):
1525
+ raise_tool_error(
1526
+ QingflowApiError.config_error(
1527
+ f"workflow logs are not visible for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
1528
+ )
1529
+ )
1530
+ page = self.backend.request(
1531
+ "POST",
1532
+ context,
1533
+ "/application/workflow/node/record",
1534
+ json_body={
1535
+ "key": resolved_app_key,
1536
+ "rowRecordId": resolved_record_id,
1537
+ "nodeId": resolved_workflow_node_id,
1538
+ "role": 3,
1539
+ "pageNum": 1,
1540
+ "pageSize": 200,
1541
+ },
1542
+ )
1543
+ items = self._normalize_workflow_logs(page)
1544
+ result = {
1545
+ "profile": profile,
1546
+ "ws_id": session_profile.selected_ws_id,
1547
+ "ok": True,
1548
+ "request_route": self._request_route_payload(context),
1549
+ "warnings": [],
1550
+ "output_profile": "normal",
1551
+ "data": {
1552
+ "selection": {
1553
+ "app_key": resolved_app_key,
1554
+ "record_id": record_id_text,
1555
+ "workflow_node_id": resolved_workflow_node_id,
1556
+ },
1557
+ "visibility": {
1558
+ "audit_record_visible": visibility.get("audit_record_visible"),
1559
+ "qrobot_record_visible": visibility.get("qrobot_record_visible"),
1560
+ },
1561
+ "items": items,
1562
+ },
1563
+ }
1564
+ if task_id_text is not None:
1565
+ selection = result["data"].get("selection")
1566
+ if isinstance(selection, dict):
1567
+ selection["task_id"] = task_id_text
1568
+ return result
1569
+
1570
+ return self._run(profile, runner)
1571
+
1572
+ def _build_task_context(
1573
+ self,
1574
+ profile: str,
1575
+ context: BackendRequestContext,
1576
+ *,
1577
+ app_key: str,
1578
+ record_id: int,
1579
+ workflow_node_id: int,
1580
+ include_candidates: bool,
1581
+ include_associated_reports: bool,
1582
+ current_uid: int | None = None,
1583
+ ) -> dict[str, Any]:
1584
+ """执行内部辅助逻辑。"""
1585
+ audit_infos = self.backend.request(
1586
+ "GET",
1587
+ context,
1588
+ f"/app/{app_key}/apply/{record_id}/auditInfo",
1589
+ params={"type": 1},
1590
+ )
1591
+ node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
1592
+ detail = self.backend.request(
1593
+ "GET",
1594
+ context,
1595
+ f"/app/{app_key}/apply/{record_id}",
1596
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
1597
+ )
1598
+ app_name = self._task_app_name(context=context, app_key=app_key, detail=detail, node_info=node_info)
1599
+ associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
1600
+ associated_reports = {"visible": associated_report_visible, "loaded": False, "count": 0, "items": []}
1601
+ if include_associated_reports and associated_report_visible:
1602
+ asos_chart_list = self.backend.request(
1603
+ "GET",
1604
+ context,
1605
+ f"/app/{app_key}/asosChart",
1606
+ params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
1607
+ )
1608
+ associated_items = [
1609
+ self._normalize_associated_report(item)
1610
+ for item in (asos_chart_list.get("asosCharts") or [])
1611
+ if isinstance(item, dict)
1612
+ ]
1613
+ associated_reports = {
1614
+ "visible": True,
1615
+ "loaded": True,
1616
+ "count": len(associated_items),
1617
+ "items": associated_items,
1618
+ }
1619
+ rollback_items: list[dict[str, Any]] = []
1620
+ transfer_items: list[dict[str, Any]] = []
1621
+ transfer_warnings: list[JSONObject] = []
1622
+ transfer_pagination: JSONObject = {
1623
+ "loaded": False,
1624
+ "page_size": 100,
1625
+ "fetched_pages": 0,
1626
+ "reported_total": None,
1627
+ "page_amount": None,
1628
+ "truncated": False,
1629
+ }
1630
+ if include_candidates:
1631
+ rollback_result = self.backend.request(
1632
+ "GET",
1633
+ context,
1634
+ f"/app/{app_key}/apply/{record_id}/revertNode",
1635
+ params={"auditNodeId": workflow_node_id},
1636
+ )
1637
+ rollback_items = self._rollback_candidate_items(rollback_result)
1638
+ transfer_items, transfer_warnings, transfer_pagination = self._transfer_candidate_items(
1639
+ context,
1640
+ app_key=app_key,
1641
+ record_id=record_id,
1642
+ workflow_node_id=workflow_node_id,
1643
+ current_uid=current_uid,
1644
+ )
1645
+
1646
+ update_schema_state = self._build_task_update_schema(
1647
+ profile=profile,
1648
+ context=context,
1649
+ app_key=app_key,
1650
+ record_id=record_id,
1651
+ workflow_node_id=workflow_node_id,
1652
+ node_info=node_info,
1653
+ current_answers=detail.get("answers") or [],
1654
+ )
1655
+ update_schema = update_schema_state["public_schema"]
1656
+ save_only_available, capability_warnings, save_only_source = self._resolve_task_save_only_availability(
1657
+ context,
1658
+ app_key=app_key,
1659
+ workflow_node_id=workflow_node_id,
1660
+ )
1661
+ capabilities = self._build_capabilities(
1662
+ node_info,
1663
+ allow_save_only=save_only_available,
1664
+ warnings=capability_warnings,
1665
+ save_only_source=save_only_source,
1666
+ )
1667
+ visibility = self._build_visibility(node_info, detail)
1668
+ record_id_text = stringify_backend_id(record_id)
1669
+ return {
1670
+ "task": {
1671
+ "app_key": app_key,
1672
+ "app_name": app_name,
1673
+ "record_id": record_id_text,
1674
+ "workflow_node_id": workflow_node_id,
1675
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1676
+ "actionable": True,
1677
+ },
1678
+ "node": {
1679
+ "workflow_node_id": workflow_node_id,
1680
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1681
+ "raw": dict(node_info),
1682
+ },
1683
+ "record": {
1684
+ "apply_id": stringify_backend_id(detail.get("applyId") or record_id),
1685
+ "apply_status": detail.get("applyStatus"),
1686
+ "apply_num": detail.get("applyNum"),
1687
+ "custom_apply_num": detail.get("customApplyNum"),
1688
+ "apply_user": detail.get("applyUser"),
1689
+ "apply_time": detail.get("applyTime"),
1690
+ "last_update_time": detail.get("lastUpdateTime"),
1691
+ "answers": detail.get("answers") or [],
1692
+ },
1693
+ "capabilities": capabilities,
1694
+ "field_permissions": {
1695
+ "que_auth_setting": node_info.get("queAuthSetting") or [],
1696
+ "editable_question_ids": update_schema_state["editable_question_ids"],
1697
+ "editable_question_ids_source": update_schema_state["editable_question_ids_source"],
1698
+ },
1699
+ "visibility": visibility,
1700
+ "associated_reports": associated_reports,
1701
+ "candidates": {
1702
+ "rollback_nodes": rollback_items,
1703
+ "transfer_members": transfer_items,
1704
+ "loaded": include_candidates,
1705
+ "transfer_pagination": transfer_pagination,
1706
+ "warnings": transfer_warnings,
1707
+ },
1708
+ "workflow_log_summary": {
1709
+ "visible": visibility["audit_record_visible"],
1710
+ "available": visibility["audit_record_visible"],
1711
+ "history_count": None,
1712
+ "qrobot_log_visible": visibility["qrobot_record_visible"],
1713
+ },
1714
+ "update_schema": update_schema,
1715
+ }
1716
+
1717
+ def _compact_task_get_context(self, data: dict[str, Any]) -> dict[str, Any]:
1718
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
1719
+ record = data.get("record") if isinstance(data.get("record"), dict) else {}
1720
+ capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
1721
+ update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
1722
+ associated_reports = data.get("associated_reports") if isinstance(data.get("associated_reports"), dict) else {}
1723
+ candidates = data.get("candidates") if isinstance(data.get("candidates"), dict) else {}
1724
+ workflow_log = data.get("workflow_log_summary") if isinstance(data.get("workflow_log_summary"), dict) else {}
1725
+
1726
+ available_actions = [
1727
+ str(item)
1728
+ for item in (capabilities.get("available_actions") or [])
1729
+ if str(item).strip()
1730
+ ]
1731
+ writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
1732
+ rollback_items = [
1733
+ self._compact_rollback_candidate(item)
1734
+ for item in (candidates.get("rollback_nodes") or [])
1735
+ if isinstance(item, dict)
1736
+ ]
1737
+ transfer_items = [
1738
+ self._compact_transfer_member(item)
1739
+ for item in (candidates.get("transfer_members") or [])
1740
+ if isinstance(item, dict)
1741
+ ]
1742
+ associated_items = [
1743
+ self._compact_associated_report(item)
1744
+ for item in (associated_reports.get("items") or [])
1745
+ if isinstance(item, dict)
1746
+ ]
1747
+ transfer_pagination = candidates.get("transfer_pagination") if isinstance(candidates.get("transfer_pagination"), dict) else {}
1748
+ compact: dict[str, Any] = {
1749
+ "task": {
1750
+ "app_key": task.get("app_key"),
1751
+ "app_name": task.get("app_name"),
1752
+ "record_id": stringify_backend_id(task.get("record_id")),
1753
+ "workflow_node_id": task.get("workflow_node_id"),
1754
+ "workflow_node_name": task.get("workflow_node_name"),
1755
+ "initiator": self._compact_initiator(record.get("apply_user")),
1756
+ "actionable": task.get("actionable"),
1757
+ },
1758
+ "record_summary": {
1759
+ "apply_status": record.get("apply_status"),
1760
+ "apply_num": record.get("apply_num"),
1761
+ "custom_apply_num": record.get("custom_apply_num"),
1762
+ "apply_time": record.get("apply_time"),
1763
+ "last_update_time": record.get("last_update_time"),
1764
+ "core_fields": self._task_record_core_fields(record.get("answers") or []),
1765
+ },
1766
+ "available_actions": available_actions,
1767
+ "editable_fields": [
1768
+ self._compact_task_editable_field(item, update_schema)
1769
+ for item in writable_fields
1770
+ if isinstance(item, dict)
1771
+ ],
1772
+ "extras": {
1773
+ "workflow_log": {
1774
+ "available": bool(workflow_log.get("available")),
1775
+ "qrobot_log_visible": bool(workflow_log.get("qrobot_log_visible")),
1776
+ "history_count": workflow_log.get("history_count"),
1777
+ },
1778
+ "associated_reports": {
1779
+ "available": bool(associated_reports.get("visible")),
1780
+ "loaded": bool(associated_reports.get("loaded")),
1781
+ "count": len(associated_items),
1782
+ "items": associated_items,
1783
+ },
1784
+ "rollback_candidates": {
1785
+ "available": "rollback" in available_actions,
1786
+ "loaded": bool(candidates.get("loaded")),
1787
+ "count": len(rollback_items),
1788
+ "items": rollback_items,
1789
+ },
1790
+ "transfer_candidates": {
1791
+ "available": "transfer" in available_actions,
1792
+ "loaded": bool(transfer_pagination.get("loaded")),
1793
+ "count": len(transfer_items),
1794
+ "items": transfer_items,
1795
+ "pagination": transfer_pagination,
1796
+ "warnings": candidates.get("warnings") or [],
1797
+ },
1798
+ },
1799
+ }
1800
+ action_metadata = self._compact_task_action_metadata(capabilities)
1801
+ if action_metadata:
1802
+ compact["action_metadata"] = action_metadata
1803
+ editable_metadata = self._compact_task_editable_metadata(update_schema)
1804
+ if editable_metadata:
1805
+ compact["editable_metadata"] = editable_metadata
1806
+ return compact
1807
+
1808
+ def _compact_task_action_metadata(self, capabilities: dict[str, Any]) -> dict[str, Any]:
1809
+ constraints = capabilities.get("action_constraints") if isinstance(capabilities.get("action_constraints"), dict) else {}
1810
+ metadata: dict[str, Any] = {}
1811
+ feedback_required_for = constraints.get("feedback_required_for") if isinstance(constraints.get("feedback_required_for"), list) else []
1812
+ if feedback_required_for:
1813
+ metadata["feedback_required_for"] = feedback_required_for
1814
+ visible_but_unimplemented = capabilities.get("visible_but_unimplemented_actions")
1815
+ if visible_but_unimplemented:
1816
+ metadata["visible_but_unimplemented_actions"] = visible_but_unimplemented
1817
+ if capabilities.get("save_only_source"):
1818
+ metadata["save_only_source"] = capabilities.get("save_only_source")
1819
+ if capabilities.get("warnings"):
1820
+ metadata["warnings"] = capabilities.get("warnings")
1821
+ return metadata
1822
+
1823
+ def _compact_task_editable_metadata(self, update_schema: dict[str, Any]) -> dict[str, Any]:
1824
+ metadata: dict[str, Any] = {}
1825
+ blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
1826
+ warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
1827
+ if blockers:
1828
+ metadata["blockers"] = blockers
1829
+ if warnings:
1830
+ metadata["warnings"] = warnings
1831
+ return metadata
1832
+
1833
+ def _compact_initiator(self, payload: Any) -> dict[str, Any] | None:
1834
+ if not isinstance(payload, dict):
1835
+ return None
1836
+ compact = {
1837
+ "uid": payload.get("uid"),
1838
+ "displayName": payload.get("displayName") or payload.get("name") or payload.get("nickName"),
1839
+ "email": payload.get("email"),
1840
+ "mobile": payload.get("mobile"),
1841
+ "headImg": payload.get("headImg"),
1842
+ }
1843
+ return {key: value for key, value in compact.items() if value not in (None, "", [])} or None
1844
+
1845
+ def _task_app_name(
1846
+ self,
1847
+ *,
1848
+ context: BackendRequestContext,
1849
+ app_key: str,
1850
+ detail: dict[str, Any],
1851
+ node_info: dict[str, Any],
1852
+ ) -> Any:
1853
+ for source in (detail, node_info):
1854
+ for key in ("formTitle", "appName", "worksheetName", "appTitle"):
1855
+ value = source.get(key)
1856
+ if value not in (None, ""):
1857
+ if app_key:
1858
+ self._app_name_cache[app_key] = str(value)
1859
+ return value
1860
+ normalized_app_key = str(app_key or "").strip()
1861
+ if not normalized_app_key:
1862
+ return None
1863
+ if normalized_app_key in self._app_name_cache:
1864
+ return self._app_name_cache[normalized_app_key]
1865
+ resolved = self._resolve_task_app_name_from_base_info(context=context, app_key=normalized_app_key)
1866
+ if resolved is None:
1867
+ resolved = self._resolve_task_app_name_from_visible_apps(context=context, app_key=normalized_app_key)
1868
+ self._app_name_cache[normalized_app_key] = resolved
1869
+ return resolved
1870
+
1871
+ def _resolve_task_app_name_from_base_info(
1872
+ self,
1873
+ *,
1874
+ context: BackendRequestContext,
1875
+ app_key: str,
1876
+ ) -> str | None:
1877
+ try:
1878
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
1879
+ except QingflowApiError:
1880
+ return None
1881
+ if not isinstance(base_info, dict):
1882
+ return None
1883
+ for key in ("formTitle", "title", "appName", "name"):
1884
+ value = str(base_info.get(key) or "").strip()
1885
+ if value:
1886
+ return value
1887
+ return None
1888
+
1889
+ def _resolve_task_app_name_from_visible_apps(
1890
+ self,
1891
+ *,
1892
+ context: BackendRequestContext,
1893
+ app_key: str,
1894
+ ) -> str | None:
1895
+ try:
1896
+ visible_apps = self.backend.request("GET", context, "/tag/apps")
1897
+ except QingflowApiError:
1898
+ return None
1899
+ return self._find_task_app_name_in_visible_apps(visible_apps, app_key=app_key)
1900
+
1901
+ def _find_task_app_name_in_visible_apps(self, payload: Any, *, app_key: str) -> str | None:
1902
+ if isinstance(payload, list):
1903
+ for item in payload:
1904
+ resolved = self._find_task_app_name_in_visible_apps(item, app_key=app_key)
1905
+ if resolved:
1906
+ return resolved
1907
+ return None
1908
+ if not isinstance(payload, dict):
1909
+ return None
1910
+ candidate_app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
1911
+ if candidate_app_key == app_key:
1912
+ for key in ("formTitle", "title", "appName", "name"):
1913
+ value = str(payload.get(key) or "").strip()
1914
+ if value:
1915
+ return value
1916
+ for value in payload.values():
1917
+ if isinstance(value, (list, dict)):
1918
+ resolved = self._find_task_app_name_in_visible_apps(value, app_key=app_key)
1919
+ if resolved:
1920
+ return resolved
1921
+ return None
1922
+
1923
+ def _task_record_core_fields(self, answers: Any, *, limit: int = 12) -> dict[str, Any]:
1924
+ if not isinstance(answers, list):
1925
+ return {}
1926
+ core_fields: dict[str, Any] = {}
1927
+ for answer in answers:
1928
+ if not isinstance(answer, dict):
1929
+ continue
1930
+ title = answer.get("queTitle") or answer.get("title") or answer.get("fieldName")
1931
+ if not title:
1932
+ que_id = answer.get("queId")
1933
+ title = f"field_{que_id}" if que_id not in (None, "") else None
1934
+ if not title:
1935
+ continue
1936
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
1937
+ if table_values:
1938
+ value: Any = f"子表格 {len(table_values)} 行"
1939
+ else:
1940
+ values = self._extract_answer_values(answer)
1941
+ if not values:
1942
+ continue
1943
+ value = values[0] if len(values) == 1 else values
1944
+ if value in (None, "", []):
1945
+ continue
1946
+ core_fields[str(title)] = self._compact_task_value(value)
1947
+ if len(core_fields) >= limit:
1948
+ break
1949
+ return core_fields
1950
+
1951
+ def _compact_task_value(self, value: Any) -> Any:
1952
+ if isinstance(value, list):
1953
+ return [self._compact_task_value(item) for item in value[:8]]
1954
+ text = re.sub(r"<[^>]+>", " ", str(value))
1955
+ text = re.sub(r"\s+", " ", text).strip()
1956
+ if len(text) <= 160:
1957
+ return text
1958
+ return text[:157].rstrip() + "..."
1959
+
1960
+ def _compact_task_editable_field(self, field: dict[str, Any], update_schema: dict[str, Any]) -> dict[str, Any]:
1961
+ payload_template = update_schema.get("payload_template") if isinstance(update_schema.get("payload_template"), dict) else {}
1962
+ title = field.get("title")
1963
+ compact: dict[str, Any] = {}
1964
+ for key in ("field_id", "title", "kind", "required", "candidate_hint"):
1965
+ if key in field:
1966
+ compact[key] = field.get(key)
1967
+ if title in payload_template:
1968
+ compact["template"] = payload_template.get(title)
1969
+ return compact
1970
+
1971
+ def _compact_associated_report(self, item: dict[str, Any]) -> dict[str, Any]:
1972
+ return {
1973
+ key: value
1974
+ for key, value in {
1975
+ "report_id": item.get("report_id"),
1976
+ "chart_key": item.get("chart_key"),
1977
+ "chart_name": item.get("chart_name"),
1978
+ "graph_type": item.get("graph_type"),
1979
+ "source_type": item.get("source_type"),
1980
+ "target_app_key": item.get("target_app_key"),
1981
+ "target_app_name": item.get("target_app_name"),
1982
+ }.items()
1983
+ if value not in (None, "", [])
1984
+ }
1985
+
1986
+ def _compact_rollback_candidate(self, item: dict[str, Any]) -> dict[str, Any]:
1987
+ return {
1988
+ key: value
1989
+ for key, value in {
1990
+ "workflow_node_id": item.get("auditNodeId") or item.get("nodeId"),
1991
+ "workflow_node_name": item.get("auditNodeName") or item.get("nodeName"),
1992
+ }.items()
1993
+ if value not in (None, "", [])
1994
+ }
1995
+
1996
+ def _compact_transfer_member(self, item: dict[str, Any]) -> dict[str, Any]:
1997
+ uid = item.get("uid")
1998
+ if uid is None:
1999
+ uid = item.get("userId") or item.get("memberId") or item.get("id")
2000
+ return {
2001
+ key: value
2002
+ for key, value in {
2003
+ "uid": uid,
2004
+ "name": item.get("name") or item.get("userName") or item.get("memberName") or item.get("realName"),
2005
+ "email": item.get("email") or item.get("mail"),
2006
+ "department_id": item.get("departmentId") or item.get("deptId"),
2007
+ "department_name": item.get("departmentName") or item.get("deptName"),
2008
+ }.items()
2009
+ if value not in (None, "", [])
2010
+ }
2011
+
2012
+ def _normalize_task_item(self, raw: dict[str, Any]) -> dict[str, Any]:
2013
+ """执行内部辅助逻辑。"""
2014
+ app_key = raw.get("appKey") or raw.get("app_key")
2015
+ record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
2016
+ workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
2017
+ return {
2018
+ "task_id": stringify_backend_id(raw.get("id") or raw.get("taskId") or record_id),
2019
+ "app_key": app_key,
2020
+ "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
2021
+ "record_id": stringify_backend_id(record_id),
2022
+ "workflow_node_id": workflow_node_id,
2023
+ "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
2024
+ "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
2025
+ "summary_fields": self._normalize_task_summary_fields(raw.get("dataSnapshot")),
2026
+ }
2027
+
2028
+ def _public_task_item(self, item: dict[str, Any]) -> dict[str, Any]:
2029
+ return {
2030
+ "task_id": item.get("task_id"),
2031
+ "app_name": item.get("app_name"),
2032
+ "workflow_node_name": item.get("workflow_node_name"),
2033
+ "apply_time": item.get("apply_time"),
2034
+ "summary_fields": item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else [],
2035
+ }
2036
+
2037
+ def _normalize_task_summary_fields(self, raw: Any) -> list[dict[str, Any]]:
2038
+ """执行内部辅助逻辑。"""
2039
+ if not isinstance(raw, list):
2040
+ return []
2041
+ summary_fields: list[dict[str, Any]] = []
2042
+ for item in raw:
2043
+ if not isinstance(item, dict):
2044
+ continue
2045
+ summary_field: dict[str, Any] = {
2046
+ "field_id": item.get("fieldId"),
2047
+ "title": item.get("fieldTitle"),
2048
+ "type": item.get("fieldType"),
2049
+ "answer": item.get("fieldAnswer"),
2050
+ "desensitized": self._coerce_bool(item.get("beingDesensitized")),
2051
+ }
2052
+ associated_field_type = item.get("associatedQueType")
2053
+ if associated_field_type is not None:
2054
+ summary_field["associated_field_type"] = associated_field_type
2055
+ summary_fields.append(summary_field)
2056
+ return summary_fields
2057
+
2058
+ def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
2059
+ """执行内部辅助逻辑。"""
2060
+ if not isinstance(infos, list) or not infos:
2061
+ raise_tool_error(
2062
+ QingflowApiError.config_error(
2063
+ f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
2064
+ )
2065
+ )
2066
+ for item in infos:
2067
+ if not isinstance(item, dict):
2068
+ continue
2069
+ candidate = item.get("auditNodeId")
2070
+ if not isinstance(candidate, int):
2071
+ candidate = item.get("nodeId")
2072
+ if candidate == workflow_node_id:
2073
+ return item
2074
+ raise_tool_error(
2075
+ QingflowApiError.config_error(
2076
+ f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
2077
+ )
2078
+ )
2079
+
2080
+ def _build_capabilities(
2081
+ self,
2082
+ node_info: dict[str, Any],
2083
+ *,
2084
+ allow_save_only: bool,
2085
+ warnings: list[JSONObject] | None = None,
2086
+ save_only_source: str = "workflow_editable_que_ids",
2087
+ ) -> dict[str, Any]:
2088
+ """执行内部辅助逻辑。"""
2089
+ available_actions = ["approve"]
2090
+ if self._coerce_bool(node_info.get("rejectBtnStatus")):
2091
+ available_actions.append("reject")
2092
+ if self._coerce_bool(node_info.get("canRevert")):
2093
+ available_actions.append("rollback")
2094
+ if self._coerce_bool(node_info.get("canTransfer")):
2095
+ available_actions.append("transfer")
2096
+ if self._coerce_bool(node_info.get("canUrge")):
2097
+ available_actions.append("urge")
2098
+ if allow_save_only:
2099
+ available_actions.append("save_only")
2100
+
2101
+ visible_but_unimplemented_actions: list[str] = []
2102
+ if self._coerce_bool(node_info.get("canRevoke")):
2103
+ visible_but_unimplemented_actions.append("revoke")
2104
+ if self._coerce_bool(node_info.get("beingEndWorkflow")):
2105
+ visible_but_unimplemented_actions.append("end_workflow")
2106
+ if self._coerce_bool(node_info.get("beingCanApplyAgain")):
2107
+ visible_but_unimplemented_actions.append("apply_again")
2108
+
2109
+ feedback_required_for = []
2110
+ raw_feedback_required = node_info.get("feedbackRequiredOperationType")
2111
+ if isinstance(raw_feedback_required, list):
2112
+ feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
2113
+
2114
+ return {
2115
+ "available_actions": available_actions,
2116
+ "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
2117
+ "save_only_source": save_only_source,
2118
+ "warnings": list(warnings or []),
2119
+ "action_constraints": {
2120
+ "feedback_required_for": feedback_required_for,
2121
+ "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
2122
+ "submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
2123
+ "can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
2124
+ "can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
2125
+ },
2126
+ }
2127
+
2128
+ def _build_task_update_schema(
2129
+ self,
2130
+ profile: str,
2131
+ context: BackendRequestContext,
2132
+ *,
2133
+ app_key: str,
2134
+ record_id: int,
2135
+ workflow_node_id: int,
2136
+ node_info: dict[str, Any],
2137
+ current_answers: Any,
2138
+ ) -> dict[str, Any]:
2139
+ """执行内部辅助逻辑。"""
2140
+ record_id_text = stringify_backend_id(record_id)
2141
+ try:
2142
+ app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
2143
+ except QingflowApiError as error:
2144
+ public_schema: JSONObject = {
2145
+ "schema_scope": "task_update_ready",
2146
+ "writable_fields": [],
2147
+ "payload_template": {},
2148
+ "blockers": ["TASK_UPDATE_SCHEMA_UNAVAILABLE"],
2149
+ "warnings": [
2150
+ {
2151
+ "code": "TASK_UPDATE_SCHEMA_UNAVAILABLE",
2152
+ "message": "task detail could not load the form schema for the current app, so node-scoped update schema is unavailable.",
2153
+ }
2154
+ ],
2155
+ "selection": {
2156
+ "app_key": app_key,
2157
+ "record_id": record_id_text,
2158
+ "workflow_node_id": workflow_node_id,
2159
+ },
2160
+ "transport_error": {
2161
+ "http_status": error.http_status,
2162
+ "backend_code": error.backend_code,
2163
+ "category": error.category,
2164
+ },
2165
+ }
2166
+ return {
2167
+ "public_schema": public_schema,
2168
+ "index": None,
2169
+ "editable_question_ids": [],
2170
+ "effective_editable_question_ids": [],
2171
+ "editable_question_ids_source": "schema_unavailable",
2172
+ }
2173
+
2174
+ question_relations = _collect_question_relations(app_schema)
2175
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
2176
+ base_index = _build_applicant_top_level_field_index(app_schema)
2177
+ linked_field_ids.update(_collect_option_linked_field_ids(base_index))
2178
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
2179
+ app_schema,
2180
+ linked_field_ids=linked_field_ids,
2181
+ )
2182
+ index = _merge_field_indexes(base_index, linked_hidden_index)
2183
+ editable_question_ids, schema_warnings, source = self._resolve_task_editable_question_ids(
2184
+ context,
2185
+ app_key=app_key,
2186
+ workflow_node_id=workflow_node_id,
2187
+ node_info=node_info,
2188
+ )
2189
+ index, augmentation_warnings = self._augment_task_editable_field_index(
2190
+ index=index,
2191
+ current_answers=current_answers,
2192
+ editable_question_ids=editable_question_ids,
2193
+ )
2194
+ schema_warnings.extend(augmentation_warnings)
2195
+ effective_editable_ids = set(editable_question_ids)
2196
+ for field in index.by_id.values():
2197
+ if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
2198
+ effective_editable_ids.add(field.que_id)
2199
+ writable_fields: list[JSONObject] = []
2200
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
2201
+ index=index,
2202
+ question_relations=question_relations,
2203
+ )
2204
+ for field in index.by_id.values():
2205
+ if field.que_type in LAYOUT_ONLY_QUE_TYPES or field.que_id not in effective_editable_ids:
2206
+ continue
2207
+ editable_field = _clone_form_field(field, readonly=False)
2208
+ write_hints = self._record_tools._schema_write_hints(editable_field)
2209
+ if not bool(write_hints.get("writable")):
2210
+ continue
2211
+ writable_field = self._record_tools._ready_schema_field_payload(
2212
+ profile,
2213
+ context,
2214
+ editable_field,
2215
+ ws_id=context.ws_id,
2216
+ required_override=False,
2217
+ linkage_payloads_by_field_id=linkage_payloads_by_field_id,
2218
+ )
2219
+ writable_field.setdefault("field_id", editable_field.que_id)
2220
+ writable_fields.append(writable_field)
2221
+ blockers: list[str] = []
2222
+ if not writable_fields:
2223
+ blockers.append("NO_TASK_EDITABLE_FIELDS")
2224
+ schema_warnings.append(
2225
+ {
2226
+ "code": "NO_TASK_EDITABLE_FIELDS",
2227
+ "message": "the current task node does not expose any writable fields for task-scoped edits.",
2228
+ }
2229
+ )
2230
+ public_schema: JSONObject = {
2231
+ "schema_scope": "task_update_ready",
2232
+ "writable_fields": writable_fields,
2233
+ "payload_template": {
2234
+ item["title"]: self._record_tools._ready_schema_template_value(item)
2235
+ for item in writable_fields
2236
+ if isinstance(item, dict) and item.get("title")
2237
+ },
2238
+ "blockers": blockers,
2239
+ "warnings": schema_warnings,
2240
+ "selection": {
2241
+ "app_key": app_key,
2242
+ "record_id": record_id_text,
2243
+ "workflow_node_id": workflow_node_id,
2244
+ },
2245
+ }
2246
+ return {
2247
+ "public_schema": public_schema,
2248
+ "index": index,
2249
+ "editable_question_ids": sorted(editable_question_ids),
2250
+ "effective_editable_question_ids": sorted(effective_editable_ids),
2251
+ "editable_question_ids_source": source,
2252
+ }
2253
+
2254
+ def _augment_task_editable_field_index(
2255
+ self,
2256
+ *,
2257
+ index: FieldIndex,
2258
+ current_answers: Any,
2259
+ editable_question_ids: set[int],
2260
+ ) -> tuple[FieldIndex, list[JSONObject]]:
2261
+ """执行内部辅助逻辑。"""
2262
+ if not editable_question_ids:
2263
+ return index, []
2264
+ missing_field_ids = {
2265
+ str(que_id)
2266
+ for que_id in editable_question_ids
2267
+ if que_id > 0 and str(que_id) not in index.by_id
2268
+ }
2269
+ if not missing_field_ids:
2270
+ return index, []
2271
+ answer_backed_index = _build_answer_backed_field_index(
2272
+ current_answers,
2273
+ field_id_filter=missing_field_ids,
2274
+ )
2275
+ if not answer_backed_index.by_id:
2276
+ return index, []
2277
+ augmented_index = _merge_field_indexes(index, answer_backed_index)
2278
+ return augmented_index, [
2279
+ {
2280
+ "code": "TASK_RUNTIME_EDITABLE_FIELDS_AUGMENTED",
2281
+ "message": "task update schema added backend-editable fields from current task answers because applicant schema did not expose them.",
2282
+ "fields": [
2283
+ {
2284
+ "field_id": field.que_id,
2285
+ "title": field.que_title,
2286
+ "que_type": field.que_type,
2287
+ }
2288
+ for field in answer_backed_index.by_id.values()
2289
+ ],
2290
+ }
2291
+ ]
2292
+
2293
+ def _resolve_task_editable_question_ids(
2294
+ self,
2295
+ context: BackendRequestContext,
2296
+ *,
2297
+ app_key: str,
2298
+ workflow_node_id: int,
2299
+ node_info: dict[str, Any],
2300
+ ) -> tuple[set[int], list[JSONObject], str]:
2301
+ """执行内部辅助逻辑。"""
2302
+ warnings: list[JSONObject] = []
2303
+ try:
2304
+ payload = self.backend.request(
2305
+ "GET",
2306
+ context,
2307
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
2308
+ )
2309
+ question_ids = self._extract_question_ids(payload)
2310
+ if question_ids:
2311
+ return question_ids, warnings, "workflow_editable_que_ids"
2312
+ except QingflowApiError as error:
2313
+ if error.backend_code not in {40002, 40027, 404} and error.http_status != 404:
2314
+ raise
2315
+ warnings.append(
2316
+ {
2317
+ "code": "TASK_EDITABLE_IDS_FALLBACK",
2318
+ "message": "editable question ids endpoint is unavailable in the current route; task update schema fell back to queAuthSetting and may be conservative.",
2319
+ }
2320
+ )
2321
+ fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
2322
+ return fallback_ids, warnings, "que_auth_setting"
2323
+
2324
+ def _resolve_task_save_only_availability(
2325
+ self,
2326
+ context: BackendRequestContext,
2327
+ *,
2328
+ app_key: str,
2329
+ workflow_node_id: int,
2330
+ ) -> tuple[bool, list[JSONObject], str]:
2331
+ """执行内部辅助逻辑。"""
2332
+ try:
2333
+ payload = self.backend.request(
2334
+ "GET",
2335
+ context,
2336
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
2337
+ )
2338
+ except QingflowApiError as error:
2339
+ warning: JSONObject = {
2340
+ "code": "TASK_SAVE_ONLY_SIGNAL_UNAVAILABLE",
2341
+ "message": "save_only is hidden because backend editableQueIds is unavailable for the current node; MCP no longer infers save_only from local schema reconstruction.",
2342
+ }
2343
+ if error.backend_code is not None:
2344
+ warning["backend_code"] = error.backend_code
2345
+ if error.http_status is not None:
2346
+ warning["http_status"] = error.http_status
2347
+ return False, [warning], "backend_editable_que_ids_unavailable"
2348
+ return bool(self._extract_question_ids(payload)), [], "workflow_editable_que_ids"
2349
+
2350
+ def _extract_question_ids(self, payload: Any) -> set[int]:
2351
+ """执行内部辅助逻辑。"""
2352
+ candidates: list[Any] = []
2353
+ if isinstance(payload, list):
2354
+ candidates = payload
2355
+ elif isinstance(payload, dict):
2356
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
2357
+ value = payload.get(key)
2358
+ if isinstance(value, list):
2359
+ candidates = value
2360
+ break
2361
+ question_ids: set[int] = set()
2362
+ for item in candidates:
2363
+ if isinstance(item, int) and item > 0:
2364
+ question_ids.add(item)
2365
+ continue
2366
+ if isinstance(item, dict):
2367
+ for key in ("queId", "questionId", "id"):
2368
+ value = _coerce_count(item.get(key))
2369
+ if value is not None and value > 0:
2370
+ question_ids.add(value)
2371
+ break
2372
+ return question_ids
2373
+
2374
+ def _editable_ids_from_que_auth_setting(self, payload: Any) -> set[int]:
2375
+ """执行内部辅助逻辑。"""
2376
+ if not isinstance(payload, list):
2377
+ return set()
2378
+ editable_ids: set[int] = set()
2379
+ for item in payload:
2380
+ if not isinstance(item, dict):
2381
+ continue
2382
+ que_id = _coerce_count(item.get("queId") or item.get("questionId"))
2383
+ if que_id is None or que_id <= 0:
2384
+ continue
2385
+ explicit_editable_keys = ("editable", "writable", "canEdit", "editStatus", "beingEditable")
2386
+ explicit_readonly_keys = ("readonly", "beingReadonly")
2387
+ if any(key in item for key in explicit_editable_keys):
2388
+ if any(bool(item.get(key)) for key in explicit_editable_keys):
2389
+ editable_ids.add(que_id)
2390
+ continue
2391
+ if any(bool(item.get(key)) for key in explicit_readonly_keys):
2392
+ continue
2393
+ if item.get("readable") is False:
2394
+ continue
2395
+ if item.get("readable") is True or item.get("visible") is True:
2396
+ editable_ids.add(que_id)
2397
+ return editable_ids
2398
+
2399
+ def _prepare_task_field_update(
2400
+ self,
2401
+ *,
2402
+ profile: str,
2403
+ context: BackendRequestContext,
2404
+ app_key: str,
2405
+ record_id: int,
2406
+ workflow_node_id: int,
2407
+ task_context: dict[str, Any],
2408
+ fields: dict[str, Any],
2409
+ ) -> dict[str, Any]:
2410
+ """执行内部辅助逻辑。"""
2411
+ record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
2412
+ current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
2413
+ node = task_context.get("node") if isinstance(task_context.get("node"), dict) else {}
2414
+ node_info = node.get("raw") if isinstance(node.get("raw"), dict) else {}
2415
+ schema_state = self._build_task_update_schema(
2416
+ profile=profile,
2417
+ context=context,
2418
+ app_key=app_key,
2419
+ record_id=record_id,
2420
+ workflow_node_id=workflow_node_id,
2421
+ node_info=node_info,
2422
+ current_answers=current_answers,
2423
+ )
2424
+ update_schema = schema_state["public_schema"]
2425
+ if update_schema.get("blockers"):
2426
+ raise_tool_error(
2427
+ QingflowApiError(
2428
+ category="config",
2429
+ message="task field update is blocked because the current node does not expose a usable update schema",
2430
+ details={
2431
+ "error_code": "TASK_UPDATE_SCHEMA_BLOCKED",
2432
+ "update_schema": update_schema,
2433
+ },
2434
+ )
2435
+ )
2436
+ index = schema_state["index"]
2437
+ preflight = self._record_tools._build_record_write_preflight(
2438
+ profile=profile,
2439
+ context=context,
2440
+ operation="update",
2441
+ app_key=app_key,
2442
+ apply_id=record_id,
2443
+ answers=[],
2444
+ fields=fields,
2445
+ force_refresh_form=False,
2446
+ view_id=None,
2447
+ list_type=None,
2448
+ view_key=None,
2449
+ view_name=None,
2450
+ existing_answers_override=current_answers,
2451
+ field_index_override=index,
2452
+ )
2453
+ effective_editable_ids = set(schema_state["effective_editable_question_ids"])
2454
+ scoped_field_errors = self._task_scope_field_errors(
2455
+ normalized_answers=preflight.get("normalized_answers") or [],
2456
+ index=index,
2457
+ effective_editable_ids=effective_editable_ids,
2458
+ )
2459
+ field_errors = list(preflight.get("field_errors") or [])
2460
+ field_errors.extend(scoped_field_errors)
2461
+ blockers = list(preflight.get("blockers") or [])
2462
+ if scoped_field_errors:
2463
+ blockers.append("payload writes fields that are not editable on the current task node")
2464
+ confirmation_requests = list(preflight.get("confirmation_requests") or [])
2465
+ if field_errors or confirmation_requests or blockers:
2466
+ raise_tool_error(
2467
+ QingflowApiError(
2468
+ category="config",
2469
+ message="task field update preflight was blocked",
2470
+ details={
2471
+ "error_code": "TASK_FIELD_PLAN_BLOCKED",
2472
+ "blockers": blockers,
2473
+ "field_errors": field_errors,
2474
+ "confirmation_requests": confirmation_requests,
2475
+ "update_schema": update_schema,
2476
+ "recommended_next_actions": preflight.get("recommended_next_actions") or [],
2477
+ },
2478
+ )
2479
+ )
2480
+ normalized_answers = [item for item in (preflight.get("normalized_answers") or []) if isinstance(item, dict)]
2481
+ merged_answers = self._record_tools._merge_record_answers(current_answers, normalized_answers)
2482
+ return {
2483
+ "update_schema": update_schema,
2484
+ "normalized_answers": normalized_answers,
2485
+ "merged_answers": merged_answers,
2486
+ }
2487
+
2488
+ def _task_scope_field_errors(
2489
+ self,
2490
+ *,
2491
+ normalized_answers: list[dict[str, Any]],
2492
+ index: Any,
2493
+ effective_editable_ids: set[int],
2494
+ ) -> list[dict[str, Any]]:
2495
+ """执行内部辅助逻辑。"""
2496
+ if index is None:
2497
+ return []
2498
+ field_errors: list[dict[str, Any]] = []
2499
+ for answer in normalized_answers:
2500
+ que_id = _coerce_count(answer.get("queId"))
2501
+ if que_id is None or que_id <= 0:
2502
+ continue
2503
+ field = index.by_id.get(str(que_id))
2504
+ field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
2505
+ if que_id not in effective_editable_ids:
2506
+ field_errors.append(
2507
+ {
2508
+ "location": field.que_title if field is not None else str(que_id),
2509
+ "message": "field is not editable on the current task node",
2510
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
2511
+ "field": field_payload,
2512
+ }
2513
+ )
2514
+ continue
2515
+ if field is None or field.que_type not in SUBTABLE_QUE_TYPES:
2516
+ continue
2517
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
2518
+ subtable_index = self._record_tools._subtable_field_index_optional(field)
2519
+ for row_ordinal, row in enumerate(table_values, start=1):
2520
+ row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
2521
+ for cell in row_cells:
2522
+ cell_que_id = _coerce_count(cell.get("queId"))
2523
+ if cell_que_id is None or cell_que_id <= 0 or cell_que_id in effective_editable_ids:
2524
+ continue
2525
+ subfield = subtable_index.by_id.get(str(cell_que_id)) if subtable_index is not None else None
2526
+ field_errors.append(
2527
+ {
2528
+ "location": f"{field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else cell_que_id}",
2529
+ "message": "subtable field is not editable on the current task node",
2530
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
2531
+ "field": _field_ref_payload(subfield) if subfield is not None else {"que_id": cell_que_id},
2532
+ }
2533
+ )
2534
+ return field_errors
2535
+
2536
+ def _task_save_only(
2537
+ self,
2538
+ *,
2539
+ profile: str,
2540
+ app_key: str,
2541
+ record_id: int,
2542
+ workflow_node_id: int,
2543
+ apply_answers: list[dict[str, Any]],
2544
+ ) -> dict[str, Any]:
2545
+ """执行内部辅助逻辑。"""
2546
+ def runner(session_profile, context):
2547
+ result = self.backend.request(
2548
+ "POST",
2549
+ context,
2550
+ f"/app/{app_key}/apply/{record_id}",
2551
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": apply_answers},
2552
+ )
2553
+ return {
2554
+ "profile": profile,
2555
+ "ws_id": session_profile.selected_ws_id,
2556
+ "app_key": app_key,
2557
+ "apply_id": record_id,
2558
+ "result": result,
2559
+ "request_route": self._request_route_payload(context),
2560
+ }
2561
+
2562
+ return self._run(profile, runner)
2563
+
2564
+ def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
2565
+ """执行内部辅助逻辑。"""
2566
+ return {
2567
+ "comment_visible": self._coerce_bool(node_info.get("commentStatus")),
2568
+ "audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
2569
+ "workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
2570
+ "qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
2571
+ "associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
2572
+ }
2573
+
2574
+ def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
2575
+ """执行内部辅助逻辑。"""
2576
+ node_visible = node_info.get("asosChartVisible")
2577
+ if node_visible is not None:
2578
+ return self._coerce_bool(node_visible)
2579
+ return self._coerce_bool(detail.get("viewAsosChartVisible"))
2580
+
2581
+ def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
2582
+ """执行内部辅助逻辑。"""
2583
+ graph_type = str(raw.get("graphType") or "").strip().lower()
2584
+ source_type = str(raw.get("sourceType") or "").strip().lower()
2585
+ return {
2586
+ "report_id": raw.get("id"),
2587
+ "chart_key": raw.get("chartKey"),
2588
+ "chart_name": raw.get("chartName"),
2589
+ "graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
2590
+ "source_type": source_type or "qingflow",
2591
+ "target_app_key": raw.get("appKey"),
2592
+ "target_app_name": raw.get("formTitle"),
2593
+ "match_rules": raw.get("matchRules") or [],
2594
+ "raw": dict(raw),
2595
+ }
2596
+
2597
+ def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
2598
+ """执行内部辅助逻辑。"""
2599
+ if isinstance(payload, dict):
2600
+ revert_nodes = payload.get("revertNodes")
2601
+ if isinstance(revert_nodes, list):
2602
+ return [item for item in revert_nodes if isinstance(item, dict)]
2603
+ return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
2604
+
2605
+ def _filter_transfer_members(self, items: Any, *, current_uid: int | None) -> list[dict[str, Any]]:
2606
+ """执行内部辅助逻辑。"""
2607
+ if not isinstance(items, list):
2608
+ return []
2609
+ filtered: list[dict[str, Any]] = []
2610
+ for item in items:
2611
+ if not isinstance(item, dict):
2612
+ continue
2613
+ uid = _coerce_count(item.get("uid") or item.get("userId") or item.get("memberId") or item.get("id"))
2614
+ if current_uid is not None and uid == current_uid:
2615
+ continue
2616
+ filtered.append(item)
2617
+ return filtered
2618
+
2619
+ def _transfer_candidate_items(
2620
+ self,
2621
+ context: BackendRequestContext,
2622
+ *,
2623
+ app_key: str,
2624
+ record_id: int,
2625
+ workflow_node_id: int,
2626
+ current_uid: int | None,
2627
+ ) -> tuple[list[dict[str, Any]], list[JSONObject], JSONObject]:
2628
+ page_size = 100
2629
+ max_pages = 100
2630
+ page_num = 1
2631
+ fetched_pages = 0
2632
+ fetched_raw_count = 0
2633
+ page_amount: int | None = None
2634
+ reported_total: int | None = None
2635
+ items: list[dict[str, Any]] = []
2636
+ seen_member_keys: set[str] = set()
2637
+ warnings: list[JSONObject] = []
2638
+
2639
+ while page_num <= max_pages:
2640
+ result = self.backend.request(
2641
+ "GET",
2642
+ context,
2643
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
2644
+ params={"pageNum": page_num, "pageSize": page_size, "auditNodeId": workflow_node_id},
2645
+ )
2646
+ fetched_pages += 1
2647
+ raw_items = _approval_page_items(result)
2648
+ fetched_raw_count += len(raw_items)
2649
+ if page_amount is None:
2650
+ page_amount = _coerce_count(_approval_page_amount(result))
2651
+ if reported_total is None:
2652
+ reported_total = _coerce_count(_approval_page_total(result))
2653
+ for item in self._filter_transfer_members(raw_items, current_uid=current_uid):
2654
+ member_key = self._transfer_member_dedupe_key(item)
2655
+ if member_key in seen_member_keys:
2656
+ continue
2657
+ seen_member_keys.add(member_key)
2658
+ items.append(item)
2659
+ if not raw_items:
2660
+ break
2661
+ if page_amount is not None and page_num >= page_amount:
2662
+ break
2663
+ if reported_total is not None and fetched_raw_count >= reported_total:
2664
+ break
2665
+ page_num += 1
2666
+ truncated = page_num > max_pages
2667
+ if truncated:
2668
+ warnings.append(
2669
+ {
2670
+ "code": "TRANSFER_CANDIDATES_TRUNCATED",
2671
+ "message": "transfer candidates reached the MCP safety page cap; returned candidates may be incomplete.",
2672
+ "max_pages": max_pages,
2673
+ "page_size": page_size,
2674
+ }
2675
+ )
2676
+ pagination: JSONObject = {
2677
+ "loaded": True,
2678
+ "page_size": page_size,
2679
+ "fetched_pages": fetched_pages,
2680
+ "reported_total": reported_total,
2681
+ "page_amount": page_amount,
2682
+ "truncated": truncated,
2683
+ }
2684
+ return items, warnings, pagination
2685
+
2686
+ def _transfer_member_dedupe_key(self, item: dict[str, Any]) -> str:
2687
+ uid = item.get("uid") or item.get("userId") or item.get("memberId") or item.get("id")
2688
+ if uid not in (None, ""):
2689
+ return f"uid:{uid}"
2690
+ return json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
2691
+
2692
+ def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
2693
+ """执行内部辅助逻辑。"""
2694
+ associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
2695
+ for item in associated_reports:
2696
+ if isinstance(item, dict) and item.get("report_id") == report_id:
2697
+ return item
2698
+ return None
2699
+
2700
+ def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
2701
+ """执行内部辅助逻辑。"""
2702
+ key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
2703
+ key_values: list[dict[str, Any]] = []
2704
+ for answer in answers:
2705
+ if not isinstance(answer, dict):
2706
+ continue
2707
+ answer_que_id = answer.get("queId")
2708
+ if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
2709
+ extracted_values = self._extract_answer_values(answer)
2710
+ key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
2711
+ table_values = answer.get("tableValues")
2712
+ if not isinstance(table_values, list):
2713
+ continue
2714
+ for idx, row in enumerate(table_values, start=1):
2715
+ if not isinstance(row, list):
2716
+ continue
2717
+ for sub_answer in row:
2718
+ if not isinstance(sub_answer, dict):
2719
+ continue
2720
+ sub_que_id = sub_answer.get("queId")
2721
+ if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
2722
+ extracted_values = self._extract_answer_values(sub_answer)
2723
+ key_values.append(
2724
+ {
2725
+ "keyQueId": sub_que_id,
2726
+ "ordinal": idx,
2727
+ "values": extracted_values or None,
2728
+ }
2729
+ )
2730
+ return {
2731
+ "asosChart": self._sanitize_associated_chart(asos_chart),
2732
+ "keyQueValues": key_values,
2733
+ }
2734
+
2735
+ def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
2736
+ """执行内部辅助逻辑。"""
2737
+ question_ids: set[int] = set()
2738
+
2739
+ def visit(node: Any) -> None:
2740
+ if isinstance(node, list):
2741
+ for item in node:
2742
+ visit(item)
2743
+ return
2744
+ if not isinstance(node, dict):
2745
+ return
2746
+ for key in ("queId", "judgeQueId"):
2747
+ value = node.get(key)
2748
+ if isinstance(value, int):
2749
+ question_ids.add(value)
2750
+ for value in node.values():
2751
+ if isinstance(value, (list, dict)):
2752
+ visit(value)
2753
+
2754
+ visit(match_rules)
2755
+ return question_ids
2756
+
2757
+ def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
2758
+ """执行内部辅助逻辑。"""
2759
+ values = answer.get("values")
2760
+ if not isinstance(values, list):
2761
+ return []
2762
+ normalized: list[str] = []
2763
+ for item in values:
2764
+ if item is None:
2765
+ continue
2766
+ if isinstance(item, (str, int, float, bool)):
2767
+ normalized.append(str(item))
2768
+ continue
2769
+ if not isinstance(item, dict):
2770
+ continue
2771
+ for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
2772
+ value = item.get(key)
2773
+ if value not in (None, ""):
2774
+ normalized.append(str(value))
2775
+ break
2776
+ deduped: list[str] = []
2777
+ seen: set[str] = set()
2778
+ for item in normalized:
2779
+ if item in seen:
2780
+ continue
2781
+ deduped.append(item)
2782
+ seen.add(item)
2783
+ return deduped
2784
+
2785
+ def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
2786
+ """执行内部辅助逻辑。"""
2787
+ return {
2788
+ "id": asos_chart.get("id"),
2789
+ "appKey": asos_chart.get("appKey"),
2790
+ "formTitle": asos_chart.get("formTitle"),
2791
+ "chartKey": asos_chart.get("chartKey"),
2792
+ "chartName": asos_chart.get("chartName"),
2793
+ "chartType": asos_chart.get("chartType"),
2794
+ "matchRules": asos_chart.get("matchRules") or [],
2795
+ "sourceType": asos_chart.get("sourceType"),
2796
+ "graphType": asos_chart.get("graphType"),
2797
+ "viewType": asos_chart.get("viewType"),
2798
+ }
2799
+
2800
+ def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
2801
+ """执行内部辅助逻辑。"""
2802
+ if not chart_key:
2803
+ return False
2804
+ try:
2805
+ auth = self.backend.request(
2806
+ "GET",
2807
+ context,
2808
+ f"/chart/{chart_key}/auth",
2809
+ )
2810
+ except QingflowApiError:
2811
+ return False
2812
+ if not isinstance(auth, dict):
2813
+ return False
2814
+ return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
2815
+
2816
+ def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
2817
+ """执行内部辅助逻辑。"""
2818
+ if isinstance(payload, dict):
2819
+ rows = payload.get("rows")
2820
+ if not isinstance(rows, list):
2821
+ rows = payload.get("list") if isinstance(payload.get("list"), list) else []
2822
+ series = payload.get("series")
2823
+ if not isinstance(series, list):
2824
+ series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
2825
+ metrics = payload.get("metrics")
2826
+ if not isinstance(metrics, list):
2827
+ metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
2828
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
2829
+ return {
2830
+ "summary": summary,
2831
+ "rows": rows or [],
2832
+ "series": series or [],
2833
+ "metrics": metrics or [],
2834
+ }
2835
+ if isinstance(payload, list):
2836
+ return {"summary": {}, "rows": payload, "series": [], "metrics": []}
2837
+ return {"summary": {}, "rows": [], "series": [], "metrics": []}
2838
+
2839
+ def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
2840
+ """执行内部辅助逻辑。"""
2841
+ if isinstance(payload, dict):
2842
+ page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
2843
+ if not isinstance(page, list):
2844
+ nested = payload.get("data")
2845
+ if isinstance(nested, dict):
2846
+ page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
2847
+ if not isinstance(page, list):
2848
+ page = []
2849
+ elif isinstance(payload, list):
2850
+ page = payload
2851
+ else:
2852
+ page = []
2853
+
2854
+ items: list[dict[str, Any]] = []
2855
+ for node_record in page:
2856
+ if not isinstance(node_record, dict):
2857
+ continue
2858
+ node_id = node_record.get("nodeId")
2859
+ node_name = node_record.get("nodeName")
2860
+ operation_record_list = node_record.get("operationRecordList")
2861
+ if not isinstance(operation_record_list, list):
2862
+ operation_record_list = []
2863
+ for operation in operation_record_list:
2864
+ if not isinstance(operation, dict):
2865
+ continue
2866
+ detail = self._first_nested_operation_detail(operation)
2867
+ items.append(
2868
+ {
2869
+ "log_id": operation.get("workflowNodeOperationRecordId")
2870
+ or node_record.get("workflowNodeProcessRecordId"),
2871
+ "node_id": node_id,
2872
+ "node_name": node_name,
2873
+ "operator": operation.get("operator"),
2874
+ "operation": operation.get("operationType"),
2875
+ "operation_result": detail,
2876
+ "operation_time": operation.get("operationTime"),
2877
+ "remark": self._extract_remark(detail),
2878
+ "signature_url": self._extract_signature_url(detail),
2879
+ "attachments": self._extract_attachments(detail),
2880
+ "qrobot_related": any(
2881
+ operation.get(key)
2882
+ for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
2883
+ ),
2884
+ }
2885
+ )
2886
+ return items
2887
+
2888
+ def _workflow_log_digest(self, items: list[dict[str, Any]]) -> str | None:
2889
+ """执行内部辅助逻辑。"""
2890
+ if not items:
2891
+ return None
2892
+ try:
2893
+ return json.dumps(items, ensure_ascii=False, sort_keys=True, default=str)
2894
+ except TypeError:
2895
+ return str(items)
2896
+
2897
+ def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
2898
+ """执行内部辅助逻辑。"""
2899
+ for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
2900
+ value = operation.get(key)
2901
+ if value is not None:
2902
+ return value
2903
+ return None
2904
+
2905
+ def _extract_remark(self, detail: Any) -> Any:
2906
+ """执行内部辅助逻辑。"""
2907
+ if not isinstance(detail, dict):
2908
+ return None
2909
+ for key in ("remark", "feedback", "comment", "content"):
2910
+ value = detail.get(key)
2911
+ if value not in (None, ""):
2912
+ return value
2913
+ return None
2914
+
2915
+ def _extract_signature_url(self, detail: Any) -> Any:
2916
+ """执行内部辅助逻辑。"""
2917
+ if not isinstance(detail, dict):
2918
+ return None
2919
+ for key in ("signatureUrl", "handSignImageUrl"):
2920
+ value = detail.get(key)
2921
+ if value not in (None, ""):
2922
+ return value
2923
+ return None
2924
+
2925
+ def _extract_attachments(self, detail: Any) -> Any:
2926
+ """执行内部辅助逻辑。"""
2927
+ if not isinstance(detail, dict):
2928
+ return []
2929
+ for key in ("attachments", "files", "uploadFiles"):
2930
+ value = detail.get(key)
2931
+ if isinstance(value, list):
2932
+ return value
2933
+ return []
2934
+
2935
+ def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
2936
+ """执行内部辅助逻辑。"""
2937
+ for key in ("audit_feedback", "auditFeedback"):
2938
+ value = payload.get(key)
2939
+ if isinstance(value, str) and value.strip():
2940
+ return value.strip()
2941
+ return None
2942
+
2943
+ def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
2944
+ """执行内部辅助逻辑。"""
2945
+ candidates = (key, *aliases)
2946
+ value: Any = None
2947
+ for candidate in candidates:
2948
+ if candidate in payload:
2949
+ value = payload.get(candidate)
2950
+ break
2951
+ if not isinstance(value, int) or value <= 0:
2952
+ names = ", ".join(candidates)
2953
+ raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
2954
+ return value
2955
+
2956
+ def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
2957
+ """执行内部辅助逻辑。"""
2958
+ if not app_key:
2959
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
2960
+ if record_id <= 0:
2961
+ raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
2962
+ if workflow_node_id <= 0:
2963
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
2964
+
2965
+ def _coerce_bool(self, value: Any) -> bool:
2966
+ """执行内部辅助逻辑。"""
2967
+ if isinstance(value, bool):
2968
+ return value
2969
+ if isinstance(value, int):
2970
+ return value != 0
2971
+ if isinstance(value, str):
2972
+ return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
2973
+ return bool(value)
2974
+
2975
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
2976
+ """执行内部辅助逻辑。"""
2977
+ describe_route = getattr(self.backend, "describe_route", None)
2978
+ if callable(describe_route):
2979
+ payload = describe_route(context)
2980
+ if isinstance(payload, dict):
2981
+ return payload
2982
+ return {
2983
+ "base_url": context.base_url,
2984
+ "qf_version": context.qf_version,
2985
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
2986
+ }