@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -0,0 +1,1335 @@
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
+
227
+ def runner(session_profile, context):
228
+ try:
229
+ task_context = self._build_task_context(
230
+ context,
231
+ app_key=app_key,
232
+ record_id=record_id,
233
+ workflow_node_id=workflow_node_id,
234
+ include_candidates=False,
235
+ include_associated_reports=False,
236
+ )
237
+ except QingflowApiError as error:
238
+ if error.backend_code == 46001:
239
+ return self._task_action_visibility_unverified_response(
240
+ profile=profile,
241
+ session_profile=session_profile,
242
+ context=context,
243
+ app_key=app_key,
244
+ record_id=record_id,
245
+ workflow_node_id=workflow_node_id,
246
+ action=normalized_action,
247
+ source_error=error,
248
+ before_apply_status=None,
249
+ )
250
+ raise
251
+ capabilities = task_context.get("capabilities") or {}
252
+ available_actions = capabilities.get("available_actions") or []
253
+ if normalized_action not in available_actions:
254
+ raise_tool_error(
255
+ QingflowApiError.config_error(
256
+ f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
257
+ )
258
+ )
259
+ feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
260
+ if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
261
+ raise_tool_error(
262
+ QingflowApiError.config_error(
263
+ f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
264
+ )
265
+ )
266
+ before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
267
+ try:
268
+ raw = self._execute_task_action(
269
+ profile=profile,
270
+ app_key=app_key,
271
+ record_id=record_id,
272
+ workflow_node_id=workflow_node_id,
273
+ normalized_action=normalized_action,
274
+ payload=body,
275
+ )
276
+ except QingflowApiError as error:
277
+ if error.backend_code == 46001:
278
+ return self._task_action_visibility_unverified_response(
279
+ profile=profile,
280
+ session_profile=session_profile,
281
+ context=context,
282
+ app_key=app_key,
283
+ record_id=record_id,
284
+ workflow_node_id=workflow_node_id,
285
+ action=normalized_action,
286
+ source_error=error,
287
+ before_apply_status=before_apply_status,
288
+ )
289
+ raise
290
+
291
+ verification, warnings = self._verify_task_action_runtime(
292
+ profile=profile,
293
+ context=context,
294
+ app_key=app_key,
295
+ record_id=record_id,
296
+ workflow_node_id=workflow_node_id,
297
+ action=normalized_action,
298
+ before_apply_status=before_apply_status,
299
+ )
300
+ runtime_verified = bool(verification.get("runtime_continuation_verified"))
301
+ status = "success" if runtime_verified else "partial_success"
302
+ return {
303
+ "profile": raw.get("profile", profile),
304
+ "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
305
+ "ok": bool(raw.get("ok", True)),
306
+ "status": status,
307
+ "error_code": None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED",
308
+ "request_route": raw.get("request_route") or self._request_route_payload(context),
309
+ "warnings": warnings,
310
+ "verification": verification,
311
+ "output_profile": "normal",
312
+ "data": {
313
+ "action": normalized_action,
314
+ "resource": {
315
+ "app_key": app_key,
316
+ "record_id": record_id,
317
+ "workflow_node_id": workflow_node_id,
318
+ },
319
+ "selection": {"action": normalized_action},
320
+ "result": raw.get("result"),
321
+ "human_review": True,
322
+ },
323
+ }
324
+
325
+ return self._run(profile, runner)
326
+
327
+ def _execute_task_action(
328
+ self,
329
+ *,
330
+ profile: str,
331
+ app_key: str,
332
+ record_id: int,
333
+ workflow_node_id: int,
334
+ normalized_action: str,
335
+ payload: dict[str, Any],
336
+ ) -> dict[str, Any]:
337
+ if normalized_action == "approve":
338
+ action_payload = dict(payload)
339
+ action_payload["nodeId"] = workflow_node_id
340
+ return self._approval_tools.record_approve(
341
+ profile=profile,
342
+ app_key=app_key,
343
+ apply_id=record_id,
344
+ payload=action_payload,
345
+ )
346
+ if normalized_action == "reject":
347
+ action_payload = dict(payload)
348
+ action_payload["nodeId"] = workflow_node_id
349
+ if not self._extract_audit_feedback(action_payload):
350
+ raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
351
+ return self._approval_tools.record_reject(
352
+ profile=profile,
353
+ app_key=app_key,
354
+ apply_id=record_id,
355
+ payload=action_payload,
356
+ )
357
+ if normalized_action == "rollback":
358
+ target_node_id = self._extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
359
+ action_payload: JSONObject = {
360
+ "auditNodeId": workflow_node_id,
361
+ "targetAuditNodeId": target_node_id,
362
+ }
363
+ audit_feedback = self._extract_audit_feedback(payload)
364
+ if audit_feedback:
365
+ action_payload["auditFeedback"] = audit_feedback
366
+ return self._approval_tools.record_rollback(
367
+ profile=profile,
368
+ app_key=app_key,
369
+ apply_id=record_id,
370
+ payload=action_payload,
371
+ )
372
+ if normalized_action == "transfer":
373
+ target_member_id = self._extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId"))
374
+ action_payload = {
375
+ "auditNodeId": workflow_node_id,
376
+ "uid": target_member_id,
377
+ }
378
+ audit_feedback = self._extract_audit_feedback(payload)
379
+ if audit_feedback:
380
+ action_payload["auditFeedback"] = audit_feedback
381
+ return self._approval_tools.record_transfer(
382
+ profile=profile,
383
+ app_key=app_key,
384
+ apply_id=record_id,
385
+ payload=action_payload,
386
+ )
387
+ return self._task_tools.task_urge(
388
+ profile=profile,
389
+ app_key=app_key,
390
+ row_record_id=record_id,
391
+ )
392
+
393
+ def _verify_task_action_runtime(
394
+ self,
395
+ *,
396
+ profile: str,
397
+ context: BackendRequestContext,
398
+ app_key: str,
399
+ record_id: int,
400
+ workflow_node_id: int,
401
+ action: str,
402
+ before_apply_status: Any,
403
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
404
+ verification: dict[str, Any] = {
405
+ "action_executed": True,
406
+ "runtime_continuation_verified": action == "urge",
407
+ "scope": "workflow_runtime",
408
+ "task_context_visibility_verified": True,
409
+ }
410
+ warnings: list[dict[str, Any]] = []
411
+ if action == "urge":
412
+ return verification, warnings
413
+
414
+ state_after: dict[str, Any] | None = None
415
+ try:
416
+ state_after = self.backend.request(
417
+ "GET",
418
+ context,
419
+ f"/app/{app_key}/apply/{record_id}",
420
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
421
+ )
422
+ verification["record_state_readable"] = True
423
+ verification["before_apply_status"] = before_apply_status
424
+ verification["after_apply_status"] = state_after.get("applyStatus") if isinstance(state_after, dict) else None
425
+ verification["record_state_changed"] = verification["after_apply_status"] != before_apply_status
426
+ except QingflowApiError as error:
427
+ verification["record_state_readable"] = False
428
+ verification["record_state_changed"] = False
429
+ verification["record_state_error"] = {
430
+ "http_status": error.http_status,
431
+ "backend_code": error.backend_code,
432
+ "category": error.category,
433
+ }
434
+
435
+ log_items: list[dict[str, Any]] = []
436
+ try:
437
+ log_page = self.backend.request(
438
+ "POST",
439
+ context,
440
+ "/application/workflow/node/record",
441
+ json_body={
442
+ "key": app_key,
443
+ "rowRecordId": record_id,
444
+ "nodeId": workflow_node_id,
445
+ "role": 3,
446
+ "pageNum": 1,
447
+ "pageSize": 50,
448
+ },
449
+ )
450
+ log_items = self._normalize_workflow_logs(log_page)
451
+ verification["workflow_log_visible"] = True
452
+ verification["workflow_log_count"] = len(log_items)
453
+ except QingflowApiError as error:
454
+ verification["workflow_log_visible"] = False
455
+ verification["workflow_log_count"] = None
456
+ verification["workflow_log_error"] = {
457
+ "http_status": error.http_status,
458
+ "backend_code": error.backend_code,
459
+ "category": error.category,
460
+ }
461
+
462
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
463
+ initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
464
+ downstream_todo_detected = any(
465
+ int(item.get("record_id") or 0) == record_id and int(item.get("workflow_node_id") or 0) != workflow_node_id
466
+ for item in todo_items
467
+ if isinstance(item, dict)
468
+ )
469
+ initiated_visible = any(
470
+ int(item.get("record_id") or 0) == record_id
471
+ for item in initiated_items
472
+ if isinstance(item, dict)
473
+ )
474
+ verification["downstream_todo_detected"] = downstream_todo_detected
475
+ verification["initiated_task_visible"] = initiated_visible
476
+
477
+ runtime_verified = bool(
478
+ verification.get("record_state_changed")
479
+ or downstream_todo_detected
480
+ or (verification.get("workflow_log_visible") and len(log_items) > 0)
481
+ )
482
+ verification["runtime_continuation_verified"] = runtime_verified
483
+ if not runtime_verified:
484
+ warnings.append(
485
+ {
486
+ "code": "WORKFLOW_CONTINUATION_UNVERIFIED",
487
+ "message": "task action executed, but MCP could not verify downstream workflow continuation from record state, workflow logs, or downstream todo tasks.",
488
+ }
489
+ )
490
+ return verification, warnings
491
+
492
+ def _task_action_visibility_unverified_response(
493
+ self,
494
+ *,
495
+ profile: str,
496
+ session_profile,
497
+ context: BackendRequestContext,
498
+ app_key: str,
499
+ record_id: int,
500
+ workflow_node_id: int,
501
+ action: str,
502
+ source_error: QingflowApiError,
503
+ before_apply_status: Any,
504
+ ) -> dict[str, Any]:
505
+ verification, warnings = self._verify_task_action_runtime(
506
+ profile=profile,
507
+ context=context,
508
+ app_key=app_key,
509
+ record_id=record_id,
510
+ workflow_node_id=workflow_node_id,
511
+ action=action,
512
+ before_apply_status=before_apply_status,
513
+ )
514
+ verification["action_executed"] = False
515
+ verification["task_context_visibility_verified"] = bool(verification.get("runtime_continuation_verified"))
516
+ if verification["task_context_visibility_verified"]:
517
+ warnings.append(
518
+ {
519
+ "code": "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR",
520
+ "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.",
521
+ }
522
+ )
523
+ return {
524
+ "profile": profile,
525
+ "ws_id": session_profile.selected_ws_id,
526
+ "ok": True,
527
+ "status": "partial_success",
528
+ "error_code": "TASK_ALREADY_PROCESSED",
529
+ "request_route": self._request_route_payload(context),
530
+ "warnings": warnings,
531
+ "verification": verification,
532
+ "output_profile": "normal",
533
+ "data": {
534
+ "action": action,
535
+ "resource": {
536
+ "app_key": app_key,
537
+ "record_id": record_id,
538
+ "workflow_node_id": workflow_node_id,
539
+ },
540
+ "selection": {"action": action},
541
+ "result": None,
542
+ "human_review": True,
543
+ },
544
+ }
545
+ warnings.append(
546
+ {
547
+ "code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
548
+ "message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
549
+ }
550
+ )
551
+ return {
552
+ "profile": profile,
553
+ "ws_id": session_profile.selected_ws_id,
554
+ "ok": False,
555
+ "status": "failed",
556
+ "error_code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
557
+ "request_route": self._request_route_payload(context),
558
+ "warnings": warnings,
559
+ "verification": verification,
560
+ "output_profile": "normal",
561
+ "data": {
562
+ "action": action,
563
+ "resource": {
564
+ "app_key": app_key,
565
+ "record_id": record_id,
566
+ "workflow_node_id": workflow_node_id,
567
+ },
568
+ "selection": {"action": action},
569
+ "result": None,
570
+ "human_review": True,
571
+ "transport_error": {
572
+ "http_status": source_error.http_status,
573
+ "backend_code": source_error.backend_code,
574
+ "category": source_error.category,
575
+ },
576
+ },
577
+ }
578
+
579
+ def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
580
+ try:
581
+ response = self.task_list(
582
+ profile=profile,
583
+ task_box=task_box,
584
+ flow_status="all",
585
+ app_key=app_key,
586
+ workflow_node_id=None,
587
+ query=None,
588
+ page=1,
589
+ page_size=50,
590
+ )
591
+ except QingflowApiError:
592
+ return []
593
+ data = response.get("data") if isinstance(response, dict) else None
594
+ items = data.get("items") if isinstance(data, dict) else None
595
+ if not isinstance(items, list):
596
+ return []
597
+ return [item for item in items if isinstance(item, dict)]
598
+
599
+ def task_associated_report_detail_get(
600
+ self,
601
+ *,
602
+ profile: str,
603
+ app_key: str,
604
+ record_id: int,
605
+ workflow_node_id: int,
606
+ report_id: int,
607
+ page: int,
608
+ page_size: int,
609
+ ) -> dict[str, Any]:
610
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
611
+ if report_id <= 0:
612
+ raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
613
+ if page <= 0 or page_size <= 0:
614
+ raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
615
+
616
+ def runner(session_profile, context):
617
+ task_context = self._build_task_context(
618
+ context,
619
+ app_key=app_key,
620
+ record_id=record_id,
621
+ workflow_node_id=workflow_node_id,
622
+ include_candidates=False,
623
+ include_associated_reports=True,
624
+ )
625
+ report_item = self._find_associated_report(task_context, report_id)
626
+ if report_item is None:
627
+ raise_tool_error(
628
+ QingflowApiError.config_error(
629
+ f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
630
+ )
631
+ )
632
+ association_query = self._build_association_query(
633
+ report_item["raw"],
634
+ task_context.get("record", {}).get("answers") or [],
635
+ )
636
+ selection = {
637
+ "app_key": app_key,
638
+ "record_id": record_id,
639
+ "workflow_node_id": workflow_node_id,
640
+ "report_id": report_id,
641
+ "target_app_key": report_item.get("target_app_key"),
642
+ "target_app_name": report_item.get("target_app_name"),
643
+ "chart_key": report_item.get("chart_key"),
644
+ "chart_name": report_item.get("chart_name"),
645
+ }
646
+ context_payload = {
647
+ "match_rules": report_item.get("match_rules") or [],
648
+ "resolved_filters": association_query.get("keyQueValues") or [],
649
+ }
650
+
651
+ if report_item.get("graph_type") == "view":
652
+ viewgraph_key = str(report_item.get("chart_key") or "")
653
+ body = {
654
+ "filter": {},
655
+ "viewgraphKey": viewgraph_key,
656
+ "equipmentType": 0,
657
+ "associationQuery": association_query,
658
+ }
659
+ result = self.backend.request(
660
+ "POST",
661
+ context,
662
+ f"/view/{viewgraph_key}/apply/filter",
663
+ json_body=body,
664
+ )
665
+ items = _task_page_items(result)
666
+ return {
667
+ "profile": profile,
668
+ "ws_id": session_profile.selected_ws_id,
669
+ "ok": True,
670
+ "request_route": self._request_route_payload(context),
671
+ "warnings": [],
672
+ "output_profile": "normal",
673
+ "data": {
674
+ "result_type": "view_list",
675
+ "result": {
676
+ "items": items,
677
+ "pagination": {
678
+ "page": page,
679
+ "page_size": page_size,
680
+ "returned_items": len(items),
681
+ "page_amount": _task_page_amount(result),
682
+ "reported_total": _task_page_total(result),
683
+ },
684
+ },
685
+ "selection": selection,
686
+ "context": context_payload,
687
+ },
688
+ }
689
+
690
+ chart_key = str(report_item.get("chart_key") or "")
691
+ source_type = report_item.get("source_type")
692
+ if source_type == "qingbi":
693
+ qingbi_context = BackendRequestContext(
694
+ base_url=_qingbi_base_url(context.base_url),
695
+ token=context.token,
696
+ ws_id=context.ws_id,
697
+ qf_request_id=context.qf_request_id,
698
+ qf_version=context.qf_version,
699
+ qf_version_source=context.qf_version_source,
700
+ )
701
+ chart_result = self.backend.request(
702
+ "POST",
703
+ qingbi_context,
704
+ f"/qingbi/charts/data/{chart_key}",
705
+ params={
706
+ "qfUUID": uuid4().hex,
707
+ "pageNum": page,
708
+ "pageSize": page_size,
709
+ },
710
+ json_body={
711
+ "asosChartId": report_id,
712
+ "keyQueValues": association_query.get("keyQueValues") or [],
713
+ },
714
+ )
715
+ request_route = {
716
+ "base_url": qingbi_context.base_url,
717
+ "qf_version": qingbi_context.qf_version,
718
+ "qf_version_source": qingbi_context.qf_version_source or "context",
719
+ }
720
+ elif self._qingflow_chart_uses_apply_filter(context, chart_key):
721
+ chart_result = self.backend.request(
722
+ "POST",
723
+ context,
724
+ f"/chart/{chart_key}/apply/filter",
725
+ json_body={
726
+ "filter": {
727
+ "pageNum": page,
728
+ "pageSize": page_size,
729
+ },
730
+ "asosChartId": report_id,
731
+ "keyQueValues": association_query.get("keyQueValues") or [],
732
+ },
733
+ )
734
+ items = _task_page_items(chart_result)
735
+ return {
736
+ "profile": profile,
737
+ "ws_id": session_profile.selected_ws_id,
738
+ "ok": True,
739
+ "request_route": self._request_route_payload(context),
740
+ "warnings": [],
741
+ "output_profile": "normal",
742
+ "data": {
743
+ "result_type": "view_list",
744
+ "result": {
745
+ "items": items,
746
+ "pagination": {
747
+ "page": page,
748
+ "page_size": page_size,
749
+ "returned_items": len(items),
750
+ "page_amount": _task_page_amount(chart_result),
751
+ "reported_total": _task_page_total(chart_result),
752
+ },
753
+ },
754
+ "selection": selection,
755
+ "context": context_payload,
756
+ },
757
+ }
758
+ else:
759
+ chart_result = self.backend.request(
760
+ "POST",
761
+ context,
762
+ f"/chart/{chart_key}/chartData",
763
+ json_body={
764
+ "asosChartId": report_id,
765
+ "keyQueValues": association_query.get("keyQueValues") or [],
766
+ },
767
+ )
768
+ request_route = self._request_route_payload(context)
769
+
770
+ return {
771
+ "profile": profile,
772
+ "ws_id": session_profile.selected_ws_id,
773
+ "ok": True,
774
+ "request_route": request_route,
775
+ "warnings": [],
776
+ "output_profile": "normal",
777
+ "data": {
778
+ "result_type": "chart_data",
779
+ "result": self._normalize_chart_result(chart_result),
780
+ "selection": selection,
781
+ "context": context_payload,
782
+ },
783
+ }
784
+
785
+ return self._run(profile, runner)
786
+
787
+ def task_workflow_log_get(
788
+ self,
789
+ *,
790
+ profile: str,
791
+ app_key: str,
792
+ record_id: int,
793
+ workflow_node_id: int,
794
+ ) -> dict[str, Any]:
795
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
796
+
797
+ def runner(session_profile, context):
798
+ task_context = self._build_task_context(
799
+ context,
800
+ app_key=app_key,
801
+ record_id=record_id,
802
+ workflow_node_id=workflow_node_id,
803
+ include_candidates=False,
804
+ include_associated_reports=False,
805
+ )
806
+ visibility = task_context.get("visibility") or {}
807
+ if not visibility.get("audit_record_visible"):
808
+ raise_tool_error(
809
+ QingflowApiError.config_error(
810
+ f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
811
+ )
812
+ )
813
+ page = self.backend.request(
814
+ "POST",
815
+ context,
816
+ "/application/workflow/node/record",
817
+ json_body={
818
+ "key": app_key,
819
+ "rowRecordId": record_id,
820
+ "nodeId": workflow_node_id,
821
+ "role": 3,
822
+ "pageNum": 1,
823
+ "pageSize": 200,
824
+ },
825
+ )
826
+ items = self._normalize_workflow_logs(page)
827
+ return {
828
+ "profile": profile,
829
+ "ws_id": session_profile.selected_ws_id,
830
+ "ok": True,
831
+ "request_route": self._request_route_payload(context),
832
+ "warnings": [],
833
+ "output_profile": "normal",
834
+ "data": {
835
+ "selection": {
836
+ "app_key": app_key,
837
+ "record_id": record_id,
838
+ "workflow_node_id": workflow_node_id,
839
+ },
840
+ "visibility": {
841
+ "audit_record_visible": visibility.get("audit_record_visible"),
842
+ "qrobot_record_visible": visibility.get("qrobot_record_visible"),
843
+ },
844
+ "items": items,
845
+ },
846
+ }
847
+
848
+ return self._run(profile, runner)
849
+
850
+ def _build_task_context(
851
+ self,
852
+ context: BackendRequestContext,
853
+ *,
854
+ app_key: str,
855
+ record_id: int,
856
+ workflow_node_id: int,
857
+ include_candidates: bool,
858
+ include_associated_reports: bool,
859
+ ) -> dict[str, Any]:
860
+ audit_infos = self.backend.request(
861
+ "GET",
862
+ context,
863
+ f"/app/{app_key}/apply/{record_id}/auditInfo",
864
+ params={"type": 1},
865
+ )
866
+ node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
867
+ detail = self.backend.request(
868
+ "GET",
869
+ context,
870
+ f"/app/{app_key}/apply/{record_id}",
871
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
872
+ )
873
+ associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
874
+ associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
875
+ if include_associated_reports and associated_report_visible:
876
+ asos_chart_list = self.backend.request(
877
+ "GET",
878
+ context,
879
+ f"/app/{app_key}/asosChart",
880
+ params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
881
+ )
882
+ associated_items = [
883
+ self._normalize_associated_report(item)
884
+ for item in (asos_chart_list.get("asosCharts") or [])
885
+ if isinstance(item, dict)
886
+ ]
887
+ associated_reports = {
888
+ "visible": True,
889
+ "count": len(associated_items),
890
+ "items": associated_items,
891
+ }
892
+ rollback_items: list[dict[str, Any]] = []
893
+ transfer_items: list[dict[str, Any]] = []
894
+ if include_candidates:
895
+ rollback_result = self.backend.request(
896
+ "GET",
897
+ context,
898
+ f"/app/{app_key}/apply/{record_id}/revertNode",
899
+ params={"auditNodeId": workflow_node_id},
900
+ )
901
+ rollback_items = self._rollback_candidate_items(rollback_result)
902
+ transfer_result = self.backend.request(
903
+ "GET",
904
+ context,
905
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
906
+ params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
907
+ )
908
+ transfer_items = _approval_page_items(transfer_result)
909
+
910
+ capabilities = self._build_capabilities(node_info)
911
+ visibility = self._build_visibility(node_info, detail)
912
+ return {
913
+ "task": {
914
+ "app_key": app_key,
915
+ "record_id": record_id,
916
+ "workflow_node_id": workflow_node_id,
917
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
918
+ "actionable": True,
919
+ },
920
+ "record": {
921
+ "apply_id": detail.get("applyId", record_id),
922
+ "apply_status": detail.get("applyStatus"),
923
+ "apply_num": detail.get("applyNum"),
924
+ "custom_apply_num": detail.get("customApplyNum"),
925
+ "apply_user": detail.get("applyUser"),
926
+ "apply_time": detail.get("applyTime"),
927
+ "last_update_time": detail.get("lastUpdateTime"),
928
+ "answers": detail.get("answers") or [],
929
+ },
930
+ "capabilities": capabilities,
931
+ "field_permissions": {
932
+ "que_auth_setting": node_info.get("queAuthSetting") or [],
933
+ },
934
+ "visibility": visibility,
935
+ "associated_reports": associated_reports,
936
+ "candidates": {
937
+ "rollback_nodes": rollback_items,
938
+ "transfer_members": transfer_items,
939
+ },
940
+ "workflow_log_summary": {
941
+ "visible": visibility["audit_record_visible"],
942
+ "available": visibility["audit_record_visible"],
943
+ "history_count": None,
944
+ "qrobot_log_visible": visibility["qrobot_record_visible"],
945
+ },
946
+ }
947
+
948
+ def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
949
+ app_key = raw.get("appKey") or raw.get("app_key")
950
+ record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
951
+ workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
952
+ apply_user = raw.get("applyUser")
953
+ if apply_user is None:
954
+ user_uid = raw.get("applyUserUid")
955
+ user_name = raw.get("applyUserName")
956
+ if user_uid is not None or user_name is not None:
957
+ apply_user = {"uid": user_uid, "name": user_name}
958
+ return {
959
+ "task_id": raw.get("id") or raw.get("taskId") or record_id,
960
+ "app_key": app_key,
961
+ "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
962
+ "record_id": record_id,
963
+ "workflow_node_id": workflow_node_id,
964
+ "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
965
+ "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
966
+ "apply_user": apply_user,
967
+ "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
968
+ "task_box": task_box,
969
+ "flow_status": flow_status,
970
+ "actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
971
+ }
972
+
973
+ def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
974
+ if not isinstance(infos, list) or not infos:
975
+ raise_tool_error(
976
+ QingflowApiError.config_error(
977
+ f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
978
+ )
979
+ )
980
+ for item in infos:
981
+ if not isinstance(item, dict):
982
+ continue
983
+ candidate = item.get("auditNodeId")
984
+ if not isinstance(candidate, int):
985
+ candidate = item.get("nodeId")
986
+ if candidate == workflow_node_id:
987
+ return item
988
+ raise_tool_error(
989
+ QingflowApiError.config_error(
990
+ f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
991
+ )
992
+ )
993
+
994
+ def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
995
+ available_actions = ["approve"]
996
+ if self._coerce_bool(node_info.get("rejectBtnStatus")):
997
+ available_actions.append("reject")
998
+ if self._coerce_bool(node_info.get("canRevert")):
999
+ available_actions.append("rollback")
1000
+ if self._coerce_bool(node_info.get("canTransfer")):
1001
+ available_actions.append("transfer")
1002
+ if self._coerce_bool(node_info.get("canUrge")):
1003
+ available_actions.append("urge")
1004
+
1005
+ visible_but_unimplemented_actions: list[str] = []
1006
+ if self._coerce_bool(node_info.get("canRevoke")):
1007
+ visible_but_unimplemented_actions.append("revoke")
1008
+ if self._coerce_bool(node_info.get("beingEndWorkflow")):
1009
+ visible_but_unimplemented_actions.append("end_workflow")
1010
+ if self._coerce_bool(node_info.get("beingCanApplyAgain")):
1011
+ visible_but_unimplemented_actions.append("apply_again")
1012
+
1013
+ feedback_required_for = []
1014
+ raw_feedback_required = node_info.get("feedbackRequiredOperationType")
1015
+ if isinstance(raw_feedback_required, list):
1016
+ feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
1017
+
1018
+ return {
1019
+ "available_actions": available_actions,
1020
+ "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
1021
+ "action_constraints": {
1022
+ "feedback_required_for": feedback_required_for,
1023
+ "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
1024
+ "submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
1025
+ "can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
1026
+ "can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
1027
+ },
1028
+ }
1029
+
1030
+ def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
1031
+ return {
1032
+ "comment_visible": self._coerce_bool(node_info.get("commentStatus")),
1033
+ "audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
1034
+ "workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
1035
+ "qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
1036
+ "associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
1037
+ }
1038
+
1039
+ def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
1040
+ node_visible = node_info.get("asosChartVisible")
1041
+ if node_visible is not None:
1042
+ return self._coerce_bool(node_visible)
1043
+ return self._coerce_bool(detail.get("viewAsosChartVisible"))
1044
+
1045
+ def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
1046
+ graph_type = str(raw.get("graphType") or "").strip().lower()
1047
+ source_type = str(raw.get("sourceType") or "").strip().lower()
1048
+ return {
1049
+ "report_id": raw.get("id"),
1050
+ "chart_key": raw.get("chartKey"),
1051
+ "chart_name": raw.get("chartName"),
1052
+ "graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
1053
+ "source_type": source_type or "qingflow",
1054
+ "target_app_key": raw.get("appKey"),
1055
+ "target_app_name": raw.get("formTitle"),
1056
+ "match_rules": raw.get("matchRules") or [],
1057
+ "raw": dict(raw),
1058
+ }
1059
+
1060
+ def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
1061
+ if isinstance(payload, dict):
1062
+ revert_nodes = payload.get("revertNodes")
1063
+ if isinstance(revert_nodes, list):
1064
+ return [item for item in revert_nodes if isinstance(item, dict)]
1065
+ return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
1066
+
1067
+ def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
1068
+ associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
1069
+ for item in associated_reports:
1070
+ if isinstance(item, dict) and item.get("report_id") == report_id:
1071
+ return item
1072
+ return None
1073
+
1074
+ def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
1075
+ key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
1076
+ key_values: list[dict[str, Any]] = []
1077
+ for answer in answers:
1078
+ if not isinstance(answer, dict):
1079
+ continue
1080
+ answer_que_id = answer.get("queId")
1081
+ if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
1082
+ extracted_values = self._extract_answer_values(answer)
1083
+ key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
1084
+ table_values = answer.get("tableValues")
1085
+ if not isinstance(table_values, list):
1086
+ continue
1087
+ for idx, row in enumerate(table_values, start=1):
1088
+ if not isinstance(row, list):
1089
+ continue
1090
+ for sub_answer in row:
1091
+ if not isinstance(sub_answer, dict):
1092
+ continue
1093
+ sub_que_id = sub_answer.get("queId")
1094
+ if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
1095
+ extracted_values = self._extract_answer_values(sub_answer)
1096
+ key_values.append(
1097
+ {
1098
+ "keyQueId": sub_que_id,
1099
+ "ordinal": idx,
1100
+ "values": extracted_values or None,
1101
+ }
1102
+ )
1103
+ return {
1104
+ "asosChart": self._sanitize_associated_chart(asos_chart),
1105
+ "keyQueValues": key_values,
1106
+ }
1107
+
1108
+ def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
1109
+ question_ids: set[int] = set()
1110
+
1111
+ def visit(node: Any) -> None:
1112
+ if isinstance(node, list):
1113
+ for item in node:
1114
+ visit(item)
1115
+ return
1116
+ if not isinstance(node, dict):
1117
+ return
1118
+ for key in ("queId", "judgeQueId"):
1119
+ value = node.get(key)
1120
+ if isinstance(value, int):
1121
+ question_ids.add(value)
1122
+ for value in node.values():
1123
+ if isinstance(value, (list, dict)):
1124
+ visit(value)
1125
+
1126
+ visit(match_rules)
1127
+ return question_ids
1128
+
1129
+ def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
1130
+ values = answer.get("values")
1131
+ if not isinstance(values, list):
1132
+ return []
1133
+ normalized: list[str] = []
1134
+ for item in values:
1135
+ if item is None:
1136
+ continue
1137
+ if isinstance(item, (str, int, float, bool)):
1138
+ normalized.append(str(item))
1139
+ continue
1140
+ if not isinstance(item, dict):
1141
+ continue
1142
+ for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
1143
+ value = item.get(key)
1144
+ if value not in (None, ""):
1145
+ normalized.append(str(value))
1146
+ break
1147
+ deduped: list[str] = []
1148
+ seen: set[str] = set()
1149
+ for item in normalized:
1150
+ if item in seen:
1151
+ continue
1152
+ deduped.append(item)
1153
+ seen.add(item)
1154
+ return deduped
1155
+
1156
+ def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
1157
+ return {
1158
+ "id": asos_chart.get("id"),
1159
+ "appKey": asos_chart.get("appKey"),
1160
+ "formTitle": asos_chart.get("formTitle"),
1161
+ "chartKey": asos_chart.get("chartKey"),
1162
+ "chartName": asos_chart.get("chartName"),
1163
+ "chartType": asos_chart.get("chartType"),
1164
+ "matchRules": asos_chart.get("matchRules") or [],
1165
+ "sourceType": asos_chart.get("sourceType"),
1166
+ "graphType": asos_chart.get("graphType"),
1167
+ "viewType": asos_chart.get("viewType"),
1168
+ }
1169
+
1170
+ def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
1171
+ if not chart_key:
1172
+ return False
1173
+ try:
1174
+ auth = self.backend.request(
1175
+ "GET",
1176
+ context,
1177
+ f"/chart/{chart_key}/auth",
1178
+ )
1179
+ except QingflowApiError:
1180
+ return False
1181
+ if not isinstance(auth, dict):
1182
+ return False
1183
+ return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
1184
+
1185
+ def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
1186
+ if isinstance(payload, dict):
1187
+ rows = payload.get("rows")
1188
+ if not isinstance(rows, list):
1189
+ rows = payload.get("list") if isinstance(payload.get("list"), list) else []
1190
+ series = payload.get("series")
1191
+ if not isinstance(series, list):
1192
+ series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
1193
+ metrics = payload.get("metrics")
1194
+ if not isinstance(metrics, list):
1195
+ metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
1196
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
1197
+ return {
1198
+ "summary": summary,
1199
+ "rows": rows or [],
1200
+ "series": series or [],
1201
+ "metrics": metrics or [],
1202
+ }
1203
+ if isinstance(payload, list):
1204
+ return {"summary": {}, "rows": payload, "series": [], "metrics": []}
1205
+ return {"summary": {}, "rows": [], "series": [], "metrics": []}
1206
+
1207
+ def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
1208
+ if isinstance(payload, dict):
1209
+ page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
1210
+ if not isinstance(page, list):
1211
+ nested = payload.get("data")
1212
+ if isinstance(nested, dict):
1213
+ page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
1214
+ if not isinstance(page, list):
1215
+ page = []
1216
+ elif isinstance(payload, list):
1217
+ page = payload
1218
+ else:
1219
+ page = []
1220
+
1221
+ items: list[dict[str, Any]] = []
1222
+ for node_record in page:
1223
+ if not isinstance(node_record, dict):
1224
+ continue
1225
+ node_id = node_record.get("nodeId")
1226
+ node_name = node_record.get("nodeName")
1227
+ operation_record_list = node_record.get("operationRecordList")
1228
+ if not isinstance(operation_record_list, list):
1229
+ operation_record_list = []
1230
+ for operation in operation_record_list:
1231
+ if not isinstance(operation, dict):
1232
+ continue
1233
+ detail = self._first_nested_operation_detail(operation)
1234
+ items.append(
1235
+ {
1236
+ "log_id": operation.get("workflowNodeOperationRecordId")
1237
+ or node_record.get("workflowNodeProcessRecordId"),
1238
+ "node_id": node_id,
1239
+ "node_name": node_name,
1240
+ "operator": operation.get("operator"),
1241
+ "operation": operation.get("operationType"),
1242
+ "operation_result": detail,
1243
+ "operation_time": operation.get("operationTime"),
1244
+ "remark": self._extract_remark(detail),
1245
+ "signature_url": self._extract_signature_url(detail),
1246
+ "attachments": self._extract_attachments(detail),
1247
+ "qrobot_related": any(
1248
+ operation.get(key)
1249
+ for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
1250
+ ),
1251
+ }
1252
+ )
1253
+ return items
1254
+
1255
+ def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
1256
+ for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
1257
+ value = operation.get(key)
1258
+ if value is not None:
1259
+ return value
1260
+ return None
1261
+
1262
+ def _extract_remark(self, detail: Any) -> Any:
1263
+ if not isinstance(detail, dict):
1264
+ return None
1265
+ for key in ("remark", "feedback", "comment", "content"):
1266
+ value = detail.get(key)
1267
+ if value not in (None, ""):
1268
+ return value
1269
+ return None
1270
+
1271
+ def _extract_signature_url(self, detail: Any) -> Any:
1272
+ if not isinstance(detail, dict):
1273
+ return None
1274
+ for key in ("signatureUrl", "handSignImageUrl"):
1275
+ value = detail.get(key)
1276
+ if value not in (None, ""):
1277
+ return value
1278
+ return None
1279
+
1280
+ def _extract_attachments(self, detail: Any) -> Any:
1281
+ if not isinstance(detail, dict):
1282
+ return []
1283
+ for key in ("attachments", "files", "uploadFiles"):
1284
+ value = detail.get(key)
1285
+ if isinstance(value, list):
1286
+ return value
1287
+ return []
1288
+
1289
+ def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
1290
+ for key in ("audit_feedback", "auditFeedback"):
1291
+ value = payload.get(key)
1292
+ if isinstance(value, str) and value.strip():
1293
+ return value.strip()
1294
+ return None
1295
+
1296
+ def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
1297
+ candidates = (key, *aliases)
1298
+ value: Any = None
1299
+ for candidate in candidates:
1300
+ if candidate in payload:
1301
+ value = payload.get(candidate)
1302
+ break
1303
+ if not isinstance(value, int) or value <= 0:
1304
+ names = ", ".join(candidates)
1305
+ raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
1306
+ return value
1307
+
1308
+ def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
1309
+ if not app_key:
1310
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1311
+ if record_id <= 0:
1312
+ raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
1313
+ if workflow_node_id <= 0:
1314
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
1315
+
1316
+ def _coerce_bool(self, value: Any) -> bool:
1317
+ if isinstance(value, bool):
1318
+ return value
1319
+ if isinstance(value, int):
1320
+ return value != 0
1321
+ if isinstance(value, str):
1322
+ return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
1323
+ return bool(value)
1324
+
1325
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
1326
+ describe_route = getattr(self.backend, "describe_route", None)
1327
+ if callable(describe_route):
1328
+ payload = describe_route(context)
1329
+ if isinstance(payload, dict):
1330
+ return payload
1331
+ return {
1332
+ "base_url": context.base_url,
1333
+ "qf_version": context.qf_version,
1334
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
1335
+ }