@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.
@@ -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 and mode=plan|apply. "
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
- raw = self.record_get(
536
- profile=profile,
537
- app_key=app_key,
538
- apply_id=record_id,
539
- role=1,
540
- list_type=None,
541
- audit_node_id=workflow_node_id,
542
- )
543
- return {
544
- "profile": profile,
545
- "ws_id": raw.get("ws_id"),
546
- "ok": bool(raw.get("ok", True)),
547
- "request_route": raw.get("request_route"),
548
- "warnings": [],
549
- "output_profile": normalized_output_profile,
550
- "data": {
551
- "app_key": app_key,
552
- "record_id": record_id,
553
- "record": raw.get("result"),
554
- "selection": {
555
- "columns": columns,
556
- "workflow_node_id": workflow_node_id,
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 normalized_mode not in {"plan", "apply"}:
582
- raise_tool_error(QingflowApiError.config_error("mode must be plan or apply"))
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
- normalized_payload: JSONObject = {
595
- "operation": "insert",
596
- "record_id": None,
597
- "record_ids": [],
598
- "answers": normalized_answers,
599
- "submit_type": submit_type_value,
600
- }
601
- if normalized_mode == "plan":
602
- raw_plan = self.record_write_plan(
603
- profile=profile,
604
- operation="create",
605
- app_key=app_key,
606
- apply_id=None,
607
- answers=normalized_answers,
608
- fields={},
609
- force_refresh_form=False,
610
- )
611
- return self._record_write_plan_response(
612
- raw_plan,
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
- normalized_payload = {
644
- "operation": "update",
645
- "record_id": record_id,
646
- "record_ids": [],
647
- "answers": normalized_answers,
648
- "submit_type": submit_type_value,
649
- }
650
- if normalized_mode == "plan":
651
- raw_plan = self.record_write_plan(
652
- profile=profile,
653
- operation="update",
654
- app_key=app_key,
655
- apply_id=record_id,
656
- answers=normalized_answers,
657
- fields={},
658
- force_refresh_form=False,
659
- )
660
- return self._record_write_plan_response(
661
- raw_plan,
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
- limited_rows = all_rows[:limit]
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
- totals = {
1367
- "scanned_count": matched_rows,
1368
- "group_count": group_count,
1369
- "metric_totals": metric_totals,
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
- data: JSONObject = {
1372
- "query": {
1373
- "app_key": app_key,
1374
- "dimensions": [
1375
- {
1376
- "field_id": cast(FormField, item["field"]).que_id,
1377
- "title": cast(FormField, item["field"]).que_title,
1378
- "alias": item["alias"],
1379
- "bucket": item["bucket"],
1380
- }
1381
- for item in dimensions
1382
- ],
1383
- "metrics": [
1384
- {
1385
- "op": item["op"],
1386
- "field_id": cast(FormField | None, item["field"]).que_id if item["field"] is not None else None,
1387
- "title": cast(FormField | None, item["field"]).que_title if item["field"] is not None else None,
1388
- "alias": item["alias"],
1389
- }
1390
- for item in metrics
1391
- ],
1392
- "filters": [
1393
- {
1394
- "field_id": cast(FormField, item["field"]).que_id,
1395
- "title": cast(FormField, item["field"]).que_title,
1396
- "op": item["op"],
1397
- "value": item.get("value"),
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
- "query_id": query_id,
1426
- "request_route": self._request_route_payload(context),
1427
- "data": data,
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["data"]["debug"] = {
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
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
1594
- index = _build_field_index(schema)
1595
- normalized_fields = fields or {}
1596
- normalized_answers_input = answers or []
1597
- resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=index)
1598
- support_matrix = _summarize_write_support(resolved_fields)
1599
- invalid_fields: list[JSONObject] = []
1600
- normalized_answers: list[JSONObject] = []
1601
- try:
1602
- normalized_answers = self._resolve_answers(
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
- cache_key = (profile, app_key)
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("GET", context, f"/app/{app_key}/form", params={"type": 1})
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 _record_write_plan_response(
3091
+ def _record_write_blocked_response(
2963
3092
  self,
2964
- raw_plan: JSONObject,
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, raw_plan.get("data", {}))
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": "PLAN_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
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": raw_plan.get("profile"),
2977
- "ws_id": raw_plan.get("ws_id"),
2978
- "ok": bool(raw_plan.get("ok", True)),
2979
- "request_route": raw_plan.get("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, "mode": "plan"},
2984
- "resource": {"type": "record", "app_key": plan_data.get("app_key"), "record_id": plan_data.get("apply_id")},
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
- "legacy_plan": plan_data,
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, "mode": "apply"},
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
- response["data"]["debug"] = {
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 or queTitle/que_title",
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("queId", item.get("que_id", item.get("queTitle", item.get("que_title"))))
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):