@josephyan/qingflow-app-builder-mcp 0.2.0-beta.31 → 0.2.0-beta.32

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.31
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.32
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.31 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.32 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.31",
3
+ "version": "0.2.0-beta.32",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b31"
7
+ version = "0.2.0b32"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b31"
5
+ __version__ = "0.2.0b32"
@@ -17,6 +17,7 @@ from .tools.qingbi_report_tools import QingbiReportTools
17
17
  from .tools.record_tools import RecordTools
18
18
  from .tools.role_tools import RoleTools
19
19
  from .tools.solution_tools import SolutionTools
20
+ from .tools.task_context_tools import TaskContextTools
20
21
  from .tools.view_tools import ViewTools
21
22
  from .tools.workflow_tools import WorkflowTools
22
23
  from .tools.workspace_tools import WorkspaceTools
@@ -93,6 +94,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
93
94
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
94
95
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
95
96
 
97
+ ## Task Workflow Path
98
+
99
+ `task_list -> task_get -> task_action_execute`
100
+
101
+ - Use `task_associated_report_detail_get` for associated view or report details.
102
+ - Use `task_workflow_log_get` for full workflow log history.
103
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
104
+
96
105
  ## Time Handling
97
106
 
98
107
  Normalize relative dates before building DSL.
@@ -115,6 +124,7 @@ Avoid builder-side app or schema changes here.""",
115
124
  WorkspaceTools(sessions, backend).register(server)
116
125
  FileTools(sessions, backend).register(server)
117
126
  RecordTools(sessions, backend).register(server)
127
+ TaskContextTools(sessions, backend).register(server)
118
128
  RoleTools(sessions, backend).register(server)
119
129
  AppTools(sessions, backend).register(server)
120
130
  QingbiReportTools(sessions, backend).register(server)
@@ -12,6 +12,7 @@ from .tools.auth_tools import AuthTools
12
12
  from .tools.directory_tools import DirectoryTools
13
13
  from .tools.file_tools import FileTools
14
14
  from .tools.record_tools import RecordTools
15
+ from .tools.task_context_tools import TaskContextTools
15
16
  from .tools.workspace_tools import WorkspaceTools
16
17
 
17
18
 
@@ -81,6 +82,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
81
82
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
82
83
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
83
84
 
85
+ ## Task Workflow Path
86
+
87
+ `task_list -> task_get -> task_action_execute`
88
+
89
+ - Use `task_associated_report_detail_get` for associated view or report details.
90
+ - Use `task_workflow_log_get` for full workflow log history.
91
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
92
+
84
93
  ## Time Handling
85
94
 
86
95
  Normalize relative dates before building DSL.
@@ -221,6 +230,7 @@ Avoid builder-side app or schema changes here.""",
221
230
  )
222
231
 
223
232
  RecordTools(sessions, backend).register(server)
233
+ TaskContextTools(sessions, backend).register(server)
224
234
  DirectoryTools(sessions, backend).register(server)
225
235
 
226
236
  return server
@@ -611,9 +611,11 @@ class ApprovalTools(ToolBase):
611
611
  self._normalize_alias(body, "formId", "form_id")
612
612
 
613
613
  node_id = self._extract_node_id(body)
614
- body["nodeId"] = node_id
614
+ body["nodeId"] = self._resolve_actionable_node_id(context, app_key, apply_id, node_id)
615
615
  body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
616
616
  body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
617
+ if body.get("answers") is None:
618
+ body["answers"] = self._fetch_current_todo_answers(context, app_key, apply_id, body["nodeId"])
617
619
 
618
620
  self._validate_approval_payload(body)
619
621
  return body
@@ -672,6 +674,54 @@ class ApprovalTools(ToolBase):
672
674
  elif alias_value is not None and payload.get(canonical_key) != alias_value:
673
675
  raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
674
676
 
677
+ def _resolve_actionable_node_id(self, context, app_key: str, apply_id: int, node_id: int) -> int: # type: ignore[no-untyped-def]
678
+ infos = self.backend.request(
679
+ "GET",
680
+ context,
681
+ f"/app/{app_key}/apply/{apply_id}/auditInfo",
682
+ params={"type": 1},
683
+ )
684
+ if not isinstance(infos, list) or not infos:
685
+ raise_tool_error(
686
+ QingflowApiError.config_error(
687
+ f"apply_id={apply_id} is not currently actionable for the logged-in user in todo list"
688
+ )
689
+ )
690
+ actionable_node_ids = {
691
+ candidate
692
+ for item in infos
693
+ if isinstance(item, dict)
694
+ for candidate in (item.get("auditNodeId"), item.get("nodeId"))
695
+ if isinstance(candidate, int) and candidate > 0
696
+ }
697
+ if node_id not in actionable_node_ids:
698
+ raise_tool_error(
699
+ QingflowApiError.config_error(
700
+ f"payload.nodeId={node_id} is not an actionable todo node for apply_id={apply_id}"
701
+ )
702
+ )
703
+ return node_id
704
+
705
+ def _fetch_current_todo_answers(self, context, app_key: str, apply_id: int, node_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
706
+ detail = self.backend.request(
707
+ "GET",
708
+ context,
709
+ f"/app/{app_key}/apply/{apply_id}",
710
+ params={"role": 3, "listType": 1, "auditNodeId": node_id},
711
+ )
712
+ answers = detail.get("answers") if isinstance(detail, dict) else None
713
+ if not isinstance(answers, list):
714
+ raise_tool_error(
715
+ QingflowApiError.config_error(
716
+ f"cannot resolve current answers for apply_id={apply_id} nodeId={node_id}"
717
+ )
718
+ )
719
+ normalized_answers: list[dict[str, Any]] = []
720
+ for item in answers:
721
+ if isinstance(item, dict):
722
+ normalized_answers.append(dict(item))
723
+ return normalized_answers
724
+
675
725
  def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
676
726
  self._reject_unsupported_fields(payload)
677
727
  if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
@@ -680,6 +730,9 @@ class ApprovalTools(ToolBase):
680
730
  raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
681
731
  if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
682
732
  raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
733
+ answers = payload.get("answers")
734
+ if answers is not None and not isinstance(answers, list):
735
+ raise_tool_error(QingflowApiError.config_error("payload.answers must be an array when provided"))
683
736
 
684
737
  def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
685
738
  self._reject_unsupported_fields(payload)
@@ -0,0 +1,1063 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from ..backend_client import BackendRequestContext
9
+ from ..config import DEFAULT_PROFILE
10
+ from ..errors import QingflowApiError, raise_tool_error
11
+ from ..json_types import JSONObject
12
+ from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
13
+ from .base import ToolBase
14
+ from .qingbi_report_tools import _qingbi_base_url
15
+ from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
16
+
17
+
18
+ class TaskContextTools(ToolBase):
19
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
20
+ super().__init__(sessions, backend)
21
+ self._task_tools = TaskTools(sessions, backend)
22
+ self._approval_tools = ApprovalTools(sessions, backend)
23
+
24
+ def register(self, mcp: FastMCP) -> None:
25
+ @mcp.tool()
26
+ def task_list(
27
+ profile: str = DEFAULT_PROFILE,
28
+ task_box: str = "todo",
29
+ flow_status: str = "all",
30
+ app_key: str | None = None,
31
+ workflow_node_id: int | None = None,
32
+ query: str | None = None,
33
+ page: int = 1,
34
+ page_size: int = 20,
35
+ ) -> dict[str, Any]:
36
+ return self.task_list(
37
+ profile=profile,
38
+ task_box=task_box,
39
+ flow_status=flow_status,
40
+ app_key=app_key,
41
+ workflow_node_id=workflow_node_id,
42
+ query=query,
43
+ page=page,
44
+ page_size=page_size,
45
+ )
46
+
47
+ @mcp.tool()
48
+ def task_get(
49
+ profile: str = DEFAULT_PROFILE,
50
+ app_key: str = "",
51
+ record_id: int = 0,
52
+ workflow_node_id: int = 0,
53
+ include_candidates: bool = True,
54
+ include_associated_reports: bool = True,
55
+ ) -> dict[str, Any]:
56
+ return self.task_get(
57
+ profile=profile,
58
+ app_key=app_key,
59
+ record_id=record_id,
60
+ workflow_node_id=workflow_node_id,
61
+ include_candidates=include_candidates,
62
+ include_associated_reports=include_associated_reports,
63
+ )
64
+
65
+ @mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
66
+ def task_action_execute(
67
+ profile: str = DEFAULT_PROFILE,
68
+ app_key: str = "",
69
+ record_id: int = 0,
70
+ workflow_node_id: int = 0,
71
+ action: str = "",
72
+ payload: dict[str, Any] | None = None,
73
+ ) -> dict[str, Any]:
74
+ return self.task_action_execute(
75
+ profile=profile,
76
+ app_key=app_key,
77
+ record_id=record_id,
78
+ workflow_node_id=workflow_node_id,
79
+ action=action,
80
+ payload=payload or {},
81
+ )
82
+
83
+ @mcp.tool()
84
+ def task_associated_report_detail_get(
85
+ profile: str = DEFAULT_PROFILE,
86
+ app_key: str = "",
87
+ record_id: int = 0,
88
+ workflow_node_id: int = 0,
89
+ report_id: int = 0,
90
+ page: int = 1,
91
+ page_size: int = 20,
92
+ ) -> dict[str, Any]:
93
+ return self.task_associated_report_detail_get(
94
+ profile=profile,
95
+ app_key=app_key,
96
+ record_id=record_id,
97
+ workflow_node_id=workflow_node_id,
98
+ report_id=report_id,
99
+ page=page,
100
+ page_size=page_size,
101
+ )
102
+
103
+ @mcp.tool()
104
+ def task_workflow_log_get(
105
+ profile: str = DEFAULT_PROFILE,
106
+ app_key: str = "",
107
+ record_id: int = 0,
108
+ workflow_node_id: int = 0,
109
+ ) -> dict[str, Any]:
110
+ return self.task_workflow_log_get(
111
+ profile=profile,
112
+ app_key=app_key,
113
+ record_id=record_id,
114
+ workflow_node_id=workflow_node_id,
115
+ )
116
+
117
+ def task_list(
118
+ self,
119
+ *,
120
+ profile: str,
121
+ task_box: str,
122
+ flow_status: str,
123
+ app_key: str | None,
124
+ workflow_node_id: int | None,
125
+ query: str | None,
126
+ page: int,
127
+ page_size: int,
128
+ ) -> dict[str, Any]:
129
+ normalized_type = self._task_tools._task_box_to_type(task_box)
130
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
131
+ raw = self._task_tools.task_list(
132
+ profile=profile,
133
+ type=normalized_type,
134
+ process_status=normalized_status,
135
+ app_key=app_key,
136
+ node_id=workflow_node_id,
137
+ search_key=query,
138
+ page_num=page,
139
+ page_size=page_size,
140
+ create_time_asc=None,
141
+ )
142
+ task_page = raw.get("page", {})
143
+ items = [
144
+ self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
145
+ for item in _task_page_items(task_page)
146
+ if isinstance(item, dict)
147
+ ]
148
+ return {
149
+ "profile": profile,
150
+ "ws_id": raw.get("ws_id"),
151
+ "ok": True,
152
+ "request_route": raw.get("request_route"),
153
+ "warnings": [],
154
+ "output_profile": "normal",
155
+ "data": {
156
+ "items": items,
157
+ "pagination": {
158
+ "page": page,
159
+ "page_size": page_size,
160
+ "returned_items": len(items),
161
+ "page_amount": _task_page_amount(task_page),
162
+ "reported_total": _task_page_total(task_page),
163
+ },
164
+ "selection": {
165
+ "task_box": task_box,
166
+ "flow_status": flow_status,
167
+ "app_key": app_key,
168
+ "workflow_node_id": workflow_node_id,
169
+ "query": query,
170
+ },
171
+ },
172
+ }
173
+
174
+ def task_get(
175
+ self,
176
+ *,
177
+ profile: str,
178
+ app_key: str,
179
+ record_id: int,
180
+ workflow_node_id: int,
181
+ include_candidates: bool,
182
+ include_associated_reports: bool,
183
+ ) -> dict[str, Any]:
184
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
185
+
186
+ def runner(session_profile, context):
187
+ data = self._build_task_context(
188
+ context,
189
+ app_key=app_key,
190
+ record_id=record_id,
191
+ workflow_node_id=workflow_node_id,
192
+ include_candidates=include_candidates,
193
+ include_associated_reports=include_associated_reports,
194
+ )
195
+ return {
196
+ "profile": profile,
197
+ "ws_id": session_profile.selected_ws_id,
198
+ "ok": True,
199
+ "request_route": self._request_route_payload(context),
200
+ "warnings": [],
201
+ "output_profile": "normal",
202
+ "data": data,
203
+ }
204
+
205
+ return self._run(profile, runner)
206
+
207
+ def task_action_execute(
208
+ self,
209
+ *,
210
+ profile: str,
211
+ app_key: str,
212
+ record_id: int,
213
+ workflow_node_id: int,
214
+ action: str,
215
+ payload: dict[str, Any],
216
+ ) -> dict[str, Any]:
217
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
218
+ normalized_action = (action or "").strip().lower()
219
+ if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge"}:
220
+ raise_tool_error(
221
+ QingflowApiError.not_supported(
222
+ "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, or urge"
223
+ )
224
+ )
225
+ body = dict(payload or {})
226
+ context_summary = self.task_get(
227
+ profile=profile,
228
+ app_key=app_key,
229
+ record_id=record_id,
230
+ workflow_node_id=workflow_node_id,
231
+ include_candidates=False,
232
+ include_associated_reports=False,
233
+ )
234
+ capabilities = ((context_summary.get("data") or {}).get("capabilities") or {})
235
+ available_actions = capabilities.get("available_actions") or []
236
+ if normalized_action not in available_actions:
237
+ raise_tool_error(
238
+ QingflowApiError.config_error(
239
+ f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
240
+ )
241
+ )
242
+ feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
243
+ if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
244
+ raise_tool_error(
245
+ QingflowApiError.config_error(
246
+ f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
247
+ )
248
+ )
249
+
250
+ if normalized_action == "approve":
251
+ action_payload = dict(body)
252
+ action_payload["nodeId"] = workflow_node_id
253
+ raw = self._approval_tools.record_approve(
254
+ profile=profile,
255
+ app_key=app_key,
256
+ apply_id=record_id,
257
+ payload=action_payload,
258
+ )
259
+ elif normalized_action == "reject":
260
+ action_payload = dict(body)
261
+ action_payload["nodeId"] = workflow_node_id
262
+ if not self._extract_audit_feedback(action_payload):
263
+ raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
264
+ raw = self._approval_tools.record_reject(
265
+ profile=profile,
266
+ app_key=app_key,
267
+ apply_id=record_id,
268
+ payload=action_payload,
269
+ )
270
+ elif normalized_action == "rollback":
271
+ target_node_id = self._extract_positive_int(body, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
272
+ action_payload: JSONObject = {
273
+ "auditNodeId": workflow_node_id,
274
+ "targetAuditNodeId": target_node_id,
275
+ }
276
+ audit_feedback = self._extract_audit_feedback(body)
277
+ if audit_feedback:
278
+ action_payload["auditFeedback"] = audit_feedback
279
+ raw = self._approval_tools.record_rollback(
280
+ profile=profile,
281
+ app_key=app_key,
282
+ apply_id=record_id,
283
+ payload=action_payload,
284
+ )
285
+ elif normalized_action == "transfer":
286
+ target_member_id = self._extract_positive_int(body, "target_member_id", aliases=("uid", "targetMemberId"))
287
+ action_payload = {
288
+ "auditNodeId": workflow_node_id,
289
+ "uid": target_member_id,
290
+ }
291
+ audit_feedback = self._extract_audit_feedback(body)
292
+ if audit_feedback:
293
+ action_payload["auditFeedback"] = audit_feedback
294
+ raw = self._approval_tools.record_transfer(
295
+ profile=profile,
296
+ app_key=app_key,
297
+ apply_id=record_id,
298
+ payload=action_payload,
299
+ )
300
+ else:
301
+ raw = self._task_tools.task_urge(
302
+ profile=profile,
303
+ app_key=app_key,
304
+ row_record_id=record_id,
305
+ )
306
+
307
+ return {
308
+ "profile": raw.get("profile", profile),
309
+ "ws_id": raw.get("ws_id"),
310
+ "ok": bool(raw.get("ok", True)),
311
+ "request_route": raw.get("request_route"),
312
+ "warnings": [],
313
+ "output_profile": "normal",
314
+ "data": {
315
+ "action": normalized_action,
316
+ "resource": {
317
+ "app_key": app_key,
318
+ "record_id": record_id,
319
+ "workflow_node_id": workflow_node_id,
320
+ },
321
+ "selection": {"action": normalized_action},
322
+ "result": raw.get("result"),
323
+ "human_review": True,
324
+ },
325
+ }
326
+
327
+ def task_associated_report_detail_get(
328
+ self,
329
+ *,
330
+ profile: str,
331
+ app_key: str,
332
+ record_id: int,
333
+ workflow_node_id: int,
334
+ report_id: int,
335
+ page: int,
336
+ page_size: int,
337
+ ) -> dict[str, Any]:
338
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
339
+ if report_id <= 0:
340
+ raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
341
+ if page <= 0 or page_size <= 0:
342
+ raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
343
+
344
+ def runner(session_profile, context):
345
+ task_context = self._build_task_context(
346
+ context,
347
+ app_key=app_key,
348
+ record_id=record_id,
349
+ workflow_node_id=workflow_node_id,
350
+ include_candidates=False,
351
+ include_associated_reports=True,
352
+ )
353
+ report_item = self._find_associated_report(task_context, report_id)
354
+ if report_item is None:
355
+ raise_tool_error(
356
+ QingflowApiError.config_error(
357
+ f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
358
+ )
359
+ )
360
+ association_query = self._build_association_query(
361
+ report_item["raw"],
362
+ task_context.get("record", {}).get("answers") or [],
363
+ )
364
+ selection = {
365
+ "app_key": app_key,
366
+ "record_id": record_id,
367
+ "workflow_node_id": workflow_node_id,
368
+ "report_id": report_id,
369
+ "target_app_key": report_item.get("target_app_key"),
370
+ "target_app_name": report_item.get("target_app_name"),
371
+ "chart_key": report_item.get("chart_key"),
372
+ "chart_name": report_item.get("chart_name"),
373
+ }
374
+ context_payload = {
375
+ "match_rules": report_item.get("match_rules") or [],
376
+ "resolved_filters": association_query.get("keyQueValues") or [],
377
+ }
378
+
379
+ if report_item.get("graph_type") == "view":
380
+ viewgraph_key = str(report_item.get("chart_key") or "")
381
+ body = {
382
+ "filter": {},
383
+ "viewgraphKey": viewgraph_key,
384
+ "equipmentType": 0,
385
+ "associationQuery": association_query,
386
+ }
387
+ result = self.backend.request(
388
+ "POST",
389
+ context,
390
+ f"/view/{viewgraph_key}/apply/filter",
391
+ json_body=body,
392
+ )
393
+ items = _task_page_items(result)
394
+ return {
395
+ "profile": profile,
396
+ "ws_id": session_profile.selected_ws_id,
397
+ "ok": True,
398
+ "request_route": self._request_route_payload(context),
399
+ "warnings": [],
400
+ "output_profile": "normal",
401
+ "data": {
402
+ "result_type": "view_list",
403
+ "result": {
404
+ "items": items,
405
+ "pagination": {
406
+ "page": page,
407
+ "page_size": page_size,
408
+ "returned_items": len(items),
409
+ "page_amount": _task_page_amount(result),
410
+ "reported_total": _task_page_total(result),
411
+ },
412
+ },
413
+ "selection": selection,
414
+ "context": context_payload,
415
+ },
416
+ }
417
+
418
+ chart_key = str(report_item.get("chart_key") or "")
419
+ source_type = report_item.get("source_type")
420
+ if source_type == "qingbi":
421
+ qingbi_context = BackendRequestContext(
422
+ base_url=_qingbi_base_url(context.base_url),
423
+ token=context.token,
424
+ ws_id=context.ws_id,
425
+ qf_request_id=context.qf_request_id,
426
+ qf_version=context.qf_version,
427
+ qf_version_source=context.qf_version_source,
428
+ )
429
+ chart_result = self.backend.request(
430
+ "POST",
431
+ qingbi_context,
432
+ f"/qingbi/charts/data/{chart_key}",
433
+ params={
434
+ "qfUUID": uuid4().hex,
435
+ "pageNum": page,
436
+ "pageSize": page_size,
437
+ },
438
+ json_body={
439
+ "asosChartId": report_id,
440
+ "keyQueValues": association_query.get("keyQueValues") or [],
441
+ },
442
+ )
443
+ request_route = {
444
+ "base_url": qingbi_context.base_url,
445
+ "qf_version": qingbi_context.qf_version,
446
+ "qf_version_source": qingbi_context.qf_version_source or "context",
447
+ }
448
+ elif self._qingflow_chart_uses_apply_filter(context, chart_key):
449
+ chart_result = self.backend.request(
450
+ "POST",
451
+ context,
452
+ f"/chart/{chart_key}/apply/filter",
453
+ json_body={
454
+ "filter": {
455
+ "pageNum": page,
456
+ "pageSize": page_size,
457
+ },
458
+ "asosChartId": report_id,
459
+ "keyQueValues": association_query.get("keyQueValues") or [],
460
+ },
461
+ )
462
+ items = _task_page_items(chart_result)
463
+ return {
464
+ "profile": profile,
465
+ "ws_id": session_profile.selected_ws_id,
466
+ "ok": True,
467
+ "request_route": self._request_route_payload(context),
468
+ "warnings": [],
469
+ "output_profile": "normal",
470
+ "data": {
471
+ "result_type": "view_list",
472
+ "result": {
473
+ "items": items,
474
+ "pagination": {
475
+ "page": page,
476
+ "page_size": page_size,
477
+ "returned_items": len(items),
478
+ "page_amount": _task_page_amount(chart_result),
479
+ "reported_total": _task_page_total(chart_result),
480
+ },
481
+ },
482
+ "selection": selection,
483
+ "context": context_payload,
484
+ },
485
+ }
486
+ else:
487
+ chart_result = self.backend.request(
488
+ "POST",
489
+ context,
490
+ f"/chart/{chart_key}/chartData",
491
+ json_body={
492
+ "asosChartId": report_id,
493
+ "keyQueValues": association_query.get("keyQueValues") or [],
494
+ },
495
+ )
496
+ request_route = self._request_route_payload(context)
497
+
498
+ return {
499
+ "profile": profile,
500
+ "ws_id": session_profile.selected_ws_id,
501
+ "ok": True,
502
+ "request_route": request_route,
503
+ "warnings": [],
504
+ "output_profile": "normal",
505
+ "data": {
506
+ "result_type": "chart_data",
507
+ "result": self._normalize_chart_result(chart_result),
508
+ "selection": selection,
509
+ "context": context_payload,
510
+ },
511
+ }
512
+
513
+ return self._run(profile, runner)
514
+
515
+ def task_workflow_log_get(
516
+ self,
517
+ *,
518
+ profile: str,
519
+ app_key: str,
520
+ record_id: int,
521
+ workflow_node_id: int,
522
+ ) -> dict[str, Any]:
523
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
524
+
525
+ def runner(session_profile, context):
526
+ task_context = self._build_task_context(
527
+ context,
528
+ app_key=app_key,
529
+ record_id=record_id,
530
+ workflow_node_id=workflow_node_id,
531
+ include_candidates=False,
532
+ include_associated_reports=False,
533
+ )
534
+ visibility = task_context.get("visibility") or {}
535
+ if not visibility.get("audit_record_visible"):
536
+ raise_tool_error(
537
+ QingflowApiError.config_error(
538
+ f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
539
+ )
540
+ )
541
+ page = self.backend.request(
542
+ "POST",
543
+ context,
544
+ "/application/workflow/node/record",
545
+ json_body={
546
+ "key": app_key,
547
+ "rowRecordId": record_id,
548
+ "nodeId": workflow_node_id,
549
+ "role": 3,
550
+ "pageNum": 1,
551
+ "pageSize": 200,
552
+ },
553
+ )
554
+ items = self._normalize_workflow_logs(page)
555
+ return {
556
+ "profile": profile,
557
+ "ws_id": session_profile.selected_ws_id,
558
+ "ok": True,
559
+ "request_route": self._request_route_payload(context),
560
+ "warnings": [],
561
+ "output_profile": "normal",
562
+ "data": {
563
+ "selection": {
564
+ "app_key": app_key,
565
+ "record_id": record_id,
566
+ "workflow_node_id": workflow_node_id,
567
+ },
568
+ "visibility": {
569
+ "audit_record_visible": visibility.get("audit_record_visible"),
570
+ "qrobot_record_visible": visibility.get("qrobot_record_visible"),
571
+ },
572
+ "items": items,
573
+ },
574
+ }
575
+
576
+ return self._run(profile, runner)
577
+
578
+ def _build_task_context(
579
+ self,
580
+ context: BackendRequestContext,
581
+ *,
582
+ app_key: str,
583
+ record_id: int,
584
+ workflow_node_id: int,
585
+ include_candidates: bool,
586
+ include_associated_reports: bool,
587
+ ) -> dict[str, Any]:
588
+ audit_infos = self.backend.request(
589
+ "GET",
590
+ context,
591
+ f"/app/{app_key}/apply/{record_id}/auditInfo",
592
+ params={"type": 1},
593
+ )
594
+ node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
595
+ detail = self.backend.request(
596
+ "GET",
597
+ context,
598
+ f"/app/{app_key}/apply/{record_id}",
599
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
600
+ )
601
+ associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
602
+ associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
603
+ if include_associated_reports and associated_report_visible:
604
+ asos_chart_list = self.backend.request(
605
+ "GET",
606
+ context,
607
+ f"/app/{app_key}/asosChart",
608
+ params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
609
+ )
610
+ associated_items = [
611
+ self._normalize_associated_report(item)
612
+ for item in (asos_chart_list.get("asosCharts") or [])
613
+ if isinstance(item, dict)
614
+ ]
615
+ associated_reports = {
616
+ "visible": True,
617
+ "count": len(associated_items),
618
+ "items": associated_items,
619
+ }
620
+ rollback_items: list[dict[str, Any]] = []
621
+ transfer_items: list[dict[str, Any]] = []
622
+ if include_candidates:
623
+ rollback_result = self.backend.request(
624
+ "GET",
625
+ context,
626
+ f"/app/{app_key}/apply/{record_id}/revertNode",
627
+ params={"auditNodeId": workflow_node_id},
628
+ )
629
+ rollback_items = self._rollback_candidate_items(rollback_result)
630
+ transfer_result = self.backend.request(
631
+ "GET",
632
+ context,
633
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
634
+ params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
635
+ )
636
+ transfer_items = _approval_page_items(transfer_result)
637
+
638
+ capabilities = self._build_capabilities(node_info)
639
+ visibility = self._build_visibility(node_info, detail)
640
+ return {
641
+ "task": {
642
+ "app_key": app_key,
643
+ "record_id": record_id,
644
+ "workflow_node_id": workflow_node_id,
645
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
646
+ "actionable": True,
647
+ },
648
+ "record": {
649
+ "apply_id": detail.get("applyId", record_id),
650
+ "apply_status": detail.get("applyStatus"),
651
+ "apply_num": detail.get("applyNum"),
652
+ "custom_apply_num": detail.get("customApplyNum"),
653
+ "apply_user": detail.get("applyUser"),
654
+ "apply_time": detail.get("applyTime"),
655
+ "last_update_time": detail.get("lastUpdateTime"),
656
+ "answers": detail.get("answers") or [],
657
+ },
658
+ "capabilities": capabilities,
659
+ "field_permissions": {
660
+ "que_auth_setting": node_info.get("queAuthSetting") or [],
661
+ },
662
+ "visibility": visibility,
663
+ "associated_reports": associated_reports,
664
+ "candidates": {
665
+ "rollback_nodes": rollback_items,
666
+ "transfer_members": transfer_items,
667
+ },
668
+ "workflow_log_summary": {
669
+ "visible": visibility["audit_record_visible"],
670
+ "available": visibility["audit_record_visible"],
671
+ "history_count": None,
672
+ "qrobot_log_visible": visibility["qrobot_record_visible"],
673
+ },
674
+ }
675
+
676
+ def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
677
+ app_key = raw.get("appKey") or raw.get("app_key")
678
+ record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
679
+ workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
680
+ apply_user = raw.get("applyUser")
681
+ if apply_user is None:
682
+ user_uid = raw.get("applyUserUid")
683
+ user_name = raw.get("applyUserName")
684
+ if user_uid is not None or user_name is not None:
685
+ apply_user = {"uid": user_uid, "name": user_name}
686
+ return {
687
+ "task_id": raw.get("id") or raw.get("taskId") or record_id,
688
+ "app_key": app_key,
689
+ "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
690
+ "record_id": record_id,
691
+ "workflow_node_id": workflow_node_id,
692
+ "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
693
+ "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
694
+ "apply_user": apply_user,
695
+ "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
696
+ "task_box": task_box,
697
+ "flow_status": flow_status,
698
+ "actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
699
+ }
700
+
701
+ def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
702
+ if not isinstance(infos, list) or not infos:
703
+ raise_tool_error(
704
+ QingflowApiError.config_error(
705
+ f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
706
+ )
707
+ )
708
+ for item in infos:
709
+ if not isinstance(item, dict):
710
+ continue
711
+ candidate = item.get("auditNodeId")
712
+ if not isinstance(candidate, int):
713
+ candidate = item.get("nodeId")
714
+ if candidate == workflow_node_id:
715
+ return item
716
+ raise_tool_error(
717
+ QingflowApiError.config_error(
718
+ f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
719
+ )
720
+ )
721
+
722
+ def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
723
+ available_actions = ["approve"]
724
+ if self._coerce_bool(node_info.get("rejectBtnStatus")):
725
+ available_actions.append("reject")
726
+ if self._coerce_bool(node_info.get("canRevert")):
727
+ available_actions.append("rollback")
728
+ if self._coerce_bool(node_info.get("canTransfer")):
729
+ available_actions.append("transfer")
730
+ if self._coerce_bool(node_info.get("canUrge")):
731
+ available_actions.append("urge")
732
+
733
+ visible_but_unimplemented_actions: list[str] = []
734
+ if self._coerce_bool(node_info.get("canRevoke")):
735
+ visible_but_unimplemented_actions.append("revoke")
736
+ if self._coerce_bool(node_info.get("beingEndWorkflow")):
737
+ visible_but_unimplemented_actions.append("end_workflow")
738
+ if self._coerce_bool(node_info.get("beingCanApplyAgain")):
739
+ visible_but_unimplemented_actions.append("apply_again")
740
+
741
+ feedback_required_for = []
742
+ raw_feedback_required = node_info.get("feedbackRequiredOperationType")
743
+ if isinstance(raw_feedback_required, list):
744
+ feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
745
+
746
+ return {
747
+ "available_actions": available_actions,
748
+ "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
749
+ "action_constraints": {
750
+ "feedback_required_for": feedback_required_for,
751
+ "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
752
+ "submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
753
+ "can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
754
+ "can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
755
+ },
756
+ }
757
+
758
+ def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
759
+ return {
760
+ "comment_visible": self._coerce_bool(node_info.get("commentStatus")),
761
+ "audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
762
+ "workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
763
+ "qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
764
+ "associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
765
+ }
766
+
767
+ def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
768
+ node_visible = node_info.get("asosChartVisible")
769
+ if node_visible is not None:
770
+ return self._coerce_bool(node_visible)
771
+ return self._coerce_bool(detail.get("viewAsosChartVisible"))
772
+
773
+ def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
774
+ graph_type = str(raw.get("graphType") or "").strip().lower()
775
+ source_type = str(raw.get("sourceType") or "").strip().lower()
776
+ return {
777
+ "report_id": raw.get("id"),
778
+ "chart_key": raw.get("chartKey"),
779
+ "chart_name": raw.get("chartName"),
780
+ "graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
781
+ "source_type": source_type or "qingflow",
782
+ "target_app_key": raw.get("appKey"),
783
+ "target_app_name": raw.get("formTitle"),
784
+ "match_rules": raw.get("matchRules") or [],
785
+ "raw": dict(raw),
786
+ }
787
+
788
+ def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
789
+ if isinstance(payload, dict):
790
+ revert_nodes = payload.get("revertNodes")
791
+ if isinstance(revert_nodes, list):
792
+ return [item for item in revert_nodes if isinstance(item, dict)]
793
+ return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
794
+
795
+ def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
796
+ associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
797
+ for item in associated_reports:
798
+ if isinstance(item, dict) and item.get("report_id") == report_id:
799
+ return item
800
+ return None
801
+
802
+ def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
803
+ key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
804
+ key_values: list[dict[str, Any]] = []
805
+ for answer in answers:
806
+ if not isinstance(answer, dict):
807
+ continue
808
+ answer_que_id = answer.get("queId")
809
+ if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
810
+ extracted_values = self._extract_answer_values(answer)
811
+ key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
812
+ table_values = answer.get("tableValues")
813
+ if not isinstance(table_values, list):
814
+ continue
815
+ for idx, row in enumerate(table_values, start=1):
816
+ if not isinstance(row, list):
817
+ continue
818
+ for sub_answer in row:
819
+ if not isinstance(sub_answer, dict):
820
+ continue
821
+ sub_que_id = sub_answer.get("queId")
822
+ if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
823
+ extracted_values = self._extract_answer_values(sub_answer)
824
+ key_values.append(
825
+ {
826
+ "keyQueId": sub_que_id,
827
+ "ordinal": idx,
828
+ "values": extracted_values or None,
829
+ }
830
+ )
831
+ return {
832
+ "asosChart": self._sanitize_associated_chart(asos_chart),
833
+ "keyQueValues": key_values,
834
+ }
835
+
836
+ def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
837
+ question_ids: set[int] = set()
838
+
839
+ def visit(node: Any) -> None:
840
+ if isinstance(node, list):
841
+ for item in node:
842
+ visit(item)
843
+ return
844
+ if not isinstance(node, dict):
845
+ return
846
+ for key in ("queId", "judgeQueId"):
847
+ value = node.get(key)
848
+ if isinstance(value, int):
849
+ question_ids.add(value)
850
+ for value in node.values():
851
+ if isinstance(value, (list, dict)):
852
+ visit(value)
853
+
854
+ visit(match_rules)
855
+ return question_ids
856
+
857
+ def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
858
+ values = answer.get("values")
859
+ if not isinstance(values, list):
860
+ return []
861
+ normalized: list[str] = []
862
+ for item in values:
863
+ if item is None:
864
+ continue
865
+ if isinstance(item, (str, int, float, bool)):
866
+ normalized.append(str(item))
867
+ continue
868
+ if not isinstance(item, dict):
869
+ continue
870
+ for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
871
+ value = item.get(key)
872
+ if value not in (None, ""):
873
+ normalized.append(str(value))
874
+ break
875
+ deduped: list[str] = []
876
+ seen: set[str] = set()
877
+ for item in normalized:
878
+ if item in seen:
879
+ continue
880
+ deduped.append(item)
881
+ seen.add(item)
882
+ return deduped
883
+
884
+ def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
885
+ return {
886
+ "id": asos_chart.get("id"),
887
+ "appKey": asos_chart.get("appKey"),
888
+ "formTitle": asos_chart.get("formTitle"),
889
+ "chartKey": asos_chart.get("chartKey"),
890
+ "chartName": asos_chart.get("chartName"),
891
+ "chartType": asos_chart.get("chartType"),
892
+ "matchRules": asos_chart.get("matchRules") or [],
893
+ "sourceType": asos_chart.get("sourceType"),
894
+ "graphType": asos_chart.get("graphType"),
895
+ "viewType": asos_chart.get("viewType"),
896
+ }
897
+
898
+ def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
899
+ if not chart_key:
900
+ return False
901
+ try:
902
+ auth = self.backend.request(
903
+ "GET",
904
+ context,
905
+ f"/chart/{chart_key}/auth",
906
+ )
907
+ except QingflowApiError:
908
+ return False
909
+ if not isinstance(auth, dict):
910
+ return False
911
+ return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
912
+
913
+ def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
914
+ if isinstance(payload, dict):
915
+ rows = payload.get("rows")
916
+ if not isinstance(rows, list):
917
+ rows = payload.get("list") if isinstance(payload.get("list"), list) else []
918
+ series = payload.get("series")
919
+ if not isinstance(series, list):
920
+ series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
921
+ metrics = payload.get("metrics")
922
+ if not isinstance(metrics, list):
923
+ metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
924
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
925
+ return {
926
+ "summary": summary,
927
+ "rows": rows or [],
928
+ "series": series or [],
929
+ "metrics": metrics or [],
930
+ }
931
+ if isinstance(payload, list):
932
+ return {"summary": {}, "rows": payload, "series": [], "metrics": []}
933
+ return {"summary": {}, "rows": [], "series": [], "metrics": []}
934
+
935
+ def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
936
+ if isinstance(payload, dict):
937
+ page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
938
+ if not isinstance(page, list):
939
+ nested = payload.get("data")
940
+ if isinstance(nested, dict):
941
+ page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
942
+ if not isinstance(page, list):
943
+ page = []
944
+ elif isinstance(payload, list):
945
+ page = payload
946
+ else:
947
+ page = []
948
+
949
+ items: list[dict[str, Any]] = []
950
+ for node_record in page:
951
+ if not isinstance(node_record, dict):
952
+ continue
953
+ node_id = node_record.get("nodeId")
954
+ node_name = node_record.get("nodeName")
955
+ operation_record_list = node_record.get("operationRecordList")
956
+ if not isinstance(operation_record_list, list):
957
+ operation_record_list = []
958
+ for operation in operation_record_list:
959
+ if not isinstance(operation, dict):
960
+ continue
961
+ detail = self._first_nested_operation_detail(operation)
962
+ items.append(
963
+ {
964
+ "log_id": operation.get("workflowNodeOperationRecordId")
965
+ or node_record.get("workflowNodeProcessRecordId"),
966
+ "node_id": node_id,
967
+ "node_name": node_name,
968
+ "operator": operation.get("operator"),
969
+ "operation": operation.get("operationType"),
970
+ "operation_result": detail,
971
+ "operation_time": operation.get("operationTime"),
972
+ "remark": self._extract_remark(detail),
973
+ "signature_url": self._extract_signature_url(detail),
974
+ "attachments": self._extract_attachments(detail),
975
+ "qrobot_related": any(
976
+ operation.get(key)
977
+ for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
978
+ ),
979
+ }
980
+ )
981
+ return items
982
+
983
+ def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
984
+ for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
985
+ value = operation.get(key)
986
+ if value is not None:
987
+ return value
988
+ return None
989
+
990
+ def _extract_remark(self, detail: Any) -> Any:
991
+ if not isinstance(detail, dict):
992
+ return None
993
+ for key in ("remark", "feedback", "comment", "content"):
994
+ value = detail.get(key)
995
+ if value not in (None, ""):
996
+ return value
997
+ return None
998
+
999
+ def _extract_signature_url(self, detail: Any) -> Any:
1000
+ if not isinstance(detail, dict):
1001
+ return None
1002
+ for key in ("signatureUrl", "handSignImageUrl"):
1003
+ value = detail.get(key)
1004
+ if value not in (None, ""):
1005
+ return value
1006
+ return None
1007
+
1008
+ def _extract_attachments(self, detail: Any) -> Any:
1009
+ if not isinstance(detail, dict):
1010
+ return []
1011
+ for key in ("attachments", "files", "uploadFiles"):
1012
+ value = detail.get(key)
1013
+ if isinstance(value, list):
1014
+ return value
1015
+ return []
1016
+
1017
+ def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
1018
+ for key in ("audit_feedback", "auditFeedback"):
1019
+ value = payload.get(key)
1020
+ if isinstance(value, str) and value.strip():
1021
+ return value.strip()
1022
+ return None
1023
+
1024
+ def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
1025
+ candidates = (key, *aliases)
1026
+ value: Any = None
1027
+ for candidate in candidates:
1028
+ if candidate in payload:
1029
+ value = payload.get(candidate)
1030
+ break
1031
+ if not isinstance(value, int) or value <= 0:
1032
+ names = ", ".join(candidates)
1033
+ raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
1034
+ return value
1035
+
1036
+ def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
1037
+ if not app_key:
1038
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1039
+ if record_id <= 0:
1040
+ raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
1041
+ if workflow_node_id <= 0:
1042
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
1043
+
1044
+ def _coerce_bool(self, value: Any) -> bool:
1045
+ if isinstance(value, bool):
1046
+ return value
1047
+ if isinstance(value, int):
1048
+ return value != 0
1049
+ if isinstance(value, str):
1050
+ return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
1051
+ return bool(value)
1052
+
1053
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
1054
+ describe_route = getattr(self.backend, "describe_route", None)
1055
+ if callable(describe_route):
1056
+ payload = describe_route(context)
1057
+ if isinstance(payload, dict):
1058
+ return payload
1059
+ return {
1060
+ "base_url": context.base_url,
1061
+ "qf_version": context.qf_version,
1062
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
1063
+ }