@josephyan/qingflow-cli 1.0.10 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -3
  2. package/npm/bin/qingflow.mjs +32 -1
  3. package/npm/lib/runtime.mjs +43 -2
  4. package/package.json +1 -1
  5. package/pyproject.toml +2 -1
  6. package/skills/qingflow-cli/SKILL.md +440 -0
  7. package/skills/qingflow-cli/manifest.yaml +10 -0
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +94 -0
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +485 -0
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +237 -0
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_MATCH_RULES.md +137 -0
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +263 -0
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +304 -0
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md +41 -0
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +139 -0
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +84 -0
  17. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +129 -0
  18. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +195 -0
  19. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +159 -0
  20. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +20 -0
  21. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md +176 -0
  22. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +163 -0
  23. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +107 -0
  24. package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +151 -0
  25. package/skills/qingflow-cli/reference/_batch_schema_complex.json +18 -0
  26. package/skills/qingflow-cli/reference/_batch_schema_scalar.json +17 -0
  27. package/skills/qingflow-cli/reference/charts_remove.example.json +1 -0
  28. package/skills/qingflow-cli/reference/charts_reorder.example.json +1 -0
  29. package/skills/qingflow-cli/reference/charts_upsert_bar.example.json +8 -0
  30. package/skills/qingflow-cli/reference/charts_upsert_dashboard_starter.example.json +37 -0
  31. package/skills/qingflow-cli/reference/charts_upsert_minimal.example.json +13 -0
  32. package/skills/qingflow-cli/reference/portal_sections_all_types.example.json +131 -0
  33. package/skills/qingflow-cli/reference/portal_sections_five_types.example.json +126 -0
  34. package/skills/qingflow-cli/reference/portal_sections_standard_workbench.example.json +128 -0
  35. package/skills/qingflow-cli/reference/schema_add_fields_minimal.example.json +7 -0
  36. package/skills/qingflow-cli/reference/schema_apply_add_fields_all_types.json +78 -0
  37. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +7 -0
  38. package/skills/qingflow-cli/scripts/builder-package-from-app-list.py +140 -0
  39. package/skills/qingflow-cli/scripts/find-app-by-keyword.py +132 -0
  40. package/skills/qingflow-cli/scripts/validate_qingflow_output_files.py +87 -0
  41. package/src/qingflow_mcp/__init__.py +1 -1
  42. package/src/qingflow_mcp/builder_facade/models.py +532 -48
  43. package/src/qingflow_mcp/builder_facade/service.py +9194 -2384
  44. package/src/qingflow_mcp/builder_facade/workflow_spec.py +111 -0
  45. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  46. package/src/qingflow_mcp/cli/commands/builder.py +354 -56
  47. package/src/qingflow_mcp/cli/commands/record.py +89 -4
  48. package/src/qingflow_mcp/cli/formatters.py +53 -15
  49. package/src/qingflow_mcp/cli/main.py +204 -3
  50. package/src/qingflow_mcp/public_surface.py +11 -8
  51. package/src/qingflow_mcp/response_trim.py +185 -46
  52. package/src/qingflow_mcp/server.py +18 -15
  53. package/src/qingflow_mcp/server_app_builder.py +108 -30
  54. package/src/qingflow_mcp/server_app_user.py +20 -21
  55. package/src/qingflow_mcp/solution/compiler/__init__.py +1 -3
  56. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  57. package/src/qingflow_mcp/solution/executor.py +3 -133
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2617 -440
  59. package/src/qingflow_mcp/tools/app_tools.py +53 -8
  60. package/src/qingflow_mcp/tools/package_tools.py +16 -2
  61. package/src/qingflow_mcp/tools/record_tools.py +3408 -599
  62. package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
  63. package/src/qingflow_mcp/tools/solution_tools.py +30 -2
  64. package/src/qingflow_mcp/tools/workflow_tools.py +3 -31
  65. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +0 -173
@@ -2,18 +2,22 @@ from __future__ import annotations
2
2
 
3
3
  import csv
4
4
  import html
5
+ import mimetypes
5
6
  import json
6
7
  import os
7
8
  import re
8
9
  import time
10
+ import zipfile
9
11
  from copy import deepcopy
10
12
  from dataclasses import dataclass
11
13
  from datetime import UTC, datetime, timedelta
12
14
  from decimal import Decimal, InvalidOperation
15
+ from io import BytesIO
13
16
  from pathlib import Path
14
17
  from typing import Any, cast
15
18
  from urllib.parse import parse_qs, unquote, urlsplit
16
19
  from uuid import uuid4
20
+ from xml.etree import ElementTree
17
21
 
18
22
  from mcp.server.fastmcp import FastMCP
19
23
 
@@ -34,15 +38,29 @@ from .directory_tools import _directory_has_more, _directory_items
34
38
 
35
39
  DEFAULT_QUERY_PAGE_SIZE = 50
36
40
  DEFAULT_LIST_PAGE_SIZE = 200
41
+ DEFAULT_RECORD_LIST_RETURN_LIMIT = 10
37
42
  BACKEND_RECORD_ACCESS_PAGE_SIZE = 1000
38
43
  DEFAULT_RECORD_ACCESS_SHARD_ROWS = 20_000
39
44
  RECORD_ACCESS_UNBOUNDED_ROW_THRESHOLD = 50_000
40
45
  RECORD_ACCESS_TIME_BUDGET_SECONDS = 55.0
41
46
  RECORD_ACCESS_MIN_REMAINING_SECONDS = 8.0
42
47
  RECORD_GET_DETAIL_LOG_PAGE_SIZE = 10
48
+ RECORD_LOGS_PAGE_SIZE = 200
49
+ RECORD_LOGS_PREVIEW_LIMIT = 10
50
+ RECORD_LOGS_MAX_ITEMS = 20_000
51
+ RECORD_LOGS_TIME_BUDGET_SECONDS = 55.0
52
+ RECORD_LOGS_MIN_REMAINING_SECONDS = 8.0
43
53
  RECORD_GET_MEDIA_MAX_IMAGES = 30
44
54
  RECORD_GET_MEDIA_MAX_IMAGE_BYTES = 20 * 1024 * 1024
45
55
  RECORD_GET_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024
56
+ RECORD_GET_FILE_MAX_FILES = 50
57
+ RECORD_GET_FILE_MAX_BYTES = 50 * 1024 * 1024
58
+ RECORD_GET_FILE_MAX_TOTAL_BYTES = 200 * 1024 * 1024
59
+ RECORD_GET_FILE_TIME_BUDGET_SECONDS = 55.0
60
+ RECORD_GET_FILE_MIN_REMAINING_SECONDS = 8.0
61
+ RECORD_GET_FILE_EXTRACT_PREVIEW_CHARS = 20_000
62
+ RECORD_GET_FILE_EXTRACT_XLSX_MAX_ROWS_PER_SHEET = 200
63
+ RECORD_GET_FILE_EXTRACT_PDF_MAX_PAGES = 50
46
64
  DEFAULT_ANALYSIS_PAGE_SIZE = 1000
47
65
  DEFAULT_SCAN_MAX_PAGES = 10
48
66
  DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
@@ -256,6 +274,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
256
274
  FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
257
275
 
258
276
 
277
+ def _pick_route_payload(payload: JSONObject) -> JSONObject:
278
+ return {
279
+ key: payload[key]
280
+ for key in (
281
+ "route_type",
282
+ "endpoint_kind",
283
+ "status",
284
+ "role",
285
+ "task_id",
286
+ "workflow_node_id",
287
+ "view_id",
288
+ "view_key",
289
+ "view_name",
290
+ "error_code",
291
+ "backend_code",
292
+ "http_status",
293
+ "request_id",
294
+ "message",
295
+ "reason",
296
+ )
297
+ if key in payload and payload[key] not in (None, "", [], {})
298
+ }
299
+
300
+
259
301
  class RecordTools(ToolBase):
260
302
  """记录工具(中文名:记录读写与分析)。
261
303
 
@@ -355,7 +397,7 @@ class RecordTools(ToolBase):
355
397
  description=(
356
398
  "Browse Qingflow records with a schema-first list DSL. "
357
399
  "Use record_browse_schema_get first, then pass field_id-only columns, where, and order_by clauses. "
358
- "This route is for browse/export/sample inspection only, not analysis."
400
+ "This route returns up to 10 rows plus total counts for browse, sample inspection, and fuzzy record lookup; it is not for analysis."
359
401
  )
360
402
  )
361
403
  def record_list(
@@ -366,7 +408,6 @@ class RecordTools(ToolBase):
366
408
  query_fields: list[JSONObject | int] | None = None,
367
409
  where: list[JSONObject] | None = None,
368
410
  order_by: list[JSONObject] | None = None,
369
- limit: int = 50,
370
411
  page: int = 1,
371
412
  view_id: str | None = None,
372
413
  output_profile: str = "normal",
@@ -379,7 +420,6 @@ class RecordTools(ToolBase):
379
420
  query_fields=query_fields or [],
380
421
  where=where or [],
381
422
  order_by=order_by or [],
382
- limit=limit,
383
423
  page=page,
384
424
  view_id=view_id,
385
425
  list_type=None,
@@ -431,6 +471,20 @@ class RecordTools(ToolBase):
431
471
  output_profile=output_profile,
432
472
  )
433
473
 
474
+ @mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. This tool hides pagination and returns file paths plus completeness metadata.")
475
+ def record_logs_get(
476
+ profile: str = DEFAULT_PROFILE,
477
+ app_key: str = "",
478
+ record_id: str = "",
479
+ view_id: str | None = None,
480
+ ) -> JSONObject:
481
+ return self.record_logs_get(
482
+ profile=profile,
483
+ app_key=app_key,
484
+ record_id=record_id,
485
+ view_id=view_id,
486
+ )
487
+
434
488
  @mcp.tool()
435
489
  def record_browse_schema_get(
436
490
  app_key: str = "",
@@ -459,21 +513,22 @@ class RecordTools(ToolBase):
459
513
 
460
514
  @mcp.tool(
461
515
  description=(
462
- "Insert one Qingflow record using an applicant-node field map. "
516
+ "Insert Qingflow records using applicant-node field maps. "
463
517
  "Use record_insert_schema_get first. "
464
- "This tool performs internal preflight validation before any write is applied."
518
+ "Prefer items=[{'fields': {...}}]; a single insert is one item. "
519
+ "Each item performs internal preflight validation before that item is written."
465
520
  )
466
521
  )
467
522
  def record_insert(
468
523
  app_key: str = "",
469
- fields: JSONObject | None = None,
524
+ items: list[JSONObject] | None = None,
470
525
  verify_write: bool = True,
471
526
  output_profile: str = "normal",
472
527
  ) -> JSONObject:
473
528
  return self.record_insert_public(
474
529
  profile=DEFAULT_PROFILE,
475
530
  app_key=app_key,
476
- fields=fields or {},
531
+ items=items,
477
532
  verify_write=verify_write,
478
533
  output_profile=output_profile,
479
534
  )
@@ -481,8 +536,9 @@ class RecordTools(ToolBase):
481
536
  @mcp.tool(
482
537
  description=(
483
538
  "Update one Qingflow record using a field map. "
484
- "Use record_update_schema_get first. "
485
- "This tool automatically probes accessible views in order and uses the first safe match."
539
+ "For simple field changes, call this tool directly after the target record is clear. "
540
+ "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
541
+ "Use record_update_schema_get for diagnostics or complex field-scope inspection."
486
542
  )
487
543
  )
488
544
  def record_update(
@@ -983,15 +1039,59 @@ class RecordTools(ToolBase):
983
1039
  item["title"]: self._ready_schema_template_value(item)
984
1040
  for item in writable_fields
985
1041
  },
1042
+ "available_update_routes": self._record_update_schema_available_routes(matched_probes),
1043
+ "recommended_update_route": {
1044
+ "route_type": "auto",
1045
+ "order": ["admin_direct", "view_edit", "task_save_only"],
1046
+ "message": "record_update will try data-manager direct edit first, then a matching custom-view edit route, then a unique current-user todo save-only route when the target fields are editable on that workflow node.",
1047
+ },
986
1048
  }
987
1049
  if normalized_output_profile == "verbose":
988
1050
  response["view_probe_summary"] = probe_summary
989
1051
  response["record_context_probe"] = probe_summary
990
1052
  response["ambiguous_fields"] = ambiguous_fields
1053
+ response["route_probe_summary"] = probe_summary
991
1054
  return response
992
1055
 
993
1056
  return self._run_record_tool(profile, runner)
994
1057
 
1058
+ def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
1059
+ routes: list[JSONObject] = [
1060
+ {
1061
+ "route_type": "admin_direct",
1062
+ "endpoint_kind": "app_apply_update",
1063
+ "role": 1,
1064
+ "availability": "attempted_on_update",
1065
+ "message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
1066
+ }
1067
+ ]
1068
+ for probe in matched_probes:
1069
+ if probe.route.kind != "custom":
1070
+ continue
1071
+ view_key = self._route_view_key(probe.route)
1072
+ if not view_key:
1073
+ continue
1074
+ routes.append(
1075
+ {
1076
+ "route_type": "view_edit",
1077
+ "endpoint_kind": "view_apply_update",
1078
+ "view_id": probe.route.view_id,
1079
+ "view_key": view_key,
1080
+ "view_name": probe.route.name,
1081
+ "availability": "candidate",
1082
+ "message": "Uses the same custom-view detail edit route as the frontend.",
1083
+ }
1084
+ )
1085
+ routes.append(
1086
+ {
1087
+ "route_type": "task_save_only",
1088
+ "endpoint_kind": "workflow_node_save_only",
1089
+ "availability": "auto_probe_on_update",
1090
+ "message": "record_update probes the current user's todo list and uses save-only only when exactly one matching task exists and the requested fields are editable on that workflow node.",
1091
+ }
1092
+ )
1093
+ return routes
1094
+
995
1095
  def _record_update_schema_blocked_response(
996
1096
  self,
997
1097
  *,
@@ -1053,11 +1153,16 @@ class RecordTools(ToolBase):
1053
1153
  required = bool(required_override) if required_override is not None else bool(field.required or any(item.get("required") for item in row_fields))
1054
1154
  else:
1055
1155
  required = bool(required_override) if required_override is not None else bool(field.required)
1156
+ write_format = _write_format_for_field(field)
1056
1157
  payload: JSONObject = {
1057
1158
  "title": field.que_title,
1058
1159
  "kind": kind,
1059
1160
  "required": required,
1161
+ "format_hint": _ready_schema_format_hint(kind, write_format),
1060
1162
  }
1163
+ example_value = _ready_schema_example_value(kind, field, write_format, row_fields=row_fields)
1164
+ if example_value is not None:
1165
+ payload["example_value"] = example_value
1061
1166
  if include_field_id:
1062
1167
  payload["field_id"] = field.que_id
1063
1168
  if kind in {"single_select", "multi_select"} and field.options:
@@ -1618,8 +1723,8 @@ class RecordTools(ToolBase):
1618
1723
  query_fields: list[JSONObject | int] | None = None,
1619
1724
  where: list[JSONObject],
1620
1725
  order_by: list[JSONObject],
1621
- limit: int,
1622
- page: int,
1726
+ limit: int = DEFAULT_RECORD_LIST_RETURN_LIMIT,
1727
+ page: int = 1,
1623
1728
  view_id: str | None = None,
1624
1729
  list_type: int | None = None,
1625
1730
  view_key: str | None = None,
@@ -1664,127 +1769,133 @@ class RecordTools(ToolBase):
1664
1769
  },
1665
1770
  )
1666
1771
  )
1667
- resolved_columns = normalized_columns or self._derive_public_list_columns_for_public(
1668
- profile=profile,
1669
- app_key=app_key,
1670
- resolved_view=view_route,
1671
- )
1672
- resolved_query_fields = self._resolve_record_list_query_fields_for_public(
1673
- profile=profile,
1674
- app_key=app_key,
1675
- resolved_view=view_route,
1676
- selectors=normalized_query_field_selectors,
1677
- )
1772
+ filters = self._normalize_record_list_where(where)
1773
+ sorts = self._normalize_record_list_order_by(order_by)
1678
1774
 
1679
- raw = self.record_query(
1680
- profile=profile,
1681
- query_mode="list",
1682
- app_key=app_key,
1683
- apply_id=None,
1684
- page_num=page,
1685
- page_size=DEFAULT_LIST_PAGE_SIZE,
1686
- requested_pages=1,
1687
- scan_max_pages=1,
1688
- auto_expand_pages=False,
1689
- query_key=normalized_query,
1690
- search_que_ids=resolved_query_fields,
1691
- filters=self._normalize_record_list_where(where),
1692
- sorts=self._normalize_record_list_order_by(order_by),
1693
- max_rows=limit,
1694
- max_columns=len(resolved_columns),
1695
- select_columns=resolved_columns,
1696
- amount_column=None,
1697
- time_range={},
1698
- stat_policy={},
1699
- strict_full=False,
1700
- output_profile="verbose" if normalized_output_profile in {"verbose", "normalized"} else DEFAULT_OUTPUT_PROFILE,
1701
- list_type=view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
1702
- view_key=view_route.view_selection.view_key if view_route.view_selection is not None else None,
1703
- view_name=view_route.view_selection.view_name if view_route.view_selection is not None else None,
1704
- )
1705
- list_data = cast(JSONObject, cast(JSONObject, raw["data"])["list"])
1706
- pagination = cast(JSONObject, list_data["pagination"])
1707
- warnings: list[JSONObject] = []
1708
- warnings.extend(legacy_warnings)
1709
- warnings.extend(compatibility_warnings)
1710
- warnings.extend(_view_filter_trust_warnings(view_route))
1711
- warning = _normalize_optional_text(list_data.get("analysis_warning"))
1712
- if warning:
1713
- warnings.append({"code": "BROWSE_ONLY", "message": warning})
1714
- list_type_used = _coerce_count(pagination.get("list_type_used"))
1715
- if list_type_used is not None and list_type_used != DEFAULT_RECORD_LIST_TYPE:
1716
- warnings.append(
1717
- {
1718
- "code": "LIST_TYPE_FALLBACK",
1719
- "message": (
1720
- f"record_list not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
1721
- f"fell back to listType={list_type_used} ({get_record_list_type_label(list_type_used)})."
1722
- ),
1723
- }
1775
+ def runner(session_profile, context):
1776
+ browse_scope = self._build_browse_read_scope(
1777
+ profile,
1778
+ context,
1779
+ app_key,
1780
+ view_route,
1781
+ force_refresh=False,
1724
1782
  )
1725
- rows = list_data.get("rows", [])
1726
- normalized_public_rows = _normalize_public_record_rows(rows if isinstance(rows, list) else [])
1727
- lookup_payload = _build_record_list_lookup_payload(
1728
- query=normalized_query,
1729
- items=normalized_public_rows,
1730
- pagination=pagination,
1731
- limit=limit,
1732
- )
1733
- response: JSONObject = {
1734
- "profile": profile,
1735
- "ws_id": raw.get("ws_id"),
1736
- "ok": bool(raw.get("ok", True)),
1737
- "request_route": raw.get("request_route"),
1738
- "warnings": warnings,
1739
- "verification": _view_filter_verification_payload(view_route),
1740
- "output_profile": normalized_output_profile,
1741
- "data": {
1742
- "app_key": app_key,
1743
- "items": normalized_public_rows,
1744
- "pagination": {
1745
- "page": page,
1746
- "limit": limit,
1747
- "returned_items": pagination.get("returned_items"),
1748
- "result_amount": pagination.get("result_amount"),
1749
- "list_type_used": list_type_used,
1750
- },
1751
- "selection": {
1752
- "columns": [_column_selector_payload(field_id) for field_id in resolved_columns],
1753
- "query_fields": [_column_selector_payload(field_id) for field_id in resolved_query_fields],
1754
- "view": _accessible_view_payload(view_route),
1783
+ index = cast(FieldIndex, browse_scope["index"])
1784
+ selected_fields = (
1785
+ self._resolve_record_list_columns(normalized_columns, index, view_route=view_route)
1786
+ if normalized_columns
1787
+ else self._derive_record_list_fields_from_index(index)
1788
+ )
1789
+ resolved_columns = [field.que_id for field in selected_fields]
1790
+ resolved_query_fields = self._resolve_record_list_query_fields(
1791
+ normalized_query_field_selectors,
1792
+ index,
1793
+ view_route=view_route,
1794
+ )
1795
+ match_rules = self._resolve_record_list_match_rules(context, filters, index, view_route=view_route)
1796
+ sort_rules = self._resolve_record_list_sort_rules(sorts, index, view_route=view_route)
1797
+ raw = self._record_list_query_view_fields(
1798
+ session_profile=session_profile,
1799
+ context=context,
1800
+ app_key=app_key,
1801
+ view_route=view_route,
1802
+ page_num=page,
1803
+ page_size=DEFAULT_LIST_PAGE_SIZE,
1804
+ query_key=normalized_query,
1805
+ search_que_ids=resolved_query_fields or None,
1806
+ match_rules=match_rules,
1807
+ sort_rules=sort_rules,
1808
+ max_rows=limit,
1809
+ selected_fields=selected_fields,
1810
+ output_profile="verbose" if normalized_output_profile in {"verbose", "normalized"} else DEFAULT_OUTPUT_PROFILE,
1811
+ )
1812
+ list_data = cast(JSONObject, cast(JSONObject, raw["data"])["list"])
1813
+ pagination = cast(JSONObject, list_data["pagination"])
1814
+ warnings: list[JSONObject] = []
1815
+ warnings.extend(legacy_warnings)
1816
+ warnings.extend(compatibility_warnings)
1817
+ warnings.extend(_view_filter_trust_warnings(view_route))
1818
+ warning = _normalize_optional_text(list_data.get("analysis_warning"))
1819
+ if warning:
1820
+ warnings.append({"code": "BROWSE_ONLY", "message": warning})
1821
+ list_type_used = _coerce_count(pagination.get("list_type_used"))
1822
+ if list_type_used is not None and list_type_used != DEFAULT_RECORD_LIST_TYPE:
1823
+ warnings.append(
1824
+ {
1825
+ "code": "LIST_TYPE_FALLBACK",
1826
+ "message": (
1827
+ f"record_list not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
1828
+ f"fell back to listType={list_type_used} ({get_record_list_type_label(list_type_used)})."
1829
+ ),
1830
+ }
1831
+ )
1832
+ rows = list_data.get("rows", [])
1833
+ normalized_public_rows = _normalize_public_record_rows(rows if isinstance(rows, list) else [])
1834
+ lookup_payload = _build_record_list_lookup_payload(
1835
+ query=normalized_query,
1836
+ items=normalized_public_rows,
1837
+ pagination=pagination,
1838
+ )
1839
+ total_count = _coerce_count(pagination.get("result_amount"))
1840
+ returned_count = _coerce_count(pagination.get("returned_items"))
1841
+ if returned_count is None:
1842
+ returned_count = len(normalized_public_rows)
1843
+ truncated = bool(total_count is not None and total_count > returned_count)
1844
+ response: JSONObject = {
1845
+ "profile": profile,
1846
+ "ws_id": raw.get("ws_id"),
1847
+ "ok": bool(raw.get("ok", True)),
1848
+ "request_route": raw.get("request_route"),
1849
+ "warnings": warnings,
1850
+ "verification": _view_filter_verification_payload(view_route),
1851
+ "output_profile": normalized_output_profile,
1852
+ "data": {
1853
+ "app_key": app_key,
1854
+ "items": normalized_public_rows,
1855
+ "pagination": {
1856
+ "returned_count": returned_count,
1857
+ "total_count": total_count,
1858
+ "truncated": truncated,
1859
+ },
1860
+ "selection": {
1861
+ "columns": [_column_selector_payload(field_id) for field_id in resolved_columns],
1862
+ "query_fields": [_column_selector_payload(field_id) for field_id in resolved_query_fields],
1863
+ "view": _accessible_view_payload(view_route),
1864
+ },
1755
1865
  },
1756
- },
1757
- }
1758
- if lookup_payload is not None:
1759
- response["lookup"] = lookup_payload
1760
- if normalized_output_profile == "normalized":
1761
- normalized_rows = list_data.get("normalized_rows")
1762
- if isinstance(normalized_rows, list):
1763
- item_by_apply_id = {
1764
- _coerce_count(item.get("apply_id")): item
1765
- for item in cast(list[JSONObject], response["data"]["items"])
1766
- if isinstance(item, dict) and _coerce_count(item.get("apply_id")) is not None
1767
- }
1768
- for entry in normalized_rows:
1769
- if not isinstance(entry, dict):
1770
- continue
1771
- apply_id = _coerce_count(entry.get("apply_id"))
1772
- if apply_id is None:
1773
- continue
1774
- target = item_by_apply_id.get(apply_id)
1775
- if target is None:
1776
- continue
1777
- target["normalized_record"] = cast(JSONObject, entry.get("normalized_record") or {})
1778
- target["normalized_ambiguous_fields"] = cast(JSONObject, entry.get("normalized_ambiguous_fields") or {})
1779
- if normalized_output_profile == "verbose":
1780
- response["data"]["debug"] = {
1781
- "completeness": raw.get("completeness"),
1782
- "evidence": raw.get("evidence"),
1783
- "resolved_mappings": raw.get("resolved_mappings"),
1784
- "row_cap_hit": list_data.get("row_cap_hit"),
1785
- "sample_only": list_data.get("sample_only"),
1786
1866
  }
1787
- return response
1867
+ if lookup_payload is not None:
1868
+ response["lookup"] = lookup_payload
1869
+ if normalized_output_profile == "normalized":
1870
+ normalized_rows = list_data.get("normalized_rows")
1871
+ if isinstance(normalized_rows, list):
1872
+ item_by_apply_id = {
1873
+ _coerce_count(item.get("apply_id")): item
1874
+ for item in cast(list[JSONObject], response["data"]["items"])
1875
+ if isinstance(item, dict) and _coerce_count(item.get("apply_id")) is not None
1876
+ }
1877
+ for entry in normalized_rows:
1878
+ if not isinstance(entry, dict):
1879
+ continue
1880
+ apply_id = _coerce_count(entry.get("apply_id"))
1881
+ if apply_id is None:
1882
+ continue
1883
+ target = item_by_apply_id.get(apply_id)
1884
+ if target is None:
1885
+ continue
1886
+ target["normalized_record"] = cast(JSONObject, entry.get("normalized_record") or {})
1887
+ target["normalized_ambiguous_fields"] = cast(JSONObject, entry.get("normalized_ambiguous_fields") or {})
1888
+ if normalized_output_profile == "verbose":
1889
+ response["data"]["debug"] = {
1890
+ "completeness": raw.get("completeness"),
1891
+ "evidence": raw.get("evidence"),
1892
+ "resolved_mappings": raw.get("resolved_mappings"),
1893
+ "row_cap_hit": list_data.get("row_cap_hit"),
1894
+ "sample_only": list_data.get("sample_only"),
1895
+ }
1896
+ return response
1897
+
1898
+ return self._run_record_tool(profile, runner)
1788
1899
 
1789
1900
  @tool_cn_name("记录访问")
1790
1901
  def record_access(
@@ -2152,6 +2263,130 @@ class RecordTools(ToolBase):
2152
2263
 
2153
2264
  return self._run_record_tool(profile, runner)
2154
2265
 
2266
+ @tool_cn_name("记录全量日志")
2267
+ def record_logs_get(
2268
+ self,
2269
+ *,
2270
+ profile: str,
2271
+ app_key: str,
2272
+ record_id: Any,
2273
+ view_id: str | None = None,
2274
+ ) -> JSONObject:
2275
+ """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2276
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2277
+
2278
+ def runner(session_profile, context):
2279
+ resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
2280
+ profile,
2281
+ context,
2282
+ app_key,
2283
+ view_id=view_id,
2284
+ list_type=None,
2285
+ view_key=None,
2286
+ view_name=None,
2287
+ allow_default=True,
2288
+ )
2289
+ warnings: list[JSONObject] = []
2290
+ warnings.extend(compatibility_warnings)
2291
+ warnings.extend(_view_filter_trust_warnings(resolved_view))
2292
+ unavailable_context: list[JSONObject] = []
2293
+
2294
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2295
+ index = _build_top_level_field_index(schema)
2296
+ audit_info = self._record_get_audit_info(
2297
+ context,
2298
+ app_key=app_key,
2299
+ record_id=record_id_int,
2300
+ resolved_view=resolved_view,
2301
+ )
2302
+ audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2303
+ detail_result, used_list_type, used_role = self._record_get_apply_detail(
2304
+ context,
2305
+ app_key=app_key,
2306
+ record_id=record_id_int,
2307
+ resolved_view=resolved_view,
2308
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2309
+ )
2310
+ answer_list = _record_detail_answers(detail_result)
2311
+ selected_fields = list(index.by_id.values())
2312
+ fields = [
2313
+ _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
2314
+ for field in selected_fields
2315
+ ]
2316
+ app_name = self._record_get_detail_app_name(
2317
+ profile,
2318
+ context,
2319
+ app_key=app_key,
2320
+ schema=schema,
2321
+ used_list_type=used_list_type,
2322
+ )
2323
+ view_payload = _accessible_view_payload(resolved_view)
2324
+ record_payload = _record_detail_record_payload(
2325
+ app_key=app_key,
2326
+ record_id=record_id_int,
2327
+ detail=detail_result,
2328
+ answer_list=cast(list[JSONValue], answer_list),
2329
+ fields=fields,
2330
+ )
2331
+ log_visibility = self._record_get_log_visibility_context(
2332
+ context,
2333
+ app_key=app_key,
2334
+ record_id=record_id_int,
2335
+ resolved_view=resolved_view,
2336
+ role=used_role,
2337
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2338
+ unavailable_context=unavailable_context,
2339
+ )
2340
+ run_dir = _record_logs_run_dir()
2341
+ run_dir.mkdir(parents=True, exist_ok=True)
2342
+ deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
2343
+ data_logs = self._record_get_full_data_logs_context(
2344
+ context,
2345
+ app_key=app_key,
2346
+ record_id=record_id_int,
2347
+ role=used_role,
2348
+ log_visibility=log_visibility,
2349
+ unavailable_context=unavailable_context,
2350
+ run_dir=run_dir,
2351
+ deadline=deadline,
2352
+ )
2353
+ workflow_logs = self._record_get_full_workflow_logs_context(
2354
+ context,
2355
+ app_key=app_key,
2356
+ record_id=record_id_int,
2357
+ resolved_view=resolved_view,
2358
+ role=used_role,
2359
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2360
+ log_visibility=log_visibility,
2361
+ unavailable_context=unavailable_context,
2362
+ run_dir=run_dir,
2363
+ deadline=deadline,
2364
+ )
2365
+ status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
2366
+ context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
2367
+ payload: JSONObject = {
2368
+ "ok": True,
2369
+ "status": status,
2370
+ "output_profile": "record_logs",
2371
+ "app": {"app_key": app_key, "app_name": app_name},
2372
+ "view": view_payload,
2373
+ "record": record_payload,
2374
+ "local_dir": str(run_dir),
2375
+ "data_logs": data_logs,
2376
+ "workflow_logs": workflow_logs,
2377
+ "warnings": warnings,
2378
+ "unavailable_context": unavailable_context,
2379
+ "context_integrity": context_integrity,
2380
+ }
2381
+ summary_path = run_dir / "summary.json"
2382
+ summary_payload = deepcopy(payload)
2383
+ summary_payload.pop("request_route", None)
2384
+ summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
2385
+ payload["summary_path"] = str(summary_path)
2386
+ return payload
2387
+
2388
+ return self._run_record_tool(profile, runner)
2389
+
2155
2390
  def _record_get_detail_context(
2156
2391
  self,
2157
2392
  *,
@@ -2304,12 +2539,23 @@ class RecordTools(ToolBase):
2304
2539
  fields=fields,
2305
2540
  references=references,
2306
2541
  )
2542
+ file_assets = self._record_get_file_assets(
2543
+ context,
2544
+ app_key=app_key,
2545
+ record_id=record_id_int,
2546
+ resolved_view=resolved_view,
2547
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2548
+ fields=fields,
2549
+ references=references,
2550
+ media_assets=media_assets,
2551
+ )
2307
2552
  context_integrity = _record_detail_context_integrity(
2308
2553
  references=references,
2309
2554
  data_logs=data_logs,
2310
2555
  workflow_logs=workflow_logs,
2311
2556
  associated_resources=associated_resources,
2312
2557
  media_assets=media_assets,
2558
+ file_assets=file_assets,
2313
2559
  unavailable_context=unavailable_context,
2314
2560
  )
2315
2561
  payload: JSONObject = {
@@ -2332,6 +2578,7 @@ class RecordTools(ToolBase):
2332
2578
  "requested_focus_fields": [_column_selector_payload(field_id) for field_id in requested_focus_field_ids],
2333
2579
  "references": references,
2334
2580
  "media_assets": media_assets,
2581
+ "file_assets": file_assets,
2335
2582
  "data_logs": data_logs,
2336
2583
  "workflow_logs": workflow_logs,
2337
2584
  "associated_resources": associated_resources,
@@ -2789,57 +3036,159 @@ class RecordTools(ToolBase):
2789
3036
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
2790
3037
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
2791
3038
 
2792
- def _record_get_associated_resources(
3039
+ def _record_get_full_data_logs_context(
2793
3040
  self,
2794
3041
  context, # type: ignore[no-untyped-def]
2795
3042
  *,
2796
3043
  app_key: str,
2797
- resolved_view: AccessibleViewRoute,
3044
+ record_id: int,
2798
3045
  role: int,
2799
- audit_node_id: int | None,
3046
+ log_visibility: JSONObject,
2800
3047
  unavailable_context: list[JSONObject],
2801
- ) -> list[JSONObject]:
2802
- """执行内部辅助逻辑。"""
3048
+ run_dir: Path,
3049
+ deadline: float,
3050
+ ) -> JSONObject:
3051
+ """读取全量数据日志并写入 JSONL。"""
3052
+ if log_visibility.get("status") == "unavailable":
3053
+ return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
3054
+ if log_visibility.get("data_log_visible") is False:
3055
+ return _record_logs_hidden_payload("data_logs")
3056
+
3057
+ def fetch_page(page_num: int) -> JSONValue:
3058
+ return self.backend.request(
3059
+ "POST",
3060
+ context,
3061
+ f"/worksheet/data/log/{app_key}/{record_id}/page",
3062
+ json_body={
3063
+ "viewChannel": log_visibility.get("channel"),
3064
+ "role": role,
3065
+ "pageNum": page_num,
3066
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3067
+ },
3068
+ )
3069
+
2803
3070
  try:
2804
- if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
2805
- payload = self.backend.request(
2806
- "GET",
2807
- context,
2808
- f"/view/{app_key}/asosChart",
2809
- params={
2810
- "role": role,
2811
- "viewgraphKey": resolved_view.view_selection.view_key,
2812
- "beingConfig": False,
2813
- },
2814
- )
2815
- else:
2816
- params: JSONObject = {"role": role, "beingDraft": False}
2817
- if audit_node_id is not None:
2818
- params["auditNodeId"] = audit_node_id
2819
- payload = self.backend.request("GET", context, f"/app/{app_key}/asosChart", params=params)
3071
+ return _record_logs_fetch_all_to_jsonl(
3072
+ fetch_page=fetch_page,
3073
+ normalizer=_record_detail_data_log_item,
3074
+ source="data_logs",
3075
+ file_path=run_dir / "data-logs.jsonl",
3076
+ deadline=deadline,
3077
+ )
2820
3078
  except QingflowApiError as exc:
2821
- unavailable_context.append(_record_detail_unavailable_context("associated_resources", "关联资源获取失败。", exc))
2822
- return []
2823
- return [_record_detail_associated_resource(item) for item in _record_detail_associated_resource_items(payload)]
3079
+ unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3080
+ return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
2824
3081
 
2825
- def _record_get_media_assets(
3082
+ def _record_get_full_workflow_logs_context(
2826
3083
  self,
2827
3084
  context, # type: ignore[no-untyped-def]
2828
3085
  *,
2829
3086
  app_key: str,
2830
3087
  record_id: int,
2831
3088
  resolved_view: AccessibleViewRoute,
3089
+ role: int,
2832
3090
  audit_node_id: int | None,
2833
- fields: list[JSONObject],
2834
- references: list[JSONObject],
3091
+ log_visibility: JSONObject,
3092
+ unavailable_context: list[JSONObject],
3093
+ run_dir: Path,
3094
+ deadline: float,
2835
3095
  ) -> JSONObject:
2836
- """Collect and localize image assets that the frontend detail page can render."""
2837
- try:
2838
- def refresh_source_url(candidate: JSONObject) -> str | None:
2839
- return self._record_get_refreshed_media_source_url(
3096
+ """读取全量流程日志并写入 JSONL。"""
3097
+ if log_visibility.get("status") == "unavailable":
3098
+ return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
3099
+ if log_visibility.get("workflow_log_visible") is False:
3100
+ return _record_logs_hidden_payload("workflow_logs")
3101
+
3102
+ def fetch_page(page_num: int) -> JSONValue:
3103
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3104
+ return self.backend.request(
3105
+ "POST",
2840
3106
  context,
2841
- app_key=app_key,
2842
- record_id=record_id,
3107
+ f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
3108
+ json_body={
3109
+ "key": resolved_view.view_selection.view_key,
3110
+ "rowRecordId": str(record_id),
3111
+ "pageNum": page_num,
3112
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3113
+ },
3114
+ )
3115
+ return self.backend.request(
3116
+ "POST",
3117
+ context,
3118
+ "/application/workflow/node/record",
3119
+ json_body={
3120
+ "key": app_key,
3121
+ "rowRecordId": str(record_id),
3122
+ "nodeId": audit_node_id,
3123
+ "role": role,
3124
+ "pageNum": page_num,
3125
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3126
+ },
3127
+ )
3128
+
3129
+ try:
3130
+ return _record_logs_fetch_all_to_jsonl(
3131
+ fetch_page=fetch_page,
3132
+ normalizer=_record_detail_workflow_log_item,
3133
+ source="workflow_logs",
3134
+ file_path=run_dir / "workflow-logs.jsonl",
3135
+ deadline=deadline,
3136
+ )
3137
+ except QingflowApiError as exc:
3138
+ unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3139
+ return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3140
+
3141
+ def _record_get_associated_resources(
3142
+ self,
3143
+ context, # type: ignore[no-untyped-def]
3144
+ *,
3145
+ app_key: str,
3146
+ resolved_view: AccessibleViewRoute,
3147
+ role: int,
3148
+ audit_node_id: int | None,
3149
+ unavailable_context: list[JSONObject],
3150
+ ) -> list[JSONObject]:
3151
+ """执行内部辅助逻辑。"""
3152
+ try:
3153
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3154
+ payload = self.backend.request(
3155
+ "GET",
3156
+ context,
3157
+ f"/view/{app_key}/asosChart",
3158
+ params={
3159
+ "role": role,
3160
+ "viewgraphKey": resolved_view.view_selection.view_key,
3161
+ "beingConfig": False,
3162
+ },
3163
+ )
3164
+ else:
3165
+ params: JSONObject = {"role": role, "beingDraft": False}
3166
+ if audit_node_id is not None:
3167
+ params["auditNodeId"] = audit_node_id
3168
+ payload = self.backend.request("GET", context, f"/app/{app_key}/asosChart", params=params)
3169
+ except QingflowApiError as exc:
3170
+ unavailable_context.append(_record_detail_unavailable_context("associated_resources", "关联资源获取失败。", exc))
3171
+ return []
3172
+ return [_record_detail_associated_resource(item) for item in _record_detail_associated_resource_items(payload)]
3173
+
3174
+ def _record_get_media_assets(
3175
+ self,
3176
+ context, # type: ignore[no-untyped-def]
3177
+ *,
3178
+ app_key: str,
3179
+ record_id: int,
3180
+ resolved_view: AccessibleViewRoute,
3181
+ audit_node_id: int | None,
3182
+ fields: list[JSONObject],
3183
+ references: list[JSONObject],
3184
+ ) -> JSONObject:
3185
+ """Collect and localize image assets that the frontend detail page can render."""
3186
+ try:
3187
+ def refresh_source_url(candidate: JSONObject) -> str | None:
3188
+ return self._record_get_refreshed_media_source_url(
3189
+ context,
3190
+ app_key=app_key,
3191
+ record_id=record_id,
2843
3192
  resolved_view=resolved_view,
2844
3193
  audit_node_id=audit_node_id,
2845
3194
  candidate=candidate,
@@ -2867,6 +3216,53 @@ class RecordTools(ToolBase):
2867
3216
  ],
2868
3217
  }
2869
3218
 
3219
+ def _record_get_file_assets(
3220
+ self,
3221
+ context, # type: ignore[no-untyped-def]
3222
+ *,
3223
+ app_key: str,
3224
+ record_id: int,
3225
+ resolved_view: AccessibleViewRoute,
3226
+ audit_node_id: int | None,
3227
+ fields: list[JSONObject],
3228
+ references: list[JSONObject],
3229
+ media_assets: JSONObject,
3230
+ ) -> JSONObject:
3231
+ """Collect and localize file assets from the frontend detail context."""
3232
+ try:
3233
+ def refresh_source_url(candidate: JSONObject) -> str | None:
3234
+ return self._record_get_refreshed_media_source_url(
3235
+ context,
3236
+ app_key=app_key,
3237
+ record_id=record_id,
3238
+ resolved_view=resolved_view,
3239
+ audit_node_id=audit_node_id,
3240
+ candidate=candidate,
3241
+ )
3242
+
3243
+ return _record_detail_file_assets_payload(
3244
+ backend=self.backend,
3245
+ context=context,
3246
+ app_key=app_key,
3247
+ record_id=record_id,
3248
+ fields=fields,
3249
+ references=references,
3250
+ media_assets=media_assets,
3251
+ refresh_source_url=refresh_source_url,
3252
+ )
3253
+ except Exception as exc: # defensive: file assets should never break the core record detail.
3254
+ return {
3255
+ "status": "unavailable",
3256
+ "local_dir": None,
3257
+ "items": [],
3258
+ "warnings": [
3259
+ {
3260
+ "code": "FILE_ASSETS_UNAVAILABLE",
3261
+ "message": f"record_get could not collect file assets: {exc}",
3262
+ }
3263
+ ],
3264
+ }
3265
+
2870
3266
  def _record_get_refreshed_media_source_url(
2871
3267
  self,
2872
3268
  context, # type: ignore[no-untyped-def]
@@ -2919,6 +3315,7 @@ class RecordTools(ToolBase):
2919
3315
  profile: str = DEFAULT_PROFILE,
2920
3316
  app_key: str,
2921
3317
  fields: JSONObject | None = None,
3318
+ items: list[JSONObject] | None = None,
2922
3319
  verify_write: bool = True,
2923
3320
  output_profile: str = "normal",
2924
3321
  ) -> JSONObject:
@@ -2926,88 +3323,497 @@ class RecordTools(ToolBase):
2926
3323
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
2927
3324
  if not app_key:
2928
3325
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
3326
+ if items is not None:
3327
+ normalized_items = self._normalize_public_record_insert_batch_items(fields=fields, items=items)
3328
+ return self._record_insert_public_batch(
3329
+ profile=profile,
3330
+ app_key=app_key,
3331
+ items=normalized_items,
3332
+ verify_write=verify_write,
3333
+ output_profile=normalized_output_profile,
3334
+ )
2929
3335
  if fields is not None and not isinstance(fields, dict):
2930
3336
  raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
2931
- submit_type_value = self._normalize_record_write_submit_type("submit")
2932
- raw_preflight = self._preflight_record_write(
3337
+ return self._record_insert_public_single(
2933
3338
  profile=profile,
2934
- operation="create",
2935
3339
  app_key=app_key,
2936
- apply_id=None,
2937
- answers=[],
2938
3340
  fields=cast(JSONObject, fields or {}),
2939
- force_refresh_form=False,
2940
- view_id=None,
2941
- list_type=None,
2942
- view_key=None,
2943
- view_name=None,
2944
- )
2945
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
2946
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
2947
- normalized_payload: JSONObject = self._record_write_normalized_payload(
2948
- operation="insert",
2949
- record_id=None,
2950
- record_ids=[],
2951
- normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
2952
- submit_type=submit_type_value,
2953
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3341
+ verify_write=verify_write,
3342
+ output_profile=normalized_output_profile,
3343
+ capture_exceptions=False,
2954
3344
  )
2955
- if preflight_data.get("blockers"):
2956
- return self._record_write_blocked_response(
2957
- raw_preflight,
2958
- operation="insert",
2959
- normalized_payload=normalized_payload,
2960
- output_profile=normalized_output_profile,
2961
- human_review=False,
2962
- target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
2963
- )
3345
+
3346
+ def _record_insert_public_single(
3347
+ self,
3348
+ *,
3349
+ profile: str,
3350
+ app_key: str,
3351
+ fields: JSONObject,
3352
+ verify_write: bool,
3353
+ output_profile: str,
3354
+ capture_exceptions: bool,
3355
+ ) -> JSONObject:
3356
+ """执行内部辅助逻辑。"""
3357
+ submit_type_value = self._normalize_record_write_submit_type("submit")
3358
+ write_attempted = False
2964
3359
  try:
2965
- raw_apply = self.record_create(
3360
+ raw_preflight = self._preflight_record_write(
2966
3361
  profile=profile,
3362
+ operation="create",
2967
3363
  app_key=app_key,
2968
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
2969
- fields={},
3364
+ apply_id=None,
3365
+ answers=[],
3366
+ fields=fields,
3367
+ force_refresh_form=False,
3368
+ view_id=None,
3369
+ list_type=None,
3370
+ view_key=None,
3371
+ view_name=None,
3372
+ )
3373
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3374
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3375
+ normalized_payload: JSONObject = self._record_write_normalized_payload(
3376
+ operation="insert",
3377
+ record_id=None,
3378
+ record_ids=[],
3379
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
2970
3380
  submit_type=submit_type_value,
2971
- verify_write=verify_write,
2972
- force_refresh_form=preflight_used_force_refresh,
3381
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
2973
3382
  )
2974
- except QingflowApiError as exc:
2975
- self._raise_record_write_permission_error(
3383
+ if preflight_data.get("blockers"):
3384
+ return self._record_write_blocked_response(
3385
+ raw_preflight,
3386
+ operation="insert",
3387
+ normalized_payload=normalized_payload,
3388
+ output_profile=output_profile,
3389
+ human_review=False,
3390
+ target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
3391
+ )
3392
+ try:
3393
+ write_attempted = True
3394
+ raw_apply = self.record_create(
3395
+ profile=profile,
3396
+ app_key=app_key,
3397
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3398
+ fields={},
3399
+ submit_type=submit_type_value,
3400
+ verify_write=verify_write,
3401
+ force_refresh_form=preflight_used_force_refresh,
3402
+ )
3403
+ except QingflowApiError as exc:
3404
+ self._raise_record_write_permission_error(
3405
+ exc,
3406
+ operation="insert",
3407
+ app_key=app_key,
3408
+ record_id=None,
3409
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3410
+ )
3411
+ raise
3412
+ return self._record_write_apply_response(
3413
+ raw_apply,
3414
+ operation="insert",
3415
+ normalized_payload=normalized_payload,
3416
+ output_profile=output_profile,
3417
+ human_review=False,
3418
+ preflight=raw_preflight,
3419
+ )
3420
+ except (QingflowApiError, RuntimeError) as exc:
3421
+ if not capture_exceptions:
3422
+ raise
3423
+ return self._record_write_exception_response(
2976
3424
  exc,
2977
3425
  operation="insert",
3426
+ profile=profile,
2978
3427
  app_key=app_key,
2979
3428
  record_id=None,
2980
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3429
+ output_profile=output_profile,
3430
+ human_review=False,
3431
+ write_executed=write_attempted,
2981
3432
  )
2982
- raise
2983
- return self._record_write_apply_response(
2984
- raw_apply,
2985
- operation="insert",
2986
- normalized_payload=normalized_payload,
2987
- output_profile=normalized_output_profile,
2988
- human_review=False,
2989
- preflight=raw_preflight,
2990
- )
2991
3433
 
2992
- @tool_cn_name("更新记录")
2993
- def record_update_public(
3434
+ def _normalize_public_record_insert_batch_items(
2994
3435
  self,
2995
3436
  *,
2996
- profile: str = DEFAULT_PROFILE,
3437
+ fields: JSONObject | None,
3438
+ items: list[JSONObject] | None,
3439
+ ) -> list[JSONObject]:
3440
+ """执行内部辅助逻辑。"""
3441
+ if fields is not None:
3442
+ raise_tool_error(QingflowApiError.config_error("record_insert batch mode does not accept fields"))
3443
+ if not isinstance(items, list) or not items:
3444
+ raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
3445
+ normalized_items: list[JSONObject] = []
3446
+ for index, item in enumerate(items):
3447
+ if not isinstance(item, dict):
3448
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
3449
+ item_fields = item.get("fields")
3450
+ if not isinstance(item_fields, dict):
3451
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
3452
+ normalized_items.append({"fields": cast(JSONObject, item_fields)})
3453
+ return normalized_items
3454
+
3455
+ def _record_insert_public_batch(
3456
+ self,
3457
+ *,
3458
+ profile: str,
2997
3459
  app_key: str,
2998
- record_id: Any | None,
2999
- fields: JSONObject | None = None,
3000
- items: list[JSONObject] | None = None,
3001
- dry_run: bool = False,
3002
- verify_write: bool = True,
3003
- output_profile: str = "normal",
3460
+ items: list[JSONObject],
3461
+ verify_write: bool,
3462
+ output_profile: str,
3004
3463
  ) -> JSONObject:
3005
- """执行记录相关逻辑。"""
3006
- normalized_output_profile = self._normalize_public_output_profile(output_profile)
3007
- if not app_key:
3008
- raise_tool_error(QingflowApiError.config_error("app_key is required"))
3009
- if items is not None:
3010
- if dry_run not in {True, False}:
3464
+ """执行内部辅助逻辑。"""
3465
+ responses: list[JSONObject] = []
3466
+ for item in items:
3467
+ responses.append(
3468
+ self._record_insert_public_single(
3469
+ profile=profile,
3470
+ app_key=app_key,
3471
+ fields=cast(JSONObject, item["fields"]),
3472
+ verify_write=verify_write,
3473
+ output_profile=output_profile,
3474
+ capture_exceptions=True,
3475
+ )
3476
+ )
3477
+ return self._record_insert_batch_response(
3478
+ profile=profile,
3479
+ app_key=app_key,
3480
+ responses=responses,
3481
+ output_profile=output_profile,
3482
+ )
3483
+
3484
+ def _record_insert_batch_response(
3485
+ self,
3486
+ *,
3487
+ profile: str,
3488
+ app_key: str,
3489
+ responses: list[JSONObject],
3490
+ output_profile: str,
3491
+ ) -> JSONObject:
3492
+ """执行内部辅助逻辑。"""
3493
+ items = [
3494
+ self._record_insert_batch_item_from_response(index=index, response=response, output_profile=output_profile)
3495
+ for index, response in enumerate(responses)
3496
+ ]
3497
+ summary = self._record_insert_batch_summary(items)
3498
+ status, ok, message = self._record_insert_batch_envelope_status(summary=summary)
3499
+ first_response = responses[0] if responses else {}
3500
+ created_record_ids = [
3501
+ cast(str, item["record_id"])
3502
+ for item in items
3503
+ if isinstance(item.get("record_id"), str) and item.get("record_id")
3504
+ ]
3505
+ write_executed = any(bool(item.get("write_executed")) for item in items)
3506
+ verification_status = self._record_insert_batch_verification_status(items)
3507
+ return {
3508
+ "profile": first_response.get("profile", profile),
3509
+ "ws_id": first_response.get("ws_id"),
3510
+ "ok": ok,
3511
+ "status": status,
3512
+ "mode": "batch",
3513
+ "total": summary["total"],
3514
+ "succeeded": summary["succeeded"],
3515
+ "failed": summary["failed"],
3516
+ "created_record_ids": created_record_ids,
3517
+ "write_executed": write_executed,
3518
+ "verification_status": verification_status,
3519
+ "safe_to_retry": not write_executed,
3520
+ "request_route": first_response.get("request_route"),
3521
+ "warnings": [],
3522
+ "output_profile": output_profile,
3523
+ "items": items,
3524
+ "data": {
3525
+ "app_key": app_key,
3526
+ "mode": "batch",
3527
+ "summary": summary,
3528
+ "created_record_ids": created_record_ids,
3529
+ "items": items,
3530
+ },
3531
+ "message": message,
3532
+ }
3533
+
3534
+ def _record_insert_batch_summary(self, items: list[JSONObject]) -> JSONObject:
3535
+ """执行内部辅助逻辑。"""
3536
+ created = [item for item in items if isinstance(item.get("record_id"), str) and item.get("record_id")]
3537
+ failed = [item for item in items if item.get("status") not in {"success", "verification_failed"}]
3538
+ return {
3539
+ "total": len(items),
3540
+ "succeeded": len(created),
3541
+ "failed": len(failed),
3542
+ "created_count": len(created),
3543
+ "blocked_count": sum(1 for item in items if item.get("status") == "blocked"),
3544
+ "confirmation_count": sum(1 for item in items if item.get("status") == "needs_confirmation"),
3545
+ "verification_failed_count": sum(1 for item in items if item.get("status") == "verification_failed"),
3546
+ }
3547
+
3548
+ def _record_insert_batch_envelope_status(self, *, summary: JSONObject) -> tuple[str, bool, str]:
3549
+ """执行内部辅助逻辑。"""
3550
+ succeeded = int(summary["succeeded"])
3551
+ failed = int(summary["failed"])
3552
+ if succeeded and failed:
3553
+ return "partial_success", False, "batch insert completed with partial failures"
3554
+ if succeeded and int(summary["verification_failed_count"]):
3555
+ return "verification_failed", True, "batch insert completed but verification failed for some created records"
3556
+ if succeeded:
3557
+ return "success", True, "batch insert completed"
3558
+ if int(summary["confirmation_count"]):
3559
+ return "needs_confirmation", False, "batch insert requires confirmation before retrying failed rows"
3560
+ if int(summary["blocked_count"]):
3561
+ return "blocked", False, "batch insert preflight blocked all rows"
3562
+ return "failed", False, "batch insert failed"
3563
+
3564
+ def _record_insert_batch_verification_status(self, items: list[JSONObject]) -> str:
3565
+ """执行内部辅助逻辑。"""
3566
+ statuses = {str(item.get("verification_status") or "not_requested") for item in items}
3567
+ if "failed" in statuses:
3568
+ return "failed"
3569
+ if "verified" in statuses:
3570
+ return "verified"
3571
+ return "not_requested"
3572
+
3573
+ def _record_insert_batch_item_from_response(
3574
+ self,
3575
+ *,
3576
+ index: int,
3577
+ response: JSONObject,
3578
+ output_profile: str,
3579
+ ) -> JSONObject:
3580
+ """执行内部辅助逻辑。"""
3581
+ data = cast(JSONObject, response.get("data", {})) if isinstance(response.get("data"), dict) else {}
3582
+ resource = _public_record_resource(data.get("resource"))
3583
+ record_id = _public_record_id_text(response.get("record_id"))
3584
+ apply_id = _public_record_id_text(response.get("apply_id"))
3585
+ if record_id is None and isinstance(resource, dict):
3586
+ record_id = _public_record_id_text(resource.get("record_id"))
3587
+ if apply_id is None and isinstance(resource, dict):
3588
+ apply_id = _public_record_id_text(resource.get("apply_id"))
3589
+ item: JSONObject = {
3590
+ "index": index,
3591
+ "row_number": index + 1,
3592
+ "status": response.get("status"),
3593
+ "write_executed": bool(response.get("write_executed")),
3594
+ "verification_status": response.get("verification_status", "not_requested"),
3595
+ "safe_to_retry": bool(response.get("safe_to_retry", True)),
3596
+ }
3597
+ if record_id is not None:
3598
+ item["record_id"] = record_id
3599
+ if apply_id is not None:
3600
+ item["apply_id"] = apply_id
3601
+ if resource:
3602
+ item["resource"] = resource
3603
+ verification = data.get("verification")
3604
+ if isinstance(verification, dict):
3605
+ compact_verification = {
3606
+ key: verification[key]
3607
+ for key in ("verified", "verification_mode", "field_level_verified")
3608
+ if key in verification
3609
+ }
3610
+ if compact_verification:
3611
+ item["verification"] = compact_verification
3612
+ field_errors = cast(list[JSONObject], data.get("field_errors", [])) if isinstance(data.get("field_errors"), list) else []
3613
+ confirmation_requests = cast(list[JSONObject], data.get("confirmation_requests", [])) if isinstance(data.get("confirmation_requests"), list) else []
3614
+ failed_fields = self._record_write_failed_fields(field_errors=field_errors, confirmation_requests=confirmation_requests)
3615
+ if failed_fields:
3616
+ item["failed_fields"] = failed_fields
3617
+ if confirmation_requests:
3618
+ item["confirmation_requests"] = [
3619
+ self._record_write_semantic_confirmation_request(request)
3620
+ for request in confirmation_requests
3621
+ if isinstance(request, dict)
3622
+ ]
3623
+ blockers = data.get("blockers")
3624
+ if isinstance(blockers, list) and blockers:
3625
+ item["blockers"] = blockers
3626
+ warnings = response.get("warnings")
3627
+ if isinstance(warnings, list) and warnings:
3628
+ item["warnings"] = warnings
3629
+ error = data.get("error")
3630
+ if isinstance(error, dict):
3631
+ item["error"] = error
3632
+ if output_profile == "verbose" and isinstance(data.get("debug"), dict):
3633
+ item["debug"] = data.get("debug")
3634
+ return item
3635
+
3636
+ def _record_write_failed_fields(
3637
+ self,
3638
+ *,
3639
+ field_errors: list[JSONObject],
3640
+ confirmation_requests: list[JSONObject],
3641
+ ) -> list[JSONObject]:
3642
+ """执行内部辅助逻辑。"""
3643
+ failed_fields = [
3644
+ self._record_write_semantic_field_error(error)
3645
+ for error in field_errors
3646
+ if isinstance(error, dict)
3647
+ ]
3648
+ failed_fields.extend(
3649
+ self._record_write_failed_field_from_confirmation(request)
3650
+ for request in confirmation_requests
3651
+ if isinstance(request, dict)
3652
+ )
3653
+ return failed_fields
3654
+
3655
+ def _record_write_semantic_field_error(self, error: JSONObject) -> JSONObject:
3656
+ """执行内部辅助逻辑。"""
3657
+ field = error.get("field")
3658
+ field_payload = cast(JSONObject, field) if isinstance(field, dict) else {}
3659
+ error_code = _normalize_optional_text(error.get("error_code")) or "INVALID_FIELD_VALUE"
3660
+ title = (
3661
+ _normalize_optional_text(field_payload.get("que_title"))
3662
+ or _normalize_optional_text(field_payload.get("title"))
3663
+ or _normalize_optional_text(error.get("location"))
3664
+ or "unknown field"
3665
+ )
3666
+ field_id = (
3667
+ field_payload.get("que_id")
3668
+ if field_payload.get("que_id") is not None
3669
+ else field_payload.get("field_id")
3670
+ )
3671
+ expected_format = error.get("expected_format") if isinstance(error.get("expected_format"), dict) else None
3672
+ if expected_format is None:
3673
+ expected_format = self._record_write_expected_format_from_field_payload(field_payload)
3674
+ payload: JSONObject = {
3675
+ "title": title,
3676
+ "field_id": field_id,
3677
+ "error_code": error_code,
3678
+ "message": self._record_write_semantic_error_message(error_code, error.get("message")),
3679
+ "next_action": self._record_write_next_action_for_error(error_code),
3680
+ }
3681
+ if expected_format is not None:
3682
+ payload["expected_format"] = expected_format
3683
+ payload["example_value"] = self._record_write_example_value_for_format(expected_format, field_payload)
3684
+ if error.get("received_value") is not None:
3685
+ payload["received_value"] = error.get("received_value")
3686
+ if error.get("fix_hint") is not None:
3687
+ payload["fix_hint"] = error.get("fix_hint")
3688
+ if error.get("details") is not None:
3689
+ payload["details"] = error.get("details")
3690
+ return payload
3691
+
3692
+ def _record_write_semantic_confirmation_request(self, request: JSONObject) -> JSONObject:
3693
+ """执行内部辅助逻辑。"""
3694
+ field_ref = request.get("field_ref")
3695
+ field_payload = cast(JSONObject, field_ref) if isinstance(field_ref, dict) else {}
3696
+ payload: JSONObject = {
3697
+ "field": request.get("field"),
3698
+ "title": _normalize_optional_text(request.get("field")) or _normalize_optional_text(field_payload.get("que_title")),
3699
+ "field_id": field_payload.get("que_id"),
3700
+ "kind": request.get("kind"),
3701
+ "input": request.get("input"),
3702
+ "candidates": request.get("candidates", []),
3703
+ "next_action": "让用户确认候选,或用显式 id/object 只重试本行。",
3704
+ }
3705
+ if request.get("parent_field") is not None:
3706
+ payload["parent_field"] = request.get("parent_field")
3707
+ if request.get("row_ordinal") is not None:
3708
+ payload["row_ordinal"] = request.get("row_ordinal")
3709
+ return payload
3710
+
3711
+ def _record_write_failed_field_from_confirmation(self, request: JSONObject) -> JSONObject:
3712
+ """执行内部辅助逻辑。"""
3713
+ semantic = self._record_write_semantic_confirmation_request(request)
3714
+ return {
3715
+ "title": semantic.get("title") or semantic.get("field"),
3716
+ "field_id": semantic.get("field_id"),
3717
+ "error_code": "LOOKUP_NEEDS_CONFIRMATION",
3718
+ "message": "候选不唯一,需要用户确认。",
3719
+ "kind": semantic.get("kind"),
3720
+ "input": semantic.get("input"),
3721
+ "candidates": semantic.get("candidates", []),
3722
+ "next_action": semantic.get("next_action"),
3723
+ }
3724
+
3725
+ def _record_write_expected_format_from_field_payload(self, field_payload: JSONObject) -> JSONObject | None:
3726
+ """执行内部辅助逻辑。"""
3727
+ que_type = _coerce_count(field_payload.get("que_type"))
3728
+ if que_type is None:
3729
+ return None
3730
+ synthetic_field = FormField(
3731
+ que_id=_coerce_count(field_payload.get("que_id")) or 0,
3732
+ que_title=_normalize_optional_text(field_payload.get("que_title")) or _normalize_optional_text(field_payload.get("title")) or "",
3733
+ que_type=que_type,
3734
+ required=False,
3735
+ readonly=False,
3736
+ system=False,
3737
+ options=[],
3738
+ aliases=[],
3739
+ target_app_key=None,
3740
+ target_app_name_hint=None,
3741
+ member_select_scope_type=None,
3742
+ member_select_scope=None,
3743
+ dept_select_scope_type=None,
3744
+ dept_select_scope=None,
3745
+ raw={},
3746
+ )
3747
+ return _write_format_for_field(synthetic_field)
3748
+
3749
+ def _record_write_example_value_for_format(self, expected_format: JSONObject, field_payload: JSONObject) -> JSONValue:
3750
+ """执行内部辅助逻辑。"""
3751
+ examples = expected_format.get("examples")
3752
+ if isinstance(examples, list) and examples:
3753
+ return cast(JSONValue, examples[0])
3754
+ kind = _normalize_optional_text(expected_format.get("kind"))
3755
+ if kind == "member_list":
3756
+ return "张三"
3757
+ if kind == "department_list":
3758
+ return "直销部"
3759
+ if kind == "relation_record":
3760
+ return {"apply_id": "5001"}
3761
+ if kind == "attachment_list":
3762
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
3763
+ if kind == "subtable_rows":
3764
+ return {"rows": [{"子字段": "值"}]}
3765
+ if kind == "date_string":
3766
+ return "2026-03-13 10:00:00"
3767
+ if kind == "boolean_label":
3768
+ return "是"
3769
+ if kind in {"single_select", "multi_select"}:
3770
+ options = expected_format.get("options")
3771
+ if isinstance(options, list) and options:
3772
+ return cast(JSONValue, options[0])
3773
+ que_type = _coerce_count(field_payload.get("que_type"))
3774
+ if que_type in NUMBER_QUE_TYPES:
3775
+ return 100
3776
+ return "文本"
3777
+
3778
+ def _record_write_semantic_error_message(self, error_code: str, fallback: JSONValue) -> str:
3779
+ """执行内部辅助逻辑。"""
3780
+ if error_code == "MISSING_REQUIRED_FIELD":
3781
+ return "缺少必填字段。"
3782
+ if error_code == "FIELD_NOT_FOUND":
3783
+ return "字段不存在或字段标题不匹配。"
3784
+ if error_code == "AMBIGUOUS_FIELD":
3785
+ return "字段标题存在歧义。"
3786
+ if error_code in {"INVALID_FIELD_VALUE", "INVALID_MEMBER_VALUE", "INVALID_DEPARTMENT_VALUE", "INVALID_RELATION_VALUE"}:
3787
+ return _normalize_optional_text(fallback) or "字段值格式不正确。"
3788
+ return _normalize_optional_text(fallback) or "字段写入失败。"
3789
+
3790
+ def _record_write_next_action_for_error(self, error_code: str) -> str:
3791
+ """执行内部辅助逻辑。"""
3792
+ if error_code == "MISSING_REQUIRED_FIELD":
3793
+ return "补充该字段后只重试本行。"
3794
+ if error_code in {"FIELD_NOT_FOUND", "AMBIGUOUS_FIELD"}:
3795
+ return "重新调用 schema 工具确认字段标题或 field_id 后,只重试本行。"
3796
+ return "修正该字段值后只重试本行。"
3797
+
3798
+ @tool_cn_name("更新记录")
3799
+ def record_update_public(
3800
+ self,
3801
+ *,
3802
+ profile: str = DEFAULT_PROFILE,
3803
+ app_key: str,
3804
+ record_id: Any | None,
3805
+ fields: JSONObject | None = None,
3806
+ items: list[JSONObject] | None = None,
3807
+ dry_run: bool = False,
3808
+ verify_write: bool = True,
3809
+ output_profile: str = "normal",
3810
+ ) -> JSONObject:
3811
+ """执行记录相关逻辑。"""
3812
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
3813
+ if not app_key:
3814
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
3815
+ if items is not None:
3816
+ if dry_run not in {True, False}:
3011
3817
  raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
3012
3818
  normalized_items = self._normalize_public_record_update_batch_items(
3013
3819
  record_id=record_id,
@@ -3022,87 +3828,736 @@ class RecordTools(ToolBase):
3022
3828
  verify_write=verify_write,
3023
3829
  output_profile=normalized_output_profile,
3024
3830
  )
3025
- if dry_run:
3026
- raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3027
- if record_id is None:
3028
- raise_tool_error(QingflowApiError.config_error("record_id is required"))
3029
- record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3030
- if fields is not None and not isinstance(fields, dict):
3031
- raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3032
- return self._record_update_public_single(
3033
- profile=profile,
3034
- app_key=app_key,
3035
- record_id=record_id_int,
3036
- fields=cast(JSONObject, fields or {}),
3037
- verify_write=verify_write,
3038
- output_profile=normalized_output_profile,
3039
- )
3831
+ if dry_run:
3832
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3833
+ if record_id is None:
3834
+ raise_tool_error(QingflowApiError.config_error("record_id is required"))
3835
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3836
+ if fields is not None and not isinstance(fields, dict):
3837
+ raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3838
+ return self._record_update_public_single(
3839
+ profile=profile,
3840
+ app_key=app_key,
3841
+ record_id=record_id_int,
3842
+ fields=cast(JSONObject, fields or {}),
3843
+ verify_write=verify_write,
3844
+ output_profile=normalized_output_profile,
3845
+ )
3846
+
3847
+ def _record_update_public_single(
3848
+ self,
3849
+ *,
3850
+ profile: str,
3851
+ app_key: str,
3852
+ record_id: int,
3853
+ fields: JSONObject,
3854
+ verify_write: bool,
3855
+ output_profile: str,
3856
+ ) -> JSONObject:
3857
+ """执行内部辅助逻辑。"""
3858
+ raw_preflight = self._preflight_record_update_with_auto_view(
3859
+ profile=profile,
3860
+ app_key=app_key,
3861
+ record_id=record_id,
3862
+ fields=fields,
3863
+ force_refresh_form=False,
3864
+ )
3865
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3866
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3867
+ normalized_payload = self._record_write_normalized_payload(
3868
+ operation="update",
3869
+ record_id=record_id,
3870
+ record_ids=[],
3871
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3872
+ submit_type=1,
3873
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3874
+ )
3875
+ if preflight_data.get("blockers"):
3876
+ return self._record_write_blocked_response(
3877
+ raw_preflight,
3878
+ operation="update",
3879
+ normalized_payload=normalized_payload,
3880
+ output_profile=output_profile,
3881
+ human_review=True,
3882
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
3883
+ )
3884
+ route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
3885
+ profile=profile,
3886
+ app_key=app_key,
3887
+ record_id=record_id,
3888
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3889
+ preflight_data=preflight_data,
3890
+ verify_write=verify_write,
3891
+ force_refresh_form=preflight_used_force_refresh,
3892
+ )
3893
+ if route_blocker is not None:
3894
+ return self._record_update_route_blocked_response(
3895
+ raw_preflight=raw_preflight,
3896
+ operation="update",
3897
+ normalized_payload=normalized_payload,
3898
+ output_profile=output_profile,
3899
+ human_review=True,
3900
+ app_key=app_key,
3901
+ record_id=record_id,
3902
+ tried_routes=tried_routes,
3903
+ route_blocker=route_blocker,
3904
+ )
3905
+ raw_apply = cast(JSONObject, route_apply)
3906
+ return self._record_write_apply_response(
3907
+ raw_apply,
3908
+ operation="update",
3909
+ normalized_payload=normalized_payload,
3910
+ output_profile=output_profile,
3911
+ human_review=True,
3912
+ preflight=raw_preflight,
3913
+ )
3914
+
3915
+ def _record_update_apply_with_auto_route(
3916
+ self,
3917
+ *,
3918
+ profile: str,
3919
+ app_key: str,
3920
+ record_id: int,
3921
+ normalized_answers: list[JSONObject],
3922
+ preflight_data: JSONObject,
3923
+ verify_write: bool,
3924
+ force_refresh_form: bool,
3925
+ ) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
3926
+ """Try record update routes in the same order a frontend user would expect."""
3927
+ tried_routes: list[JSONObject] = []
3928
+ admin_attempt = self._record_update_route_attempt(
3929
+ route_type="admin_direct",
3930
+ endpoint_kind="app_apply_update",
3931
+ role=1,
3932
+ reason="try data-manager direct edit first",
3933
+ )
3934
+ try:
3935
+ raw_apply = self.record_update(
3936
+ profile=profile,
3937
+ app_key=app_key,
3938
+ apply_id=record_id,
3939
+ answers=normalized_answers,
3940
+ fields={},
3941
+ role=1,
3942
+ verify_write=verify_write,
3943
+ force_refresh_form=force_refresh_form,
3944
+ )
3945
+ admin_attempt["status"] = "success"
3946
+ raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
3947
+ raw_apply["tried_routes"] = [admin_attempt]
3948
+ return raw_apply, [admin_attempt], None
3949
+ except (QingflowApiError, RuntimeError) as exc:
3950
+ api_error = self._record_update_extract_api_error(exc)
3951
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3952
+ raise
3953
+ admin_attempt.update(self._record_update_route_error_payload(
3954
+ api_error,
3955
+ status="denied",
3956
+ error_code="ADMIN_UPDATE_PERMISSION_DENIED",
3957
+ ))
3958
+ tried_routes.append(admin_attempt)
3959
+
3960
+ view_route = self._record_update_selected_custom_view_route(preflight_data)
3961
+ if view_route is None:
3962
+ tried_routes.append(
3963
+ self._record_update_route_attempt(
3964
+ route_type="view_edit",
3965
+ endpoint_kind="view_apply_update",
3966
+ status="skipped",
3967
+ error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
3968
+ reason="preflight did not select a single custom view route for this payload",
3969
+ )
3970
+ )
3971
+ else:
3972
+ view_attempt = self._record_update_route_attempt(
3973
+ route_type="view_edit",
3974
+ endpoint_kind="view_apply_update",
3975
+ view_id=cast(str, view_route.get("view_id")),
3976
+ view_key=cast(str, view_route.get("view_key")),
3977
+ view_name=_normalize_optional_text(view_route.get("name")),
3978
+ reason="fallback to frontend custom-view detail edit route",
3979
+ )
3980
+ try:
3981
+ raw_apply = self._record_update_via_custom_view(
3982
+ profile=profile,
3983
+ app_key=app_key,
3984
+ apply_id=record_id,
3985
+ view_key=cast(str, view_route["view_key"]),
3986
+ answers=normalized_answers,
3987
+ verify_write=verify_write,
3988
+ force_refresh_form=force_refresh_form,
3989
+ )
3990
+ view_attempt["status"] = "success"
3991
+ tried_routes.append(view_attempt)
3992
+ raw_apply["update_route"] = self._record_update_route_public(view_attempt)
3993
+ raw_apply["tried_routes"] = tried_routes
3994
+ return raw_apply, tried_routes, None
3995
+ except (QingflowApiError, RuntimeError) as exc:
3996
+ api_error = self._record_update_extract_api_error(exc)
3997
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3998
+ raise
3999
+ view_attempt.update(self._record_update_route_error_payload(
4000
+ api_error,
4001
+ status="denied",
4002
+ error_code="VIEW_UPDATE_PERMISSION_DENIED",
4003
+ ))
4004
+ tried_routes.append(view_attempt)
4005
+
4006
+ task_route = self._record_update_task_save_only_candidate(
4007
+ profile=profile,
4008
+ app_key=app_key,
4009
+ record_id=record_id,
4010
+ normalized_answers=normalized_answers,
4011
+ )
4012
+ task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
4013
+ if not task_route.get("available"):
4014
+ tried_routes.append(task_attempt or self._record_update_route_attempt(
4015
+ route_type="task_save_only",
4016
+ endpoint_kind="workflow_node_save_only",
4017
+ status="skipped",
4018
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4019
+ reason="no unique current-user todo task can edit the requested fields",
4020
+ ))
4021
+ else:
4022
+ task_attempt = self._record_update_route_attempt(
4023
+ route_type="task_save_only",
4024
+ endpoint_kind="workflow_node_save_only",
4025
+ role=3,
4026
+ task_id=_normalize_optional_text(task_route.get("task_id")),
4027
+ workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
4028
+ reason="fallback to current-user workflow todo save-only route",
4029
+ )
4030
+ try:
4031
+ raw_apply = self._record_update_via_task_save_only(
4032
+ profile=profile,
4033
+ app_key=app_key,
4034
+ apply_id=record_id,
4035
+ workflow_node_id=cast(int, task_route["workflow_node_id"]),
4036
+ answers=normalized_answers,
4037
+ verify_write=verify_write,
4038
+ force_refresh_form=force_refresh_form,
4039
+ )
4040
+ task_attempt["status"] = "success"
4041
+ tried_routes.append(task_attempt)
4042
+ raw_apply["update_route"] = self._record_update_route_public(task_attempt)
4043
+ raw_apply["tried_routes"] = tried_routes
4044
+ return raw_apply, tried_routes, None
4045
+ except (QingflowApiError, RuntimeError) as exc:
4046
+ api_error = self._record_update_extract_api_error(exc)
4047
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4048
+ raise
4049
+ task_attempt.update(self._record_update_route_error_payload(
4050
+ api_error,
4051
+ status="denied",
4052
+ error_code="TASK_UPDATE_PERMISSION_DENIED",
4053
+ ))
4054
+ tried_routes.append(task_attempt)
4055
+ return None, tried_routes, {
4056
+ "error_code": "NO_AVAILABLE_UPDATE_ROUTE",
4057
+ "message": "No available record update route could execute this payload for the current user.",
4058
+ "recommended_next_actions": [
4059
+ "If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
4060
+ "If the record is editable from a specific table view in the UI, make sure the target fields are visible and editable in that view.",
4061
+ "If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
4062
+ ],
4063
+ }
4064
+
4065
+ def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
4066
+ selection = preflight_data.get("selection")
4067
+ if not isinstance(selection, dict):
4068
+ return None
4069
+ view = selection.get("view")
4070
+ if not isinstance(view, dict):
4071
+ return None
4072
+ view_id = _normalize_optional_text(view.get("view_id"))
4073
+ if not view_id or not view_id.startswith("custom:"):
4074
+ return None
4075
+ view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
4076
+ if not view_key:
4077
+ return None
4078
+ return {
4079
+ "view_id": view_id,
4080
+ "view_key": view_key,
4081
+ "name": view.get("name"),
4082
+ }
4083
+
4084
+ def _record_update_route_attempt(
4085
+ self,
4086
+ *,
4087
+ route_type: str,
4088
+ endpoint_kind: str,
4089
+ status: str = "attempted",
4090
+ role: int | None = None,
4091
+ task_id: str | None = None,
4092
+ workflow_node_id: int | None = None,
4093
+ view_id: str | None = None,
4094
+ view_key: str | None = None,
4095
+ view_name: str | None = None,
4096
+ error_code: str | None = None,
4097
+ reason: str | None = None,
4098
+ ) -> JSONObject:
4099
+ payload: JSONObject = {
4100
+ "route_type": route_type,
4101
+ "endpoint_kind": endpoint_kind,
4102
+ "status": status,
4103
+ }
4104
+ if role is not None:
4105
+ payload["role"] = role
4106
+ if task_id:
4107
+ payload["task_id"] = task_id
4108
+ if workflow_node_id is not None:
4109
+ payload["workflow_node_id"] = workflow_node_id
4110
+ if view_id:
4111
+ payload["view_id"] = view_id
4112
+ if view_key:
4113
+ payload["view_key"] = view_key
4114
+ if view_name:
4115
+ payload["view_name"] = view_name
4116
+ if error_code:
4117
+ payload["error_code"] = error_code
4118
+ if reason:
4119
+ payload["reason"] = reason
4120
+ return payload
4121
+
4122
+ def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
4123
+ return _pick_route_payload(attempt)
4124
+
4125
+ def _record_update_route_error_payload(
4126
+ self,
4127
+ exc: QingflowApiError,
4128
+ *,
4129
+ status: str,
4130
+ error_code: str,
4131
+ ) -> JSONObject:
4132
+ payload: JSONObject = {
4133
+ "status": status,
4134
+ "error_code": error_code,
4135
+ "message": exc.message,
4136
+ }
4137
+ if exc.backend_code is not None:
4138
+ payload["backend_code"] = exc.backend_code
4139
+ if exc.http_status is not None:
4140
+ payload["http_status"] = exc.http_status
4141
+ if exc.request_id is not None:
4142
+ payload["request_id"] = exc.request_id
4143
+ return payload
4144
+
4145
+ def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
4146
+ if isinstance(exc, QingflowApiError):
4147
+ return exc
4148
+ try:
4149
+ payload = json.loads(str(exc))
4150
+ except json.JSONDecodeError:
4151
+ return None
4152
+ if not isinstance(payload, dict):
4153
+ return None
4154
+ return QingflowApiError(
4155
+ category=str(payload.get("category") or "backend"),
4156
+ message=str(payload.get("message") or exc),
4157
+ backend_code=payload.get("backend_code"),
4158
+ request_id=_normalize_optional_text(payload.get("request_id")),
4159
+ http_status=_coerce_count(payload.get("http_status")),
4160
+ details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
4161
+ )
4162
+
4163
+ def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4164
+ if exc.backend_code in {40002, 40027, 40038, 404}:
4165
+ return True
4166
+ if exc.http_status == 404:
4167
+ return True
4168
+ return False
4169
+
4170
+ def _record_update_route_blocked_response(
4171
+ self,
4172
+ *,
4173
+ raw_preflight: JSONObject,
4174
+ operation: str,
4175
+ normalized_payload: JSONObject,
4176
+ output_profile: str,
4177
+ human_review: bool,
4178
+ app_key: str,
4179
+ record_id: int,
4180
+ tried_routes: list[JSONObject],
4181
+ route_blocker: JSONObject,
4182
+ ) -> JSONObject:
4183
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
4184
+ validation = cast(JSONObject, plan_data.get("validation", {}))
4185
+ warnings_payload = validation.get("warnings", [])
4186
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
4187
+ warnings.append(
4188
+ {
4189
+ "code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
4190
+ "message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
4191
+ }
4192
+ )
4193
+ recommended = list(route_blocker.get("recommended_next_actions") or [])
4194
+ response: JSONObject = {
4195
+ "profile": raw_preflight.get("profile"),
4196
+ "ws_id": raw_preflight.get("ws_id"),
4197
+ "ok": False,
4198
+ "status": "blocked",
4199
+ "write_executed": False,
4200
+ "verification_status": "not_requested",
4201
+ "safe_to_retry": True,
4202
+ "request_route": raw_preflight.get("request_route"),
4203
+ "warnings": warnings,
4204
+ "output_profile": output_profile,
4205
+ "update_route": None,
4206
+ "tried_routes": tried_routes,
4207
+ "error_code": route_blocker.get("error_code"),
4208
+ "data": {
4209
+ "action": {"operation": operation, "executed": False},
4210
+ "resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
4211
+ "verification": None,
4212
+ "normalized_payload": normalized_payload,
4213
+ "blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
4214
+ "field_errors": [],
4215
+ "confirmation_requests": [],
4216
+ "resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
4217
+ "recommended_next_actions": recommended,
4218
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
4219
+ "error": route_blocker,
4220
+ "update_route": None,
4221
+ "tried_routes": tried_routes,
4222
+ },
4223
+ }
4224
+ if output_profile == "verbose":
4225
+ response["data"]["debug"] = {"preflight": plan_data}
4226
+ return response
4227
+
4228
+ def _record_update_task_save_only_candidate(
4229
+ self,
4230
+ *,
4231
+ profile: str,
4232
+ app_key: str,
4233
+ record_id: int,
4234
+ normalized_answers: list[JSONObject],
4235
+ ) -> JSONObject:
4236
+ requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
4237
+
4238
+ def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
4239
+ attempt = self._record_update_route_attempt(
4240
+ route_type="task_save_only",
4241
+ endpoint_kind="workflow_node_save_only",
4242
+ status=status,
4243
+ error_code=error_code,
4244
+ reason=reason,
4245
+ )
4246
+ if extra:
4247
+ attempt.update(extra)
4248
+ return {"available": False, "attempt": attempt}
4249
+
4250
+ def runner(session_profile, context):
4251
+ matches: list[JSONObject] = []
4252
+ pages_scanned = 0
4253
+ for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
4254
+ try:
4255
+ task_page = self.backend.request(
4256
+ "POST",
4257
+ context,
4258
+ "/task/dynamic/page",
4259
+ json_body={
4260
+ "type": 1,
4261
+ "processStatus": 1,
4262
+ "appKey": app_key,
4263
+ "pageNum": page_num,
4264
+ "pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
4265
+ },
4266
+ )
4267
+ except QingflowApiError as exc:
4268
+ return unavailable(
4269
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4270
+ reason="current-user todo task list is unavailable",
4271
+ extra=self._record_update_route_error_payload(
4272
+ exc,
4273
+ status="skipped",
4274
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4275
+ ),
4276
+ )
4277
+ pages_scanned += 1
4278
+ rows = task_page.get("list") if isinstance(task_page, dict) else None
4279
+ items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
4280
+ for item in items:
4281
+ candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
4282
+ if candidate_record_id == record_id:
4283
+ matches.append(dict(item))
4284
+ if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
4285
+ break
4286
+
4287
+ if not matches:
4288
+ return unavailable(
4289
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4290
+ reason="no current-user todo task was found for this record",
4291
+ extra={"pages_scanned": pages_scanned},
4292
+ )
4293
+ if len(matches) > 1:
4294
+ return unavailable(
4295
+ error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
4296
+ reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
4297
+ extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
4298
+ )
4299
+
4300
+ task = matches[0]
4301
+ workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
4302
+ if workflow_node_id is None:
4303
+ return unavailable(
4304
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4305
+ reason="matched todo task does not expose a workflow node id",
4306
+ extra={"matched_task": self._record_update_compact_task_match(task)},
4307
+ )
4308
+ try:
4309
+ editable_payload = self.backend.request(
4310
+ "GET",
4311
+ context,
4312
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4313
+ )
4314
+ except QingflowApiError as exc:
4315
+ return unavailable(
4316
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4317
+ reason="workflow node editable field list is unavailable; record_update will not guess task editability",
4318
+ extra=self._record_update_route_error_payload(
4319
+ exc,
4320
+ status="skipped",
4321
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4322
+ ),
4323
+ )
4324
+ editable_question_ids = self._record_update_extract_question_ids(editable_payload)
4325
+ if not editable_question_ids:
4326
+ return unavailable(
4327
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4328
+ reason="workflow node editable field list is empty",
4329
+ extra={
4330
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4331
+ "workflow_node_id": workflow_node_id,
4332
+ },
4333
+ )
4334
+ effective_editable_question_ids = self._record_update_effective_task_editable_ids(
4335
+ editable_question_ids,
4336
+ normalized_answers=normalized_answers,
4337
+ )
4338
+ non_editable = sorted(
4339
+ question_id for question_id in requested_question_ids
4340
+ if question_id not in effective_editable_question_ids
4341
+ )
4342
+ if non_editable:
4343
+ return unavailable(
4344
+ status="denied",
4345
+ error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
4346
+ reason="one or more requested fields are not editable on the current workflow node",
4347
+ extra={
4348
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4349
+ "workflow_node_id": workflow_node_id,
4350
+ "non_editable_question_ids": non_editable,
4351
+ },
4352
+ )
4353
+ return {
4354
+ "available": True,
4355
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4356
+ "workflow_node_id": workflow_node_id,
4357
+ "matched_task": self._record_update_compact_task_match(task),
4358
+ "editable_question_ids": sorted(editable_question_ids),
4359
+ "effective_editable_question_ids": sorted(effective_editable_question_ids),
4360
+ }
4361
+
4362
+ return self._run_record_tool(profile, runner)
4363
+
4364
+ def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
4365
+ return {
4366
+ key: value
4367
+ for key, value in {
4368
+ "task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
4369
+ "record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
4370
+ "workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
4371
+ "workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
4372
+ }.items()
4373
+ if value not in (None, "", [], {})
4374
+ }
4375
+
4376
+ def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
4377
+ question_ids: set[int] = set()
4378
+ for answer in answers:
4379
+ if not isinstance(answer, dict):
4380
+ continue
4381
+ que_id = _coerce_count(answer.get("queId"))
4382
+ if que_id is not None and que_id > 0:
4383
+ question_ids.add(que_id)
4384
+ table_values = answer.get("tableValues")
4385
+ if not isinstance(table_values, list):
4386
+ continue
4387
+ for row in table_values:
4388
+ if not isinstance(row, list):
4389
+ continue
4390
+ for cell in row:
4391
+ if not isinstance(cell, dict):
4392
+ continue
4393
+ cell_que_id = _coerce_count(cell.get("queId"))
4394
+ if cell_que_id is not None and cell_que_id > 0:
4395
+ question_ids.add(cell_que_id)
4396
+ return question_ids
4397
+
4398
+ def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
4399
+ candidates: list[Any] = []
4400
+ if isinstance(payload, list):
4401
+ candidates = payload
4402
+ elif isinstance(payload, dict):
4403
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
4404
+ value = payload.get(key)
4405
+ if isinstance(value, list):
4406
+ candidates = value
4407
+ break
4408
+ question_ids: set[int] = set()
4409
+ for item in candidates:
4410
+ value: Any = item
4411
+ if isinstance(item, dict):
4412
+ value = item.get("queId", item.get("questionId", item.get("id")))
4413
+ que_id = _coerce_count(value)
4414
+ if que_id is not None and que_id > 0:
4415
+ question_ids.add(que_id)
4416
+ return question_ids
4417
+
4418
+ def _record_update_effective_task_editable_ids(
4419
+ self,
4420
+ editable_question_ids: set[int],
4421
+ *,
4422
+ normalized_answers: list[JSONObject],
4423
+ ) -> set[int]:
4424
+ effective_editable_ids = set(editable_question_ids)
4425
+ for answer in normalized_answers:
4426
+ if not isinstance(answer, dict):
4427
+ continue
4428
+ parent_que_id = _coerce_count(answer.get("queId"))
4429
+ if parent_que_id is None or parent_que_id <= 0:
4430
+ continue
4431
+ table_values = answer.get("tableValues")
4432
+ if not isinstance(table_values, list) or not table_values:
4433
+ continue
4434
+ row_subfield_ids: set[int] = set()
4435
+ for row in table_values:
4436
+ if not isinstance(row, list):
4437
+ continue
4438
+ for cell in row:
4439
+ if not isinstance(cell, dict):
4440
+ continue
4441
+ cell_que_id = _coerce_count(cell.get("queId"))
4442
+ if cell_que_id is not None and cell_que_id > 0:
4443
+ row_subfield_ids.add(cell_que_id)
4444
+ if row_subfield_ids & editable_question_ids:
4445
+ effective_editable_ids.add(parent_que_id)
4446
+ return effective_editable_ids
4447
+
4448
+ def _record_update_via_custom_view(
4449
+ self,
4450
+ *,
4451
+ profile: str,
4452
+ app_key: str,
4453
+ apply_id: int,
4454
+ view_key: str,
4455
+ answers: list[JSONObject],
4456
+ verify_write: bool,
4457
+ force_refresh_form: bool,
4458
+ ) -> JSONObject:
4459
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4460
+ normalized_view_key = view_key.strip()
4461
+ if not normalized_view_key:
4462
+ raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4463
+
4464
+ def runner(session_profile, context):
4465
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4466
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4467
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4468
+ result = self.backend.request(
4469
+ "POST",
4470
+ context,
4471
+ f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
4472
+ json_body={"answers": normalized_answers},
4473
+ )
4474
+ verification = self._verify_record_write_result(
4475
+ context,
4476
+ app_key=app_key,
4477
+ apply_id=normalized_apply_id,
4478
+ normalized_answers=normalized_answers,
4479
+ index=cast(FieldIndex, index),
4480
+ verify_view_key=normalized_view_key,
4481
+ ) if verify_write and index is not None else None
4482
+ verified = True if verification is None else bool(verification.get("verified"))
4483
+ return self._attach_human_review_notice(
4484
+ {
4485
+ "profile": profile,
4486
+ "ws_id": session_profile.selected_ws_id,
4487
+ "request_route": self._request_route_payload(context),
4488
+ "app_key": app_key,
4489
+ "apply_id": normalized_apply_id,
4490
+ "record_id": normalized_apply_id,
4491
+ "result": result,
4492
+ "normalized_answers": normalized_answers,
4493
+ "status": "completed" if verified else "verification_failed",
4494
+ "ok": True,
4495
+ "verify_write": verify_write,
4496
+ "write_verified": verified if verify_write else None,
4497
+ "verification": verification,
4498
+ "resource": _record_resource_payload(normalized_apply_id),
4499
+ },
4500
+ operation="update",
4501
+ target="record data",
4502
+ )
3040
4503
 
3041
- def _record_update_public_single(
4504
+ return self._run_record_tool(profile, runner)
4505
+
4506
+ def _record_update_via_task_save_only(
3042
4507
  self,
3043
4508
  *,
3044
4509
  profile: str,
3045
4510
  app_key: str,
3046
- record_id: int,
3047
- fields: JSONObject,
4511
+ apply_id: int,
4512
+ workflow_node_id: int,
4513
+ answers: list[JSONObject],
3048
4514
  verify_write: bool,
3049
- output_profile: str,
4515
+ force_refresh_form: bool,
3050
4516
  ) -> JSONObject:
3051
- """执行内部辅助逻辑。"""
3052
- raw_preflight = self._preflight_record_update_with_auto_view(
3053
- profile=profile,
3054
- app_key=app_key,
3055
- record_id=record_id,
3056
- fields=fields,
3057
- force_refresh_form=False,
3058
- )
3059
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3060
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3061
- normalized_payload = self._record_write_normalized_payload(
3062
- operation="update",
3063
- record_id=record_id,
3064
- record_ids=[],
3065
- normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3066
- submit_type=1,
3067
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3068
- )
3069
- if preflight_data.get("blockers"):
3070
- return self._record_write_blocked_response(
3071
- raw_preflight,
3072
- operation="update",
3073
- normalized_payload=normalized_payload,
3074
- output_profile=output_profile,
3075
- human_review=True,
3076
- target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
4517
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4518
+ if workflow_node_id <= 0:
4519
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
4520
+
4521
+ def runner(session_profile, context):
4522
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4523
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4524
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4525
+ result = self.backend.request(
4526
+ "POST",
4527
+ context,
4528
+ f"/app/{app_key}/apply/{normalized_apply_id}",
4529
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
3077
4530
  )
3078
- try:
3079
- raw_apply = self.record_update(
3080
- profile=profile,
4531
+ verification = self._verify_record_write_result(
4532
+ context,
3081
4533
  app_key=app_key,
3082
- apply_id=record_id,
3083
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3084
- fields={},
3085
- role=1,
3086
- verify_write=verify_write,
3087
- force_refresh_form=preflight_used_force_refresh,
3088
- )
3089
- except QingflowApiError as exc:
3090
- self._raise_record_write_permission_error(
3091
- exc,
4534
+ apply_id=normalized_apply_id,
4535
+ normalized_answers=normalized_answers,
4536
+ index=cast(FieldIndex, index),
4537
+ ) if verify_write and index is not None else None
4538
+ verified = True if verification is None else bool(verification.get("verified"))
4539
+ return self._attach_human_review_notice(
4540
+ {
4541
+ "profile": profile,
4542
+ "ws_id": session_profile.selected_ws_id,
4543
+ "request_route": self._request_route_payload(context),
4544
+ "app_key": app_key,
4545
+ "apply_id": normalized_apply_id,
4546
+ "record_id": normalized_apply_id,
4547
+ "result": result,
4548
+ "normalized_answers": normalized_answers,
4549
+ "status": "completed" if verified else "verification_failed",
4550
+ "ok": True,
4551
+ "verify_write": verify_write,
4552
+ "write_verified": verified if verify_write else None,
4553
+ "verification": verification,
4554
+ "resource": _record_resource_payload(normalized_apply_id),
4555
+ },
3092
4556
  operation="update",
3093
- app_key=app_key,
3094
- record_id=record_id,
3095
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
4557
+ target="record data",
3096
4558
  )
3097
- raise
3098
- return self._record_write_apply_response(
3099
- raw_apply,
3100
- operation="update",
3101
- normalized_payload=normalized_payload,
3102
- output_profile=output_profile,
3103
- human_review=True,
3104
- preflight=raw_preflight,
3105
- )
4559
+
4560
+ return self._run_record_tool(profile, runner)
3106
4561
 
3107
4562
  def _record_update_public_batch(
3108
4563
  self,
@@ -3265,13 +4720,44 @@ class RecordTools(ToolBase):
3265
4720
  """执行内部辅助逻辑。"""
3266
4721
  summary = self._record_update_batch_summary(responses)
3267
4722
  batch_items = [self._record_update_batch_item_from_response(response, output_profile=output_profile) for response in responses]
4723
+ public_items = [self._record_update_public_batch_item(item, index=index) for index, item in enumerate(batch_items)]
3268
4724
  status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
3269
4725
  first_response = responses[0] if responses else {}
4726
+ applied_count = int(summary.get("applied_count") or 0)
4727
+ ready_count = int(summary.get("ready_count") or 0)
4728
+ verified_count = int(summary.get("verified_count") or 0)
4729
+ field_level_verified_count = int(summary.get("field_level_verified_count") or 0)
4730
+ confirmation_count = int(summary.get("confirmation_count") or 0)
4731
+ blocked_count = int(summary.get("blocked_count") or 0)
4732
+ failed_count = int(summary.get("failed_count") or 0)
4733
+ write_executed = applied_count > 0
4734
+ verification_status = "not_requested"
4735
+ if write_executed:
4736
+ verification_status = "verified" if verified_count == applied_count else "failed"
4737
+ updated_record_ids = [
4738
+ str(item.get("record_id"))
4739
+ for item in public_items
4740
+ if item.get("record_id") not in (None, "") and str(item.get("status") or "").lower() == "success"
4741
+ ]
3270
4742
  return {
3271
4743
  "profile": first_response.get("profile", profile),
3272
4744
  "ws_id": first_response.get("ws_id"),
3273
4745
  "ok": ok,
3274
4746
  "status": status,
4747
+ "mode": "batch",
4748
+ "dry_run": dry_run,
4749
+ "app_key": app_key,
4750
+ "total": int(summary.get("total") or 0),
4751
+ "succeeded": ready_count if dry_run else applied_count,
4752
+ "failed": blocked_count + failed_count,
4753
+ "needs_confirmation": confirmation_count,
4754
+ "updated_record_ids": updated_record_ids,
4755
+ "write_executed": write_executed,
4756
+ "safe_to_retry": not write_executed,
4757
+ "verification_status": verification_status,
4758
+ "field_level_verified_count": field_level_verified_count,
4759
+ "summary": summary,
4760
+ "items": public_items,
3275
4761
  "request_route": first_response.get("request_route"),
3276
4762
  "warnings": [],
3277
4763
  "output_profile": output_profile,
@@ -3285,6 +4771,31 @@ class RecordTools(ToolBase):
3285
4771
  "message": message,
3286
4772
  }
3287
4773
 
4774
+ def _record_update_public_batch_item(self, item: JSONObject, *, index: int) -> JSONObject:
4775
+ """执行内部辅助逻辑。"""
4776
+ public = dict(item)
4777
+ public.setdefault("index", index)
4778
+ public.setdefault("row_number", index + 1)
4779
+ resource = public.get("resource")
4780
+ if isinstance(resource, dict):
4781
+ record_id = resource.get("record_id")
4782
+ apply_id = resource.get("apply_id")
4783
+ if record_id not in (None, ""):
4784
+ public["record_id"] = str(record_id)
4785
+ if apply_id not in (None, ""):
4786
+ public["apply_id"] = str(apply_id)
4787
+ status = str(public.get("status") or "").lower()
4788
+ verification = public.get("verification")
4789
+ if isinstance(verification, dict):
4790
+ if bool(verification.get("verified")):
4791
+ public.setdefault("verification_status", "verified")
4792
+ elif status == "success":
4793
+ public.setdefault("verification_status", "failed")
4794
+ public.setdefault("write_executed", status == "success")
4795
+ public.setdefault("safe_to_retry", not bool(public.get("write_executed")))
4796
+ public.setdefault("verification_status", "not_requested")
4797
+ return public
4798
+
3288
4799
  def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
3289
4800
  """执行内部辅助逻辑。"""
3290
4801
  summary: JSONObject = {
@@ -3345,6 +4856,12 @@ class RecordTools(ToolBase):
3345
4856
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
3346
4857
  "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
3347
4858
  }
4859
+ update_route = response.get("update_route")
4860
+ if isinstance(update_route, dict):
4861
+ item["update_route"] = update_route
4862
+ tried_routes = response.get("tried_routes")
4863
+ if isinstance(tried_routes, list):
4864
+ item["tried_routes"] = tried_routes
3348
4865
  blockers = data.get("blockers")
3349
4866
  if isinstance(blockers, list) and blockers:
3350
4867
  item["blockers"] = blockers
@@ -4210,6 +5727,11 @@ class RecordTools(ToolBase):
4210
5727
  delete_ids = [normalize_positive_id_int(record_id, field_name="record_id")]
4211
5728
  if not delete_ids:
4212
5729
  raise_tool_error(QingflowApiError.config_error("record_id or record_ids is required"))
5730
+ seen_delete_ids: set[int] = set()
5731
+ for item in delete_ids:
5732
+ if item in seen_delete_ids:
5733
+ raise_tool_error(QingflowApiError.config_error(f"duplicate record id in delete payload: {stringify_backend_id(item)}"))
5734
+ seen_delete_ids.add(item)
4213
5735
  normalized_payload = {
4214
5736
  "operation": "delete",
4215
5737
  "record_id": stringify_backend_id(record_id) if record_id is not None else None,
@@ -4217,16 +5739,134 @@ class RecordTools(ToolBase):
4217
5739
  "answers": [],
4218
5740
  "submit_type": 1,
4219
5741
  }
4220
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
4221
- return self._record_write_apply_response(
4222
- raw_apply,
4223
- operation="delete",
5742
+ return self._record_delete_public_batch(
5743
+ profile=profile,
5744
+ app_key=app_key,
5745
+ delete_ids=delete_ids,
4224
5746
  normalized_payload=normalized_payload,
4225
5747
  output_profile=normalized_output_profile,
4226
- human_review=True,
4227
- preflight=None,
4228
5748
  )
4229
5749
 
5750
+ def _record_delete_public_batch(
5751
+ self,
5752
+ *,
5753
+ profile: str,
5754
+ app_key: str,
5755
+ delete_ids: list[int],
5756
+ normalized_payload: JSONObject,
5757
+ output_profile: str,
5758
+ ) -> JSONObject:
5759
+ items: list[JSONObject] = []
5760
+ request_route: JSONObject | None = None
5761
+ ws_id: object = None
5762
+ for index, delete_id in enumerate(delete_ids):
5763
+ record_id_text = stringify_backend_id(delete_id)
5764
+ try:
5765
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
5766
+ request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
5767
+ ws_id = raw_apply.get("ws_id", ws_id)
5768
+ single_payload = {
5769
+ "operation": "delete",
5770
+ "record_id": record_id_text,
5771
+ "record_ids": [record_id_text],
5772
+ "answers": [],
5773
+ "submit_type": 1,
5774
+ }
5775
+ single_response = self._record_write_apply_response(
5776
+ raw_apply,
5777
+ operation="delete",
5778
+ normalized_payload=single_payload,
5779
+ output_profile=output_profile,
5780
+ human_review=True,
5781
+ preflight=None,
5782
+ )
5783
+ item_status = str(single_response.get("status") or "success")
5784
+ item: JSONObject = {
5785
+ "index": index,
5786
+ "row_number": index + 1,
5787
+ "record_id": record_id_text,
5788
+ "status": item_status,
5789
+ "write_executed": bool(single_response.get("write_executed")),
5790
+ "verification_status": single_response.get("verification_status", "not_requested"),
5791
+ "safe_to_retry": bool(single_response.get("safe_to_retry", False)),
5792
+ }
5793
+ if item_status != "success":
5794
+ item["error"] = (single_response.get("data") or {}).get("error") if isinstance(single_response.get("data"), dict) else None
5795
+ items.append(item)
5796
+ except (QingflowApiError, RuntimeError) as exc:
5797
+ error_response = self._record_write_exception_response(
5798
+ exc,
5799
+ operation="delete",
5800
+ profile=profile,
5801
+ app_key=app_key,
5802
+ record_id=record_id_text,
5803
+ output_profile=output_profile,
5804
+ human_review=True,
5805
+ write_executed=False,
5806
+ )
5807
+ request_route = cast(JSONObject, error_response.get("request_route")) if isinstance(error_response.get("request_route"), dict) else request_route
5808
+ item = {
5809
+ "index": index,
5810
+ "row_number": index + 1,
5811
+ "record_id": record_id_text,
5812
+ "status": "failed",
5813
+ "write_executed": False,
5814
+ "verification_status": "not_requested",
5815
+ "safe_to_retry": True,
5816
+ "error": (error_response.get("data") or {}).get("error") if isinstance(error_response.get("data"), dict) else {"message": str(exc)},
5817
+ }
5818
+ items.append(item)
5819
+ deleted_ids = [
5820
+ str(item["record_id"])
5821
+ for item in items
5822
+ if str(item.get("status") or "") == "success"
5823
+ ]
5824
+ failed_ids = [
5825
+ str(item["record_id"])
5826
+ for item in items
5827
+ if str(item.get("status") or "") != "success"
5828
+ ]
5829
+ total = len(items)
5830
+ succeeded = len(deleted_ids)
5831
+ failed = len(failed_ids)
5832
+ if succeeded and failed:
5833
+ status = "partial_success"
5834
+ ok = False
5835
+ elif succeeded:
5836
+ status = "success"
5837
+ ok = True
5838
+ else:
5839
+ status = "failed"
5840
+ ok = False
5841
+ write_executed = any(bool(item.get("write_executed")) for item in items)
5842
+ return {
5843
+ "profile": profile,
5844
+ "ws_id": ws_id,
5845
+ "ok": ok,
5846
+ "status": status,
5847
+ "mode": "batch",
5848
+ "total": total,
5849
+ "succeeded": succeeded,
5850
+ "failed": failed,
5851
+ "deleted_ids": deleted_ids,
5852
+ "failed_ids": failed_ids,
5853
+ "write_executed": write_executed,
5854
+ "verification_status": "not_requested",
5855
+ "safe_to_retry": False if write_executed else True,
5856
+ "request_route": request_route,
5857
+ "warnings": [],
5858
+ "output_profile": output_profile,
5859
+ "items": items,
5860
+ "data": {
5861
+ "action": {"operation": "delete", "executed": write_executed},
5862
+ "resource": {"type": "record", "app_key": app_key, "record_id": None, "record_ids": [stringify_backend_id(item) for item in delete_ids]},
5863
+ "normalized_payload": normalized_payload,
5864
+ "deleted_ids": deleted_ids,
5865
+ "failed_ids": failed_ids,
5866
+ "items": items,
5867
+ },
5868
+ }
5869
+
4230
5870
  @tool_cn_name("写入记录")
4231
5871
  def record_write(
4232
5872
  self,
@@ -6785,6 +8425,7 @@ class RecordTools(ToolBase):
6785
8425
  field_index_override=index,
6786
8426
  )
6787
8427
  except RecordInputError as error:
8428
+ normalized_answers = list(lookup_resolution.normalized_answers)
6788
8429
  invalid_fields.append(
6789
8430
  {
6790
8431
  "location": _stringify_json(error.details.get("location") if error.details else None),
@@ -7281,7 +8922,7 @@ class RecordTools(ToolBase):
7281
8922
  "result": result,
7282
8923
  "normalized_answers": normalized_answers,
7283
8924
  "status": "completed" if verified else "verification_failed",
7284
- "ok": verified,
8925
+ "ok": True,
7285
8926
  "apply_id": apply_id,
7286
8927
  "record_id": apply_id,
7287
8928
  "verify_write": verify_write,
@@ -7485,7 +9126,7 @@ class RecordTools(ToolBase):
7485
9126
  "result": result,
7486
9127
  "normalized_answers": normalized_answers,
7487
9128
  "status": "completed" if verified else "verification_failed",
7488
- "ok": verified,
9129
+ "ok": True,
7489
9130
  "verify_write": verify_write,
7490
9131
  "write_verified": verified if verify_write else None,
7491
9132
  "verification": verification,
@@ -7879,22 +9520,188 @@ class RecordTools(ToolBase):
7879
9520
  },
7880
9521
  },
7881
9522
  },
7882
- "output_profile": output_profile,
7883
- "next_page_token": None,
9523
+ "output_profile": output_profile,
9524
+ "next_page_token": None,
9525
+ }
9526
+ if output_profile == "verbose":
9527
+ cast(JSONObject, cast(JSONObject, response["data"])["list"])["normalized_rows"] = normalized_rows
9528
+ response["completeness"] = completeness
9529
+ evidence["backend_reported_total"] = reported_total
9530
+ response["evidence"] = evidence
9531
+ response["resolved_mappings"] = {
9532
+ "select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in selected_fields],
9533
+ "filters": [_field_mapping_entry("filter", entry["field"], requested=entry["requested"]) for entry in self._resolve_filter_field_entries(filters, index)],
9534
+ "time_range": _field_mapping_entry("time", time_field, requested=time_field.que_title) if time_field is not None else None,
9535
+ }
9536
+ return response
9537
+
9538
+ return self._run_record_tool(profile, runner)
9539
+
9540
+ def _record_list_query_view_fields(
9541
+ self,
9542
+ *,
9543
+ session_profile,
9544
+ context,
9545
+ app_key: str,
9546
+ view_route: AccessibleViewRoute,
9547
+ page_num: int,
9548
+ page_size: int,
9549
+ query_key: str | None,
9550
+ search_que_ids: list[int] | None,
9551
+ match_rules: list[JSONObject],
9552
+ sort_rules: list[JSONObject],
9553
+ max_rows: int,
9554
+ selected_fields: list[FormField],
9555
+ output_profile: str,
9556
+ ) -> JSONObject:
9557
+ """Run public record_list with fields already resolved from the selected view schema."""
9558
+ view_selection = view_route.view_selection
9559
+ current_page = max(page_num, 1)
9560
+ used_list_type: int | None = None
9561
+ if view_selection is not None:
9562
+ fallback_list_types = [view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
9563
+ elif view_route.list_type is not None and view_route.list_type != DEFAULT_RECORD_LIST_TYPE:
9564
+ fallback_list_types = [view_route.list_type]
9565
+ else:
9566
+ fallback_list_types = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
9567
+ last_error: QingflowApiError | None = None
9568
+ page: JSONObject | None = None
9569
+ for candidate_list_type in fallback_list_types:
9570
+ try:
9571
+ page = self._search_page(
9572
+ context,
9573
+ app_key=app_key,
9574
+ view_selection=view_selection,
9575
+ page_num=current_page,
9576
+ page_size=page_size,
9577
+ query_key=query_key,
9578
+ match_rules=match_rules,
9579
+ sorts=sort_rules,
9580
+ search_que_ids=search_que_ids,
9581
+ list_type=candidate_list_type,
9582
+ )
9583
+ used_list_type = None if view_selection is not None else candidate_list_type
9584
+ break
9585
+ except QingflowApiError as exc:
9586
+ last_error = exc
9587
+ if self._should_retry_list_type_fallback(exc) and candidate_list_type != fallback_list_types[-1]:
9588
+ continue
9589
+ raise
9590
+ if page is None:
9591
+ if last_error is not None:
9592
+ raise last_error
9593
+ raise_tool_error(QingflowApiError.config_error("record_list failed: no accessible listType"))
9594
+
9595
+ page_rows = page.get("list")
9596
+ items = page_rows if isinstance(page_rows, list) else []
9597
+ reported_total = _coerce_count(page.get("total"))
9598
+ if reported_total is None:
9599
+ reported_total = _coerce_count(page.get("count"))
9600
+ result_amount = _effective_total(page, page_size)
9601
+ has_more = _page_has_more(page, current_page, page_size, len(items))
9602
+ rows: list[JSONObject] = []
9603
+ normalized_rows: list[JSONObject] = []
9604
+ page_apply_order: list[int] = []
9605
+ page_answer_map: dict[int, list[JSONValue]] = {}
9606
+ for item in items:
9607
+ if not isinstance(item, dict):
9608
+ continue
9609
+ answers = item.get("answers")
9610
+ answer_list = answers if isinstance(answers, list) else []
9611
+ apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
9612
+ row = _build_flat_row(answer_list, selected_fields, apply_id=apply_id)
9613
+ rows.append(row)
9614
+ if apply_id is not None:
9615
+ page_apply_order.append(apply_id)
9616
+ page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
9617
+ if len(rows) >= max_rows:
9618
+ break
9619
+ if output_profile == "verbose" and page_apply_order:
9620
+ for apply_id in page_apply_order:
9621
+ normalized_record, normalized_ambiguous_fields = _build_normalized_row_from_answers(
9622
+ page_answer_map.get(apply_id, []),
9623
+ selected_fields,
9624
+ )
9625
+ normalized_rows.append(
9626
+ {
9627
+ "apply_id": apply_id,
9628
+ "normalized_record": normalized_record,
9629
+ "normalized_ambiguous_fields": normalized_ambiguous_fields,
9630
+ }
9631
+ )
9632
+ effective_result_amount = result_amount if result_amount is not None else len(rows)
9633
+ completeness = _build_completeness(
9634
+ result_amount=effective_result_amount,
9635
+ returned_items=len(rows),
9636
+ fetched_pages=1,
9637
+ requested_pages=1,
9638
+ has_more=has_more,
9639
+ next_page_token=None,
9640
+ is_complete=not has_more and len(rows) < max_rows,
9641
+ omitted_items=max(0, effective_result_amount - len(rows)),
9642
+ extra={},
9643
+ )
9644
+ evidence = {
9645
+ "query_id": _query_id(),
9646
+ "app_key": app_key,
9647
+ "filters": _echo_filters(match_rules),
9648
+ "selected_columns": [field.que_title for field in selected_fields],
9649
+ "time_range": None,
9650
+ "source_pages": [current_page],
9651
+ "view": _view_selection_payload(view_selection),
9652
+ "backend_reported_total": reported_total,
9653
+ }
9654
+ response: JSONObject = {
9655
+ "profile": session_profile.profile,
9656
+ "ws_id": session_profile.selected_ws_id,
9657
+ "ok": True,
9658
+ "request_route": self._request_route_payload(context),
9659
+ "data": {
9660
+ "mode": "list",
9661
+ "source_tool": "record_list",
9662
+ "view": _view_selection_payload(view_selection),
9663
+ "list": {
9664
+ "rows": rows,
9665
+ "row_cap_hit": _list_row_cap_hit(returned_items=len(rows), row_cap=max_rows),
9666
+ "sample_only": _list_sample_only(
9667
+ returned_items=len(rows),
9668
+ row_cap=max_rows,
9669
+ result_amount=effective_result_amount,
9670
+ ),
9671
+ "safe_for_final_conclusion": False,
9672
+ "analysis_warning": _list_sample_warning(
9673
+ returned_items=len(rows),
9674
+ row_cap=max_rows,
9675
+ result_amount=effective_result_amount,
9676
+ ),
9677
+ "pagination": {
9678
+ "page_num": current_page,
9679
+ "page_size": page_size,
9680
+ "requested_pages": 1,
9681
+ "result_amount": effective_result_amount,
9682
+ "returned_items": len(rows),
9683
+ "list_type_used": used_list_type,
9684
+ },
9685
+ "applied_limits": {
9686
+ "row_cap": max_rows,
9687
+ "column_cap": len(selected_fields),
9688
+ "selected_columns": [field.que_title for field in selected_fields],
9689
+ },
9690
+ },
9691
+ },
9692
+ "output_profile": output_profile,
9693
+ "next_page_token": None,
9694
+ }
9695
+ if output_profile == "verbose":
9696
+ cast(JSONObject, cast(JSONObject, response["data"])["list"])["normalized_rows"] = normalized_rows
9697
+ response["completeness"] = completeness
9698
+ response["evidence"] = evidence
9699
+ response["resolved_mappings"] = {
9700
+ "select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in selected_fields],
9701
+ "filters": [],
9702
+ "time_range": None,
7884
9703
  }
7885
- if output_profile == "verbose":
7886
- cast(JSONObject, cast(JSONObject, response["data"])["list"])["normalized_rows"] = normalized_rows
7887
- response["completeness"] = completeness
7888
- evidence["backend_reported_total"] = reported_total
7889
- response["evidence"] = evidence
7890
- response["resolved_mappings"] = {
7891
- "select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in selected_fields],
7892
- "filters": [_field_mapping_entry("filter", entry["field"], requested=entry["requested"]) for entry in self._resolve_filter_field_entries(filters, index)],
7893
- "time_range": _field_mapping_entry("time", time_field, requested=time_field.que_title) if time_field is not None else None,
7894
- }
7895
- return response
7896
-
7897
- return self._run_record_tool(profile, runner)
9704
+ return response
7898
9705
 
7899
9706
  def _get_form_schema(self, profile: str, context, app_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
7900
9707
  """执行内部辅助逻辑。"""
@@ -8392,63 +10199,7 @@ class RecordTools(ToolBase):
8392
10199
  force_refresh=False,
8393
10200
  )
8394
10201
  index = cast(FieldIndex, browse_scope["index"])
8395
- visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
8396
- resolved: list[int] = []
8397
- seen: set[int] = set()
8398
- for selector in selectors:
8399
- try:
8400
- field = self._resolve_field_selector(selector, index, location="record_list.query_fields")
8401
- except RecordInputError as exc:
8402
- if exc.error_code == "FIELD_NOT_FOUND":
8403
- raise RecordInputError(
8404
- message=(
8405
- f"record_list query field_id '{selector}' is not in the selected view schema "
8406
- f"({resolved_view.view_id})."
8407
- ),
8408
- error_code="QUERY_FIELD_NOT_IN_VIEW_SCHEMA",
8409
- fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
8410
- details={
8411
- "location": "record_list.query_fields",
8412
- "requested": selector,
8413
- "view_id": resolved_view.view_id,
8414
- "view_name": resolved_view.name,
8415
- },
8416
- ) from exc
8417
- raise
8418
- if field.que_id not in visible_question_ids:
8419
- raise RecordInputError(
8420
- message=(
8421
- f"record_list query field_id '{field.que_id}' is not readable in the selected view "
8422
- f"({resolved_view.view_id})."
8423
- ),
8424
- error_code="QUERY_FIELD_NOT_IN_VIEW_SCHEMA",
8425
- fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
8426
- details={
8427
- "location": "record_list.query_fields",
8428
- "requested": selector,
8429
- "field_id": field.que_id,
8430
- "view_id": resolved_view.view_id,
8431
- "view_name": resolved_view.name,
8432
- },
8433
- )
8434
- if field.que_id in seen:
8435
- continue
8436
- resolved.append(field.que_id)
8437
- seen.add(field.que_id)
8438
- if len(resolved) > BACKEND_LIST_SEARCH_FIELD_LIMIT:
8439
- raise RecordInputError(
8440
- message=(
8441
- f"record_list query_fields supports at most {BACKEND_LIST_SEARCH_FIELD_LIMIT} fields."
8442
- ),
8443
- error_code="QUERY_FIELDS_TOO_MANY",
8444
- fix_hint="Narrow query_fields to the most likely title/name/customer/number fields, or omit query_fields to use the backend default search scope.",
8445
- details={
8446
- "location": "record_list.query_fields",
8447
- "max_fields": BACKEND_LIST_SEARCH_FIELD_LIMIT,
8448
- "received": len(resolved),
8449
- },
8450
- )
8451
- return resolved
10202
+ return self._resolve_record_list_query_fields(selectors, index, view_route=resolved_view)
8452
10203
 
8453
10204
  return cast(list[int], self._run_record_tool(profile, runner))
8454
10205
 
@@ -8460,7 +10211,7 @@ class RecordTools(ToolBase):
8460
10211
  resolved_view: AccessibleViewRoute,
8461
10212
  ) -> list[int]:
8462
10213
  """执行内部辅助逻辑。"""
8463
- browse_scope = self._build_browse_write_scope(
10214
+ browse_scope = self._build_browse_read_scope(
8464
10215
  profile,
8465
10216
  context,
8466
10217
  app_key,
@@ -8468,33 +10219,7 @@ class RecordTools(ToolBase):
8468
10219
  force_refresh=False,
8469
10220
  )
8470
10221
  index = cast(FieldIndex, browse_scope["index"])
8471
- visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
8472
- ordered_visible_fields = [
8473
- field
8474
- for field in self._schema_fields_for_mode(
8475
- profile,
8476
- context,
8477
- app_key,
8478
- index,
8479
- schema_mode="browse",
8480
- resolved_view=resolved_view,
8481
- )
8482
- if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
8483
- ]
8484
- field_ids = [field.que_id for field in ordered_visible_fields[:MAX_LIST_COLUMN_LIMIT]]
8485
- if not field_ids:
8486
- field_ids = [
8487
- field.que_id
8488
- for field in index.by_id.values()
8489
- if field.que_type not in LAYOUT_ONLY_QUE_TYPES
8490
- ][:MAX_LIST_COLUMN_LIMIT]
8491
- if not field_ids:
8492
- raise_tool_error(
8493
- QingflowApiError.config_error(
8494
- "record_list could not determine readable columns for the selected view"
8495
- )
8496
- )
8497
- return field_ids
10222
+ return [field.que_id for field in self._derive_record_list_fields_from_index(index)]
8498
10223
 
8499
10224
  def _get_view_question_ids(self, profile: str, context, view_key: str) -> set[int]: # type: ignore[no-untyped-def]
8500
10225
  """执行内部辅助逻辑。"""
@@ -9940,6 +11665,9 @@ class RecordTools(ToolBase):
9940
11665
  "ws_id": raw_preflight.get("ws_id"),
9941
11666
  "ok": False,
9942
11667
  "status": status,
11668
+ "write_executed": False,
11669
+ "verification_status": "not_requested",
11670
+ "safe_to_retry": True,
9943
11671
  "request_route": raw_preflight.get("request_route"),
9944
11672
  "warnings": warnings,
9945
11673
  "output_profile": output_profile,
@@ -9983,6 +11711,9 @@ class RecordTools(ToolBase):
9983
11711
  "ws_id": raw_preflight.get("ws_id"),
9984
11712
  "ok": True,
9985
11713
  "status": "ready",
11714
+ "write_executed": False,
11715
+ "verification_status": "not_requested",
11716
+ "safe_to_retry": True,
9986
11717
  "request_route": raw_preflight.get("request_route"),
9987
11718
  "warnings": warnings,
9988
11719
  "output_profile": output_profile,
@@ -10025,17 +11756,49 @@ class RecordTools(ToolBase):
10025
11756
  resolved_fields = cast(list[JSONObject], preflight_data.get("lookup_resolved_fields", []))
10026
11757
  if isinstance(verification_warnings, list):
10027
11758
  warnings.extend(cast(list[JSONObject], [item for item in verification_warnings if isinstance(item, dict)]))
11759
+ resource = _public_record_resource(raw_apply.get("resource"))
11760
+ record_id = _public_record_id_text(resource.get("record_id")) if isinstance(resource, dict) else None
11761
+ apply_id = _public_record_id_text(resource.get("apply_id")) if isinstance(resource, dict) else None
11762
+ if record_id is None:
11763
+ record_id = _public_record_id_text(raw_apply.get("record_id"))
11764
+ if apply_id is None:
11765
+ apply_id = _public_record_id_text(raw_apply.get("apply_id"))
11766
+ if apply_id is None:
11767
+ apply_id = record_id
11768
+ if record_id is None:
11769
+ record_id = apply_id
11770
+ write_executed = True
11771
+ verification_requested = (
11772
+ raw_apply.get("verify_write") is True
11773
+ or raw_apply.get("write_verified") is not None
11774
+ or isinstance(raw_apply.get("verification"), dict)
11775
+ )
11776
+ if verification_requested:
11777
+ verification_status = "verified" if bool(verification.get("verified")) else "failed"
11778
+ else:
11779
+ verification_status = "not_requested"
11780
+ raw_status = _normalize_optional_text(raw_apply.get("status"))
11781
+ response_status = "verification_failed" if verification_status == "failed" else "success"
11782
+ if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
11783
+ response_status = raw_status or "failed"
11784
+ update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
11785
+ tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
10028
11786
  response: JSONObject = {
10029
11787
  "profile": raw_apply.get("profile"),
10030
11788
  "ws_id": raw_apply.get("ws_id"),
10031
- "ok": bool(raw_apply.get("ok", True)),
10032
- "status": "success" if bool(raw_apply.get("ok", True)) else _normalize_optional_text(raw_apply.get("status")) or "failed",
11789
+ "ok": True if verification_status == "failed" and write_executed else bool(raw_apply.get("ok", True)),
11790
+ "status": response_status,
11791
+ "write_executed": write_executed,
11792
+ "verification_status": verification_status,
11793
+ "safe_to_retry": False,
10033
11794
  "request_route": raw_apply.get("request_route"),
10034
11795
  "warnings": warnings,
10035
11796
  "output_profile": output_profile,
11797
+ "update_route": update_route,
11798
+ "tried_routes": tried_routes,
10036
11799
  "data": {
10037
11800
  "action": {"operation": operation, "executed": True},
10038
- "resource": _public_record_resource(raw_apply.get("resource")),
11801
+ "resource": resource,
10039
11802
  "verification": raw_apply.get("verification"),
10040
11803
  "normalized_payload": normalized_payload,
10041
11804
  "blockers": [],
@@ -10043,8 +11806,14 @@ class RecordTools(ToolBase):
10043
11806
  "confirmation_requests": [],
10044
11807
  "resolved_fields": resolved_fields,
10045
11808
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
11809
+ "update_route": update_route,
11810
+ "tried_routes": tried_routes,
10046
11811
  },
10047
11812
  }
11813
+ if record_id is not None:
11814
+ response["record_id"] = record_id
11815
+ if apply_id is not None:
11816
+ response["apply_id"] = apply_id
10048
11817
  if output_profile == "verbose":
10049
11818
  debug: JSONObject = {
10050
11819
  "legacy_result": raw_apply.get("result"),
@@ -10063,9 +11832,10 @@ class RecordTools(ToolBase):
10063
11832
  operation: str,
10064
11833
  profile: str,
10065
11834
  app_key: str,
10066
- record_id: int,
11835
+ record_id: Any | None,
10067
11836
  output_profile: str,
10068
11837
  human_review: bool,
11838
+ write_executed: bool = True,
10069
11839
  ) -> JSONObject:
10070
11840
  """执行内部辅助逻辑。"""
10071
11841
  error_payload: JSONObject = {
@@ -10106,11 +11876,14 @@ class RecordTools(ToolBase):
10106
11876
  "ws_id": None,
10107
11877
  "ok": False,
10108
11878
  "status": "failed",
11879
+ "write_executed": write_executed,
11880
+ "verification_status": "failed" if write_executed else "not_requested",
11881
+ "safe_to_retry": not write_executed,
10109
11882
  "request_route": request_route,
10110
11883
  "warnings": [],
10111
11884
  "output_profile": output_profile,
10112
11885
  "data": {
10113
- "action": {"operation": operation, "executed": True},
11886
+ "action": {"operation": operation, "executed": write_executed},
10114
11887
  "resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
10115
11888
  "verification": None,
10116
11889
  "normalized_payload": None,
@@ -10446,6 +12219,153 @@ class RecordTools(ToolBase):
10446
12219
  seen.add(field.que_id)
10447
12220
  return fields
10448
12221
 
12222
+ def _derive_record_list_fields_from_index(self, index: FieldIndex) -> list[FormField]:
12223
+ fields = [
12224
+ field
12225
+ for field in index.by_id.values()
12226
+ if field.que_type not in LAYOUT_ONLY_QUE_TYPES
12227
+ ][:MAX_LIST_COLUMN_LIMIT]
12228
+ if not fields:
12229
+ raise_tool_error(
12230
+ QingflowApiError.config_error(
12231
+ "record_list could not determine readable columns for the selected view"
12232
+ )
12233
+ )
12234
+ return fields
12235
+
12236
+ def _resolve_record_list_columns(
12237
+ self,
12238
+ selectors: list[int],
12239
+ index: FieldIndex,
12240
+ *,
12241
+ view_route: AccessibleViewRoute,
12242
+ ) -> list[FormField]:
12243
+ if not selectors:
12244
+ raise_tool_error(QingflowApiError.config_error("columns is required"))
12245
+ fields: list[FormField] = []
12246
+ seen: set[int] = set()
12247
+ for selector in selectors:
12248
+ try:
12249
+ field = self._resolve_field_selector(selector, index, location="record_list.columns")
12250
+ except RecordInputError as exc:
12251
+ if exc.error_code == "FIELD_NOT_FOUND":
12252
+ raise self._record_list_field_not_in_view_error(
12253
+ exc,
12254
+ location="record_list.columns",
12255
+ error_code="FIELD_NOT_IN_VIEW_SCHEMA",
12256
+ view_route=view_route,
12257
+ ) from exc
12258
+ raise
12259
+ if field.que_id in seen:
12260
+ continue
12261
+ fields.append(field)
12262
+ seen.add(field.que_id)
12263
+ return fields
12264
+
12265
+ def _resolve_record_list_query_fields(
12266
+ self,
12267
+ selectors: list[int],
12268
+ index: FieldIndex,
12269
+ *,
12270
+ view_route: AccessibleViewRoute,
12271
+ ) -> list[int]:
12272
+ resolved: list[int] = []
12273
+ seen: set[int] = set()
12274
+ for selector in selectors:
12275
+ try:
12276
+ field = self._resolve_field_selector(selector, index, location="record_list.query_fields")
12277
+ except RecordInputError as exc:
12278
+ if exc.error_code == "FIELD_NOT_FOUND":
12279
+ raise self._record_list_field_not_in_view_error(
12280
+ exc,
12281
+ location="record_list.query_fields",
12282
+ error_code="QUERY_FIELD_NOT_IN_VIEW_SCHEMA",
12283
+ view_route=view_route,
12284
+ ) from exc
12285
+ raise
12286
+ if field.que_id in seen:
12287
+ continue
12288
+ resolved.append(field.que_id)
12289
+ seen.add(field.que_id)
12290
+ if len(resolved) > BACKEND_LIST_SEARCH_FIELD_LIMIT:
12291
+ raise RecordInputError(
12292
+ message=(
12293
+ f"record_list query_fields supports at most {BACKEND_LIST_SEARCH_FIELD_LIMIT} fields."
12294
+ ),
12295
+ error_code="QUERY_FIELDS_TOO_MANY",
12296
+ fix_hint="Narrow query_fields to the most likely title/name/customer/number fields, or omit query_fields to use the backend default search scope.",
12297
+ details={
12298
+ "location": "record_list.query_fields",
12299
+ "max_fields": BACKEND_LIST_SEARCH_FIELD_LIMIT,
12300
+ "received": len(resolved),
12301
+ },
12302
+ )
12303
+ return resolved
12304
+
12305
+ def _resolve_record_list_match_rules(
12306
+ self,
12307
+ context, # type: ignore[no-untyped-def]
12308
+ filters: list[JSONObject],
12309
+ index: FieldIndex,
12310
+ *,
12311
+ view_route: AccessibleViewRoute,
12312
+ ) -> list[JSONObject]:
12313
+ try:
12314
+ return self._resolve_match_rules(context, filters, index)
12315
+ except RecordInputError as exc:
12316
+ if exc.error_code == "FIELD_NOT_FOUND":
12317
+ raise self._record_list_field_not_in_view_error(
12318
+ exc,
12319
+ location="record_list.where",
12320
+ error_code="FILTER_FIELD_NOT_IN_VIEW_SCHEMA",
12321
+ view_route=view_route,
12322
+ ) from exc
12323
+ raise
12324
+
12325
+ def _resolve_record_list_sort_rules(
12326
+ self,
12327
+ sorts: list[JSONObject],
12328
+ index: FieldIndex,
12329
+ *,
12330
+ view_route: AccessibleViewRoute,
12331
+ ) -> list[JSONObject]:
12332
+ try:
12333
+ return self._resolve_sorts(sorts, index)
12334
+ except RecordInputError as exc:
12335
+ if exc.error_code == "FIELD_NOT_FOUND":
12336
+ raise self._record_list_field_not_in_view_error(
12337
+ exc,
12338
+ location="record_list.order_by",
12339
+ error_code="SORT_FIELD_NOT_IN_VIEW_SCHEMA",
12340
+ view_route=view_route,
12341
+ ) from exc
12342
+ raise
12343
+
12344
+ def _record_list_field_not_in_view_error(
12345
+ self,
12346
+ exc: RecordInputError,
12347
+ *,
12348
+ location: str,
12349
+ error_code: str,
12350
+ view_route: AccessibleViewRoute,
12351
+ ) -> RecordInputError:
12352
+ details = exc.details if isinstance(exc.details, dict) else {}
12353
+ requested = details.get("requested")
12354
+ return RecordInputError(
12355
+ message=(
12356
+ f"{location} field_id '{requested}' is not in the selected view schema "
12357
+ f"({view_route.view_id})."
12358
+ ),
12359
+ error_code=error_code,
12360
+ fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
12361
+ details={
12362
+ "location": location,
12363
+ "requested": requested,
12364
+ "view_id": view_route.view_id,
12365
+ "view_name": view_route.name,
12366
+ },
12367
+ )
12368
+
10449
12369
  def _resolve_summary_preview_fields(
10450
12370
  self,
10451
12371
  selectors: list[str | int],
@@ -11459,6 +13379,7 @@ class RecordTools(ToolBase):
11459
13379
  normalized_answers: list[JSONObject],
11460
13380
  index: FieldIndex,
11461
13381
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13382
+ verify_view_key: str | None = None,
11462
13383
  ) -> JSONObject:
11463
13384
  """执行内部辅助逻辑。"""
11464
13385
  if apply_id is None:
@@ -11470,13 +13391,36 @@ class RecordTools(ToolBase):
11470
13391
  "count_mismatches": [],
11471
13392
  }
11472
13393
  try:
11473
- record = self.backend.request(
11474
- "GET",
11475
- context,
11476
- f"/app/{app_key}/apply/{apply_id}",
11477
- params={"role": 1, "listType": verify_list_type},
11478
- )
13394
+ if verify_view_key:
13395
+ record = self.backend.request(
13396
+ "GET",
13397
+ context,
13398
+ f"/view/{verify_view_key}/apply/{apply_id}",
13399
+ )
13400
+ else:
13401
+ record = self.backend.request(
13402
+ "GET",
13403
+ context,
13404
+ f"/app/{app_key}/apply/{apply_id}",
13405
+ params={"role": 1, "listType": verify_list_type},
13406
+ )
11479
13407
  except QingflowApiError as exc:
13408
+ if verify_view_key:
13409
+ return {
13410
+ "verified": False,
13411
+ "verification_mode": "custom_view_record_detail",
13412
+ "field_level_verified": False,
13413
+ "error": "custom_view_readback_failed",
13414
+ "missing_fields": [],
13415
+ "empty_fields": [],
13416
+ "count_mismatches": [],
13417
+ "warnings": [{
13418
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13419
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13420
+ "backend_code": exc.backend_code,
13421
+ "http_status": exc.http_status,
13422
+ }],
13423
+ }
11480
13424
  if exc.backend_code != 40002:
11481
13425
  raise
11482
13426
  return self._verify_record_write_result_via_initiated_tasks(
@@ -11540,7 +13484,7 @@ class RecordTools(ToolBase):
11540
13484
  )
11541
13485
  return {
11542
13486
  "verified": not missing_fields and not empty_fields and not count_mismatches,
11543
- "verification_mode": "initiated_record_view",
13487
+ "verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
11544
13488
  "field_level_verified": True,
11545
13489
  "missing_fields": missing_fields,
11546
13490
  "empty_fields": empty_fields,
@@ -12452,6 +14396,13 @@ def _record_access_run_dir() -> Path:
12452
14396
  return base_dir / run_id
12453
14397
 
12454
14398
 
14399
+ def _record_logs_run_dir() -> Path:
14400
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
14401
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
14402
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
14403
+ return base_dir / run_id
14404
+
14405
+
12455
14406
  def _record_access_field_payload(field: FormField) -> JSONObject:
12456
14407
  return {
12457
14408
  "field_id": field.que_id,
@@ -13192,34 +15143,187 @@ def _record_detail_visibility_value(payload: JSONObject, *, keys: tuple[str, ...
13192
15143
  return default
13193
15144
 
13194
15145
 
13195
- def _record_detail_log_hidden_payload() -> JSONObject:
15146
+ def _record_detail_log_hidden_payload() -> JSONObject:
15147
+ return {
15148
+ "status": "hidden",
15149
+ "visible": False,
15150
+ "page": 1,
15151
+ "page_size": RECORD_GET_DETAIL_LOG_PAGE_SIZE,
15152
+ "items_loaded": 0,
15153
+ "has_more": False,
15154
+ "complete": False,
15155
+ "items": [],
15156
+ }
15157
+
15158
+
15159
+ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObject:
15160
+ return {
15161
+ "status": "unavailable",
15162
+ "visible": None,
15163
+ "source": source,
15164
+ "reason": reason,
15165
+ "page": 1,
15166
+ "page_size": RECORD_GET_DETAIL_LOG_PAGE_SIZE,
15167
+ "items_loaded": 0,
15168
+ "has_more": None,
15169
+ "complete": False,
15170
+ "items": [],
15171
+ }
15172
+
15173
+
15174
+ def _record_logs_hidden_payload(source: str) -> JSONObject:
15175
+ return {
15176
+ "status": "hidden",
15177
+ "visible": False,
15178
+ "source": source,
15179
+ "complete": False,
15180
+ "items_count": 0,
15181
+ "pages_fetched": 0,
15182
+ "reported_total": None,
15183
+ "local_path": None,
15184
+ "preview_items": [],
15185
+ "warnings": [],
15186
+ }
15187
+
15188
+
15189
+ def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
15190
+ return {
15191
+ "status": "unavailable",
15192
+ "visible": None,
15193
+ "source": source,
15194
+ "reason": reason,
15195
+ "complete": False,
15196
+ "items_count": 0,
15197
+ "pages_fetched": 0,
15198
+ "reported_total": None,
15199
+ "local_path": None,
15200
+ "preview_items": [],
15201
+ "warnings": [],
15202
+ }
15203
+
15204
+
15205
+ def _record_logs_fetch_all_to_jsonl(
15206
+ *,
15207
+ fetch_page,
15208
+ normalizer,
15209
+ source: str,
15210
+ file_path: Path,
15211
+ deadline: float,
15212
+ ) -> JSONObject: # type: ignore[no-untyped-def]
15213
+ file_path.parent.mkdir(parents=True, exist_ok=True)
15214
+ page_num = 1
15215
+ pages_fetched = 0
15216
+ items_count = 0
15217
+ reported_total: int | None = None
15218
+ preview_items: list[JSONObject] = []
15219
+ warnings: list[JSONObject] = []
15220
+ stopped_reason: str | None = None
15221
+ complete = True
15222
+
15223
+ with file_path.open("w", encoding="utf-8") as handle:
15224
+ while True:
15225
+ if _record_logs_time_budget_exceeded(deadline=deadline):
15226
+ complete = False
15227
+ stopped_reason = "time_budget_exceeded"
15228
+ warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
15229
+ break
15230
+ payload = fetch_page(page_num)
15231
+ pages_fetched += 1
15232
+ items = _record_detail_page_items(payload)
15233
+ if reported_total is None:
15234
+ reported_total = _record_detail_page_total(payload)
15235
+ if not items:
15236
+ break
15237
+ for item in items:
15238
+ normalized = normalizer(item)
15239
+ handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
15240
+ items_count += 1
15241
+ if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
15242
+ preview_items.append(normalized)
15243
+ if items_count >= RECORD_LOGS_MAX_ITEMS:
15244
+ complete = False
15245
+ stopped_reason = "item_limit_exceeded"
15246
+ warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
15247
+ break
15248
+ if stopped_reason:
15249
+ break
15250
+ if reported_total is not None and items_count >= reported_total:
15251
+ break
15252
+ if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
15253
+ break
15254
+ page_num += 1
15255
+
15256
+ return {
15257
+ "status": "ok" if complete else "partial",
15258
+ "visible": True,
15259
+ "source": source,
15260
+ "complete": complete,
15261
+ "items_count": items_count,
15262
+ "pages_fetched": pages_fetched,
15263
+ "page_size": RECORD_LOGS_PAGE_SIZE,
15264
+ "reported_total": reported_total,
15265
+ "local_path": str(file_path),
15266
+ "preview_items": preview_items,
15267
+ "warnings": warnings,
15268
+ "stopped_reason": stopped_reason,
15269
+ }
15270
+
15271
+
15272
+ def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
15273
+ return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
15274
+
15275
+
15276
+ def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
13196
15277
  return {
13197
- "status": "hidden",
13198
- "visible": False,
13199
- "page": 1,
13200
- "page_size": RECORD_GET_DETAIL_LOG_PAGE_SIZE,
13201
- "items_loaded": 0,
13202
- "has_more": False,
13203
- "complete": False,
13204
- "items": [],
15278
+ "code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
15279
+ "source": source,
15280
+ "message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
15281
+ "pages_fetched": pages_fetched,
15282
+ "items_count": items_count,
13205
15283
  }
13206
15284
 
13207
15285
 
13208
- def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObject:
15286
+ def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
13209
15287
  return {
13210
- "status": "unavailable",
13211
- "visible": None,
15288
+ "code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
13212
15289
  "source": source,
13213
- "reason": reason,
13214
- "page": 1,
13215
- "page_size": RECORD_GET_DETAIL_LOG_PAGE_SIZE,
13216
- "items_loaded": 0,
13217
- "has_more": None,
13218
- "complete": False,
13219
- "items": [],
15290
+ "message": f"record_logs_get stopped after the internal {item_limit} item limit.",
15291
+ "item_limit": item_limit,
13220
15292
  }
13221
15293
 
13222
15294
 
15295
+ def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
15296
+ statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
15297
+ if statuses == {"unavailable"}:
15298
+ return "unavailable"
15299
+ if "partial" in statuses or "unavailable" in statuses:
15300
+ return "partial"
15301
+ return "success"
15302
+
15303
+
15304
+ def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
15305
+ data_integrity = _record_logs_section_integrity(data_logs)
15306
+ workflow_integrity = _record_logs_section_integrity(workflow_logs)
15307
+ return {
15308
+ "data_logs": data_integrity,
15309
+ "workflow_logs": workflow_integrity,
15310
+ "safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
15311
+ }
15312
+
15313
+
15314
+ def _record_logs_section_integrity(section: JSONObject) -> str:
15315
+ status = str(section.get("status") or "")
15316
+ if status == "ok" and section.get("complete") is True:
15317
+ return "full"
15318
+ if status == "hidden":
15319
+ return "hidden"
15320
+ if status == "partial":
15321
+ return "partial"
15322
+ if status == "unavailable":
15323
+ return "unavailable"
15324
+ return "unknown"
15325
+
15326
+
13223
15327
  def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
13224
15328
  items = _record_detail_page_items(payload)
13225
15329
  total = _record_detail_page_total(payload)
@@ -13351,11 +15455,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
13351
15455
  view_key = _normalize_optional_text(raw.get("viewKey", raw.get("viewgraphKey")))
13352
15456
  chart_key = _normalize_optional_text(raw.get("chartKey", raw.get("chartId")))
13353
15457
  is_view = bool(view_key) or graph_type.endswith("view") or graph_type == "view"
15458
+ if is_view and not view_key and chart_key:
15459
+ view_key = chart_key
15460
+ chart_key = None
13354
15461
  resource_type = "view" if is_view else "report"
13355
15462
  view_type = _normalize_optional_text(raw.get("viewType", raw.get("viewgraphType", raw.get("graphType"))))
13356
15463
  data_access = _record_detail_resource_data_access(resource_type=resource_type, view_type=view_type)
13357
15464
  return {
13358
15465
  "type": resource_type,
15466
+ "resource_type": resource_type,
13359
15467
  "name": _normalize_optional_text(raw.get("viewName", raw.get("chartName", raw.get("name", raw.get("title"))))),
13360
15468
  "app_key": _normalize_optional_text(raw.get("appKey", raw.get("targetAppKey"))),
13361
15469
  "app_name": _normalize_optional_text(raw.get("formTitle", raw.get("appName", raw.get("targetAppName")))),
@@ -13363,10 +15471,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
13363
15471
  "chart_key": chart_key,
13364
15472
  "view_type": view_type,
13365
15473
  "graph_type": raw.get("graphType"),
15474
+ "report_source": _record_detail_report_source(raw.get("sourceType")) if resource_type == "report" else None,
13366
15475
  "data_access": data_access,
13367
15476
  }
13368
15477
 
13369
15478
 
15479
+ def _record_detail_report_source(source_type: Any) -> str:
15480
+ return "dataset" if str(source_type or "").strip().upper() == "BI_DATASET" else "app"
15481
+
15482
+
13370
15483
  def _record_detail_resource_data_access(*, resource_type: str, view_type: str | None) -> JSONObject:
13371
15484
  if resource_type == "report":
13372
15485
  return {
@@ -13412,6 +15525,19 @@ _RECORD_MEDIA_IMG_SRC_RE = re.compile(r"""<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+
13412
15525
  _RECORD_MEDIA_MD_IMAGE_RE = re.compile(r"""!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)""")
13413
15526
  _RECORD_MEDIA_URL_RE = re.compile(r"""https?://[^\s<>"')\]]+""", re.IGNORECASE)
13414
15527
  _RECORD_MEDIA_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
15528
+ _RECORD_FILE_EXTENSIONS = _RECORD_MEDIA_IMAGE_EXTENSIONS | {
15529
+ ".csv",
15530
+ ".doc",
15531
+ ".docx",
15532
+ ".json",
15533
+ ".md",
15534
+ ".pdf",
15535
+ ".text",
15536
+ ".txt",
15537
+ ".xls",
15538
+ ".xlsm",
15539
+ ".xlsx",
15540
+ }
13415
15541
  _RECORD_MEDIA_IMAGE_URL_KEYS = {
13416
15542
  "image",
13417
15543
  "imageurl",
@@ -13431,6 +15557,15 @@ _RECORD_MEDIA_IMAGE_URL_KEYS = {
13431
15557
  "url",
13432
15558
  "value",
13433
15559
  }
15560
+ _RECORD_FILE_URL_KEYS = _RECORD_MEDIA_IMAGE_URL_KEYS | {
15561
+ "downloadurl",
15562
+ "download_url",
15563
+ "file",
15564
+ "href",
15565
+ "link",
15566
+ "path",
15567
+ }
15568
+ _RECORD_FILE_NAME_KEYS = {"name", "otherinfo", "filename", "file_name", "title"}
13434
15569
 
13435
15570
 
13436
15571
  def _record_detail_media_assets_payload(
@@ -13550,7 +15685,278 @@ def _record_detail_media_assets_payload(
13550
15685
  source_url=refreshed_url,
13551
15686
  warnings=warnings,
13552
15687
  environment_prefix_cache=environment_prefix_cache,
13553
- requested_strategy=download_strategy if download_strategy == "decrypted_file_url_then_storage_cookie_redirect" else refreshed_strategy,
15688
+ requested_strategy=download_strategy if download_strategy == "decrypted_file_url_then_storage_cookie_redirect" else refreshed_strategy,
15689
+ )
15690
+ source_url = refreshed_url
15691
+ base_item["source_url"] = refreshed_url
15692
+ download_succeeded = True
15693
+ except QingflowApiError as refreshed_exc:
15694
+ exc = refreshed_exc
15695
+ blocked = exc.http_status in {401, 403}
15696
+ else:
15697
+ warnings.append(
15698
+ {
15699
+ "code": "MEDIA_ASSET_STORAGE_URL_REFRESHED",
15700
+ "asset_id": asset_id,
15701
+ "message": "record_get refreshed the record detail once before downloading this media asset.",
15702
+ }
15703
+ )
15704
+ if not download_succeeded:
15705
+ warning_code = "STORAGE_COOKIE_AUTH_FAILED" if blocked and download_strategy != "referer_acl" else "MEDIA_ASSET_DOWNLOAD_FAILED"
15706
+ items.append(
15707
+ {
15708
+ **base_item,
15709
+ "storage_auth_type": _record_detail_storage_auth_type(source_url),
15710
+ "storage_cookie_prefix": environment_prefix_cache.get("value"),
15711
+ "redirected": False,
15712
+ "local_path": None,
15713
+ "mime_type": None,
15714
+ "size_bytes": None,
15715
+ "access_status": "blocked_private_url" if blocked else "download_failed",
15716
+ "download_strategy": download_strategy,
15717
+ "readable_by_agent": False,
15718
+ }
15719
+ )
15720
+ warnings.append(
15721
+ {
15722
+ "code": warning_code,
15723
+ "asset_id": asset_id,
15724
+ "message": f"record_get could not download image asset {asset_id}: {exc.message}",
15725
+ "http_status": exc.http_status,
15726
+ }
15727
+ )
15728
+ continue
15729
+
15730
+ if not isinstance(content, bytes):
15731
+ content = bytes(content or b"")
15732
+ mime_type = _record_detail_image_mime_from_bytes(content)
15733
+ if not mime_type:
15734
+ items.append(
15735
+ {
15736
+ **base_item,
15737
+ **download_meta,
15738
+ "local_path": None,
15739
+ "mime_type": _record_detail_mime_from_url(source_url),
15740
+ "size_bytes": len(content),
15741
+ "access_status": "skipped_non_image",
15742
+ "readable_by_agent": False,
15743
+ }
15744
+ )
15745
+ continue
15746
+ if len(content) > RECORD_GET_MEDIA_MAX_IMAGE_BYTES or total_bytes + len(content) > RECORD_GET_MEDIA_MAX_TOTAL_BYTES:
15747
+ items.append(
15748
+ {
15749
+ **base_item,
15750
+ **download_meta,
15751
+ "local_path": None,
15752
+ "mime_type": mime_type,
15753
+ "size_bytes": len(content),
15754
+ "access_status": "too_large",
15755
+ "readable_by_agent": False,
15756
+ }
15757
+ )
15758
+ warnings.append(
15759
+ {
15760
+ "code": "MEDIA_ASSET_SIZE_LIMIT_EXCEEDED",
15761
+ "asset_id": asset_id,
15762
+ "message": "record_get skipped an image asset because it exceeded the internal media size budget.",
15763
+ }
15764
+ )
15765
+ continue
15766
+
15767
+ ensure_local_dir()
15768
+ extension = _record_detail_image_extension(mime_type, source_url)
15769
+ local_path = local_dir / f"{asset_id}{extension}"
15770
+ local_path.write_bytes(content)
15771
+ total_bytes += len(content)
15772
+ image_count += 1
15773
+ items.append(
15774
+ {
15775
+ **base_item,
15776
+ **download_meta,
15777
+ "local_path": str(local_path),
15778
+ "mime_type": mime_type,
15779
+ "size_bytes": len(content),
15780
+ "access_status": "downloaded",
15781
+ "readable_by_agent": True,
15782
+ }
15783
+ )
15784
+
15785
+ if not items:
15786
+ status = "none"
15787
+ elif any(item.get("access_status") != "downloaded" for item in items):
15788
+ status = "partial"
15789
+ else:
15790
+ status = "ok"
15791
+ return {"status": status, "local_dir": str(local_dir) if items else None, "items": items, "warnings": warnings}
15792
+
15793
+
15794
+ def _record_detail_file_assets_payload(
15795
+ *,
15796
+ backend: Any,
15797
+ context: BackendRequestContext,
15798
+ app_key: str,
15799
+ record_id: int,
15800
+ fields: list[JSONObject],
15801
+ references: list[JSONObject],
15802
+ media_assets: JSONObject,
15803
+ refresh_source_url: Any | None = None,
15804
+ ) -> JSONObject:
15805
+ candidates: list[JSONObject] = []
15806
+ source_record_id = _public_record_id_text(record_id)
15807
+ for field in fields:
15808
+ if isinstance(field, dict):
15809
+ candidates.extend(
15810
+ _record_detail_file_candidates_from_field(
15811
+ field,
15812
+ source_app_key=app_key,
15813
+ source_record_id=source_record_id,
15814
+ forced_source=None,
15815
+ )
15816
+ )
15817
+ for reference in references:
15818
+ if not isinstance(reference, dict):
15819
+ continue
15820
+ target_fields = reference.get("target_fields") if isinstance(reference.get("target_fields"), list) else []
15821
+ target_app_key = _normalize_optional_text(reference.get("target_app_key")) or app_key
15822
+ target_record_id = _normalize_optional_text(reference.get("target_record_id"))
15823
+ for field in target_fields:
15824
+ if isinstance(field, dict):
15825
+ candidates.extend(
15826
+ _record_detail_file_candidates_from_field(
15827
+ field,
15828
+ source_app_key=target_app_key,
15829
+ source_record_id=target_record_id,
15830
+ forced_source="reference_target",
15831
+ )
15832
+ )
15833
+ if not candidates:
15834
+ return {"status": "none", "local_dir": None, "items": [], "warnings": []}
15835
+
15836
+ local_dir = _record_detail_file_assets_dir(uuid4().hex)
15837
+ local_dir_created = False
15838
+ items: list[JSONObject] = []
15839
+ warnings: list[JSONObject] = []
15840
+ file_by_url: dict[str, str] = {}
15841
+ media_by_url = _record_detail_media_assets_by_url(media_assets)
15842
+ media_by_asset_id = _record_detail_media_assets_by_asset_id(media_assets)
15843
+ total_bytes = 0
15844
+ downloaded_count = 0
15845
+ deadline = time.monotonic() + RECORD_GET_FILE_TIME_BUDGET_SECONDS
15846
+ stopped_for_time_budget = False
15847
+ environment_prefix_cache: dict[str, str] = {}
15848
+
15849
+ def ensure_local_dir() -> None:
15850
+ nonlocal local_dir_created
15851
+ if not local_dir_created:
15852
+ local_dir.mkdir(parents=True, exist_ok=True)
15853
+ local_dir_created = True
15854
+
15855
+ for candidate in candidates:
15856
+ if items and time.monotonic() + RECORD_GET_FILE_MIN_REMAINING_SECONDS >= deadline:
15857
+ stopped_for_time_budget = True
15858
+ warnings.append(
15859
+ {
15860
+ "code": "FILE_ASSET_TIME_BUDGET_EXCEEDED",
15861
+ "message": "record_get stopped downloading additional file assets to stay within the internal time budget.",
15862
+ "time_budget_seconds": RECORD_GET_FILE_TIME_BUDGET_SECONDS,
15863
+ }
15864
+ )
15865
+ break
15866
+ source_url = _normalize_optional_text(candidate.get("source_url"))
15867
+ owner = candidate.get("_owner")
15868
+ if not source_url or not isinstance(owner, dict):
15869
+ continue
15870
+ existing_asset_id = file_by_url.get(source_url)
15871
+ if existing_asset_id:
15872
+ _record_detail_attach_file_asset_id(owner, existing_asset_id)
15873
+ continue
15874
+ file_asset_id = f"file_{len(items) + 1:04d}"
15875
+ file_by_url[source_url] = file_asset_id
15876
+ _record_detail_attach_file_asset_id(owner, file_asset_id)
15877
+ base_item = _record_detail_file_asset_base_item(candidate, file_asset_id=file_asset_id)
15878
+
15879
+ media_item = media_by_url.get(source_url)
15880
+ if media_item is None:
15881
+ media_item = _record_detail_media_item_from_owner_asset_ids(owner, media_by_asset_id, candidate)
15882
+ if isinstance(media_item, dict) and media_item.get("asset_id") not in (None, ""):
15883
+ base_item["media_asset_id"] = media_item.get("asset_id")
15884
+ if downloaded_count >= RECORD_GET_FILE_MAX_FILES:
15885
+ items.append(
15886
+ {
15887
+ **base_item,
15888
+ "local_path": None,
15889
+ "mime_type": None,
15890
+ "size_bytes": None,
15891
+ "access_status": "too_large",
15892
+ "download_strategy": "skipped_limit",
15893
+ "readable_by_agent": False,
15894
+ "extraction": {"status": "skipped_too_large", "text_path": None, "preview": None},
15895
+ }
15896
+ )
15897
+ warnings.append(
15898
+ {
15899
+ "code": "FILE_ASSET_LIMIT_EXCEEDED",
15900
+ "message": f"record_get stopped downloading files after {RECORD_GET_FILE_MAX_FILES} assets.",
15901
+ }
15902
+ )
15903
+ continue
15904
+
15905
+ reused_media_path = _normalize_optional_text(media_item.get("local_path")) if isinstance(media_item, dict) else None
15906
+ if reused_media_path and media_item.get("access_status") == "downloaded":
15907
+ file_name = _record_detail_file_name_from_candidate(candidate, source_url=source_url, fallback_id=file_asset_id)
15908
+ mime_type = _normalize_optional_text(media_item.get("mime_type")) or _record_detail_mime_from_url(source_url)
15909
+ items.append(
15910
+ {
15911
+ **base_item,
15912
+ "download_strategy": media_item.get("download_strategy"),
15913
+ "storage_auth_type": media_item.get("storage_auth_type"),
15914
+ "storage_cookie_prefix": media_item.get("storage_cookie_prefix"),
15915
+ "redirected": media_item.get("redirected"),
15916
+ "file_name": file_name,
15917
+ "local_path": reused_media_path,
15918
+ "mime_type": mime_type,
15919
+ "size_bytes": media_item.get("size_bytes"),
15920
+ "access_status": "downloaded",
15921
+ "readable_by_agent": True,
15922
+ "extraction": {"status": "unsupported", "text_path": None, "preview": None},
15923
+ }
15924
+ )
15925
+ downloaded_count += 1
15926
+ continue
15927
+
15928
+ download_strategy = _record_detail_media_download_strategy(source_url)
15929
+ download_succeeded = False
15930
+ content: bytes = b""
15931
+ download_meta: JSONObject = {}
15932
+ try:
15933
+ content, download_meta = _record_detail_download_media_content(
15934
+ backend=backend,
15935
+ context=context,
15936
+ source_url=source_url,
15937
+ warnings=warnings,
15938
+ environment_prefix_cache=environment_prefix_cache,
15939
+ requested_strategy=download_strategy,
15940
+ )
15941
+ download_succeeded = True
15942
+ except QingflowApiError as exc:
15943
+ blocked = exc.http_status in {401, 403}
15944
+ if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
15945
+ refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
15946
+ if refreshed_url and refreshed_url != source_url:
15947
+ refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
15948
+ try:
15949
+ content, download_meta = _record_detail_download_media_content(
15950
+ backend=backend,
15951
+ context=context,
15952
+ source_url=refreshed_url,
15953
+ warnings=warnings,
15954
+ environment_prefix_cache=environment_prefix_cache,
15955
+ requested_strategy=(
15956
+ download_strategy
15957
+ if download_strategy == "decrypted_file_url_then_storage_cookie_redirect"
15958
+ else refreshed_strategy
15959
+ ),
13554
15960
  )
13555
15961
  source_url = refreshed_url
13556
15962
  base_item["source_url"] = refreshed_url
@@ -13561,13 +15967,13 @@ def _record_detail_media_assets_payload(
13561
15967
  else:
13562
15968
  warnings.append(
13563
15969
  {
13564
- "code": "MEDIA_ASSET_STORAGE_URL_REFRESHED",
13565
- "asset_id": asset_id,
13566
- "message": "record_get refreshed the record detail once before downloading this media asset.",
15970
+ "code": "FILE_ASSET_STORAGE_URL_REFRESHED",
15971
+ "file_asset_id": file_asset_id,
15972
+ "message": "record_get refreshed the record detail once before downloading this file asset.",
13567
15973
  }
13568
15974
  )
13569
15975
  if not download_succeeded:
13570
- warning_code = "STORAGE_COOKIE_AUTH_FAILED" if blocked and download_strategy != "referer_acl" else "MEDIA_ASSET_DOWNLOAD_FAILED"
15976
+ warning_code = "STORAGE_COOKIE_AUTH_FAILED" if blocked and download_strategy != "referer_acl" else "FILE_ASSET_DOWNLOAD_FAILED"
13571
15977
  items.append(
13572
15978
  {
13573
15979
  **base_item,
@@ -13575,18 +15981,19 @@ def _record_detail_media_assets_payload(
13575
15981
  "storage_cookie_prefix": environment_prefix_cache.get("value"),
13576
15982
  "redirected": False,
13577
15983
  "local_path": None,
13578
- "mime_type": None,
15984
+ "mime_type": _record_detail_mime_from_url(source_url),
13579
15985
  "size_bytes": None,
13580
15986
  "access_status": "blocked_private_url" if blocked else "download_failed",
13581
15987
  "download_strategy": download_strategy,
13582
15988
  "readable_by_agent": False,
15989
+ "extraction": {"status": "failed", "text_path": None, "preview": None},
13583
15990
  }
13584
15991
  )
13585
15992
  warnings.append(
13586
15993
  {
13587
15994
  "code": warning_code,
13588
- "asset_id": asset_id,
13589
- "message": f"record_get could not download image asset {asset_id}: {exc.message}",
15995
+ "file_asset_id": file_asset_id,
15996
+ "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
13590
15997
  "http_status": exc.http_status,
13591
15998
  }
13592
15999
  )
@@ -13594,62 +16001,70 @@ def _record_detail_media_assets_payload(
13594
16001
 
13595
16002
  if not isinstance(content, bytes):
13596
16003
  content = bytes(content or b"")
13597
- mime_type = _record_detail_image_mime_from_bytes(content)
13598
- if not mime_type:
13599
- items.append(
13600
- {
13601
- **base_item,
13602
- **download_meta,
13603
- "local_path": None,
13604
- "mime_type": _record_detail_mime_from_url(source_url),
13605
- "size_bytes": len(content),
13606
- "access_status": "skipped_non_image",
13607
- "readable_by_agent": False,
13608
- }
13609
- )
13610
- continue
13611
- if len(content) > RECORD_GET_MEDIA_MAX_IMAGE_BYTES or total_bytes + len(content) > RECORD_GET_MEDIA_MAX_TOTAL_BYTES:
16004
+ file_name = _record_detail_file_name_from_candidate(candidate, source_url=source_url, fallback_id=file_asset_id)
16005
+ mime_type = _record_detail_file_mime_from_content_or_name(content, source_url=source_url, file_name=file_name)
16006
+ size_bytes = len(content)
16007
+ if size_bytes > RECORD_GET_FILE_MAX_BYTES or total_bytes + size_bytes > RECORD_GET_FILE_MAX_TOTAL_BYTES:
13612
16008
  items.append(
13613
16009
  {
13614
16010
  **base_item,
13615
16011
  **download_meta,
16012
+ "file_name": file_name,
13616
16013
  "local_path": None,
13617
16014
  "mime_type": mime_type,
13618
- "size_bytes": len(content),
16015
+ "size_bytes": size_bytes,
13619
16016
  "access_status": "too_large",
13620
16017
  "readable_by_agent": False,
16018
+ "extraction": {"status": "skipped_too_large", "text_path": None, "preview": None},
13621
16019
  }
13622
16020
  )
13623
16021
  warnings.append(
13624
16022
  {
13625
- "code": "MEDIA_ASSET_SIZE_LIMIT_EXCEEDED",
13626
- "asset_id": asset_id,
13627
- "message": "record_get skipped an image asset because it exceeded the internal media size budget.",
16023
+ "code": "FILE_ASSET_SIZE_LIMIT_EXCEEDED",
16024
+ "file_asset_id": file_asset_id,
16025
+ "message": "record_get skipped a file asset because it exceeded the internal file size budget.",
13628
16026
  }
13629
16027
  )
13630
16028
  continue
13631
16029
 
13632
16030
  ensure_local_dir()
13633
- extension = _record_detail_image_extension(mime_type, source_url)
13634
- local_path = local_dir / f"{asset_id}{extension}"
16031
+ extension = _record_detail_file_extension(mime_type, source_url=source_url, file_name=file_name)
16032
+ local_path = local_dir / f"{file_asset_id}{extension}"
13635
16033
  local_path.write_bytes(content)
13636
- total_bytes += len(content)
13637
- image_count += 1
16034
+ extraction = _record_detail_extract_file_asset_text(
16035
+ content,
16036
+ mime_type=mime_type,
16037
+ file_name=file_name,
16038
+ local_dir=local_dir,
16039
+ file_asset_id=file_asset_id,
16040
+ )
16041
+ if extraction.get("status") == "failed":
16042
+ warnings.append(
16043
+ {
16044
+ "code": "FILE_ASSET_EXTRACTION_FAILED",
16045
+ "file_asset_id": file_asset_id,
16046
+ "message": f"record_get downloaded file asset {file_asset_id}, but text extraction failed.",
16047
+ }
16048
+ )
16049
+ total_bytes += size_bytes
16050
+ downloaded_count += 1
13638
16051
  items.append(
13639
16052
  {
13640
16053
  **base_item,
13641
16054
  **download_meta,
16055
+ "file_name": file_name,
13642
16056
  "local_path": str(local_path),
13643
16057
  "mime_type": mime_type,
13644
- "size_bytes": len(content),
16058
+ "size_bytes": size_bytes,
13645
16059
  "access_status": "downloaded",
13646
- "readable_by_agent": True,
16060
+ "readable_by_agent": extraction.get("status") == "ok" or _record_detail_image_mime_from_bytes(content) is not None,
16061
+ "extraction": extraction,
13647
16062
  }
13648
16063
  )
13649
16064
 
13650
16065
  if not items:
13651
16066
  status = "none"
13652
- elif any(item.get("access_status") != "downloaded" for item in items):
16067
+ elif stopped_for_time_budget or any(item.get("access_status") != "downloaded" or cast(JSONObject, item.get("extraction", {})).get("status") == "failed" for item in items):
13653
16068
  status = "partial"
13654
16069
  else:
13655
16070
  status = "ok"
@@ -13736,6 +16151,104 @@ def _record_detail_media_candidates_from_field(
13736
16151
  return candidates
13737
16152
 
13738
16153
 
16154
+ def _record_detail_file_candidates_from_field(
16155
+ field: JSONObject,
16156
+ *,
16157
+ source_app_key: str | None,
16158
+ source_record_id: str | None,
16159
+ forced_source: str | None,
16160
+ ) -> list[JSONObject]:
16161
+ field_id = _coerce_count(field.get("field_id"))
16162
+ field_title = _normalize_optional_text(field.get("title"))
16163
+ field_type = _normalize_optional_text(field.get("type"))
16164
+ candidates: list[JSONObject] = []
16165
+ seen_urls: set[str] = set()
16166
+
16167
+ def add_candidate(url: str | None, *, source: str, path: str, name: str | None = None, file_hint: bool = False) -> None:
16168
+ normalized_url = _record_detail_normalize_media_url(url)
16169
+ if not normalized_url or normalized_url in seen_urls:
16170
+ return
16171
+ if not _record_detail_supported_file_url(normalized_url):
16172
+ return
16173
+ if not file_hint and not _record_detail_url_or_name_looks_like_file(normalized_url, name):
16174
+ return
16175
+ seen_urls.add(normalized_url)
16176
+ candidates.append(
16177
+ {
16178
+ "_owner": field,
16179
+ "kind": "file",
16180
+ "source": forced_source or source,
16181
+ "source_path": path,
16182
+ "field_id": field_id,
16183
+ "field_title": field_title,
16184
+ "source_app_key": source_app_key,
16185
+ "source_record_id": source_record_id,
16186
+ "source_url": normalized_url,
16187
+ "file_name": name,
16188
+ }
16189
+ )
16190
+
16191
+ def candidate_name_from_mapping(value: dict[Any, Any]) -> str | None:
16192
+ for key, item in value.items():
16193
+ if _record_detail_media_key(key) in _RECORD_FILE_NAME_KEYS:
16194
+ text = _normalize_optional_text(item) if not isinstance(item, (dict, list)) else None
16195
+ if text:
16196
+ return text
16197
+ return None
16198
+
16199
+ def scan_text(value: str, *, path: str, source: str, file_hint: bool = False) -> None:
16200
+ for match in _RECORD_MEDIA_IMG_SRC_RE.finditer(value):
16201
+ add_candidate(match.group(1), source="rich_text", path=path, file_hint=True)
16202
+ for match in _RECORD_MEDIA_MD_IMAGE_RE.finditer(value):
16203
+ add_candidate(match.group(1), source="rich_text", path=path, file_hint=True)
16204
+ for match in _RECORD_MEDIA_URL_RE.finditer(value):
16205
+ add_candidate(match.group(0), source=source, path=path, file_hint=file_hint)
16206
+
16207
+ def scan_value(value: JSONValue, *, path: str, source: str, file_hint: bool = False) -> None:
16208
+ if isinstance(value, str):
16209
+ scan_text(value, path=path, source=source, file_hint=file_hint)
16210
+ return
16211
+ if isinstance(value, list):
16212
+ for index, item in enumerate(value):
16213
+ scan_value(cast(JSONValue, item), path=f"{path}[{index}]", source=source, file_hint=file_hint)
16214
+ return
16215
+ if not isinstance(value, dict):
16216
+ return
16217
+
16218
+ attachment = _extract_attachment_item(cast(JSONValue, value))
16219
+ if attachment:
16220
+ add_candidate(
16221
+ _normalize_optional_text(attachment.get("value")),
16222
+ source="attachment" if source == "attachment" else source,
16223
+ path=path,
16224
+ name=_normalize_optional_text(attachment.get("name")),
16225
+ file_hint=True,
16226
+ )
16227
+ candidate_name = candidate_name_from_mapping(value)
16228
+ for key, item in value.items():
16229
+ normalized_key = _record_detail_media_key(key)
16230
+ item_text = _normalize_optional_text(item) if not isinstance(item, (dict, list)) else None
16231
+ key_source = source
16232
+ key_file_hint = file_hint
16233
+ if normalized_key in _RECORD_FILE_URL_KEYS:
16234
+ key_source = "attachment" if source == "attachment" else ("image_field" if source != "subtable" else "subtable")
16235
+ key_file_hint = source == "attachment" or normalized_key not in {"value", "url"}
16236
+ if item_text:
16237
+ add_candidate(item_text, source=key_source, path=f"{path}.{key}", name=candidate_name, file_hint=key_file_hint)
16238
+ scan_value(cast(JSONValue, item), path=f"{path}.{key}", source=key_source, file_hint=key_file_hint)
16239
+
16240
+ value = cast(JSONValue, field.get("value"))
16241
+ display_value = cast(JSONValue, field.get("display_value"))
16242
+ if field_type == "attachment":
16243
+ scan_value(value, path="value", source="attachment", file_hint=True)
16244
+ elif field_type == "subtable":
16245
+ scan_value(value, path="value", source="subtable", file_hint=True)
16246
+ else:
16247
+ scan_value(value, path="value", source="image_field", file_hint=False)
16248
+ scan_value(display_value, path="display_value", source="rich_text", file_hint=False)
16249
+ return candidates
16250
+
16251
+
13739
16252
  def _record_detail_attach_asset_id(field: JSONObject, asset_id: str) -> None:
13740
16253
  asset_ids = field.get("asset_ids")
13741
16254
  if not isinstance(asset_ids, list):
@@ -13745,6 +16258,15 @@ def _record_detail_attach_asset_id(field: JSONObject, asset_id: str) -> None:
13745
16258
  asset_ids.append(asset_id)
13746
16259
 
13747
16260
 
16261
+ def _record_detail_attach_file_asset_id(field: JSONObject, file_asset_id: str) -> None:
16262
+ asset_ids = field.get("file_asset_ids")
16263
+ if not isinstance(asset_ids, list):
16264
+ asset_ids = []
16265
+ field["file_asset_ids"] = asset_ids
16266
+ if file_asset_id not in asset_ids:
16267
+ asset_ids.append(file_asset_id)
16268
+
16269
+
13748
16270
  def _record_detail_media_asset_base_item(candidate: JSONObject, *, asset_id: str) -> JSONObject:
13749
16271
  payload: JSONObject = {
13750
16272
  "asset_id": asset_id,
@@ -13760,12 +16282,77 @@ def _record_detail_media_asset_base_item(candidate: JSONObject, *, asset_id: str
13760
16282
  return payload
13761
16283
 
13762
16284
 
16285
+ def _record_detail_file_asset_base_item(candidate: JSONObject, *, file_asset_id: str) -> JSONObject:
16286
+ payload: JSONObject = {
16287
+ "file_asset_id": file_asset_id,
16288
+ "kind": candidate.get("kind") or "file",
16289
+ "source": candidate.get("source") or "attachment",
16290
+ "field_id": candidate.get("field_id"),
16291
+ "field_title": candidate.get("field_title"),
16292
+ "source_url": candidate.get("source_url"),
16293
+ }
16294
+ for key in ("source_path", "source_app_key", "source_record_id", "file_name"):
16295
+ if candidate.get(key) not in (None, ""):
16296
+ payload[key] = candidate.get(key)
16297
+ return payload
16298
+
16299
+
16300
+ def _record_detail_media_assets_by_url(media_assets: JSONObject) -> dict[str, JSONObject]:
16301
+ items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
16302
+ result: dict[str, JSONObject] = {}
16303
+ for item in items:
16304
+ if not isinstance(item, dict):
16305
+ continue
16306
+ source_url = _normalize_optional_text(item.get("source_url"))
16307
+ if source_url and source_url not in result:
16308
+ result[source_url] = cast(JSONObject, item)
16309
+ return result
16310
+
16311
+
16312
+ def _record_detail_media_assets_by_asset_id(media_assets: JSONObject) -> dict[str, JSONObject]:
16313
+ items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
16314
+ result: dict[str, JSONObject] = {}
16315
+ for item in items:
16316
+ if not isinstance(item, dict):
16317
+ continue
16318
+ asset_id = _normalize_optional_text(item.get("asset_id"))
16319
+ if asset_id and asset_id not in result:
16320
+ result[asset_id] = cast(JSONObject, item)
16321
+ return result
16322
+
16323
+
16324
+ def _record_detail_media_item_from_owner_asset_ids(
16325
+ owner: JSONObject,
16326
+ media_by_asset_id: dict[str, JSONObject],
16327
+ candidate: JSONObject,
16328
+ ) -> JSONObject | None:
16329
+ asset_ids = owner.get("asset_ids") if isinstance(owner.get("asset_ids"), list) else []
16330
+ if len(asset_ids) != 1:
16331
+ return None
16332
+ media_item = media_by_asset_id.get(str(asset_ids[0]))
16333
+ if not isinstance(media_item, dict):
16334
+ return None
16335
+ candidate_name = _normalize_optional_text(candidate.get("file_name"))
16336
+ media_name = _normalize_optional_text(media_item.get("file_name"))
16337
+ if candidate_name and media_name and candidate_name != media_name:
16338
+ return None
16339
+ if candidate.get("field_id") not in (None, media_item.get("field_id")):
16340
+ return None
16341
+ return media_item
16342
+
16343
+
13763
16344
  def _record_detail_media_assets_dir(run_id: str) -> Path:
13764
16345
  custom_home = os.environ.get("QINGFLOW_MCP_RECORD_ASSETS_HOME")
13765
16346
  base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-assets"
13766
16347
  return base_dir / run_id
13767
16348
 
13768
16349
 
16350
+ def _record_detail_file_assets_dir(run_id: str) -> Path:
16351
+ custom_home = os.environ.get("QINGFLOW_MCP_RECORD_FILES_HOME")
16352
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-files"
16353
+ return base_dir / run_id
16354
+
16355
+
13769
16356
  def _record_detail_media_download_headers(context: BackendRequestContext) -> dict[str, str]:
13770
16357
  origin = _record_detail_context_origin(context)
13771
16358
  return {"User-Agent": DEFAULT_USER_AGENT, "Referer": f"{origin}/", "Origin": origin}
@@ -13970,6 +16557,11 @@ def _record_detail_supported_media_url(url: str) -> bool:
13970
16557
  return parsed.scheme.lower() in {"http", "https"} or _record_detail_is_download_file_url(url)
13971
16558
 
13972
16559
 
16560
+ def _record_detail_supported_file_url(url: str) -> bool:
16561
+ parsed = urlsplit(url)
16562
+ return parsed.scheme.lower() in {"http", "https"} or _record_detail_is_download_file_url(url)
16563
+
16564
+
13973
16565
  def _record_detail_media_key(key: Any) -> str:
13974
16566
  return str(key or "").strip().replace("-", "_").lower()
13975
16567
 
@@ -13982,6 +16574,18 @@ def _record_detail_url_or_name_looks_like_image(url: str, name: str | None = Non
13982
16574
  return False
13983
16575
 
13984
16576
 
16577
+ def _record_detail_url_or_name_looks_like_file(url: str, name: str | None = None) -> bool:
16578
+ if _record_detail_is_download_file_url(url) or _record_detail_is_qingflow_storage_url(url):
16579
+ return True
16580
+ for value in (url, name or ""):
16581
+ if not value:
16582
+ continue
16583
+ path = unquote(urlsplit(value).path).lower() or value.lower()
16584
+ if any(path.endswith(extension) for extension in _RECORD_FILE_EXTENSIONS):
16585
+ return True
16586
+ return False
16587
+
16588
+
13985
16589
  def _record_detail_mime_from_url(url: str) -> str | None:
13986
16590
  path = unquote(urlsplit(url).path).lower()
13987
16591
  if path.endswith(".png"):
@@ -13996,9 +16600,210 @@ def _record_detail_mime_from_url(url: str) -> str | None:
13996
16600
  return "image/bmp"
13997
16601
  if path.endswith(".svg"):
13998
16602
  return "image/svg+xml"
16603
+ if path.endswith(".pdf"):
16604
+ return "application/pdf"
16605
+ if path.endswith(".docx"):
16606
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
16607
+ if path.endswith(".xlsx"):
16608
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
16609
+ if path.endswith(".xlsm"):
16610
+ return "application/vnd.ms-excel.sheet.macroEnabled.12"
16611
+ if path.endswith(".csv"):
16612
+ return "text/csv"
16613
+ if path.endswith(".txt") or path.endswith(".text"):
16614
+ return "text/plain"
16615
+ if path.endswith(".md"):
16616
+ return "text/markdown"
16617
+ if path.endswith(".json"):
16618
+ return "application/json"
16619
+ return None
16620
+
16621
+
16622
+ def _record_detail_file_name_from_candidate(candidate: JSONObject, *, source_url: str, fallback_id: str) -> str:
16623
+ raw_name = _normalize_optional_text(candidate.get("file_name"))
16624
+ if raw_name:
16625
+ return raw_name
16626
+ path_name = Path(unquote(urlsplit(source_url).path)).name
16627
+ if path_name:
16628
+ return path_name
16629
+ return fallback_id
16630
+
16631
+
16632
+ def _record_detail_file_mime_from_content_or_name(content: bytes, *, source_url: str, file_name: str) -> str | None:
16633
+ image_mime = _record_detail_image_mime_from_bytes(content)
16634
+ if image_mime:
16635
+ return image_mime
16636
+ if content.startswith(b"%PDF"):
16637
+ return "application/pdf"
16638
+ guessed = mimetypes.guess_type(file_name or source_url)[0] or _record_detail_mime_from_url(source_url)
16639
+ if guessed:
16640
+ return guessed
16641
+ lowered = (file_name or source_url).lower()
16642
+ if lowered.endswith(".docx"):
16643
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
16644
+ if lowered.endswith(".xlsx"):
16645
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
16646
+ if lowered.endswith(".xlsm"):
16647
+ return "application/vnd.ms-excel.sheet.macroEnabled.12"
16648
+ if lowered.endswith(".csv"):
16649
+ return "text/csv"
16650
+ if lowered.endswith(".json"):
16651
+ return "application/json"
16652
+ if _record_detail_bytes_look_like_text(content):
16653
+ return "text/plain"
13999
16654
  return None
14000
16655
 
14001
16656
 
16657
+ def _record_detail_file_extension(mime_type: str | None, *, source_url: str, file_name: str) -> str:
16658
+ for value in (file_name, unquote(urlsplit(source_url).path)):
16659
+ suffix = Path(value).suffix.lower()
16660
+ if suffix and re.fullmatch(r"\.[a-z0-9]{1,10}", suffix):
16661
+ return suffix
16662
+ if mime_type:
16663
+ extension = mimetypes.guess_extension(mime_type)
16664
+ if extension:
16665
+ return ".jpg" if extension == ".jpe" else extension
16666
+ return ".bin"
16667
+
16668
+
16669
+ def _record_detail_bytes_look_like_text(content: bytes) -> bool:
16670
+ if not content:
16671
+ return True
16672
+ sample = content[:4096]
16673
+ if b"\x00" in sample:
16674
+ return False
16675
+ try:
16676
+ sample.decode("utf-8")
16677
+ return True
16678
+ except UnicodeDecodeError:
16679
+ try:
16680
+ sample.decode("gb18030")
16681
+ return True
16682
+ except UnicodeDecodeError:
16683
+ return False
16684
+
16685
+
16686
+ def _record_detail_extract_file_asset_text(
16687
+ content: bytes,
16688
+ *,
16689
+ mime_type: str | None,
16690
+ file_name: str,
16691
+ local_dir: Path,
16692
+ file_asset_id: str,
16693
+ ) -> JSONObject:
16694
+ normalized_name = file_name.lower()
16695
+ try:
16696
+ text: str | None
16697
+ if normalized_name.endswith(".docx") or mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
16698
+ text = _record_detail_extract_docx_text(content)
16699
+ elif normalized_name.endswith((".xlsx", ".xlsm")) or mime_type in {
16700
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
16701
+ "application/vnd.ms-excel.sheet.macroEnabled.12",
16702
+ }:
16703
+ text = _record_detail_extract_xlsx_text(content)
16704
+ elif normalized_name.endswith(".pdf") or mime_type == "application/pdf":
16705
+ text = _record_detail_extract_pdf_text(content)
16706
+ elif normalized_name.endswith(".json") or mime_type == "application/json":
16707
+ text = _record_detail_decode_json_text(content)
16708
+ elif normalized_name.endswith((".csv", ".txt", ".text", ".md")) or (mime_type or "").startswith("text/"):
16709
+ text = _record_detail_decode_text(content)
16710
+ else:
16711
+ text = None
16712
+ except Exception as exc:
16713
+ return {"status": "failed", "text_path": None, "preview": None, "error": str(exc)}
16714
+ if text is None:
16715
+ return {"status": "unsupported", "text_path": None, "preview": None}
16716
+ text_path = local_dir / f"{file_asset_id}.txt"
16717
+ text_path.write_text(text, encoding="utf-8")
16718
+ preview = text[:RECORD_GET_FILE_EXTRACT_PREVIEW_CHARS]
16719
+ return {
16720
+ "status": "ok",
16721
+ "text_path": str(text_path),
16722
+ "preview": preview,
16723
+ "preview_truncated": len(text) > RECORD_GET_FILE_EXTRACT_PREVIEW_CHARS,
16724
+ }
16725
+
16726
+
16727
+ def _record_detail_decode_text(content: bytes) -> str:
16728
+ for encoding in ("utf-8-sig", "utf-8", "gb18030"):
16729
+ try:
16730
+ return content.decode(encoding)
16731
+ except UnicodeDecodeError:
16732
+ continue
16733
+ return content.decode("utf-8", errors="replace")
16734
+
16735
+
16736
+ def _record_detail_decode_json_text(content: bytes) -> str:
16737
+ text = _record_detail_decode_text(content)
16738
+ try:
16739
+ return json.dumps(json.loads(text), ensure_ascii=False, indent=2)
16740
+ except ValueError:
16741
+ return text
16742
+
16743
+
16744
+ def _record_detail_extract_docx_text(content: bytes) -> str:
16745
+ with zipfile.ZipFile(BytesIO(content)) as archive:
16746
+ document_xml = archive.read("word/document.xml")
16747
+ root = ElementTree.fromstring(document_xml)
16748
+ ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
16749
+ body = root.find(f"{ns}body")
16750
+ if body is None:
16751
+ return ""
16752
+
16753
+ def node_text(node: ElementTree.Element) -> str:
16754
+ return "".join(text_node.text or "" for text_node in node.iter(f"{ns}t")).strip()
16755
+
16756
+ lines: list[str] = []
16757
+ for child in list(body):
16758
+ if child.tag == f"{ns}p":
16759
+ line = node_text(child)
16760
+ if line:
16761
+ lines.append(line)
16762
+ elif child.tag == f"{ns}tbl":
16763
+ for row in child.iter(f"{ns}tr"):
16764
+ cells = [node_text(cell) for cell in row.iter(f"{ns}tc")]
16765
+ cells = [cell for cell in cells if cell]
16766
+ if cells:
16767
+ lines.append(" | ".join(cells))
16768
+ return "\n".join(lines)
16769
+
16770
+
16771
+ def _record_detail_extract_xlsx_text(content: bytes) -> str:
16772
+ from openpyxl import load_workbook
16773
+
16774
+ workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
16775
+ try:
16776
+ parts: list[str] = []
16777
+ for sheet in workbook.worksheets:
16778
+ parts.append(f"# {sheet.title}")
16779
+ row_count = 0
16780
+ for row in sheet.iter_rows(values_only=True):
16781
+ row_count += 1
16782
+ if row_count > RECORD_GET_FILE_EXTRACT_XLSX_MAX_ROWS_PER_SHEET:
16783
+ parts.append(f"... skipped rows after {RECORD_GET_FILE_EXTRACT_XLSX_MAX_ROWS_PER_SHEET}")
16784
+ break
16785
+ cells = ["" if cell is None else str(cell) for cell in row]
16786
+ if any(cell for cell in cells):
16787
+ parts.append("\t".join(cells).rstrip())
16788
+ return "\n".join(parts)
16789
+ finally:
16790
+ workbook.close()
16791
+
16792
+
16793
+ def _record_detail_extract_pdf_text(content: bytes) -> str:
16794
+ from pypdf import PdfReader
16795
+
16796
+ reader = PdfReader(BytesIO(content))
16797
+ lines: list[str] = []
16798
+ for index, page in enumerate(reader.pages[:RECORD_GET_FILE_EXTRACT_PDF_MAX_PAGES], start=1):
16799
+ page_text = page.extract_text() or ""
16800
+ if page_text.strip():
16801
+ lines.append(f"# Page {index}\n{page_text.strip()}")
16802
+ if len(reader.pages) > RECORD_GET_FILE_EXTRACT_PDF_MAX_PAGES:
16803
+ lines.append(f"... skipped pages after {RECORD_GET_FILE_EXTRACT_PDF_MAX_PAGES}")
16804
+ return "\n\n".join(lines)
16805
+
16806
+
14002
16807
  def _record_detail_image_mime_from_bytes(content: bytes) -> str | None:
14003
16808
  if content.startswith(b"\x89PNG\r\n\x1a\n"):
14004
16809
  return "image/png"
@@ -14038,6 +16843,7 @@ def _record_detail_context_integrity(
14038
16843
  workflow_logs: JSONObject,
14039
16844
  associated_resources: list[JSONObject],
14040
16845
  media_assets: JSONObject,
16846
+ file_assets: JSONObject,
14041
16847
  unavailable_context: list[JSONObject],
14042
16848
  ) -> JSONObject:
14043
16849
  reference_unavailable = any(item.get("target_detail_completeness") != "full" for item in references)
@@ -14049,6 +16855,7 @@ def _record_detail_context_integrity(
14049
16855
  "workflow_logs": workflow_logs.get("status") or "unknown",
14050
16856
  "associated_resources": "full" if associated_resources or not any(item.get("section") == "associated_resources" for item in unavailable_context) else "unavailable",
14051
16857
  "media_assets": media_assets.get("status") or "unknown",
16858
+ "file_assets": file_assets.get("status") or "unknown",
14052
16859
  "unavailable_count": len(unavailable_context),
14053
16860
  "safe_for_record_fact_conclusion": True,
14054
16861
  "safe_for_full_log_conclusion": False,
@@ -14063,6 +16870,7 @@ def _record_detail_semantic_context(payload: JSONObject) -> str:
14063
16870
  fields = payload.get("fields") if isinstance(payload.get("fields"), list) else []
14064
16871
  references = payload.get("references") if isinstance(payload.get("references"), list) else []
14065
16872
  media_assets = payload.get("media_assets") if isinstance(payload.get("media_assets"), dict) else {}
16873
+ file_assets = payload.get("file_assets") if isinstance(payload.get("file_assets"), dict) else {}
14066
16874
  data_logs = payload.get("data_logs") if isinstance(payload.get("data_logs"), dict) else {}
14067
16875
  workflow_logs = payload.get("workflow_logs") if isinstance(payload.get("workflow_logs"), dict) else {}
14068
16876
  associated_resources = payload.get("associated_resources") if isinstance(payload.get("associated_resources"), list) else []
@@ -14110,6 +16918,20 @@ def _record_detail_semantic_context(payload: JSONObject) -> str:
14110
16918
  f"(fieldId={_semantic_escape(item.get('field_id'))}),"
14111
16919
  f"本地路径:{_semantic_escape(item.get('local_path')) or '无'},{_semantic_escape(readable_text)}。"
14112
16920
  )
16921
+ file_items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
16922
+ if file_items:
16923
+ lines.extend(["", "文件附件:"])
16924
+ for item in file_items:
16925
+ if not isinstance(item, dict):
16926
+ continue
16927
+ extraction = item.get("extraction") if isinstance(item.get("extraction"), dict) else {}
16928
+ readable_text = "可由智能体读取" if item.get("readable_by_agent") else f"不可直接读取({item.get('access_status') or 'unknown'})"
16929
+ lines.append(
16930
+ f"- 文件 {_semantic_escape(item.get('file_asset_id'))}「{_semantic_escape(item.get('file_name'))}」"
16931
+ f"来自字段「{_semantic_escape(item.get('field_title'))}」(fieldId={_semantic_escape(item.get('field_id'))}),"
16932
+ f"本地路径:{_semantic_escape(item.get('local_path')) or '无'},"
16933
+ f"提取文本:{_semantic_escape(extraction.get('text_path')) or '无'},{_semantic_escape(readable_text)}。"
16934
+ )
14113
16935
  lines.extend(["", "最近数据日志:"])
14114
16936
  _append_semantic_log_lines(lines, data_logs)
14115
16937
  lines.extend(["", "最近流程日志:"])
@@ -14227,7 +17049,6 @@ def _build_record_list_lookup_payload(
14227
17049
  query: str | None,
14228
17050
  items: list[JSONObject],
14229
17051
  pagination: JSONObject,
14230
- limit: int,
14231
17052
  ) -> JSONObject | None:
14232
17053
  if not query:
14233
17054
  return None
@@ -14236,17 +17057,7 @@ def _build_record_list_lookup_payload(
14236
17057
  if returned_items is None:
14237
17058
  returned_items = len(items)
14238
17059
  truncated = bool(reported_total is not None and reported_total > returned_items)
14239
- scored: list[tuple[int, int, JSONObject]] = []
14240
- for index, item in enumerate(items):
14241
- candidate = _record_list_candidate_payload(item, query=query)
14242
- score = _coerce_count(candidate.get("score")) or 0
14243
- if score <= 0:
14244
- candidate["score"] = 40
14245
- candidate["match_reason"] = "backend_match_without_selected_field_evidence"
14246
- scored.append((int(candidate["score"]), index, candidate))
14247
- scored.sort(key=lambda entry: (-entry[0], entry[1]))
14248
- candidates = [entry[2] for entry in scored[: min(limit, LOOKUP_CONFIRMATION_CANDIDATE_LIMIT)]]
14249
- confidence = _record_list_lookup_confidence(candidates, truncated=truncated)
17060
+ confidence = _record_list_lookup_confidence(returned_items=returned_items, reported_total=reported_total, truncated=truncated)
14250
17061
  next_action = {
14251
17062
  "single_high": "record_get",
14252
17063
  "multiple": "ask_user",
@@ -14256,140 +17067,25 @@ def _build_record_list_lookup_payload(
14256
17067
  return {
14257
17068
  "mode": "candidate_locator",
14258
17069
  "query": query,
14259
- "reported_total": reported_total,
14260
- "returned_candidates": len(candidates),
17070
+ "total_count": reported_total,
17071
+ "returned_count": returned_items,
17072
+ "truncated": truncated,
14261
17073
  "confidence": confidence,
14262
17074
  "next_action": next_action,
14263
- "candidates": candidates,
14264
17075
  }
14265
17076
 
14266
17077
 
14267
- def _record_list_lookup_confidence(candidates: list[JSONObject], *, truncated: bool) -> str:
14268
- if not candidates:
17078
+ def _record_list_lookup_confidence(*, returned_items: int, reported_total: int | None, truncated: bool) -> str:
17079
+ if returned_items <= 0:
14269
17080
  return "none"
14270
17081
  if truncated:
14271
17082
  return "truncated"
14272
- if len(candidates) == 1:
14273
- return "single_high"
14274
- top = _coerce_count(candidates[0].get("score")) or 0
14275
- second = _coerce_count(candidates[1].get("score")) or 0
14276
- if top >= 90 and (top - second) >= 10:
17083
+ effective_total = reported_total if reported_total is not None else returned_items
17084
+ if effective_total == 1:
14277
17085
  return "single_high"
14278
17086
  return "multiple"
14279
17087
 
14280
17088
 
14281
- def _record_list_candidate_payload(item: JSONObject, *, query: str) -> JSONObject:
14282
- query_norm = _normalize_lookup_query_text(query)
14283
- matched_fields: list[JSONObject] = []
14284
- best_score = 0
14285
- for key, value in item.items():
14286
- if key in {"normalized_record", "normalized_ambiguous_fields"}:
14287
- continue
14288
- value_text = _record_list_value_text(value)
14289
- if not value_text:
14290
- continue
14291
- score, match_type = _record_list_field_match_score(key, value_text, query_norm)
14292
- if score <= 0:
14293
- continue
14294
- best_score = max(best_score, score)
14295
- matched_fields.append(
14296
- {
14297
- "title": key,
14298
- "value": _truncate_text(value_text, 120),
14299
- "match_type": match_type,
14300
- "score": score,
14301
- }
14302
- )
14303
- if matched_fields:
14304
- best_score = min(100, best_score + min(10, (len(matched_fields) - 1) * 3))
14305
- record_id = _normalize_optional_text(item.get("record_id")) or _normalize_optional_text(item.get("apply_id"))
14306
- display_fields = _record_list_display_fields(item)
14307
- return {
14308
- "record_id": record_id,
14309
- "title": _record_list_candidate_title(item) or record_id,
14310
- "score": best_score,
14311
- "matched_fields": sorted(matched_fields, key=lambda entry: int(entry.get("score") or 0), reverse=True)[:6],
14312
- "display_fields": display_fields,
14313
- }
14314
-
14315
-
14316
- def _record_list_field_match_score(field_title: str, value_text: str, query_norm: str) -> tuple[int, str]:
14317
- if not query_norm:
14318
- return 0, "none"
14319
- value_norm = _normalize_lookup_query_text(value_text)
14320
- if not value_norm:
14321
- return 0, "none"
14322
- title_norm = _normalize_lookup_query_text(field_title)
14323
- title_like = _record_list_title_like(field_title)
14324
- id_like = field_title in {"record_id", "apply_id"} or "编号" in field_title or "id" == title_norm
14325
- if value_norm == query_norm:
14326
- if id_like:
14327
- return 100, "exact_identifier"
14328
- if title_like:
14329
- return 96, "exact_title"
14330
- return 86, "exact"
14331
- if query_norm in value_norm:
14332
- if id_like:
14333
- return 92, "contains_identifier"
14334
- if title_like:
14335
- return 88, "contains_title"
14336
- return 68, "contains"
14337
- if title_like and value_norm in query_norm and len(value_norm) >= 2:
14338
- return 72, "reverse_contains_title"
14339
- return 0, "none"
14340
-
14341
-
14342
- def _record_list_candidate_title(item: JSONObject) -> str | None:
14343
- for key, value in item.items():
14344
- if key in {"record_id", "apply_id"}:
14345
- continue
14346
- if _record_list_title_like(key):
14347
- text = _record_list_value_text(value)
14348
- if text:
14349
- return _truncate_text(text, 80)
14350
- for key, value in item.items():
14351
- if key in {"record_id", "apply_id", "normalized_record", "normalized_ambiguous_fields"}:
14352
- continue
14353
- text = _record_list_value_text(value)
14354
- if text:
14355
- return _truncate_text(text, 80)
14356
- return None
14357
-
14358
-
14359
- def _record_list_display_fields(item: JSONObject) -> list[JSONObject]:
14360
- display: list[JSONObject] = []
14361
- for key, value in item.items():
14362
- if key in {"record_id", "apply_id", "normalized_record", "normalized_ambiguous_fields"}:
14363
- continue
14364
- text = _record_list_value_text(value)
14365
- if not text:
14366
- continue
14367
- display.append({"title": key, "value": _truncate_text(text, 120)})
14368
- if len(display) >= 6:
14369
- break
14370
- return display
14371
-
14372
-
14373
- def _record_list_title_like(field_title: str) -> bool:
14374
- lowered = field_title.lower()
14375
- return any(
14376
- token in field_title or token in lowered
14377
- for token in ("标题", "名称", "名字", "客户", "公司", "项目", "商机", "线索", "主题", "编号", "name", "title", "customer", "company")
14378
- )
14379
-
14380
-
14381
- def _record_list_value_text(value: JSONValue) -> str:
14382
- if value is None:
14383
- return ""
14384
- if isinstance(value, str):
14385
- return value.strip()
14386
- return _stringify_json(value).strip()
14387
-
14388
-
14389
- def _normalize_lookup_query_text(value: str) -> str:
14390
- return re.sub(r"\s+", "", value).lower()
14391
-
14392
-
14393
17089
  def _truncate_text(value: str, limit: int) -> str:
14394
17090
  if len(value) <= limit:
14395
17091
  return value
@@ -17423,9 +20119,122 @@ def _write_format_for_field(field: FormField) -> JSONObject:
17423
20119
  return _write_support_payload(support_level="full", kind="boolean_label", examples=["是", "否"])
17424
20120
  if field.que_type in DATE_QUE_TYPES:
17425
20121
  return _write_support_payload(support_level="full", kind="date_string", examples=["2026-03-13 10:00:00"])
20122
+ if field.que_type == 8:
20123
+ allow_decimal = bool((field.raw or {}).get("canDecimal"))
20124
+ payload = _write_support_payload(
20125
+ support_level="full",
20126
+ kind="amount_number",
20127
+ examples=[100.5 if allow_decimal else 100],
20128
+ )
20129
+ payload["allow_decimal"] = allow_decimal
20130
+ return payload
17426
20131
  return _write_support_payload(support_level="full", kind="scalar_text")
17427
20132
 
17428
20133
 
20134
+ def _ready_schema_format_hint(kind: str, write_format: JSONObject) -> str:
20135
+ if kind == "member":
20136
+ return "可直接填成员姓名;唯一匹配会自动解析,重名时会返回候选确认。也可传成员 id/value 对象。"
20137
+ if kind == "department":
20138
+ return "可直接填部门名称;唯一匹配会自动解析,重名时会返回候选确认。也可传部门 id/value 对象。"
20139
+ if kind == "relation":
20140
+ return "可传目标记录 apply_id/record_id 对象,也可填目标记录的可搜索文本;多候选时会返回确认。"
20141
+ if kind == "attachment":
20142
+ return "先调用 file_upload_local 上传文件,再写入上传返回的附件对象或 value/name。"
20143
+ if kind == "subtable":
20144
+ return "传 {'rows': [{...}]} 或直接传行对象数组;每行 key 使用子字段标题。"
20145
+ if kind == "address":
20146
+ return "传省/市/区/详细地址对象、地址明细字符串,或后端地址 parts 数组。"
20147
+ if kind == "single_select":
20148
+ return "传 options 中的一个选项文本。"
20149
+ if kind == "multi_select":
20150
+ return "传 options 中的多个选项文本数组。"
20151
+ if kind == "boolean":
20152
+ return "传 '是' 或 '否'。"
20153
+ if kind == "date":
20154
+ return "推荐传 'YYYY-MM-DD HH:MM:SS';只有日期时可传 'YYYY-MM-DD'。"
20155
+ if kind == "number":
20156
+ write_kind = _normalize_optional_text(write_format.get("kind"))
20157
+ if write_kind == "amount_number":
20158
+ if bool(write_format.get("allow_decimal")):
20159
+ return "传数字或数字字符串,支持小数。"
20160
+ return "传整数或整数字符串;该字段后端不接受小数。"
20161
+ return "传数字或数字字符串。"
20162
+ if kind == "unsupported":
20163
+ reason = _normalize_optional_text(write_format.get("reason"))
20164
+ return reason or "该字段不支持直接写入。"
20165
+ return "传文本值。"
20166
+
20167
+
20168
+ def _ready_schema_example_value(
20169
+ kind: str,
20170
+ field: FormField,
20171
+ write_format: JSONObject,
20172
+ *,
20173
+ row_fields: list[JSONObject],
20174
+ ) -> JSONValue:
20175
+ if kind == "member":
20176
+ return "张三"
20177
+ if kind == "department":
20178
+ return "直销部"
20179
+ if kind == "relation":
20180
+ return {"apply_id": "5001"}
20181
+ if kind == "attachment":
20182
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
20183
+ if kind == "subtable":
20184
+ row: JSONObject = {}
20185
+ for item in row_fields:
20186
+ if not isinstance(item, dict):
20187
+ continue
20188
+ title = _normalize_optional_text(item.get("title"))
20189
+ if not title:
20190
+ continue
20191
+ row[title] = item.get("example_value", _ready_schema_template_scalar(item.get("kind")))
20192
+ if not row:
20193
+ row = {"子字段": "值"}
20194
+ return {"rows": [row]}
20195
+ if kind == "address":
20196
+ examples = write_format.get("examples")
20197
+ if isinstance(examples, list) and examples:
20198
+ return deepcopy(cast(JSONValue, examples[0]))
20199
+ return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
20200
+ if kind == "single_select":
20201
+ return field.options[0] if field.options else "选项A"
20202
+ if kind == "multi_select":
20203
+ return [field.options[0]] if field.options else ["选项A"]
20204
+ if kind == "boolean":
20205
+ return "是"
20206
+ if kind == "date":
20207
+ return "2026-03-13 10:00:00"
20208
+ if kind == "number":
20209
+ return 100
20210
+ if kind == "unsupported":
20211
+ return None
20212
+ return "示例文本"
20213
+
20214
+
20215
+ def _ready_schema_template_scalar(kind: Any) -> JSONValue:
20216
+ normalized = _normalize_optional_text(kind)
20217
+ if normalized == "number":
20218
+ return 100
20219
+ if normalized == "date":
20220
+ return "2026-03-13 10:00:00"
20221
+ if normalized == "boolean":
20222
+ return "是"
20223
+ if normalized == "member":
20224
+ return "张三"
20225
+ if normalized == "department":
20226
+ return "直销部"
20227
+ if normalized == "relation":
20228
+ return {"apply_id": "5001"}
20229
+ if normalized == "multi_select":
20230
+ return ["选项A"]
20231
+ if normalized == "attachment":
20232
+ return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
20233
+ if normalized == "address":
20234
+ return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
20235
+ return "值"
20236
+
20237
+
17429
20238
  def _summarize_write_support(resolved_fields: list[JSONObject]) -> JSONObject:
17430
20239
  summary: JSONObject = {
17431
20240
  "full": [],