@josephyan/qingflow-app-user-mcp 0.2.0-beta.21 → 0.2.0-beta.23
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 +4 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +35 -202
- package/skills/qingflow-app-user/agents/openai.yaml +2 -2
- package/skills/qingflow-app-user/references/data-gotchas.md +3 -2
- package/skills/qingflow-app-user/references/record-patterns.md +7 -9
- package/skills/qingflow-record-analysis/SKILL.md +18 -4
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +5 -1
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +17 -5
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +3 -3
- package/skills/qingflow-record-crud/SKILL.md +181 -0
- package/skills/qingflow-record-crud/agents/openai.yaml +5 -0
- package/skills/qingflow-record-crud/references/data-gotchas.md +44 -0
- package/skills/qingflow-record-crud/references/environments.md +58 -0
- package/skills/qingflow-record-crud/references/record-patterns.md +112 -0
- package/skills/qingflow-task-ops/SKILL.md +148 -0
- package/skills/qingflow-task-ops/agents/openai.yaml +4 -0
- package/skills/qingflow-task-ops/references/environments.md +44 -0
- package/skills/qingflow-task-ops/references/workflow-usage.md +27 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +4 -2
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/tools/record_tools.py +527 -292
|
@@ -90,6 +90,14 @@ class ViewSelection:
|
|
|
90
90
|
conditions: list[list[ViewFilterCondition]]
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
@dataclass(slots=True)
|
|
94
|
+
class WorkflowNodeRef:
|
|
95
|
+
workflow_node_id: int
|
|
96
|
+
name: str
|
|
97
|
+
type: str
|
|
98
|
+
raw: JSONObject
|
|
99
|
+
|
|
100
|
+
|
|
93
101
|
@dataclass(slots=True)
|
|
94
102
|
class RecordInputError(Exception):
|
|
95
103
|
message: str
|
|
@@ -134,7 +142,8 @@ FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
|
|
|
134
142
|
class RecordTools(ToolBase):
|
|
135
143
|
def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
|
|
136
144
|
super().__init__(sessions, backend)
|
|
137
|
-
self._form_cache: dict[tuple[str, str], JSONObject] = {}
|
|
145
|
+
self._form_cache: dict[tuple[str, str, str, int], JSONObject] = {}
|
|
146
|
+
self._applicant_node_cache: dict[tuple[str, str], WorkflowNodeRef] = {}
|
|
138
147
|
self._view_list_cache: dict[tuple[str, str], list[JSONObject]] = {}
|
|
139
148
|
self._view_config_cache: dict[tuple[str, str], JSONObject] = {}
|
|
140
149
|
|
|
@@ -243,7 +252,8 @@ class RecordTools(ToolBase):
|
|
|
243
252
|
@mcp.tool(
|
|
244
253
|
description=(
|
|
245
254
|
"Write Qingflow records with a SQL-like JSON DSL. "
|
|
246
|
-
"Use record_schema_get first, then choose operation=insert|update|delete
|
|
255
|
+
"Use record_schema_get first, then choose operation=insert|update|delete. "
|
|
256
|
+
"This tool performs internal preflight validation before any write is applied. "
|
|
247
257
|
"This route does not accept raw SQL strings or free-form WHERE clauses."
|
|
248
258
|
)
|
|
249
259
|
)
|
|
@@ -251,7 +261,6 @@ class RecordTools(ToolBase):
|
|
|
251
261
|
profile: str = DEFAULT_PROFILE,
|
|
252
262
|
app_key: str = "",
|
|
253
263
|
operation: str = "insert",
|
|
254
|
-
mode: str = "plan",
|
|
255
264
|
record_id: int | None = None,
|
|
256
265
|
record_ids: list[int] | None = None,
|
|
257
266
|
values: list[JSONObject] | None = None,
|
|
@@ -264,7 +273,6 @@ class RecordTools(ToolBase):
|
|
|
264
273
|
profile=profile,
|
|
265
274
|
app_key=app_key,
|
|
266
275
|
operation=operation,
|
|
267
|
-
mode=mode,
|
|
268
276
|
record_id=record_id,
|
|
269
277
|
record_ids=record_ids or [],
|
|
270
278
|
values=values or [],
|
|
@@ -287,9 +295,10 @@ class RecordTools(ToolBase):
|
|
|
287
295
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
288
296
|
|
|
289
297
|
def runner(session_profile, context):
|
|
298
|
+
applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=False)
|
|
290
299
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
291
300
|
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
292
|
-
fields = [self._schema_field_payload(field) for field in index.by_id.values()]
|
|
301
|
+
fields = [self._schema_field_payload(field, workflow_node_id=applicant_node.workflow_node_id) for field in index.by_id.values()]
|
|
293
302
|
suggested_dimensions = [
|
|
294
303
|
{"field_id": item["field_id"], "title": item["title"]}
|
|
295
304
|
for item in fields
|
|
@@ -313,6 +322,12 @@ class RecordTools(ToolBase):
|
|
|
313
322
|
"request_route": self._request_route_payload(context),
|
|
314
323
|
"data": {
|
|
315
324
|
"app_key": app_key,
|
|
325
|
+
"schema_scope": "applicant_node",
|
|
326
|
+
"workflow_node": {
|
|
327
|
+
"workflow_node_id": applicant_node.workflow_node_id,
|
|
328
|
+
"name": applicant_node.name,
|
|
329
|
+
"type": applicant_node.type,
|
|
330
|
+
},
|
|
316
331
|
"view_resolution": _view_selection_payload(view_selection),
|
|
317
332
|
"fields": fields,
|
|
318
333
|
"suggested_dimensions": suggested_dimensions,
|
|
@@ -532,31 +547,39 @@ class RecordTools(ToolBase):
|
|
|
532
547
|
}
|
|
533
548
|
return response
|
|
534
549
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
"
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
"
|
|
552
|
-
"
|
|
553
|
-
"
|
|
554
|
-
|
|
555
|
-
"
|
|
556
|
-
"
|
|
550
|
+
def runner(session_profile, context):
|
|
551
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
552
|
+
selected_fields = list(index.by_id.values())
|
|
553
|
+
result = self.backend.request(
|
|
554
|
+
"GET",
|
|
555
|
+
context,
|
|
556
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
557
|
+
params={"role": 1},
|
|
558
|
+
)
|
|
559
|
+
answer_list = result.get("answers") if isinstance(result, dict) and isinstance(result.get("answers"), list) else []
|
|
560
|
+
row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
|
|
561
|
+
response: JSONObject = {
|
|
562
|
+
"profile": profile,
|
|
563
|
+
"ws_id": session_profile.selected_ws_id,
|
|
564
|
+
"ok": True,
|
|
565
|
+
"request_route": self._request_route_payload(context),
|
|
566
|
+
"warnings": [],
|
|
567
|
+
"output_profile": normalized_output_profile,
|
|
568
|
+
"data": {
|
|
569
|
+
"app_key": app_key,
|
|
570
|
+
"record_id": record_id,
|
|
571
|
+
"record": row,
|
|
572
|
+
"selection": {
|
|
573
|
+
"columns": columns,
|
|
574
|
+
"workflow_node_id": workflow_node_id,
|
|
575
|
+
},
|
|
557
576
|
},
|
|
558
|
-
}
|
|
559
|
-
|
|
577
|
+
}
|
|
578
|
+
if normalized_output_profile == "verbose":
|
|
579
|
+
response["data"]["debug"] = {"raw_record": result}
|
|
580
|
+
return response
|
|
581
|
+
|
|
582
|
+
return self._run_record_tool(profile, runner)
|
|
560
583
|
|
|
561
584
|
def record_write(
|
|
562
585
|
self,
|
|
@@ -564,7 +587,6 @@ class RecordTools(ToolBase):
|
|
|
564
587
|
profile: str,
|
|
565
588
|
app_key: str,
|
|
566
589
|
operation: str,
|
|
567
|
-
mode: str,
|
|
568
590
|
record_id: int | None,
|
|
569
591
|
record_ids: list[int],
|
|
570
592
|
values: list[JSONObject],
|
|
@@ -572,14 +594,18 @@ class RecordTools(ToolBase):
|
|
|
572
594
|
submit_type: str | int,
|
|
573
595
|
verify_write: bool,
|
|
574
596
|
output_profile: str,
|
|
597
|
+
mode: str | None = None,
|
|
575
598
|
) -> JSONObject:
|
|
576
599
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
577
600
|
normalized_operation = operation.strip().lower()
|
|
578
|
-
normalized_mode = mode.strip().lower()
|
|
579
601
|
if normalized_operation not in {"insert", "update", "delete"}:
|
|
580
602
|
raise_tool_error(QingflowApiError.config_error("operation must be insert, update, or delete"))
|
|
581
|
-
if
|
|
582
|
-
raise_tool_error(
|
|
603
|
+
if mode is not None:
|
|
604
|
+
raise_tool_error(
|
|
605
|
+
QingflowApiError.config_error(
|
|
606
|
+
"record_write no longer accepts mode; it now runs internal preflight automatically before apply"
|
|
607
|
+
)
|
|
608
|
+
)
|
|
583
609
|
if not app_key:
|
|
584
610
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
585
611
|
normalized_record_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
|
|
@@ -591,34 +617,36 @@ class RecordTools(ToolBase):
|
|
|
591
617
|
if set:
|
|
592
618
|
raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
|
|
593
619
|
normalized_answers = self._normalize_record_write_clauses(values, location="values")
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
"
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
620
|
+
raw_preflight = self._preflight_record_write(
|
|
621
|
+
profile=profile,
|
|
622
|
+
operation="create",
|
|
623
|
+
app_key=app_key,
|
|
624
|
+
apply_id=None,
|
|
625
|
+
answers=normalized_answers,
|
|
626
|
+
fields={},
|
|
627
|
+
force_refresh_form=False,
|
|
628
|
+
)
|
|
629
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
630
|
+
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
631
|
+
operation="insert",
|
|
632
|
+
record_id=None,
|
|
633
|
+
record_ids=[],
|
|
634
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
635
|
+
submit_type=submit_type_value,
|
|
636
|
+
)
|
|
637
|
+
if preflight_data.get("blockers"):
|
|
638
|
+
return self._record_write_blocked_response(
|
|
639
|
+
raw_preflight,
|
|
613
640
|
operation="insert",
|
|
614
641
|
normalized_payload=normalized_payload,
|
|
615
642
|
output_profile=normalized_output_profile,
|
|
616
643
|
human_review=False,
|
|
644
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
|
|
617
645
|
)
|
|
618
646
|
raw_apply = self.record_create(
|
|
619
647
|
profile=profile,
|
|
620
648
|
app_key=app_key,
|
|
621
|
-
answers=normalized_answers,
|
|
649
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
622
650
|
fields={},
|
|
623
651
|
submit_type=submit_type_value,
|
|
624
652
|
verify_write=verify_write,
|
|
@@ -630,6 +658,7 @@ class RecordTools(ToolBase):
|
|
|
630
658
|
normalized_payload=normalized_payload,
|
|
631
659
|
output_profile=normalized_output_profile,
|
|
632
660
|
human_review=False,
|
|
661
|
+
preflight=raw_preflight,
|
|
633
662
|
)
|
|
634
663
|
|
|
635
664
|
if normalized_operation == "update":
|
|
@@ -640,35 +669,37 @@ class RecordTools(ToolBase):
|
|
|
640
669
|
if values:
|
|
641
670
|
raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
|
|
642
671
|
normalized_answers = self._normalize_record_write_clauses(set, location="set")
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
"
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
672
|
+
raw_preflight = self._preflight_record_write(
|
|
673
|
+
profile=profile,
|
|
674
|
+
operation="update",
|
|
675
|
+
app_key=app_key,
|
|
676
|
+
apply_id=record_id,
|
|
677
|
+
answers=normalized_answers,
|
|
678
|
+
fields={},
|
|
679
|
+
force_refresh_form=False,
|
|
680
|
+
)
|
|
681
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
682
|
+
normalized_payload = self._record_write_normalized_payload(
|
|
683
|
+
operation="update",
|
|
684
|
+
record_id=record_id,
|
|
685
|
+
record_ids=[],
|
|
686
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
687
|
+
submit_type=submit_type_value,
|
|
688
|
+
)
|
|
689
|
+
if preflight_data.get("blockers"):
|
|
690
|
+
return self._record_write_blocked_response(
|
|
691
|
+
raw_preflight,
|
|
662
692
|
operation="update",
|
|
663
693
|
normalized_payload=normalized_payload,
|
|
664
694
|
output_profile=normalized_output_profile,
|
|
665
695
|
human_review=True,
|
|
696
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
666
697
|
)
|
|
667
698
|
raw_apply = self.record_update(
|
|
668
699
|
profile=profile,
|
|
669
700
|
app_key=app_key,
|
|
670
701
|
apply_id=record_id,
|
|
671
|
-
answers=normalized_answers,
|
|
702
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
672
703
|
fields={},
|
|
673
704
|
role=1,
|
|
674
705
|
verify_write=verify_write,
|
|
@@ -680,6 +711,7 @@ class RecordTools(ToolBase):
|
|
|
680
711
|
normalized_payload=normalized_payload,
|
|
681
712
|
output_profile=normalized_output_profile,
|
|
682
713
|
human_review=True,
|
|
714
|
+
preflight=raw_preflight,
|
|
683
715
|
)
|
|
684
716
|
|
|
685
717
|
if values or set:
|
|
@@ -694,22 +726,6 @@ class RecordTools(ToolBase):
|
|
|
694
726
|
"answers": [],
|
|
695
727
|
"submit_type": submit_type_value,
|
|
696
728
|
}
|
|
697
|
-
if normalized_mode == "plan":
|
|
698
|
-
return {
|
|
699
|
-
"profile": profile,
|
|
700
|
-
"ok": True,
|
|
701
|
-
"request_route": None,
|
|
702
|
-
"warnings": [],
|
|
703
|
-
"output_profile": normalized_output_profile,
|
|
704
|
-
"data": {
|
|
705
|
-
"action": {"operation": "delete", "mode": "plan"},
|
|
706
|
-
"resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": delete_ids},
|
|
707
|
-
"verification": None,
|
|
708
|
-
"normalized_payload": normalized_payload,
|
|
709
|
-
"blockers": [],
|
|
710
|
-
"human_review": self._record_write_human_review_payload("delete", enabled=True),
|
|
711
|
-
},
|
|
712
|
-
}
|
|
713
729
|
raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
|
|
714
730
|
return self._record_write_apply_response(
|
|
715
731
|
raw_apply,
|
|
@@ -717,9 +733,10 @@ class RecordTools(ToolBase):
|
|
|
717
733
|
normalized_payload=normalized_payload,
|
|
718
734
|
output_profile=normalized_output_profile,
|
|
719
735
|
human_review=True,
|
|
736
|
+
preflight=None,
|
|
720
737
|
)
|
|
721
738
|
|
|
722
|
-
def _schema_field_payload(self, field: FormField) -> JSONObject:
|
|
739
|
+
def _schema_field_payload(self, field: FormField, *, workflow_node_id: int) -> JSONObject:
|
|
723
740
|
write_hints = self._schema_write_hints(field)
|
|
724
741
|
return {
|
|
725
742
|
"field_id": field.que_id,
|
|
@@ -732,6 +749,8 @@ class RecordTools(ToolBase):
|
|
|
732
749
|
"role_hints": self._schema_role_hints(field),
|
|
733
750
|
"readable": True,
|
|
734
751
|
"writable": write_hints["writable"],
|
|
752
|
+
"permission_scope": "applicant_node",
|
|
753
|
+
"workflow_node_id": workflow_node_id,
|
|
735
754
|
"write_kind": write_hints["write_kind"],
|
|
736
755
|
"supported_read_ops": write_hints["supported_read_ops"],
|
|
737
756
|
"supported_write_ops": write_hints["supported_write_ops"],
|
|
@@ -1259,7 +1278,6 @@ class RecordTools(ToolBase):
|
|
|
1259
1278
|
requested_pages = int(analysis_paging["requested_pages"])
|
|
1260
1279
|
scan_max_pages = int(analysis_paging["scan_max_pages"])
|
|
1261
1280
|
auto_expand_pages = bool(analysis_paging["auto_expand_pages"])
|
|
1262
|
-
query_id = _query_id()
|
|
1263
1281
|
pages_to_scan = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
1264
1282
|
current_page = 1
|
|
1265
1283
|
scanned_pages = 0
|
|
@@ -1349,82 +1367,101 @@ class RecordTools(ToolBase):
|
|
|
1349
1367
|
]
|
|
1350
1368
|
all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
|
|
1351
1369
|
rows_truncated = len(all_rows) > limit
|
|
1352
|
-
|
|
1353
|
-
rows = limited_rows
|
|
1354
|
-
rows_returned = len(limited_rows)
|
|
1370
|
+
rows = all_rows[:limit]
|
|
1355
1371
|
group_count = len(all_rows)
|
|
1356
1372
|
statement_scope = "returned_groups_only" if rows_truncated else "full_population"
|
|
1357
1373
|
else:
|
|
1358
1374
|
rows_truncated = False
|
|
1359
1375
|
rows = [{"dimensions": {}, "metrics": metric_totals}]
|
|
1360
|
-
rows_returned = 1
|
|
1361
1376
|
group_count = 1
|
|
1362
1377
|
statement_scope = "full_population"
|
|
1363
1378
|
raw_scan_complete = not has_more
|
|
1364
1379
|
completeness_status = "complete" if raw_scan_complete else "incomplete"
|
|
1365
1380
|
reason_code = "LOCAL_VIEW_FILTERING" if local_filtering and raw_scan_complete else ("SOURCE_EXHAUSTED" if raw_scan_complete else "SCAN_LIMIT_HIT")
|
|
1366
|
-
|
|
1367
|
-
"
|
|
1368
|
-
"
|
|
1369
|
-
|
|
1381
|
+
query_payload: JSONObject = {
|
|
1382
|
+
"app_key": app_key,
|
|
1383
|
+
"dimensions": [
|
|
1384
|
+
{
|
|
1385
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
1386
|
+
"alias": item["alias"],
|
|
1387
|
+
"bucket": item["bucket"],
|
|
1388
|
+
}
|
|
1389
|
+
for item in dimensions
|
|
1390
|
+
],
|
|
1391
|
+
"metrics": [
|
|
1392
|
+
{
|
|
1393
|
+
"op": item["op"],
|
|
1394
|
+
**(
|
|
1395
|
+
{"field_id": cast(FormField, item["field"]).que_id}
|
|
1396
|
+
if item["field"] is not None
|
|
1397
|
+
else {}
|
|
1398
|
+
),
|
|
1399
|
+
"alias": item["alias"],
|
|
1400
|
+
}
|
|
1401
|
+
for item in metrics
|
|
1402
|
+
],
|
|
1403
|
+
"filters": [
|
|
1404
|
+
{
|
|
1405
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
1406
|
+
"op": item["op"],
|
|
1407
|
+
**({"value": item.get("value")} if item.get("value") is not None else {}),
|
|
1408
|
+
}
|
|
1409
|
+
for item in filters
|
|
1410
|
+
],
|
|
1411
|
+
"sort": [{"by": item["by"], "order": item["order"]} for item in sort],
|
|
1412
|
+
"limit": limit,
|
|
1413
|
+
"strict_full": strict_full,
|
|
1370
1414
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
for item in filters
|
|
1400
|
-
],
|
|
1401
|
-
"applied_sort": [{"by": item["by"], "order": item["order"]} for item in sort],
|
|
1402
|
-
"view": _view_selection_payload(view_selection),
|
|
1403
|
-
},
|
|
1404
|
-
"rows": rows,
|
|
1405
|
-
"totals": totals,
|
|
1406
|
-
"completeness": {
|
|
1407
|
-
"status": completeness_status,
|
|
1408
|
-
"reason_code": reason_code,
|
|
1409
|
-
"local_filtering_applied": local_filtering,
|
|
1410
|
-
"safe_for_final_conclusion": completeness_status == "complete",
|
|
1411
|
-
},
|
|
1412
|
-
"presentation": {
|
|
1413
|
-
"row_limit": limit,
|
|
1414
|
-
"rows_returned": rows_returned,
|
|
1415
|
-
"rows_truncated": rows_truncated,
|
|
1416
|
-
"statement_scope": statement_scope,
|
|
1417
|
-
},
|
|
1418
|
-
"warnings": self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated),
|
|
1415
|
+
view_payload = _view_selection_payload(view_selection)
|
|
1416
|
+
if view_payload:
|
|
1417
|
+
query_payload["view"] = view_payload
|
|
1418
|
+
|
|
1419
|
+
ranking: JSONObject | None = None
|
|
1420
|
+
if dimensions and sort:
|
|
1421
|
+
primary_sort = sort[0]
|
|
1422
|
+
ranking = {
|
|
1423
|
+
"order_by": primary_sort["by"],
|
|
1424
|
+
"order_direction": primary_sort["order"],
|
|
1425
|
+
"ranked": True,
|
|
1426
|
+
}
|
|
1427
|
+
rows = [
|
|
1428
|
+
{
|
|
1429
|
+
"dimensions": cast(JSONObject, row["dimensions"]),
|
|
1430
|
+
"metrics": cast(JSONObject, row["metrics"]),
|
|
1431
|
+
"rank": idx,
|
|
1432
|
+
}
|
|
1433
|
+
for idx, row in enumerate(rows, start=1)
|
|
1434
|
+
]
|
|
1435
|
+
|
|
1436
|
+
warnings = self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated)
|
|
1437
|
+
completeness: JSONObject = {
|
|
1438
|
+
"status": completeness_status,
|
|
1439
|
+
"safe_for_final_conclusion": completeness_status == "complete",
|
|
1440
|
+
"rows_truncated": rows_truncated,
|
|
1441
|
+
"statement_scope": statement_scope,
|
|
1442
|
+
"warnings": warnings,
|
|
1419
1443
|
}
|
|
1444
|
+
if completeness_status != "complete":
|
|
1445
|
+
completeness["reason_code"] = reason_code
|
|
1446
|
+
|
|
1420
1447
|
response: JSONObject = {
|
|
1421
|
-
"profile": profile,
|
|
1422
|
-
"ws_id": session_profile.selected_ws_id,
|
|
1423
1448
|
"ok": True,
|
|
1424
1449
|
"status": "success" if raw_scan_complete else "partial",
|
|
1425
|
-
"
|
|
1426
|
-
"
|
|
1427
|
-
|
|
1450
|
+
"query": query_payload,
|
|
1451
|
+
"result": {
|
|
1452
|
+
"totals": {
|
|
1453
|
+
"metric_totals": metric_totals,
|
|
1454
|
+
},
|
|
1455
|
+
"rows": rows,
|
|
1456
|
+
"row_count": len(rows),
|
|
1457
|
+
},
|
|
1458
|
+
"ranking": ranking,
|
|
1459
|
+
"ratios": None,
|
|
1460
|
+
"completeness": completeness,
|
|
1461
|
+
"presentation": {
|
|
1462
|
+
"returned_group_count": len(rows),
|
|
1463
|
+
"total_group_count": group_count,
|
|
1464
|
+
},
|
|
1428
1465
|
}
|
|
1429
1466
|
if strict_full and not raw_scan_complete:
|
|
1430
1467
|
response["ok"] = False
|
|
@@ -1435,13 +1472,15 @@ class RecordTools(ToolBase):
|
|
|
1435
1472
|
"fix_hint": "Narrow the scope with view/filter constraints, or retry after reducing the dataset size.",
|
|
1436
1473
|
}
|
|
1437
1474
|
if output_profile == "verbose":
|
|
1438
|
-
response["
|
|
1475
|
+
response["debug"] = {
|
|
1476
|
+
"request_route": self._request_route_payload(context),
|
|
1439
1477
|
"elapsed_ms": int((time.perf_counter() - started_at) * 1000),
|
|
1440
1478
|
"backend_total_hint": scan_control.get("backend_total_count", result_amount),
|
|
1441
1479
|
"backend_page_amount": scan_control.get("backend_page_amount"),
|
|
1442
1480
|
"source_pages": source_pages,
|
|
1443
|
-
"raw_scan_complete": raw_scan_complete,
|
|
1444
1481
|
"scan_control": scan_control,
|
|
1482
|
+
"reason_code": reason_code,
|
|
1483
|
+
"local_filtering_applied": local_filtering,
|
|
1445
1484
|
}
|
|
1446
1485
|
return response
|
|
1447
1486
|
|
|
@@ -1559,19 +1598,9 @@ class RecordTools(ToolBase):
|
|
|
1559
1598
|
def _build_analyze_warnings(self, *, local_filtering: bool, rows_truncated: bool) -> list[JSONObject]:
|
|
1560
1599
|
warnings: list[JSONObject] = []
|
|
1561
1600
|
if local_filtering:
|
|
1562
|
-
warnings.append(
|
|
1563
|
-
{
|
|
1564
|
-
"code": "LOCAL_FILTERING_APPLIED",
|
|
1565
|
-
"message": "Current analysis applies local filtering after scanning source pages.",
|
|
1566
|
-
}
|
|
1567
|
-
)
|
|
1601
|
+
warnings.append({"code": "LOCAL_VIEW_FILTERING"})
|
|
1568
1602
|
if rows_truncated:
|
|
1569
|
-
warnings.append(
|
|
1570
|
-
{
|
|
1571
|
-
"code": "ROWS_TRUNCATED",
|
|
1572
|
-
"message": "Result rows were truncated by limit; totals remain based on the full analyzed result set.",
|
|
1573
|
-
}
|
|
1574
|
-
)
|
|
1603
|
+
warnings.append({"code": "ROWS_TRUNCATED"})
|
|
1575
1604
|
return warnings
|
|
1576
1605
|
|
|
1577
1606
|
def record_write_plan(
|
|
@@ -1589,123 +1618,180 @@ class RecordTools(ToolBase):
|
|
|
1589
1618
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1590
1619
|
inferred_operation = operation if operation in {"create", "update"} else ("update" if apply_id else "create")
|
|
1591
1620
|
|
|
1621
|
+
return self._preflight_record_write(
|
|
1622
|
+
profile=profile,
|
|
1623
|
+
operation=inferred_operation,
|
|
1624
|
+
app_key=app_key,
|
|
1625
|
+
apply_id=apply_id,
|
|
1626
|
+
answers=answers or [],
|
|
1627
|
+
fields=fields or {},
|
|
1628
|
+
force_refresh_form=force_refresh_form,
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
def _preflight_record_write(
|
|
1632
|
+
self,
|
|
1633
|
+
*,
|
|
1634
|
+
profile: str,
|
|
1635
|
+
operation: str,
|
|
1636
|
+
app_key: str,
|
|
1637
|
+
apply_id: int | None,
|
|
1638
|
+
answers: list[JSONObject],
|
|
1639
|
+
fields: JSONObject,
|
|
1640
|
+
force_refresh_form: bool,
|
|
1641
|
+
) -> JSONObject:
|
|
1642
|
+
if not app_key:
|
|
1643
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1644
|
+
|
|
1592
1645
|
def runner(session_profile, context):
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
profile,
|
|
1604
|
-
context,
|
|
1605
|
-
app_key,
|
|
1606
|
-
answers=normalized_answers_input,
|
|
1607
|
-
fields=normalized_fields,
|
|
1608
|
-
force_refresh_form=force_refresh_form,
|
|
1609
|
-
)
|
|
1610
|
-
except RecordInputError as error:
|
|
1611
|
-
invalid_fields.append(
|
|
1612
|
-
{
|
|
1613
|
-
"location": _stringify_json(error.details.get("location") if error.details else None),
|
|
1614
|
-
"message": error.message,
|
|
1615
|
-
"error_code": error.error_code,
|
|
1616
|
-
"field": error.details.get("field") if error.details and isinstance(error.details.get("field"), dict) else None,
|
|
1617
|
-
"expected_format": error.details.get("expected_format") if error.details and isinstance(error.details.get("expected_format"), dict) else None,
|
|
1618
|
-
"received_value": error.details.get("received_value") if error.details else None,
|
|
1619
|
-
}
|
|
1620
|
-
)
|
|
1621
|
-
readonly_or_system_fields = [
|
|
1622
|
-
{
|
|
1623
|
-
"que_id": entry.get("que_id"),
|
|
1624
|
-
"que_title": entry.get("que_title"),
|
|
1625
|
-
"que_type": entry.get("que_type"),
|
|
1626
|
-
"readonly": entry.get("readonly"),
|
|
1627
|
-
"system": entry.get("system"),
|
|
1628
|
-
"source": entry.get("source"),
|
|
1629
|
-
"requested": entry.get("requested"),
|
|
1630
|
-
}
|
|
1631
|
-
for entry in resolved_fields
|
|
1632
|
-
if bool(entry.get("resolved")) and (bool(entry.get("readonly")) or bool(entry.get("system")))
|
|
1633
|
-
]
|
|
1634
|
-
provided_field_ids = {
|
|
1635
|
-
str(answer.get("queId"))
|
|
1636
|
-
for answer in normalized_answers
|
|
1637
|
-
if isinstance(answer.get("queId"), int) and int(answer["queId"]) > 0
|
|
1638
|
-
}
|
|
1639
|
-
missing_required_fields = []
|
|
1640
|
-
for field in index.by_id.values():
|
|
1641
|
-
if not field.required or str(field.que_id) in provided_field_ids:
|
|
1642
|
-
continue
|
|
1643
|
-
missing_required_fields.append(
|
|
1644
|
-
{
|
|
1645
|
-
"que_id": field.que_id,
|
|
1646
|
-
"que_title": field.que_title,
|
|
1647
|
-
"que_type": field.que_type,
|
|
1648
|
-
"reason": "required field not provided",
|
|
1649
|
-
}
|
|
1650
|
-
)
|
|
1651
|
-
question_relations = _collect_question_relations(schema)
|
|
1652
|
-
option_links = _collect_option_links(resolved_fields)
|
|
1653
|
-
validation = {
|
|
1654
|
-
"valid": not invalid_fields and not missing_required_fields and not readonly_or_system_fields,
|
|
1655
|
-
"missing_required_fields": missing_required_fields,
|
|
1656
|
-
"likely_hidden_required_fields": [],
|
|
1657
|
-
"readonly_or_system_fields": readonly_or_system_fields,
|
|
1658
|
-
"invalid_fields": invalid_fields,
|
|
1659
|
-
"warnings": [
|
|
1660
|
-
"record_write_plan is a static preflight built from form metadata; runtime visibility and dynamic linkage can still reject writes."
|
|
1661
|
-
],
|
|
1662
|
-
}
|
|
1663
|
-
blockers = []
|
|
1664
|
-
if invalid_fields:
|
|
1665
|
-
blockers.append("payload contains invalid field values")
|
|
1666
|
-
if missing_required_fields:
|
|
1667
|
-
blockers.append("required fields are missing")
|
|
1668
|
-
if readonly_or_system_fields:
|
|
1669
|
-
blockers.append("payload writes readonly or system-managed fields")
|
|
1670
|
-
if question_relations:
|
|
1671
|
-
validation["warnings"].append("form contains questionRelations; linked visibility and runtime required rules may differ at submit time.")
|
|
1672
|
-
actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
|
|
1673
|
-
if support_matrix["restricted"]:
|
|
1674
|
-
actions.append("Review write_format.required_presteps for restricted fields before submit.")
|
|
1675
|
-
if invalid_fields:
|
|
1676
|
-
actions.append("Fix invalid_fields before calling record_create or record_update.")
|
|
1677
|
-
if missing_required_fields:
|
|
1678
|
-
actions.append("Fill missing required fields before submit.")
|
|
1679
|
-
if readonly_or_system_fields:
|
|
1680
|
-
actions.append("Remove readonly/system fields from payload before submit.")
|
|
1681
|
-
if question_relations:
|
|
1682
|
-
actions.append("Treat ready_to_submit as static-only because linked fields can still appear at runtime.")
|
|
1646
|
+
plan_data = self._build_record_write_preflight(
|
|
1647
|
+
profile=profile,
|
|
1648
|
+
context=context,
|
|
1649
|
+
operation=operation,
|
|
1650
|
+
app_key=app_key,
|
|
1651
|
+
apply_id=apply_id,
|
|
1652
|
+
answers=answers,
|
|
1653
|
+
fields=fields,
|
|
1654
|
+
force_refresh_form=force_refresh_form,
|
|
1655
|
+
)
|
|
1683
1656
|
return {
|
|
1684
1657
|
"profile": profile,
|
|
1685
1658
|
"ws_id": session_profile.selected_ws_id,
|
|
1686
1659
|
"ok": True,
|
|
1687
1660
|
"request_route": self._request_route_payload(context),
|
|
1688
|
-
"data":
|
|
1689
|
-
"operation": inferred_operation,
|
|
1690
|
-
"app_key": app_key,
|
|
1691
|
-
"apply_id": apply_id,
|
|
1692
|
-
"normalized_answers": normalized_answers,
|
|
1693
|
-
"resolved_fields": resolved_fields,
|
|
1694
|
-
"support_matrix": support_matrix,
|
|
1695
|
-
"validation": validation,
|
|
1696
|
-
"dependencies": {
|
|
1697
|
-
"question_relations_present": bool(question_relations),
|
|
1698
|
-
"question_relations": question_relations,
|
|
1699
|
-
"option_links": option_links,
|
|
1700
|
-
},
|
|
1701
|
-
"ready_to_submit": validation["valid"],
|
|
1702
|
-
"blockers": blockers,
|
|
1703
|
-
"recommended_next_actions": actions,
|
|
1704
|
-
},
|
|
1661
|
+
"data": plan_data,
|
|
1705
1662
|
}
|
|
1706
1663
|
|
|
1707
1664
|
return self._run_record_tool(profile, runner)
|
|
1708
1665
|
|
|
1666
|
+
def _build_record_write_preflight(
|
|
1667
|
+
self,
|
|
1668
|
+
*,
|
|
1669
|
+
profile: str,
|
|
1670
|
+
context, # type: ignore[no-untyped-def]
|
|
1671
|
+
operation: str,
|
|
1672
|
+
app_key: str,
|
|
1673
|
+
apply_id: int | None,
|
|
1674
|
+
answers: list[JSONObject],
|
|
1675
|
+
fields: JSONObject,
|
|
1676
|
+
force_refresh_form: bool,
|
|
1677
|
+
) -> JSONObject:
|
|
1678
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
|
|
1679
|
+
index = _build_field_index(schema)
|
|
1680
|
+
normalized_fields = fields or {}
|
|
1681
|
+
normalized_answers_input = answers or []
|
|
1682
|
+
resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=index)
|
|
1683
|
+
support_matrix = _summarize_write_support(resolved_fields)
|
|
1684
|
+
invalid_fields: list[JSONObject] = []
|
|
1685
|
+
normalized_answers: list[JSONObject] = []
|
|
1686
|
+
try:
|
|
1687
|
+
normalized_answers = self._resolve_answers(
|
|
1688
|
+
profile,
|
|
1689
|
+
context,
|
|
1690
|
+
app_key,
|
|
1691
|
+
answers=normalized_answers_input,
|
|
1692
|
+
fields=normalized_fields,
|
|
1693
|
+
force_refresh_form=force_refresh_form,
|
|
1694
|
+
)
|
|
1695
|
+
except RecordInputError as error:
|
|
1696
|
+
invalid_fields.append(
|
|
1697
|
+
{
|
|
1698
|
+
"location": _stringify_json(error.details.get("location") if error.details else None),
|
|
1699
|
+
"message": error.message,
|
|
1700
|
+
"error_code": error.error_code,
|
|
1701
|
+
"field": error.details.get("field") if error.details and isinstance(error.details.get("field"), dict) else None,
|
|
1702
|
+
"expected_format": error.details.get("expected_format") if error.details and isinstance(error.details.get("expected_format"), dict) else None,
|
|
1703
|
+
"received_value": error.details.get("received_value") if error.details else None,
|
|
1704
|
+
}
|
|
1705
|
+
)
|
|
1706
|
+
readonly_or_system_fields = [
|
|
1707
|
+
{
|
|
1708
|
+
"que_id": entry.get("que_id"),
|
|
1709
|
+
"que_title": entry.get("que_title"),
|
|
1710
|
+
"que_type": entry.get("que_type"),
|
|
1711
|
+
"readonly": entry.get("readonly"),
|
|
1712
|
+
"system": entry.get("system"),
|
|
1713
|
+
"source": entry.get("source"),
|
|
1714
|
+
"requested": entry.get("requested"),
|
|
1715
|
+
}
|
|
1716
|
+
for entry in resolved_fields
|
|
1717
|
+
if bool(entry.get("resolved")) and (bool(entry.get("readonly")) or bool(entry.get("system")))
|
|
1718
|
+
]
|
|
1719
|
+
provided_field_ids = {
|
|
1720
|
+
str(answer.get("queId"))
|
|
1721
|
+
for answer in normalized_answers
|
|
1722
|
+
if isinstance(answer.get("queId"), int) and int(answer["queId"]) > 0
|
|
1723
|
+
}
|
|
1724
|
+
missing_required_fields = []
|
|
1725
|
+
for field in index.by_id.values():
|
|
1726
|
+
if not field.required or str(field.que_id) in provided_field_ids:
|
|
1727
|
+
continue
|
|
1728
|
+
missing_required_fields.append(
|
|
1729
|
+
{
|
|
1730
|
+
"que_id": field.que_id,
|
|
1731
|
+
"que_title": field.que_title,
|
|
1732
|
+
"que_type": field.que_type,
|
|
1733
|
+
"reason": "required field not provided",
|
|
1734
|
+
}
|
|
1735
|
+
)
|
|
1736
|
+
question_relations = _collect_question_relations(schema)
|
|
1737
|
+
option_links = _collect_option_links(resolved_fields)
|
|
1738
|
+
validation_warnings = [
|
|
1739
|
+
"record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
|
|
1740
|
+
]
|
|
1741
|
+
if question_relations:
|
|
1742
|
+
validation_warnings.append(
|
|
1743
|
+
"form contains questionRelations; linked visibility and runtime required rules may differ at submit time."
|
|
1744
|
+
)
|
|
1745
|
+
validation = {
|
|
1746
|
+
"valid": not invalid_fields and not missing_required_fields and not readonly_or_system_fields,
|
|
1747
|
+
"missing_required_fields": missing_required_fields,
|
|
1748
|
+
"likely_hidden_required_fields": [],
|
|
1749
|
+
"readonly_or_system_fields": readonly_or_system_fields,
|
|
1750
|
+
"invalid_fields": invalid_fields,
|
|
1751
|
+
"warnings": validation_warnings,
|
|
1752
|
+
}
|
|
1753
|
+
field_errors = self._record_write_field_errors(
|
|
1754
|
+
invalid_fields=invalid_fields,
|
|
1755
|
+
missing_required_fields=missing_required_fields,
|
|
1756
|
+
readonly_or_system_fields=readonly_or_system_fields,
|
|
1757
|
+
)
|
|
1758
|
+
blockers = []
|
|
1759
|
+
if invalid_fields:
|
|
1760
|
+
blockers.append("payload contains invalid field values")
|
|
1761
|
+
if missing_required_fields:
|
|
1762
|
+
blockers.append("required fields are missing")
|
|
1763
|
+
if readonly_or_system_fields:
|
|
1764
|
+
blockers.append("payload writes readonly or system-managed fields")
|
|
1765
|
+
actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
|
|
1766
|
+
if support_matrix["restricted"]:
|
|
1767
|
+
actions.append("Review write_format.required_presteps for restricted fields before submit.")
|
|
1768
|
+
if invalid_fields:
|
|
1769
|
+
actions.append("Fix invalid_fields before applying the write.")
|
|
1770
|
+
if missing_required_fields:
|
|
1771
|
+
actions.append("Fill missing required fields before applying the write.")
|
|
1772
|
+
if readonly_or_system_fields:
|
|
1773
|
+
actions.append("Remove readonly/system fields from payload before applying the write.")
|
|
1774
|
+
if question_relations:
|
|
1775
|
+
actions.append("Treat static preflight as conservative only because linked fields can still appear at runtime.")
|
|
1776
|
+
return {
|
|
1777
|
+
"operation": operation if operation in {"create", "update"} else ("update" if apply_id else "create"),
|
|
1778
|
+
"app_key": app_key,
|
|
1779
|
+
"apply_id": apply_id,
|
|
1780
|
+
"normalized_answers": normalized_answers,
|
|
1781
|
+
"resolved_fields": resolved_fields,
|
|
1782
|
+
"support_matrix": support_matrix,
|
|
1783
|
+
"validation": validation,
|
|
1784
|
+
"field_errors": field_errors,
|
|
1785
|
+
"dependencies": {
|
|
1786
|
+
"question_relations_present": bool(question_relations),
|
|
1787
|
+
"question_relations": question_relations,
|
|
1788
|
+
"option_links": option_links,
|
|
1789
|
+
},
|
|
1790
|
+
"ready_to_submit": validation["valid"],
|
|
1791
|
+
"blockers": blockers,
|
|
1792
|
+
"recommended_next_actions": actions,
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1709
1795
|
def record_query(
|
|
1710
1796
|
self,
|
|
1711
1797
|
*,
|
|
@@ -2330,10 +2416,16 @@ class RecordTools(ToolBase):
|
|
|
2330
2416
|
return self._run_record_tool(profile, runner)
|
|
2331
2417
|
|
|
2332
2418
|
def _get_form_schema(self, profile: str, context, app_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
|
|
2333
|
-
|
|
2419
|
+
applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=force_refresh)
|
|
2420
|
+
cache_key = (profile, app_key, "applicant_node", applicant_node.workflow_node_id)
|
|
2334
2421
|
if not force_refresh and cache_key in self._form_cache:
|
|
2335
2422
|
return self._form_cache[cache_key]
|
|
2336
|
-
schema = self.backend.request(
|
|
2423
|
+
schema = self.backend.request(
|
|
2424
|
+
"GET",
|
|
2425
|
+
context,
|
|
2426
|
+
f"/app/{app_key}/form",
|
|
2427
|
+
params={"type": 1, "beingApply": True, "auditNodeId": applicant_node.workflow_node_id},
|
|
2428
|
+
)
|
|
2337
2429
|
normalized = _normalize_form_schema(schema)
|
|
2338
2430
|
self._form_cache[cache_key] = normalized
|
|
2339
2431
|
return normalized
|
|
@@ -2341,6 +2433,26 @@ class RecordTools(ToolBase):
|
|
|
2341
2433
|
def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
|
|
2342
2434
|
return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
|
|
2343
2435
|
|
|
2436
|
+
def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
|
|
2437
|
+
cache_key = (profile, app_key)
|
|
2438
|
+
if not force_refresh and cache_key in self._applicant_node_cache:
|
|
2439
|
+
return self._applicant_node_cache[cache_key]
|
|
2440
|
+
payload = self.backend.request("GET", context, f"/app/{app_key}/auditNodes")
|
|
2441
|
+
applicant_node = _extract_applicant_node(payload)
|
|
2442
|
+
if applicant_node is None:
|
|
2443
|
+
raise_tool_error(
|
|
2444
|
+
QingflowApiError(
|
|
2445
|
+
category="config",
|
|
2446
|
+
message=f"cannot resolve applicant node for app {app_key}",
|
|
2447
|
+
details={
|
|
2448
|
+
"error_code": "APPLICANT_NODE_NOT_FOUND",
|
|
2449
|
+
"fix_hint": "Ensure the app has a workflow applicant node before using user-side record tools.",
|
|
2450
|
+
},
|
|
2451
|
+
)
|
|
2452
|
+
)
|
|
2453
|
+
self._applicant_node_cache[cache_key] = applicant_node
|
|
2454
|
+
return applicant_node
|
|
2455
|
+
|
|
2344
2456
|
def _get_view_list(self, profile: str, context, app_key: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
|
|
2345
2457
|
cache_key = (profile, app_key)
|
|
2346
2458
|
if cache_key in self._view_list_cache:
|
|
@@ -2950,6 +3062,23 @@ class RecordTools(ToolBase):
|
|
|
2950
3062
|
normalized.append(payload)
|
|
2951
3063
|
return normalized
|
|
2952
3064
|
|
|
3065
|
+
def _record_write_normalized_payload(
|
|
3066
|
+
self,
|
|
3067
|
+
*,
|
|
3068
|
+
operation: str,
|
|
3069
|
+
record_id: int | None,
|
|
3070
|
+
record_ids: list[int],
|
|
3071
|
+
normalized_answers: list[JSONObject],
|
|
3072
|
+
submit_type: int,
|
|
3073
|
+
) -> JSONObject:
|
|
3074
|
+
return {
|
|
3075
|
+
"operation": operation,
|
|
3076
|
+
"record_id": record_id,
|
|
3077
|
+
"record_ids": record_ids,
|
|
3078
|
+
"answers": normalized_answers,
|
|
3079
|
+
"submit_type": submit_type,
|
|
3080
|
+
}
|
|
3081
|
+
|
|
2953
3082
|
def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
|
|
2954
3083
|
if not enabled:
|
|
2955
3084
|
return None
|
|
@@ -2959,38 +3088,41 @@ class RecordTools(ToolBase):
|
|
|
2959
3088
|
"message": "Read the current record first and confirm the exact target before applying this high-risk write.",
|
|
2960
3089
|
}
|
|
2961
3090
|
|
|
2962
|
-
def
|
|
3091
|
+
def _record_write_blocked_response(
|
|
2963
3092
|
self,
|
|
2964
|
-
|
|
3093
|
+
raw_preflight: JSONObject,
|
|
2965
3094
|
*,
|
|
2966
3095
|
operation: str,
|
|
2967
3096
|
normalized_payload: JSONObject,
|
|
2968
3097
|
output_profile: str,
|
|
2969
3098
|
human_review: bool,
|
|
3099
|
+
target_resource: JSONObject,
|
|
2970
3100
|
) -> JSONObject:
|
|
2971
|
-
plan_data = cast(JSONObject,
|
|
3101
|
+
plan_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
2972
3102
|
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
3103
|
+
field_errors = self._record_write_public_field_errors(plan_data)
|
|
2973
3104
|
warnings_payload = validation.get("warnings", [])
|
|
2974
|
-
warnings = [{"code": "
|
|
3105
|
+
warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
2975
3106
|
response: JSONObject = {
|
|
2976
|
-
"profile":
|
|
2977
|
-
"ws_id":
|
|
2978
|
-
"ok":
|
|
2979
|
-
"request_route":
|
|
3107
|
+
"profile": raw_preflight.get("profile"),
|
|
3108
|
+
"ws_id": raw_preflight.get("ws_id"),
|
|
3109
|
+
"ok": False,
|
|
3110
|
+
"request_route": raw_preflight.get("request_route"),
|
|
2980
3111
|
"warnings": warnings,
|
|
2981
3112
|
"output_profile": output_profile,
|
|
2982
3113
|
"data": {
|
|
2983
|
-
"action": {"operation": operation, "
|
|
2984
|
-
"resource":
|
|
3114
|
+
"action": {"operation": operation, "executed": False},
|
|
3115
|
+
"resource": target_resource,
|
|
2985
3116
|
"verification": None,
|
|
2986
3117
|
"normalized_payload": normalized_payload,
|
|
2987
3118
|
"blockers": plan_data.get("blockers", []),
|
|
3119
|
+
"field_errors": field_errors,
|
|
2988
3120
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
2989
3121
|
},
|
|
2990
3122
|
}
|
|
2991
3123
|
if output_profile == "verbose":
|
|
2992
3124
|
response["data"]["debug"] = {
|
|
2993
|
-
"
|
|
3125
|
+
"preflight": plan_data,
|
|
2994
3126
|
}
|
|
2995
3127
|
return response
|
|
2996
3128
|
|
|
@@ -3002,31 +3134,107 @@ class RecordTools(ToolBase):
|
|
|
3002
3134
|
normalized_payload: JSONObject,
|
|
3003
3135
|
output_profile: str,
|
|
3004
3136
|
human_review: bool,
|
|
3137
|
+
preflight: JSONObject | None,
|
|
3005
3138
|
) -> JSONObject:
|
|
3139
|
+
preflight_data = cast(JSONObject, preflight.get("data", {})) if isinstance(preflight, dict) else {}
|
|
3140
|
+
validation = cast(JSONObject, preflight_data.get("validation", {}))
|
|
3141
|
+
warnings_payload = validation.get("warnings", [])
|
|
3142
|
+
warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
3006
3143
|
response: JSONObject = {
|
|
3007
3144
|
"profile": raw_apply.get("profile"),
|
|
3008
3145
|
"ws_id": raw_apply.get("ws_id"),
|
|
3009
3146
|
"ok": bool(raw_apply.get("ok", True)),
|
|
3010
3147
|
"request_route": raw_apply.get("request_route"),
|
|
3011
|
-
"warnings":
|
|
3148
|
+
"warnings": warnings,
|
|
3012
3149
|
"output_profile": output_profile,
|
|
3013
3150
|
"data": {
|
|
3014
|
-
"action": {"operation": operation, "
|
|
3151
|
+
"action": {"operation": operation, "executed": True},
|
|
3015
3152
|
"resource": raw_apply.get("resource"),
|
|
3016
3153
|
"verification": raw_apply.get("verification"),
|
|
3017
3154
|
"normalized_payload": normalized_payload,
|
|
3018
3155
|
"blockers": [],
|
|
3156
|
+
"field_errors": [],
|
|
3019
3157
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
3020
3158
|
},
|
|
3021
3159
|
}
|
|
3022
3160
|
if output_profile == "verbose":
|
|
3023
|
-
|
|
3161
|
+
debug: JSONObject = {
|
|
3024
3162
|
"legacy_result": raw_apply.get("result"),
|
|
3025
3163
|
"status": raw_apply.get("status"),
|
|
3026
3164
|
"write_verified": raw_apply.get("write_verified"),
|
|
3027
3165
|
}
|
|
3166
|
+
if preflight_data:
|
|
3167
|
+
debug["preflight"] = preflight_data
|
|
3168
|
+
response["data"]["debug"] = debug
|
|
3028
3169
|
return response
|
|
3029
3170
|
|
|
3171
|
+
def _record_write_public_field_errors(self, plan_data: JSONObject) -> list[JSONObject]:
|
|
3172
|
+
existing = plan_data.get("field_errors")
|
|
3173
|
+
if isinstance(existing, list):
|
|
3174
|
+
return [cast(JSONObject, item) for item in existing if isinstance(item, dict)]
|
|
3175
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
3176
|
+
invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
|
|
3177
|
+
missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
|
|
3178
|
+
readonly_or_system_fields = cast(list[JSONObject], validation.get("readonly_or_system_fields", []))
|
|
3179
|
+
return self._record_write_field_errors(
|
|
3180
|
+
invalid_fields=invalid_fields,
|
|
3181
|
+
missing_required_fields=missing_required_fields,
|
|
3182
|
+
readonly_or_system_fields=readonly_or_system_fields,
|
|
3183
|
+
)
|
|
3184
|
+
|
|
3185
|
+
def _record_write_field_errors(
|
|
3186
|
+
self,
|
|
3187
|
+
*,
|
|
3188
|
+
invalid_fields: list[JSONObject],
|
|
3189
|
+
missing_required_fields: list[JSONObject],
|
|
3190
|
+
readonly_or_system_fields: list[JSONObject],
|
|
3191
|
+
) -> list[JSONObject]:
|
|
3192
|
+
errors: list[JSONObject] = []
|
|
3193
|
+
for item in invalid_fields:
|
|
3194
|
+
errors.append(
|
|
3195
|
+
{
|
|
3196
|
+
"error_code": item.get("error_code") or "INVALID_FIELD_VALUE",
|
|
3197
|
+
"message": item.get("message") or "invalid field value",
|
|
3198
|
+
"field": item.get("field"),
|
|
3199
|
+
"location": item.get("location"),
|
|
3200
|
+
"expected_format": item.get("expected_format"),
|
|
3201
|
+
"received_value": item.get("received_value"),
|
|
3202
|
+
}
|
|
3203
|
+
)
|
|
3204
|
+
for item in missing_required_fields:
|
|
3205
|
+
errors.append(
|
|
3206
|
+
{
|
|
3207
|
+
"error_code": "MISSING_REQUIRED_FIELD",
|
|
3208
|
+
"message": item.get("reason") or "required field not provided",
|
|
3209
|
+
"field": {
|
|
3210
|
+
"que_id": item.get("que_id"),
|
|
3211
|
+
"que_title": item.get("que_title"),
|
|
3212
|
+
"que_type": item.get("que_type"),
|
|
3213
|
+
},
|
|
3214
|
+
"location": "fields",
|
|
3215
|
+
"expected_format": None,
|
|
3216
|
+
"received_value": None,
|
|
3217
|
+
}
|
|
3218
|
+
)
|
|
3219
|
+
for item in readonly_or_system_fields:
|
|
3220
|
+
errors.append(
|
|
3221
|
+
{
|
|
3222
|
+
"error_code": "READONLY_OR_SYSTEM_FIELD",
|
|
3223
|
+
"message": "field is readonly or system-managed",
|
|
3224
|
+
"field": {
|
|
3225
|
+
"que_id": item.get("que_id"),
|
|
3226
|
+
"que_title": item.get("que_title"),
|
|
3227
|
+
"que_type": item.get("que_type"),
|
|
3228
|
+
"readonly": item.get("readonly"),
|
|
3229
|
+
"system": item.get("system"),
|
|
3230
|
+
},
|
|
3231
|
+
"location": item.get("source") or "fields",
|
|
3232
|
+
"expected_format": None,
|
|
3233
|
+
"received_value": item.get("requested"),
|
|
3234
|
+
}
|
|
3235
|
+
)
|
|
3236
|
+
return errors
|
|
3237
|
+
|
|
3030
3238
|
def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
|
|
3031
3239
|
if not app_key:
|
|
3032
3240
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
@@ -3122,11 +3330,11 @@ class RecordTools(ToolBase):
|
|
|
3122
3330
|
)
|
|
3123
3331
|
|
|
3124
3332
|
def _resolve_field_from_answer_item(self, item: JSONObject, index: FieldIndex) -> FormField:
|
|
3125
|
-
for key in ("queId", "que_id", "queTitle", "que_title"):
|
|
3333
|
+
for key in ("queId", "que_id", "queTitle", "que_title", "field_id", "fieldId"):
|
|
3126
3334
|
if key in item:
|
|
3127
3335
|
return self._resolve_field_selector(cast(str | int, item[key]), index, location="answers")
|
|
3128
3336
|
raise RecordInputError(
|
|
3129
|
-
message="answer item requires queId/que_id
|
|
3337
|
+
message="answer item requires queId/que_id, queTitle/que_title, or field_id",
|
|
3130
3338
|
error_code="MISSING_FIELD_SELECTOR",
|
|
3131
3339
|
fix_hint="Provide a field selector in each answer item.",
|
|
3132
3340
|
)
|
|
@@ -3452,7 +3660,10 @@ class RecordTools(ToolBase):
|
|
|
3452
3660
|
for item in answers:
|
|
3453
3661
|
if not isinstance(item, dict):
|
|
3454
3662
|
continue
|
|
3455
|
-
selector = item.get(
|
|
3663
|
+
selector = item.get(
|
|
3664
|
+
"field_id",
|
|
3665
|
+
item.get("fieldId", item.get("queId", item.get("que_id", item.get("queTitle", item.get("que_title"))))),
|
|
3666
|
+
)
|
|
3456
3667
|
if selector is None:
|
|
3457
3668
|
continue
|
|
3458
3669
|
refs.append(self._resolve_write_plan_field_ref("answers", str(selector), index))
|
|
@@ -3724,6 +3935,30 @@ def _normalize_view_list(payload: JSONValue) -> list[JSONObject]:
|
|
|
3724
3935
|
return flattened
|
|
3725
3936
|
|
|
3726
3937
|
|
|
3938
|
+
def _normalize_audit_nodes(payload: JSONValue) -> list[JSONObject]:
|
|
3939
|
+
if isinstance(payload, list):
|
|
3940
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
3941
|
+
if isinstance(payload, dict):
|
|
3942
|
+
return [item for item in payload.values() if isinstance(item, dict)]
|
|
3943
|
+
return []
|
|
3944
|
+
|
|
3945
|
+
|
|
3946
|
+
def _extract_applicant_node(payload: JSONValue) -> WorkflowNodeRef | None:
|
|
3947
|
+
for item in _normalize_audit_nodes(payload):
|
|
3948
|
+
node_type = _coerce_count(item.get("type"))
|
|
3949
|
+
deal_type = _coerce_count(item.get("dealType"))
|
|
3950
|
+
workflow_node_id = _coerce_count(item.get("auditNodeId"))
|
|
3951
|
+
if workflow_node_id is None or node_type != 0 or deal_type != 3:
|
|
3952
|
+
continue
|
|
3953
|
+
return WorkflowNodeRef(
|
|
3954
|
+
workflow_node_id=workflow_node_id,
|
|
3955
|
+
name=_normalize_optional_text(item.get("auditNodeName")) or str(workflow_node_id),
|
|
3956
|
+
type="applicant",
|
|
3957
|
+
raw=item,
|
|
3958
|
+
)
|
|
3959
|
+
return None
|
|
3960
|
+
|
|
3961
|
+
|
|
3727
3962
|
def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterCondition]]:
|
|
3728
3963
|
raw_limit = config.get("viewgraphLimit")
|
|
3729
3964
|
if not isinstance(raw_limit, list):
|