@qingflow-tech/qingflow-app-user-mcp 1.0.11 → 1.0.12

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 (86) hide show
  1. package/README.md +9 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +255 -0
  10. package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
  11. package/skills/qingflow-app-builder/references/create-app.md +149 -0
  12. package/skills/qingflow-app-builder/references/environments.md +63 -0
  13. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  14. package/skills/qingflow-app-builder/references/gotchas.md +107 -0
  15. package/skills/qingflow-app-builder/references/match-rules.md +114 -0
  16. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  17. package/skills/qingflow-app-builder/references/solution-playbooks.md +52 -0
  18. package/skills/qingflow-app-builder/references/tool-selection.md +99 -0
  19. package/skills/qingflow-app-builder/references/update-flow.md +158 -0
  20. package/skills/qingflow-app-builder/references/update-layout.md +68 -0
  21. package/skills/qingflow-app-builder/references/update-schema.md +72 -0
  22. package/skills/qingflow-app-builder/references/update-views.md +284 -0
  23. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  24. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  25. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  26. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  27. package/skills/qingflow-app-user/SKILL.md +12 -11
  28. package/skills/qingflow-app-user/references/data-gotchas.md +2 -2
  29. package/skills/qingflow-app-user/references/public-surface-sync.md +3 -3
  30. package/skills/qingflow-app-user/references/record-patterns.md +5 -5
  31. package/skills/qingflow-app-user/references/workflow-usage.md +4 -5
  32. package/skills/qingflow-mcp-setup/SKILL.md +113 -0
  33. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  34. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  35. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  36. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  37. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  38. package/skills/qingflow-record-analysis/SKILL.md +6 -7
  39. package/skills/qingflow-record-analysis/manifest.yaml +10 -0
  40. package/skills/qingflow-record-delete/SKILL.md +5 -3
  41. package/skills/qingflow-record-import/SKILL.md +6 -2
  42. package/skills/qingflow-record-insert/SKILL.md +48 -4
  43. package/skills/qingflow-record-insert/manifest.yaml +6 -0
  44. package/skills/qingflow-record-update/SKILL.md +36 -24
  45. package/skills/qingflow-task-ops/SKILL.md +25 -25
  46. package/skills/qingflow-task-ops/references/environments.md +0 -1
  47. package/skills/qingflow-task-ops/references/workflow-usage.md +4 -6
  48. package/src/qingflow_mcp/__main__.py +6 -2
  49. package/src/qingflow_mcp/builder_facade/service.py +1488 -288
  50. package/src/qingflow_mcp/cli/commands/builder.py +2 -2
  51. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  52. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  53. package/src/qingflow_mcp/cli/commands/record.py +39 -11
  54. package/src/qingflow_mcp/cli/context.py +0 -3
  55. package/src/qingflow_mcp/cli/formatters.py +206 -7
  56. package/src/qingflow_mcp/cli/main.py +47 -3
  57. package/src/qingflow_mcp/errors.py +43 -2
  58. package/src/qingflow_mcp/public_surface.py +21 -15
  59. package/src/qingflow_mcp/response_trim.py +68 -13
  60. package/src/qingflow_mcp/server.py +11 -9
  61. package/src/qingflow_mcp/server_app_builder.py +3 -2
  62. package/src/qingflow_mcp/server_app_user.py +15 -13
  63. package/src/qingflow_mcp/solution/executor.py +112 -15
  64. package/src/qingflow_mcp/tools/ai_builder_tools.py +36 -11
  65. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  66. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  67. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  68. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  69. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  70. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  71. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  72. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  73. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  74. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  75. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  76. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  77. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  78. package/src/qingflow_mcp/tools/record_tools.py +1042 -338
  79. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  80. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  81. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  82. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  83. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  84. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  85. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  86. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta
14
14
  from decimal import Decimal, InvalidOperation
15
15
  from io import BytesIO
16
16
  from pathlib import Path
17
- from typing import Any, cast
17
+ from typing import Any, Callable, cast
18
18
  from urllib.parse import parse_qs, unquote, urlsplit
19
19
  from uuid import uuid4
20
20
  from xml.etree import ElementTree
@@ -22,7 +22,7 @@ from xml.etree import ElementTree
22
22
  from mcp.server.fastmcp import FastMCP
23
23
 
24
24
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE, DEFAULT_USER_AGENT, get_mcp_home
25
- from ..errors import QingflowApiError, raise_tool_error
25
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
26
26
  from ..id_utils import normalize_positive_id_int, stringify_backend_id
27
27
  from ..json_types import JSONObject, JSONScalar, JSONValue
28
28
  from ..list_type_labels import (
@@ -131,6 +131,27 @@ SCHEMA_LINKAGE_REFERENCE_SOURCE_MESSAGE = "updating this field may auto-fill or
131
131
  SCHEMA_LINKAGE_REFERENCE_TARGET_MESSAGE = "this field is usually filled from an upstream reference selection or default matching logic"
132
132
  SCHEMA_LINKAGE_REFERENCE_BOTH_MESSAGE = "this field participates in reference-driven auto-fill logic"
133
133
  SCHEMA_LINKAGE_FORMULA_MESSAGE = "this field is usually derived by formula or default auto-fill logic"
134
+ OPTIONAL_SCHEMA_PERMISSION_CODES = {40002, 40027, 404}
135
+ RECORD_PERMISSION_DENIED_CODES = {40002, 40027}
136
+ SYSTEM_VIEW_LIST_TYPES = {int(list_type) for _view_id, list_type, _name in SYSTEM_VIEW_DEFINITIONS}
137
+
138
+
139
+ def _is_optional_schema_permission_error(error: QingflowApiError) -> bool:
140
+ if is_auth_like_error(error):
141
+ return False
142
+ return backend_code_int(error) in OPTIONAL_SCHEMA_PERMISSION_CODES or error.http_status == 404
143
+
144
+
145
+ def _is_record_permission_denied_error(error: QingflowApiError) -> bool:
146
+ if is_auth_like_error(error):
147
+ return False
148
+ return backend_code_int(error) in RECORD_PERMISSION_DENIED_CODES
149
+
150
+
151
+ def _is_optional_record_auxiliary_lookup_error(error: QingflowApiError) -> bool:
152
+ if is_auth_like_error(error):
153
+ return False
154
+ return backend_code_int(error) in {40002, 40027, 404} or error.http_status == 404
134
155
 
135
156
 
136
157
  @dataclass(slots=True)
@@ -215,6 +236,13 @@ class AccessibleViewRoute:
215
236
  view_type: str | None = None
216
237
 
217
238
 
239
+ def _prefer_custom_update_routes(routes: list[AccessibleViewRoute]) -> list[AccessibleViewRoute]:
240
+ return [
241
+ *[route for route in routes if route.kind == "custom"],
242
+ *[route for route in routes if route.kind != "custom"],
243
+ ]
244
+
245
+
218
246
  @dataclass(slots=True)
219
247
  class RecordContextRouteProbe:
220
248
  route: AccessibleViewRoute
@@ -471,7 +499,7 @@ class RecordTools(ToolBase):
471
499
  output_profile=output_profile,
472
500
  )
473
501
 
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.")
502
+ @mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. Requires the same view_id as the frontend record context. This tool hides pagination and returns file paths plus completeness metadata.")
475
503
  def record_logs_get(
476
504
  profile: str = DEFAULT_PROFILE,
477
505
  app_key: str = "",
@@ -502,12 +530,14 @@ class RecordTools(ToolBase):
502
530
  def record_update_schema_get(
503
531
  app_key: str = "",
504
532
  record_id: str = "",
533
+ view_id: str | None = None,
505
534
  output_profile: str = "normal",
506
535
  ) -> JSONObject:
507
536
  return self.record_update_schema_get_public(
508
537
  profile=DEFAULT_PROFILE,
509
538
  app_key=app_key,
510
539
  record_id=record_id,
540
+ view_id=view_id,
511
541
  output_profile=output_profile,
512
542
  )
513
543
 
@@ -537,6 +567,7 @@ class RecordTools(ToolBase):
537
567
  description=(
538
568
  "Update one Qingflow record using a field map. "
539
569
  "For simple field changes, call this tool directly after the target record is clear. "
570
+ "Pass view_id when the frontend detail view is known; the tool will try that view first. "
540
571
  "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
541
572
  "Use record_update_schema_get for diagnostics or complex field-scope inspection."
542
573
  )
@@ -546,6 +577,7 @@ class RecordTools(ToolBase):
546
577
  record_id: str | None = None,
547
578
  fields: JSONObject | None = None,
548
579
  items: list[JSONObject] | None = None,
580
+ view_id: str | None = None,
549
581
  dry_run: bool = False,
550
582
  verify_write: bool = True,
551
583
  output_profile: str = "normal",
@@ -556,6 +588,7 @@ class RecordTools(ToolBase):
556
588
  record_id=record_id,
557
589
  fields=fields,
558
590
  items=items,
591
+ view_id=view_id,
559
592
  dry_run=dry_run,
560
593
  verify_write=verify_write,
561
594
  output_profile=output_profile,
@@ -564,13 +597,14 @@ class RecordTools(ToolBase):
564
597
  @mcp.tool(
565
598
  description=(
566
599
  "Delete Qingflow records by record_id or record_ids. "
567
- "This tool does not accept view selectors; resolve target record ids first, then delete by id."
600
+ "Pass view_id when deleting from a known system list route; custom view deletion is not supported by this backend route."
568
601
  )
569
602
  )
570
603
  def record_delete(
571
604
  app_key: str = "",
572
605
  record_id: str | None = None,
573
606
  record_ids: list[str] | None = None,
607
+ view_id: str | None = None,
574
608
  output_profile: str = "normal",
575
609
  ) -> JSONObject:
576
610
  return self.record_delete_public(
@@ -578,6 +612,7 @@ class RecordTools(ToolBase):
578
612
  app_key=app_key,
579
613
  record_id=record_id,
580
614
  record_ids=record_ids or [],
615
+ view_id=view_id,
581
616
  output_profile=output_profile,
582
617
  )
583
618
 
@@ -849,6 +884,7 @@ class RecordTools(ToolBase):
849
884
  profile: str = DEFAULT_PROFILE,
850
885
  app_key: str,
851
886
  record_id: Any,
887
+ view_id: str | None = None,
852
888
  output_profile: str = "normal",
853
889
  ) -> JSONObject:
854
890
  """执行记录相关逻辑。"""
@@ -860,21 +896,44 @@ class RecordTools(ToolBase):
860
896
  def runner(session_profile, context):
861
897
  request_route = self._request_route_payload(context)
862
898
  self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
863
- app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
864
- question_relations = _collect_question_relations(app_schema)
865
- app_index = _build_applicant_top_level_field_index(app_schema)
866
- linked_field_ids = _collect_linked_required_field_ids(question_relations)
867
- linked_field_ids.update(_collect_option_linked_field_ids(app_index))
868
- linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
869
- app_schema,
870
- linked_field_ids=linked_field_ids,
871
- )
872
- app_index = _merge_field_indexes(app_index, linked_hidden_index)
873
- linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
874
- index=app_index,
875
- question_relations=question_relations,
876
- )
899
+ linkage_payloads_by_field_id: dict[str, JSONObject] = {}
900
+ try:
901
+ app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
902
+ except QingflowApiError as exc:
903
+ if not _is_optional_schema_permission_error(exc):
904
+ raise
905
+ else:
906
+ question_relations = _collect_question_relations(app_schema)
907
+ app_index = _build_applicant_top_level_field_index(app_schema)
908
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
909
+ linked_field_ids.update(_collect_option_linked_field_ids(app_index))
910
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
911
+ app_schema,
912
+ linked_field_ids=linked_field_ids,
913
+ )
914
+ app_index = _merge_field_indexes(app_index, linked_hidden_index)
915
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
916
+ index=app_index,
917
+ question_relations=question_relations,
918
+ )
919
+ preferred_view_id = _normalize_optional_text(view_id)
877
920
  candidate_routes = self._candidate_update_views(profile, context, app_key)
921
+ if preferred_view_id:
922
+ preferred_route = next(
923
+ (
924
+ route
925
+ for route in candidate_routes
926
+ if route.view_id == preferred_view_id
927
+ ),
928
+ None,
929
+ )
930
+ if preferred_route is None:
931
+ raise_tool_error(
932
+ QingflowApiError.config_error(
933
+ f"view_id '{preferred_view_id}' is not an accessible update candidate"
934
+ )
935
+ )
936
+ candidate_routes = [preferred_route]
878
937
  probes = self._probe_candidate_record_contexts(
879
938
  context,
880
939
  app_key=app_key,
@@ -976,6 +1035,7 @@ class RecordTools(ToolBase):
976
1035
  output_profile=normalized_output_profile,
977
1036
  view_probe_summary=probe_summary,
978
1037
  ambiguous_fields=[],
1038
+ preferred_view_id=preferred_view_id,
979
1039
  )
980
1040
 
981
1041
  ambiguous_field_ids: set[int] = set()
@@ -1022,6 +1082,7 @@ class RecordTools(ToolBase):
1022
1082
  output_profile=normalized_output_profile,
1023
1083
  view_probe_summary=probe_summary,
1024
1084
  ambiguous_fields=ambiguous_fields,
1085
+ preferred_view_id=preferred_view_id,
1025
1086
  )
1026
1087
 
1027
1088
  response: JSONObject = {
@@ -1046,6 +1107,8 @@ class RecordTools(ToolBase):
1046
1107
  "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
1108
  },
1048
1109
  }
1110
+ if preferred_view_id:
1111
+ response["preferred_view_id"] = preferred_view_id
1049
1112
  if normalized_output_profile == "verbose":
1050
1113
  response["view_probe_summary"] = probe_summary
1051
1114
  response["record_context_probe"] = probe_summary
@@ -1106,6 +1169,7 @@ class RecordTools(ToolBase):
1106
1169
  output_profile: str,
1107
1170
  view_probe_summary: list[JSONObject],
1108
1171
  ambiguous_fields: list[JSONObject],
1172
+ preferred_view_id: str | None = None,
1109
1173
  ) -> JSONObject:
1110
1174
  """执行内部辅助逻辑。"""
1111
1175
  response: JSONObject = {
@@ -1123,6 +1187,8 @@ class RecordTools(ToolBase):
1123
1187
  "payload_template": {},
1124
1188
  "recommended_next_actions": recommended_next_actions,
1125
1189
  }
1190
+ if preferred_view_id:
1191
+ response["preferred_view_id"] = preferred_view_id
1126
1192
  if output_profile == "verbose":
1127
1193
  response["view_probe_summary"] = view_probe_summary
1128
1194
  response["ambiguous_fields"] = ambiguous_fields
@@ -1440,24 +1506,58 @@ class RecordTools(ToolBase):
1440
1506
  )
1441
1507
  warnings: list[JSONObject] = []
1442
1508
  scope_source = "static_applicant_scope"
1443
- if runtime_lookup:
1444
- state = self._build_candidate_lookup_state(
1445
- profile,
1446
- context,
1509
+ try:
1510
+ if runtime_lookup:
1511
+ state = self._build_candidate_lookup_state(
1512
+ profile,
1513
+ context,
1514
+ app_key=app_key,
1515
+ record_id=record_id_int,
1516
+ workflow_node_id=workflow_node_id,
1517
+ fields=normalized_fields,
1518
+ )
1519
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1520
+ scope_source = "backend_runtime_scope"
1521
+ else:
1522
+ items: list[JSONObject] | None = None
1523
+ if self._member_candidate_static_preview_should_use_backend(field):
1524
+ state = self._build_candidate_lookup_state(
1525
+ profile,
1526
+ context,
1527
+ app_key=app_key,
1528
+ record_id=None,
1529
+ workflow_node_id=None,
1530
+ fields={},
1531
+ )
1532
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1533
+ scope_source = "backend_applicant_scope"
1534
+ if items is None:
1535
+ items = self._resolve_member_candidates(context, field, keyword=keyword)
1536
+ warnings.append(
1537
+ {
1538
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1539
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1540
+ }
1541
+ )
1542
+ except (RecordInputError, QingflowApiError) as error:
1543
+ record_error = (
1544
+ error
1545
+ if isinstance(error, RecordInputError)
1546
+ else self._candidate_lookup_error(kind="member", field=field, value=keyword, error=error)
1547
+ )
1548
+ return self._candidate_lookup_failed_response(
1549
+ profile=profile,
1550
+ session_profile=session_profile,
1551
+ context=context,
1552
+ kind="member",
1553
+ error=record_error,
1554
+ field=field,
1447
1555
  app_key=app_key,
1448
- record_id=record_id_int,
1556
+ record_id_text=record_id_text,
1449
1557
  workflow_node_id=workflow_node_id,
1450
- fields=normalized_fields,
1451
- )
1452
- items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1453
- scope_source = "backend_runtime_scope"
1454
- else:
1455
- items = self._resolve_member_candidates(context, field, keyword=keyword)
1456
- warnings.append(
1457
- {
1458
- "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1459
- "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1460
- }
1558
+ fields_present=bool(normalized_fields),
1559
+ keyword=keyword,
1560
+ scope_source=scope_source,
1461
1561
  )
1462
1562
  total = len(items)
1463
1563
  start = (page_num - 1) * page_size
@@ -1550,41 +1650,75 @@ class RecordTools(ToolBase):
1550
1650
  )
1551
1651
  warnings: list[JSONObject] = []
1552
1652
  scope_source = "static_applicant_scope"
1553
- if runtime_lookup:
1554
- state = self._build_candidate_lookup_state(
1555
- profile,
1556
- context,
1557
- app_key=app_key,
1558
- record_id=record_id_int,
1559
- workflow_node_id=workflow_node_id,
1560
- fields=normalized_fields,
1561
- )
1562
- items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1563
- scope_source = "backend_runtime_scope"
1564
- else:
1565
- items = self._resolve_department_candidates(context, field, keyword=keyword)
1566
- scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1567
- if (
1568
- not items
1569
- and field.dept_select_scope_type == 2
1570
- and not _scope_has_dynamic_or_external(scope)
1571
- and not list(scope.get("depart") or [])
1572
- ):
1653
+ try:
1654
+ if runtime_lookup:
1573
1655
  state = self._build_candidate_lookup_state(
1574
1656
  profile,
1575
1657
  context,
1576
1658
  app_key=app_key,
1577
- record_id=None,
1578
- workflow_node_id=None,
1579
- fields={},
1659
+ record_id=record_id_int,
1660
+ workflow_node_id=workflow_node_id,
1661
+ fields=normalized_fields,
1580
1662
  )
1581
1663
  items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1582
1664
  scope_source = "backend_runtime_scope"
1583
- warnings.append(
1584
- {
1585
- "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1586
- "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1587
- }
1665
+ else:
1666
+ items: list[JSONObject] | None = None
1667
+ if self._department_candidate_static_preview_should_use_backend(field):
1668
+ state = self._build_candidate_lookup_state(
1669
+ profile,
1670
+ context,
1671
+ app_key=app_key,
1672
+ record_id=None,
1673
+ workflow_node_id=None,
1674
+ fields={},
1675
+ )
1676
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1677
+ scope_source = "backend_applicant_scope"
1678
+ if items is None:
1679
+ items = self._resolve_department_candidates(context, field, keyword=keyword)
1680
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1681
+ if (
1682
+ not items
1683
+ and field.dept_select_scope_type == 2
1684
+ and not _scope_has_dynamic_or_external(scope)
1685
+ and not list(scope.get("depart") or [])
1686
+ ):
1687
+ state = self._build_candidate_lookup_state(
1688
+ profile,
1689
+ context,
1690
+ app_key=app_key,
1691
+ record_id=None,
1692
+ workflow_node_id=None,
1693
+ fields={},
1694
+ )
1695
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1696
+ scope_source = "backend_applicant_scope"
1697
+ warnings.append(
1698
+ {
1699
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1700
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1701
+ }
1702
+ )
1703
+ except (RecordInputError, QingflowApiError) as error:
1704
+ record_error = (
1705
+ error
1706
+ if isinstance(error, RecordInputError)
1707
+ else self._candidate_lookup_error(kind="department", field=field, value=keyword, error=error)
1708
+ )
1709
+ return self._candidate_lookup_failed_response(
1710
+ profile=profile,
1711
+ session_profile=session_profile,
1712
+ context=context,
1713
+ kind="department",
1714
+ error=record_error,
1715
+ field=field,
1716
+ app_key=app_key,
1717
+ record_id_text=record_id_text,
1718
+ workflow_node_id=workflow_node_id,
1719
+ fields_present=bool(normalized_fields),
1720
+ keyword=keyword,
1721
+ scope_source=scope_source,
1588
1722
  )
1589
1723
  total = len(items)
1590
1724
  start = (page_num - 1) * page_size
@@ -1646,6 +1780,21 @@ class RecordTools(ToolBase):
1646
1780
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1647
1781
  if limit <= 0:
1648
1782
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1783
+ if not (
1784
+ _normalize_optional_text(view_id)
1785
+ or list_type is not None
1786
+ or _normalize_optional_text(view_key)
1787
+ or _normalize_optional_text(view_name)
1788
+ ):
1789
+ raise_tool_error(
1790
+ QingflowApiError.config_error(
1791
+ "record_analyze requires view_id. Call app_get first and pass accessible_views[].view_id.",
1792
+ details={
1793
+ "error_code": "RECORD_ANALYZE_VIEW_REQUIRED",
1794
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_analyze with view_id.",
1795
+ },
1796
+ )
1797
+ )
1649
1798
  legacy_warnings = _detect_analyze_legacy_warnings(
1650
1799
  dimensions=dimensions,
1651
1800
  metrics=metrics,
@@ -1662,7 +1811,7 @@ class RecordTools(ToolBase):
1662
1811
  list_type=list_type,
1663
1812
  view_key=view_key,
1664
1813
  view_name=view_name,
1665
- allow_default=True,
1814
+ allow_default=False,
1666
1815
  )
1667
1816
  if not _view_type_supports_analysis(resolved_view.view_type):
1668
1817
  raise_tool_error(
@@ -1743,6 +1892,21 @@ class RecordTools(ToolBase):
1743
1892
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1744
1893
  if page <= 0:
1745
1894
  raise_tool_error(QingflowApiError.config_error("page must be positive"))
1895
+ if not (
1896
+ _normalize_optional_text(view_id)
1897
+ or list_type is not None
1898
+ or _normalize_optional_text(view_key)
1899
+ or _normalize_optional_text(view_name)
1900
+ ):
1901
+ raise_tool_error(
1902
+ QingflowApiError.config_error(
1903
+ "record_list requires view_id. Call app_get first and pass accessible_views[].view_id.",
1904
+ details={
1905
+ "error_code": "RECORD_LIST_VIEW_REQUIRED",
1906
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_list with view_id.",
1907
+ },
1908
+ )
1909
+ )
1746
1910
  view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
1747
1911
  profile=profile,
1748
1912
  app_key=app_key,
@@ -1750,7 +1914,7 @@ class RecordTools(ToolBase):
1750
1914
  list_type=list_type,
1751
1915
  view_key=view_key,
1752
1916
  view_name=view_name,
1753
- allow_default=True,
1917
+ allow_default=False,
1754
1918
  )
1755
1919
  if not _view_type_supports_analysis(view_route.view_type):
1756
1920
  raise_tool_error(
@@ -2224,6 +2388,7 @@ class RecordTools(ToolBase):
2224
2388
  requested_output_profile = _normalize_record_get_detail_output_profile(output_profile)
2225
2389
  record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2226
2390
  normalized_columns = _normalize_public_column_selectors(columns)
2391
+ explicit_view_id = _normalize_optional_text(view_id)
2227
2392
 
2228
2393
  def runner(session_profile, context):
2229
2394
  resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
@@ -2249,17 +2414,45 @@ class RecordTools(ToolBase):
2249
2414
  "code": "OUTPUT_PROFILE_DEPRECATED_FOR_DETAIL_CONTEXT",
2250
2415
  "message": f"output_profile={requested_output_profile!r} is deprecated for record_get; detail_context is always returned.",
2251
2416
  })
2252
- return self._record_get_detail_context(
2253
- profile=profile,
2254
- session_profile=session_profile,
2255
- context=context,
2256
- app_key=app_key,
2257
- record_id_int=record_id_int,
2258
- resolved_view=resolved_view,
2259
- requested_focus_field_ids=normalized_columns,
2260
- workflow_node_id=workflow_node_id,
2261
- warnings=warnings,
2262
- )
2417
+ def get_detail_for_route(route: AccessibleViewRoute, route_warnings: list[JSONObject]) -> JSONObject:
2418
+ return self._record_get_detail_context(
2419
+ profile=profile,
2420
+ session_profile=session_profile,
2421
+ context=context,
2422
+ app_key=app_key,
2423
+ record_id_int=record_id_int,
2424
+ resolved_view=route,
2425
+ requested_focus_field_ids=normalized_columns,
2426
+ workflow_node_id=workflow_node_id,
2427
+ warnings=route_warnings,
2428
+ )
2429
+
2430
+ try:
2431
+ return get_detail_for_route(resolved_view, warnings)
2432
+ except QingflowApiError as exc:
2433
+ if explicit_view_id is not None:
2434
+ raise
2435
+ if not self._is_record_context_route_miss(exc):
2436
+ raise
2437
+ fallback_warnings = list(warnings)
2438
+ fallback_warnings.append(
2439
+ {
2440
+ "code": "DEFAULT_DETAIL_ROUTE_DENIED",
2441
+ "message": "record_get default system:all route was not readable; trying accessible views that match the frontend route model.",
2442
+ "backend_code": exc.backend_code,
2443
+ }
2444
+ )
2445
+ last_error = exc
2446
+ for candidate in self._candidate_update_views(profile, context, app_key):
2447
+ if candidate.view_id == resolved_view.view_id:
2448
+ continue
2449
+ try:
2450
+ return get_detail_for_route(candidate, fallback_warnings)
2451
+ except QingflowApiError as candidate_exc:
2452
+ if not self._is_record_context_route_miss(candidate_exc):
2453
+ raise
2454
+ last_error = candidate_exc
2455
+ raise last_error
2263
2456
 
2264
2457
  return self._run_record_tool(profile, runner)
2265
2458
 
@@ -2274,6 +2467,16 @@ class RecordTools(ToolBase):
2274
2467
  ) -> JSONObject:
2275
2468
  """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2276
2469
  record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2470
+ if not _normalize_optional_text(view_id):
2471
+ raise_tool_error(
2472
+ QingflowApiError.config_error(
2473
+ "record_logs_get requires view_id. Call app_get first and pass accessible_views[].view_id.",
2474
+ details={
2475
+ "error_code": "RECORD_LOGS_VIEW_REQUIRED",
2476
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_logs_get with view_id.",
2477
+ },
2478
+ )
2479
+ )
2277
2480
 
2278
2481
  def runner(session_profile, context):
2279
2482
  resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
@@ -2284,21 +2487,45 @@ class RecordTools(ToolBase):
2284
2487
  list_type=None,
2285
2488
  view_key=None,
2286
2489
  view_name=None,
2287
- allow_default=True,
2490
+ allow_default=False,
2288
2491
  )
2289
2492
  warnings: list[JSONObject] = []
2290
2493
  warnings.extend(compatibility_warnings)
2291
2494
  warnings.extend(_view_filter_trust_warnings(resolved_view))
2292
2495
  unavailable_context: list[JSONObject] = []
2293
2496
 
2294
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2497
+ schema: JSONObject = {}
2498
+ try:
2499
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2500
+ except QingflowApiError as exc:
2501
+ if not _is_optional_schema_permission_error(exc):
2502
+ raise
2503
+ unavailable_context.append(
2504
+ _record_detail_unavailable_context(
2505
+ "detail_schema",
2506
+ "记录日志字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2507
+ exc,
2508
+ )
2509
+ )
2295
2510
  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
- )
2511
+ try:
2512
+ audit_info = self._record_get_audit_info(
2513
+ context,
2514
+ app_key=app_key,
2515
+ record_id=record_id_int,
2516
+ resolved_view=resolved_view,
2517
+ )
2518
+ except QingflowApiError as exc:
2519
+ if not _is_optional_schema_permission_error(exc):
2520
+ raise
2521
+ audit_info = {}
2522
+ unavailable_context.append(
2523
+ _record_detail_unavailable_context(
2524
+ "audit_info",
2525
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据和日志。",
2526
+ exc,
2527
+ )
2528
+ )
2302
2529
  audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2303
2530
  detail_result, used_list_type, used_role = self._record_get_apply_detail(
2304
2531
  context,
@@ -2308,6 +2535,17 @@ class RecordTools(ToolBase):
2308
2535
  audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2309
2536
  )
2310
2537
  answer_list = _record_detail_answers(detail_result)
2538
+ if not index.by_id:
2539
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2540
+ if answer_index.by_id:
2541
+ index = answer_index
2542
+ unavailable_context.append(
2543
+ {
2544
+ "section": "detail_schema",
2545
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2546
+ "category": "partial_context",
2547
+ }
2548
+ )
2311
2549
  selected_fields = list(index.by_id.values())
2312
2550
  fields = [
2313
2551
  _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
@@ -2401,14 +2639,41 @@ class RecordTools(ToolBase):
2401
2639
  warnings: list[JSONObject],
2402
2640
  ) -> JSONObject:
2403
2641
  """执行内部辅助逻辑。"""
2404
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2642
+ unavailable_context: list[JSONObject] = []
2643
+ schema: JSONObject = {}
2644
+ schema_available = True
2645
+ try:
2646
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2647
+ except QingflowApiError as exc:
2648
+ if not _is_optional_schema_permission_error(exc):
2649
+ raise
2650
+ schema_available = False
2651
+ unavailable_context.append(
2652
+ _record_detail_unavailable_context(
2653
+ "detail_schema",
2654
+ "记录详情字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2655
+ exc,
2656
+ )
2657
+ )
2405
2658
  index = _build_top_level_field_index(schema)
2406
- audit_info = self._record_get_audit_info(
2407
- context,
2408
- app_key=app_key,
2409
- record_id=record_id_int,
2410
- resolved_view=resolved_view,
2411
- )
2659
+ try:
2660
+ audit_info = self._record_get_audit_info(
2661
+ context,
2662
+ app_key=app_key,
2663
+ record_id=record_id_int,
2664
+ resolved_view=resolved_view,
2665
+ )
2666
+ except QingflowApiError as exc:
2667
+ if not _is_optional_schema_permission_error(exc):
2668
+ raise
2669
+ audit_info = {}
2670
+ unavailable_context.append(
2671
+ _record_detail_unavailable_context(
2672
+ "audit_info",
2673
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据。",
2674
+ exc,
2675
+ )
2676
+ )
2412
2677
  audit_context = _record_detail_audit_context(audit_info, workflow_node_id=workflow_node_id)
2413
2678
  detail_result, used_list_type, used_role = self._record_get_apply_detail(
2414
2679
  context,
@@ -2418,13 +2683,24 @@ class RecordTools(ToolBase):
2418
2683
  audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2419
2684
  )
2420
2685
  answer_list = _record_detail_answers(detail_result)
2686
+ if not index.by_id:
2687
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2688
+ if answer_index.by_id:
2689
+ index = answer_index
2690
+ unavailable_context.append(
2691
+ {
2692
+ "section": "detail_schema",
2693
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2694
+ "category": "partial_context",
2695
+ }
2696
+ )
2421
2697
  selected_fields = list(index.by_id.values())
2422
2698
  row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id_int)
2423
2699
  normalized_record, _normalized_ambiguous_fields = _build_normalized_row_from_answers(
2424
2700
  cast(list[JSONValue], answer_list),
2425
2701
  selected_fields,
2426
2702
  )
2427
- if self._record_get_needs_schema_refresh(
2703
+ if schema_available and self._record_get_needs_schema_refresh(
2428
2704
  answer_list=cast(list[JSONValue], answer_list),
2429
2705
  selected_fields=selected_fields,
2430
2706
  record=row,
@@ -2440,7 +2716,6 @@ class RecordTools(ToolBase):
2440
2716
  index = _build_top_level_field_index(schema)
2441
2717
  selected_fields = list(index.by_id.values())
2442
2718
 
2443
- unavailable_context: list[JSONObject] = []
2444
2719
  dynamic_reference_answers, dynamic_reference_unavailable = self._record_get_dynamic_reference_answers(
2445
2720
  context,
2446
2721
  app_key=app_key,
@@ -2599,7 +2874,20 @@ class RecordTools(ToolBase):
2599
2874
  ) -> JSONObject:
2600
2875
  """执行内部辅助逻辑。"""
2601
2876
  if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
2602
- return self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
2877
+ return self._get_custom_view_browse_schema(
2878
+ profile,
2879
+ context,
2880
+ resolved_view.view_selection.view_key,
2881
+ force_refresh=force_refresh,
2882
+ )
2883
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
2884
+ return self._get_system_browse_schema(
2885
+ profile,
2886
+ context,
2887
+ app_key,
2888
+ list_type=resolved_view.list_type,
2889
+ force_refresh=force_refresh,
2890
+ )
2603
2891
  return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
2604
2892
 
2605
2893
  def _record_get_audit_info(
@@ -2660,7 +2948,7 @@ class RecordTools(ToolBase):
2660
2948
  )
2661
2949
  return result if isinstance(result, dict) else {"value": result}, list_type, role
2662
2950
  except QingflowApiError as exc:
2663
- if resolved_view.list_type is not None or exc.backend_code != 40002:
2951
+ if resolved_view.list_type is not None or not _is_record_permission_denied_error(exc):
2664
2952
  raise
2665
2953
  last_error: QingflowApiError = exc
2666
2954
  for fallback_list_type in (14, 1, 2, 12):
@@ -2678,7 +2966,7 @@ class RecordTools(ToolBase):
2678
2966
  return result if isinstance(result, dict) else {"value": result}, fallback_list_type, role
2679
2967
  except QingflowApiError as fallback_exc:
2680
2968
  last_error = fallback_exc
2681
- if fallback_exc.backend_code == 40002:
2969
+ if _is_record_permission_denied_error(fallback_exc):
2682
2970
  continue
2683
2971
  raise
2684
2972
  raise last_error
@@ -2776,6 +3064,8 @@ class RecordTools(ToolBase):
2776
3064
  if target_app_key == app_key and str(target_record_id) == str(source_record_id):
2777
3065
  reference_payload["self_reference"] = True
2778
3066
  except QingflowApiError as exc:
3067
+ if is_auth_like_error(exc):
3068
+ raise
2779
3069
  unavailable = _record_detail_unavailable_context(
2780
3070
  "reference_detail",
2781
3071
  f"引用字段「{field.que_title}」的目标记录详情获取失败。",
@@ -2873,6 +3163,8 @@ class RecordTools(ToolBase):
2873
3163
  json_body=body,
2874
3164
  )
2875
3165
  except QingflowApiError as exc:
3166
+ if is_auth_like_error(exc):
3167
+ raise
2876
3168
  unavailable = _record_detail_unavailable_context(
2877
3169
  "reference_runtime_match",
2878
3170
  "动态引用字段匹配数据获取失败。",
@@ -2927,6 +3219,8 @@ class RecordTools(ToolBase):
2927
3219
  },
2928
3220
  )
2929
3221
  except QingflowApiError as exc:
3222
+ if is_auth_like_error(exc):
3223
+ raise
2930
3224
  unavailable_context.append(_record_detail_unavailable_context("log_visibility", "侧边栏日志可见性获取失败。", exc))
2931
3225
  return {"status": "unavailable", "channel": channel, "data_log_visible": None, "workflow_log_visible": None}
2932
3226
  payload = visibility if isinstance(visibility, dict) else {}
@@ -2980,6 +3274,8 @@ class RecordTools(ToolBase):
2980
3274
  source="data_logs",
2981
3275
  )
2982
3276
  except QingflowApiError as exc:
3277
+ if is_auth_like_error(exc):
3278
+ raise
2983
3279
  unavailable_context.append(_record_detail_unavailable_context("data_logs", "最近数据日志获取失败。", exc))
2984
3280
  return _record_detail_log_unavailable_payload("data_logs", "fetch_unavailable")
2985
3281
 
@@ -3033,6 +3329,8 @@ class RecordTools(ToolBase):
3033
3329
  source="workflow_logs",
3034
3330
  )
3035
3331
  except QingflowApiError as exc:
3332
+ if is_auth_like_error(exc):
3333
+ raise
3036
3334
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
3037
3335
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
3038
3336
 
@@ -3076,6 +3374,8 @@ class RecordTools(ToolBase):
3076
3374
  deadline=deadline,
3077
3375
  )
3078
3376
  except QingflowApiError as exc:
3377
+ if is_auth_like_error(exc):
3378
+ raise
3079
3379
  unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3080
3380
  return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
3081
3381
 
@@ -3135,6 +3435,8 @@ class RecordTools(ToolBase):
3135
3435
  deadline=deadline,
3136
3436
  )
3137
3437
  except QingflowApiError as exc:
3438
+ if is_auth_like_error(exc):
3439
+ raise
3138
3440
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3139
3441
  return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3140
3442
 
@@ -3167,6 +3469,8 @@ class RecordTools(ToolBase):
3167
3469
  params["auditNodeId"] = audit_node_id
3168
3470
  payload = self.backend.request("GET", context, f"/app/{app_key}/asosChart", params=params)
3169
3471
  except QingflowApiError as exc:
3472
+ if is_auth_like_error(exc):
3473
+ raise
3170
3474
  unavailable_context.append(_record_detail_unavailable_context("associated_resources", "关联资源获取失败。", exc))
3171
3475
  return []
3172
3476
  return [_record_detail_associated_resource(item) for item in _record_detail_associated_resource_items(payload)]
@@ -3204,16 +3508,17 @@ class RecordTools(ToolBase):
3204
3508
  refresh_source_url=refresh_source_url,
3205
3509
  )
3206
3510
  except Exception as exc: # defensive: media should never break the core record detail.
3511
+ warning: JSONObject = {
3512
+ "code": "MEDIA_ASSETS_UNAVAILABLE",
3513
+ "message": f"record_get could not collect media assets: {exc}",
3514
+ }
3515
+ if isinstance(exc, QingflowApiError):
3516
+ warning.update(_record_detail_error_warning_fields(exc))
3207
3517
  return {
3208
3518
  "status": "unavailable",
3209
3519
  "local_dir": None,
3210
3520
  "items": [],
3211
- "warnings": [
3212
- {
3213
- "code": "MEDIA_ASSETS_UNAVAILABLE",
3214
- "message": f"record_get could not collect media assets: {exc}",
3215
- }
3216
- ],
3521
+ "warnings": [warning],
3217
3522
  }
3218
3523
 
3219
3524
  def _record_get_file_assets(
@@ -3251,16 +3556,17 @@ class RecordTools(ToolBase):
3251
3556
  refresh_source_url=refresh_source_url,
3252
3557
  )
3253
3558
  except Exception as exc: # defensive: file assets should never break the core record detail.
3559
+ warning = {
3560
+ "code": "FILE_ASSETS_UNAVAILABLE",
3561
+ "message": f"record_get could not collect file assets: {exc}",
3562
+ }
3563
+ if isinstance(exc, QingflowApiError):
3564
+ warning.update(_record_detail_error_warning_fields(exc))
3254
3565
  return {
3255
3566
  "status": "unavailable",
3256
3567
  "local_dir": None,
3257
3568
  "items": [],
3258
- "warnings": [
3259
- {
3260
- "code": "FILE_ASSETS_UNAVAILABLE",
3261
- "message": f"record_get could not collect file assets: {exc}",
3262
- }
3263
- ],
3569
+ "warnings": [warning],
3264
3570
  }
3265
3571
 
3266
3572
  def _record_get_refreshed_media_source_url(
@@ -3272,7 +3578,7 @@ class RecordTools(ToolBase):
3272
3578
  resolved_view: AccessibleViewRoute,
3273
3579
  audit_node_id: int | None,
3274
3580
  candidate: JSONObject,
3275
- ) -> str | None:
3581
+ ) -> JSONValue | None:
3276
3582
  """Refresh the detail payload once to recover an expired attachment storage signature."""
3277
3583
  if candidate.get("source") not in {"attachment", "image_field", "subtable"}:
3278
3584
  return None
@@ -3288,8 +3594,15 @@ class RecordTools(ToolBase):
3288
3594
  resolved_view=resolved_view,
3289
3595
  audit_node_id=audit_node_id,
3290
3596
  )
3291
- except QingflowApiError:
3292
- return None
3597
+ except QingflowApiError as exc:
3598
+ return {
3599
+ "source_url": None,
3600
+ "warning": _record_detail_unavailable_context(
3601
+ "asset_url_refresh",
3602
+ "record_get could not refresh the record detail before downloading a private asset.",
3603
+ exc,
3604
+ ),
3605
+ }
3293
3606
  for answer in _record_detail_answers(detail_result):
3294
3607
  if not isinstance(answer, dict) or _coerce_count(answer.get("queId")) != field_id:
3295
3608
  continue
@@ -3804,6 +4117,7 @@ class RecordTools(ToolBase):
3804
4117
  record_id: Any | None,
3805
4118
  fields: JSONObject | None = None,
3806
4119
  items: list[JSONObject] | None = None,
4120
+ view_id: str | None = None,
3807
4121
  dry_run: bool = False,
3808
4122
  verify_write: bool = True,
3809
4123
  output_profile: str = "normal",
@@ -3824,6 +4138,7 @@ class RecordTools(ToolBase):
3824
4138
  profile=profile,
3825
4139
  app_key=app_key,
3826
4140
  items=normalized_items,
4141
+ view_id=view_id,
3827
4142
  dry_run=dry_run,
3828
4143
  verify_write=verify_write,
3829
4144
  output_profile=normalized_output_profile,
@@ -3840,6 +4155,7 @@ class RecordTools(ToolBase):
3840
4155
  app_key=app_key,
3841
4156
  record_id=record_id_int,
3842
4157
  fields=cast(JSONObject, fields or {}),
4158
+ view_id=view_id,
3843
4159
  verify_write=verify_write,
3844
4160
  output_profile=normalized_output_profile,
3845
4161
  )
@@ -3851,17 +4167,61 @@ class RecordTools(ToolBase):
3851
4167
  app_key: str,
3852
4168
  record_id: int,
3853
4169
  fields: JSONObject,
4170
+ view_id: str | None,
3854
4171
  verify_write: bool,
3855
4172
  output_profile: str,
4173
+ capture_exceptions: bool = False,
3856
4174
  ) -> JSONObject:
3857
4175
  """执行内部辅助逻辑。"""
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
- )
4176
+ write_state = {"attempted": False}
4177
+ try:
4178
+ return self._record_update_public_single_impl(
4179
+ profile=profile,
4180
+ app_key=app_key,
4181
+ record_id=record_id,
4182
+ fields=fields,
4183
+ view_id=view_id,
4184
+ verify_write=verify_write,
4185
+ output_profile=output_profile,
4186
+ write_attempted_ref=lambda value: write_state.__setitem__("attempted", value),
4187
+ )
4188
+ except (QingflowApiError, RuntimeError) as exc:
4189
+ if not capture_exceptions:
4190
+ raise
4191
+ return self._record_write_exception_response(
4192
+ exc,
4193
+ operation="update",
4194
+ profile=profile,
4195
+ app_key=app_key,
4196
+ record_id=record_id,
4197
+ output_profile=output_profile,
4198
+ human_review=True,
4199
+ write_executed=write_state["attempted"],
4200
+ )
4201
+
4202
+ def _record_update_public_single_impl(
4203
+ self,
4204
+ *,
4205
+ profile: str,
4206
+ app_key: str,
4207
+ record_id: int,
4208
+ fields: JSONObject,
4209
+ view_id: str | None,
4210
+ verify_write: bool,
4211
+ output_profile: str,
4212
+ write_attempted_ref: Callable[[bool], None],
4213
+ ) -> JSONObject:
4214
+ """执行内部辅助逻辑。"""
4215
+ preflight_kwargs: dict[str, Any] = {
4216
+ "profile": profile,
4217
+ "app_key": app_key,
4218
+ "record_id": record_id,
4219
+ "fields": fields,
4220
+ "force_refresh_form": False,
4221
+ }
4222
+ if view_id is not None:
4223
+ preflight_kwargs["preferred_view_id"] = view_id
4224
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
3865
4225
  preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3866
4226
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3867
4227
  normalized_payload = self._record_write_normalized_payload(
@@ -3881,6 +4241,7 @@ class RecordTools(ToolBase):
3881
4241
  human_review=True,
3882
4242
  target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
3883
4243
  )
4244
+ write_attempted_ref(True)
3884
4245
  route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
3885
4246
  profile=profile,
3886
4247
  app_key=app_key,
@@ -4161,7 +4522,9 @@ class RecordTools(ToolBase):
4161
4522
  )
4162
4523
 
4163
4524
  def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4164
- if exc.backend_code in {40002, 40027, 40038, 404}:
4525
+ if is_auth_like_error(exc):
4526
+ return False
4527
+ if backend_code_int(exc) in {40002, 40027, 40038, 404}:
4165
4528
  return True
4166
4529
  if exc.http_status == 404:
4167
4530
  return True
@@ -4265,6 +4628,8 @@ class RecordTools(ToolBase):
4265
4628
  },
4266
4629
  )
4267
4630
  except QingflowApiError as exc:
4631
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4632
+ raise
4268
4633
  return unavailable(
4269
4634
  error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4270
4635
  reason="current-user todo task list is unavailable",
@@ -4312,6 +4677,8 @@ class RecordTools(ToolBase):
4312
4677
  f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4313
4678
  )
4314
4679
  except QingflowApiError as exc:
4680
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4681
+ raise
4315
4682
  return unavailable(
4316
4683
  error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4317
4684
  reason="workflow node editable field list is unavailable; record_update will not guess task editability",
@@ -4462,7 +4829,7 @@ class RecordTools(ToolBase):
4462
4829
  raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4463
4830
 
4464
4831
  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
4832
+ index = self._get_view_field_index(profile, context, normalized_view_key, force_refresh=force_refresh_form) if verify_write else None
4466
4833
  normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4467
4834
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4468
4835
  result = self.backend.request(
@@ -4565,6 +4932,7 @@ class RecordTools(ToolBase):
4565
4932
  profile: str,
4566
4933
  app_key: str,
4567
4934
  items: list[JSONObject],
4935
+ view_id: str | None,
4568
4936
  dry_run: bool,
4569
4937
  verify_write: bool,
4570
4938
  output_profile: str,
@@ -4576,6 +4944,7 @@ class RecordTools(ToolBase):
4576
4944
  app_key=app_key,
4577
4945
  record_id=cast(int, item["record_id"]),
4578
4946
  fields=cast(JSONObject, item["fields"]),
4947
+ view_id=view_id,
4579
4948
  output_profile=output_profile,
4580
4949
  )
4581
4950
  for item in items
@@ -4604,8 +4973,10 @@ class RecordTools(ToolBase):
4604
4973
  app_key=app_key,
4605
4974
  record_id=record_id,
4606
4975
  fields=fields,
4976
+ view_id=view_id,
4607
4977
  verify_write=verify_write,
4608
4978
  output_profile=output_profile,
4979
+ capture_exceptions=True,
4609
4980
  )
4610
4981
  )
4611
4982
  except (QingflowApiError, RuntimeError) as exc:
@@ -4636,16 +5007,20 @@ class RecordTools(ToolBase):
4636
5007
  app_key: str,
4637
5008
  record_id: int,
4638
5009
  fields: JSONObject,
5010
+ view_id: str | None,
4639
5011
  output_profile: str,
4640
5012
  ) -> JSONObject:
4641
5013
  """执行内部辅助逻辑。"""
4642
- raw_preflight = self._preflight_record_update_with_auto_view(
4643
- profile=profile,
4644
- app_key=app_key,
4645
- record_id=record_id,
4646
- fields=fields,
4647
- force_refresh_form=False,
4648
- )
5014
+ preflight_kwargs: dict[str, Any] = {
5015
+ "profile": profile,
5016
+ "app_key": app_key,
5017
+ "record_id": record_id,
5018
+ "fields": fields,
5019
+ "force_refresh_form": False,
5020
+ }
5021
+ if view_id is not None:
5022
+ preflight_kwargs["preferred_view_id"] = view_id
5023
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
4649
5024
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
4650
5025
  normalized_payload = self._record_write_normalized_payload(
4651
5026
  operation="update",
@@ -4851,6 +5226,9 @@ class RecordTools(ToolBase):
4851
5226
  item: JSONObject = {
4852
5227
  "resource": data.get("resource"),
4853
5228
  "status": response.get("status"),
5229
+ "write_executed": bool(response.get("write_executed")),
5230
+ "safe_to_retry": bool(response.get("safe_to_retry", True)),
5231
+ "verification_status": response.get("verification_status", "not_requested"),
4854
5232
  "verification": data.get("verification"),
4855
5233
  "field_errors": cast(list[JSONObject], data.get("field_errors", [])),
4856
5234
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
@@ -4860,7 +5238,7 @@ class RecordTools(ToolBase):
4860
5238
  if isinstance(update_route, dict):
4861
5239
  item["update_route"] = update_route
4862
5240
  tried_routes = response.get("tried_routes")
4863
- if isinstance(tried_routes, list):
5241
+ if isinstance(tried_routes, list) and (output_profile == "verbose" or response.get("status") != "success"):
4864
5242
  item["tried_routes"] = tried_routes
4865
5243
  blockers = data.get("blockers")
4866
5244
  if isinstance(blockers, list) and blockers:
@@ -4882,6 +5260,7 @@ class RecordTools(ToolBase):
4882
5260
  app_key: str,
4883
5261
  record_id: int,
4884
5262
  fields: JSONObject,
5263
+ preferred_view_id: str | None = None,
4885
5264
  force_refresh_form: bool,
4886
5265
  ) -> JSONObject:
4887
5266
  """执行内部辅助逻辑。"""
@@ -4889,6 +5268,25 @@ class RecordTools(ToolBase):
4889
5268
  request_route = self._request_route_payload(context)
4890
5269
  def build_once(*, effective_force_refresh: bool) -> JSONObject:
4891
5270
  candidate_routes = self._candidate_update_views(profile, context, app_key)
5271
+ normalized_preferred_view_id = _normalize_optional_text(preferred_view_id)
5272
+ if normalized_preferred_view_id:
5273
+ preferred_route = next(
5274
+ (
5275
+ route
5276
+ for route in candidate_routes
5277
+ if route.view_id == normalized_preferred_view_id
5278
+ ),
5279
+ None,
5280
+ )
5281
+ if preferred_route is None:
5282
+ raise_tool_error(
5283
+ QingflowApiError.config_error(
5284
+ f"view_id '{normalized_preferred_view_id}' is not an accessible update candidate"
5285
+ )
5286
+ )
5287
+ candidate_routes = [preferred_route]
5288
+ else:
5289
+ candidate_routes = _prefer_custom_update_routes(candidate_routes)
4892
5290
  probes = self._probe_candidate_record_contexts(
4893
5291
  context,
4894
5292
  app_key=app_key,
@@ -5102,41 +5500,24 @@ class RecordTools(ToolBase):
5102
5500
  "data": first_confirmation_plan,
5103
5501
  }
5104
5502
 
5105
- union_plan = self._build_record_update_union_preflight(
5106
- profile=profile,
5107
- context=context,
5108
- app_key=app_key,
5109
- record_id=record_id,
5110
- fields=fields,
5111
- current_answers=matched_answers_for_union or [],
5112
- matched_routes=matched_routes,
5113
- force_refresh_form=effective_force_refresh,
5114
- )
5115
- if union_plan is not None:
5116
- validation = union_plan.get("validation")
5117
- if isinstance(validation, dict):
5118
- warnings = validation.get("warnings")
5119
- if not isinstance(warnings, list):
5120
- warnings = []
5121
- validation["warnings"] = warnings
5122
- for message in fallback_warning_messages:
5123
- if message not in warnings:
5124
- warnings.append(message)
5125
- union_plan["view_probe_summary"] = probe_summary
5126
- union_plan["record_context_probe"] = probe_summary
5503
+ if normalized_preferred_view_id and first_blocked_plan is not None:
5504
+ first_blocked_plan["view_probe_summary"] = probe_summary
5505
+ first_blocked_plan["record_context_probe"] = probe_summary
5127
5506
  return {
5128
5507
  "profile": profile,
5129
5508
  "ws_id": session_profile.selected_ws_id,
5130
5509
  "ok": True,
5131
5510
  "request_route": request_route,
5132
- "data": union_plan,
5511
+ "data": first_blocked_plan,
5133
5512
  }
5134
5513
 
5135
5514
  blocked_data = self._build_auto_view_blocked_preflight_data(
5136
5515
  app_key=app_key,
5137
5516
  record_id=record_id,
5138
5517
  blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
5139
- warnings=[],
5518
+ warnings=[
5519
+ "record_update requires one executable frontend route for the full payload; it does not merge writable fields across multiple views."
5520
+ ],
5140
5521
  recommended_next_actions=[
5141
5522
  "Call record_update_schema_get first to inspect the overall writable field set for this record.",
5142
5523
  "Reduce the update payload until all requested fields fit inside one matched accessible view.",
@@ -5183,6 +5564,7 @@ class RecordTools(ToolBase):
5183
5564
  union_writable_field_ids: set[int] = set()
5184
5565
  union_visible_question_ids: set[int] = set()
5185
5566
  matched_view_payloads: list[JSONObject] = []
5567
+ union_index: FieldIndex | None = None
5186
5568
 
5187
5569
  for candidate in matched_routes:
5188
5570
  browse_scope = self._build_browse_write_scope(
@@ -5192,11 +5574,13 @@ class RecordTools(ToolBase):
5192
5574
  candidate,
5193
5575
  force_refresh=force_refresh_form,
5194
5576
  )
5577
+ browse_index = cast(FieldIndex, browse_scope["index"])
5578
+ union_index = browse_index if union_index is None else _merge_field_indexes(union_index, browse_index)
5195
5579
  union_writable_field_ids.update(cast(set[int], browse_scope["writable_field_ids"]))
5196
5580
  union_visible_question_ids.update(cast(set[int], browse_scope["visible_question_ids"]))
5197
5581
  matched_view_payloads.append(_accessible_view_payload(candidate))
5198
5582
 
5199
- if not union_writable_field_ids and not union_visible_question_ids:
5583
+ if union_index is None or (not union_writable_field_ids and not union_visible_question_ids):
5200
5584
  return None
5201
5585
 
5202
5586
  plan_data = self._build_record_write_preflight(
@@ -5213,10 +5597,9 @@ class RecordTools(ToolBase):
5213
5597
  view_key=None,
5214
5598
  view_name=None,
5215
5599
  existing_answers_override=current_answers,
5600
+ field_index_override=union_index,
5216
5601
  )
5217
5602
 
5218
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
5219
- app_index = _build_applicant_top_level_field_index(schema)
5220
5603
  validation = cast(JSONObject, plan_data.get("validation", {}))
5221
5604
  invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
5222
5605
  missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
@@ -5227,12 +5610,21 @@ class RecordTools(ToolBase):
5227
5610
  invalid_fields.extend(
5228
5611
  self._validate_view_scoped_subtable_answers(
5229
5612
  normalized_answers=cast(list[JSONObject], plan_data.get("normalized_answers", [])),
5230
- full_index=app_index,
5231
- selector_index=app_index,
5613
+ full_index=union_index,
5614
+ selector_index=union_index,
5232
5615
  visible_question_ids=union_visible_question_ids,
5233
5616
  )
5234
5617
  )
5235
5618
 
5619
+ readonly_or_system_fields = [
5620
+ item
5621
+ for item in readonly_or_system_fields
5622
+ if not (
5623
+ isinstance(item, dict)
5624
+ and (que_id := _coerce_count(item.get("que_id"))) is not None
5625
+ and que_id in union_writable_field_ids
5626
+ )
5627
+ ]
5236
5628
  existing_readonly_ids = {
5237
5629
  str(_coerce_count(item.get("que_id")))
5238
5630
  for item in readonly_or_system_fields
@@ -5396,7 +5788,13 @@ class RecordTools(ToolBase):
5396
5788
  view_type=None,
5397
5789
  )
5398
5790
  )
5399
- for item in self._get_view_list(profile, context, app_key):
5791
+ try:
5792
+ view_items = self._get_view_list(profile, context, app_key)
5793
+ except QingflowApiError as exc:
5794
+ if not _is_record_permission_denied_error(exc):
5795
+ raise
5796
+ view_items = []
5797
+ for item in view_items:
5400
5798
  if not isinstance(item, dict):
5401
5799
  continue
5402
5800
  view_key = _normalize_optional_text(item.get("viewKey"))
@@ -5453,7 +5851,9 @@ class RecordTools(ToolBase):
5453
5851
  return payload
5454
5852
 
5455
5853
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
5456
- if error.backend_code in {40002, 40023, 40027, 40038, 404}:
5854
+ if is_auth_like_error(error):
5855
+ return False
5856
+ if backend_code_int(error) in {40002, 40023, 40027, 40038, 404}:
5457
5857
  return True
5458
5858
  if error.http_status == 404:
5459
5859
  return True
@@ -5483,11 +5883,12 @@ class RecordTools(ToolBase):
5483
5883
  used_list_type = None
5484
5884
  else:
5485
5885
  used_list_type = resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE
5886
+ role = _record_detail_role_for_list_type(used_list_type)
5486
5887
  record = self.backend.request(
5487
5888
  "GET",
5488
5889
  context,
5489
5890
  f"/app/{app_key}/apply/{apply_id}",
5490
- params={"role": 1, "listType": used_list_type},
5891
+ params={"role": role, "listType": used_list_type},
5491
5892
  )
5492
5893
  answers = record.get("answers") if isinstance(record, dict) else None
5493
5894
  normalized_answers = [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
@@ -5517,6 +5918,8 @@ class RecordTools(ToolBase):
5517
5918
  error_payload=None,
5518
5919
  )
5519
5920
  except QingflowApiError as exc:
5921
+ if not self._is_record_context_route_miss(exc):
5922
+ raise
5520
5923
  return RecordContextRouteProbe(
5521
5924
  route=resolved_view,
5522
5925
  answer_list=None,
@@ -5588,7 +5991,7 @@ class RecordTools(ToolBase):
5588
5991
  ]
5589
5992
 
5590
5993
  def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
5591
- if exc.backend_code == 500:
5994
+ if backend_code_int(exc) == 500:
5592
5995
  return True
5593
5996
  if exc.http_status is not None and exc.http_status >= 500:
5594
5997
  return True
@@ -5713,12 +6116,15 @@ class RecordTools(ToolBase):
5713
6116
  app_key: str,
5714
6117
  record_id: Any | None = None,
5715
6118
  record_ids: list[Any] | None = None,
6119
+ view_id: str | None = None,
6120
+ list_type: int | None = None,
5716
6121
  output_profile: str = "normal",
5717
6122
  ) -> JSONObject:
5718
6123
  """执行记录相关逻辑。"""
5719
6124
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
5720
6125
  if not app_key:
5721
6126
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
6127
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
5722
6128
  normalized_record_ids: list[int] = []
5723
6129
  for index, item in enumerate(record_ids or []):
5724
6130
  normalized_record_ids.append(normalize_positive_id_int(item, field_name=f"record_ids[{index}]"))
@@ -5738,21 +6144,72 @@ class RecordTools(ToolBase):
5738
6144
  "record_ids": [stringify_backend_id(item) for item in delete_ids],
5739
6145
  "answers": [],
5740
6146
  "submit_type": 1,
6147
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
5741
6148
  }
5742
6149
  return self._record_delete_public_batch(
5743
6150
  profile=profile,
5744
6151
  app_key=app_key,
5745
6152
  delete_ids=delete_ids,
6153
+ list_type=delete_list_type,
5746
6154
  normalized_payload=normalized_payload,
5747
6155
  output_profile=normalized_output_profile,
5748
6156
  )
5749
6157
 
6158
+ def _resolve_record_delete_list_type(self, *, view_id: str | None, list_type: int | None) -> int:
6159
+ normalized_view_id = _normalize_optional_text(view_id)
6160
+ if normalized_view_id:
6161
+ if normalized_view_id.startswith("custom:"):
6162
+ raise_tool_error(
6163
+ QingflowApiError.config_error(
6164
+ "record_delete does not support custom view deletion; the backend delete route accepts system listType only",
6165
+ details={
6166
+ "error_code": "RECORD_DELETE_CUSTOM_VIEW_UNSUPPORTED",
6167
+ "view_id": normalized_view_id,
6168
+ "fix_hint": (
6169
+ "Use a system view_id from app_get.accessible_views, or resolve target record_ids with "
6170
+ "record list/get first and retry delete without a custom view selector."
6171
+ ),
6172
+ },
6173
+ )
6174
+ )
6175
+ if not normalized_view_id.startswith("system:"):
6176
+ raise_tool_error(QingflowApiError.config_error("view_id must start with system: or custom:"))
6177
+ mapped_list_type = SYSTEM_VIEW_ID_TO_LIST_TYPE.get(normalized_view_id)
6178
+ if mapped_list_type is None:
6179
+ raise_tool_error(QingflowApiError.config_error(f"unsupported view_id '{normalized_view_id}'"))
6180
+ return mapped_list_type
6181
+ if list_type is not None:
6182
+ normalized_list_type = int(list_type)
6183
+ if normalized_list_type not in SYSTEM_VIEW_LIST_TYPES:
6184
+ raise_tool_error(
6185
+ QingflowApiError.config_error(
6186
+ "record_delete list_type must map to a supported system view",
6187
+ details={
6188
+ "error_code": "RECORD_DELETE_SYSTEM_VIEW_REQUIRED",
6189
+ "list_type": normalized_list_type,
6190
+ "supported_list_types": sorted(SYSTEM_VIEW_LIST_TYPES),
6191
+ "fix_hint": "Pass a system view_id from app_get.accessible_views instead of an arbitrary list_type.",
6192
+ },
6193
+ )
6194
+ )
6195
+ return normalized_list_type
6196
+ raise_tool_error(
6197
+ QingflowApiError.config_error(
6198
+ "record_delete requires a system view_id or list_type; deleting without frontend list context is ambiguous",
6199
+ details={
6200
+ "error_code": "RECORD_DELETE_VIEW_REQUIRED",
6201
+ "fix_hint": "Pass a system view_id from app_get.accessible_views, for example --view-id system:all. If the target came from a custom view, first confirm the record_id, then choose an accessible system view for deletion.",
6202
+ },
6203
+ )
6204
+ )
6205
+
5750
6206
  def _record_delete_public_batch(
5751
6207
  self,
5752
6208
  *,
5753
6209
  profile: str,
5754
6210
  app_key: str,
5755
6211
  delete_ids: list[int],
6212
+ list_type: int,
5756
6213
  normalized_payload: JSONObject,
5757
6214
  output_profile: str,
5758
6215
  ) -> JSONObject:
@@ -5762,7 +6219,7 @@ class RecordTools(ToolBase):
5762
6219
  for index, delete_id in enumerate(delete_ids):
5763
6220
  record_id_text = stringify_backend_id(delete_id)
5764
6221
  try:
5765
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
6222
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id], list_type=list_type)
5766
6223
  request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
5767
6224
  ws_id = raw_apply.get("ws_id", ws_id)
5768
6225
  single_payload = {
@@ -5771,6 +6228,7 @@ class RecordTools(ToolBase):
5771
6228
  "record_ids": [record_id_text],
5772
6229
  "answers": [],
5773
6230
  "submit_type": 1,
6231
+ "selection": normalized_payload.get("selection"),
5774
6232
  }
5775
6233
  single_response = self._record_write_apply_response(
5776
6234
  raw_apply,
@@ -6053,12 +6511,13 @@ class RecordTools(ToolBase):
6053
6511
  preflight=raw_preflight,
6054
6512
  )
6055
6513
 
6056
- if uses_view_scope:
6514
+ if view_key is not None or view_name is not None:
6057
6515
  raise_tool_error(
6058
6516
  QingflowApiError.config_error(
6059
- "delete does not accept view selectors yet; resolve target record_ids from the selected view first, then call delete by record_id/record_ids"
6517
+ "delete does not support custom view selectors; use a system view_id/list_type or resolve target record_ids first"
6060
6518
  )
6061
6519
  )
6520
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
6062
6521
  if normalized_values or normalized_set:
6063
6522
  raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
6064
6523
  delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
@@ -6070,8 +6529,9 @@ class RecordTools(ToolBase):
6070
6529
  "record_ids": delete_ids,
6071
6530
  "answers": [],
6072
6531
  "submit_type": submit_type_value,
6532
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
6073
6533
  }
6074
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
6534
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids, list_type=delete_list_type)
6075
6535
  return self._record_write_apply_response(
6076
6536
  raw_apply,
6077
6537
  operation="delete",
@@ -6221,7 +6681,9 @@ class RecordTools(ToolBase):
6221
6681
  or _normalize_optional_text(payload.get("appName"))
6222
6682
  or _normalize_optional_text(payload.get("appTitle"))
6223
6683
  )
6224
- except QingflowApiError:
6684
+ except QingflowApiError as exc:
6685
+ if is_auth_like_error(exc):
6686
+ raise
6225
6687
  name = None
6226
6688
  self._app_name_cache[cache_key] = name
6227
6689
  return name
@@ -6375,7 +6837,9 @@ class RecordTools(ToolBase):
6375
6837
  try:
6376
6838
  result = self.backend.request("GET", context, "/data/baseInfo", params={"queId": field.que_id})
6377
6839
  payload = result if isinstance(result, dict) else None
6378
- except QingflowApiError:
6840
+ except QingflowApiError as exc:
6841
+ if is_auth_like_error(exc):
6842
+ raise
6379
6843
  payload = None
6380
6844
  self._relation_base_info_cache[cache_key] = payload or {}
6381
6845
  return payload
@@ -6648,6 +7112,26 @@ class RecordTools(ToolBase):
6648
7112
  or bool(fields)
6649
7113
  )
6650
7114
 
7115
+ def _member_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7116
+ """Return true when the frontend field-scope endpoint is safer than directory expansion."""
7117
+ scope = field.member_select_scope if isinstance(field.member_select_scope, dict) else {}
7118
+ if field.member_select_scope_type != 2:
7119
+ return False
7120
+ return bool(
7121
+ _scope_has_dynamic_or_external(scope)
7122
+ or list(scope.get("depart") or [])
7123
+ or list(scope.get("role") or [])
7124
+ )
7125
+
7126
+ def _department_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7127
+ """Return true when static preview would otherwise need ContactAuth-only directory APIs."""
7128
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
7129
+ if field.dept_select_scope_type != 2:
7130
+ return False
7131
+ if _scope_has_dynamic_or_external(scope):
7132
+ return True
7133
+ return bool(_normalize_bool(scope.get("includeSubDeparts")) or not list(scope.get("depart") or []))
7134
+
6651
7135
  def _build_candidate_lookup_state(
6652
7136
  self,
6653
7137
  profile: str,
@@ -6666,7 +7150,9 @@ class RecordTools(ToolBase):
6666
7150
  if apply_id is not None:
6667
7151
  try:
6668
7152
  base_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
6669
- except QingflowApiError:
7153
+ except QingflowApiError as exc:
7154
+ if not _is_optional_record_auxiliary_lookup_error(exc):
7155
+ raise
6670
7156
  context_complete = False
6671
7157
  state = LookupResolutionState(
6672
7158
  operation="update" if apply_id is not None else "insert",
@@ -7156,15 +7642,16 @@ class RecordTools(ToolBase):
7156
7642
  )
7157
7643
  if configured_candidate is not None:
7158
7644
  self._merge_department_candidate(merged, configured_candidate)
7159
- for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=include_sub):
7160
- normalized = _normalize_candidate_department(
7161
- dept,
7162
- source_kind="department",
7163
- source_id=dept_id,
7164
- source_value=dept_name,
7165
- )
7166
- if normalized is not None:
7167
- self._merge_department_candidate(merged, normalized)
7645
+ if include_sub:
7646
+ for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=True):
7647
+ normalized = _normalize_candidate_department(
7648
+ dept,
7649
+ source_kind="department",
7650
+ source_id=dept_id,
7651
+ source_value=dept_name,
7652
+ )
7653
+ if normalized is not None:
7654
+ self._merge_department_candidate(merged, normalized)
7168
7655
  filtered = _filter_department_candidates(list(merged.values()), keyword)
7169
7656
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
7170
7657
  return filtered
@@ -8305,22 +8792,10 @@ class RecordTools(ToolBase):
8305
8792
  field_index_override: FieldIndex | None = None,
8306
8793
  ) -> JSONObject:
8307
8794
  """执行内部辅助逻辑。"""
8308
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
8309
- base_index = field_index_override or _build_applicant_top_level_field_index(schema)
8310
- question_relations = _collect_question_relations(schema)
8311
- runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
8312
- runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
8313
- index = base_index
8314
- if operation == "create" and field_index_override is None:
8315
- linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
8316
- schema,
8317
- linked_field_ids=runtime_linked_field_ids,
8318
- )
8319
- index = _merge_field_indexes(base_index, linked_hidden_index)
8320
8795
  normalized_fields = fields or {}
8321
8796
  normalized_answers_input = answers or []
8322
8797
  resolved_view: AccessibleViewRoute | None = None
8323
- selector_index = index
8798
+ selector_index: FieldIndex | None = field_index_override
8324
8799
  browse_writable_field_ids: set[int] = set()
8325
8800
  visible_question_ids: set[int] = set()
8326
8801
  if any(item is not None for item in (view_id, list_type, view_key, view_name)):
@@ -8346,6 +8821,31 @@ class RecordTools(ToolBase):
8346
8821
  visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
8347
8822
  else:
8348
8823
  compatibility_warnings = []
8824
+ if field_index_override is not None:
8825
+ base_index = field_index_override
8826
+ question_relations: list[JSONObject] = []
8827
+ runtime_linked_field_ids: set[int] = set()
8828
+ index = base_index
8829
+ elif operation == "update" and resolved_view is not None:
8830
+ base_index = cast(FieldIndex, selector_index)
8831
+ question_relations = []
8832
+ runtime_linked_field_ids = set()
8833
+ index = base_index
8834
+ else:
8835
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
8836
+ base_index = _build_applicant_top_level_field_index(schema)
8837
+ question_relations = _collect_question_relations(schema)
8838
+ runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
8839
+ runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
8840
+ index = base_index
8841
+ if operation == "create":
8842
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
8843
+ schema,
8844
+ linked_field_ids=runtime_linked_field_ids,
8845
+ )
8846
+ index = _merge_field_indexes(base_index, linked_hidden_index)
8847
+ if selector_index is None:
8848
+ selector_index = index
8349
8849
  resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=selector_index)
8350
8850
  support_matrix = _summarize_write_support(resolved_fields)
8351
8851
  invalid_fields: list[JSONObject] = []
@@ -8389,7 +8889,9 @@ class RecordTools(ToolBase):
8389
8889
  apply_id=apply_id,
8390
8890
  )
8391
8891
  existing_answers_loaded = True
8392
- except QingflowApiError:
8892
+ except QingflowApiError as exc:
8893
+ if not _is_optional_record_auxiliary_lookup_error(exc):
8894
+ raise
8393
8895
  validation_warnings.append(
8394
8896
  "update preflight could not load the current record; required-field completeness and dynamic lookup context were not fully revalidated."
8395
8897
  )
@@ -8978,7 +9480,7 @@ class RecordTools(ToolBase):
8978
9480
  break
8979
9481
  except QingflowApiError as exc:
8980
9482
  last_error = exc
8981
- if exc.backend_code == 40002:
9483
+ if _is_record_permission_denied_error(exc):
8982
9484
  continue
8983
9485
  raise
8984
9486
  if result is None:
@@ -9081,7 +9583,21 @@ class RecordTools(ToolBase):
9081
9583
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
9082
9584
 
9083
9585
  def runner(session_profile, context):
9084
- index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
9586
+ needs_index = verify_write or bool(fields) or _answers_need_resolution(answers or [])
9587
+ update_index = None
9588
+ if needs_index:
9589
+ update_index = (
9590
+ self._get_system_browse_field_index(
9591
+ profile,
9592
+ context,
9593
+ app_key,
9594
+ list_type=DEFAULT_RECORD_LIST_TYPE,
9595
+ force_refresh=force_refresh_form,
9596
+ )
9597
+ if role == 1
9598
+ else self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
9599
+ )
9600
+ index = update_index if verify_write else None
9085
9601
  normalized_answers = self._resolve_answers(
9086
9602
  profile,
9087
9603
  context,
@@ -9089,6 +9605,7 @@ class RecordTools(ToolBase):
9089
9605
  answers=answers or [],
9090
9606
  fields=fields or {},
9091
9607
  force_refresh_form=force_refresh_form,
9608
+ field_index_override=update_index,
9092
9609
  )
9093
9610
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
9094
9611
  try:
@@ -9142,13 +9659,14 @@ class RecordTools(ToolBase):
9142
9659
  def record_delete(self, *, profile: str, app_key: str, apply_id: int, list_type: int) -> JSONObject:
9143
9660
  """执行记录相关逻辑。"""
9144
9661
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
9662
+ delete_list_type = self._resolve_record_delete_list_type(view_id=None, list_type=list_type)
9145
9663
 
9146
9664
  def runner(session_profile, context):
9147
9665
  result = self.backend.request(
9148
9666
  "DELETE",
9149
9667
  context,
9150
9668
  f"/app/{app_key}/apply",
9151
- json_body={"type": list_type, "applyIds": [normalized_apply_id]},
9669
+ json_body={"type": delete_list_type, "applyIds": [normalized_apply_id]},
9152
9670
  )
9153
9671
  return self._attach_human_review_notice(
9154
9672
  {
@@ -9157,6 +9675,7 @@ class RecordTools(ToolBase):
9157
9675
  "request_route": self._request_route_payload(context),
9158
9676
  "app_key": app_key,
9159
9677
  "apply_id": normalized_apply_id,
9678
+ "list_type": delete_list_type,
9160
9679
  "result": result,
9161
9680
  },
9162
9681
  operation="delete",
@@ -9201,7 +9720,7 @@ class RecordTools(ToolBase):
9201
9720
  "GET",
9202
9721
  context,
9203
9722
  f"/app/{app_key}/apply/{apply_id}",
9204
- params={"role": 1, "listType": list_type},
9723
+ params={"role": _record_detail_role_for_list_type(list_type), "listType": list_type},
9205
9724
  )
9206
9725
  answers = result.get("answers") if isinstance(result, dict) else None
9207
9726
  answer_list = answers if isinstance(answers, list) else []
@@ -9560,7 +10079,7 @@ class RecordTools(ToolBase):
9560
10079
  used_list_type: int | None = None
9561
10080
  if view_selection is not None:
9562
10081
  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:
10082
+ elif view_route.list_type is not None:
9564
10083
  fallback_list_types = [view_route.list_type]
9565
10084
  else:
9566
10085
  fallback_list_types = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
@@ -9791,7 +10310,7 @@ class RecordTools(ToolBase):
9791
10310
  try:
9792
10311
  payload = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
9793
10312
  except QingflowApiError as exc:
9794
- if exc.backend_code in {40002, 40027, 404} or exc.http_status == 404:
10313
+ if _is_optional_schema_permission_error(exc):
9795
10314
  self._view_config_cache[cache_key] = None
9796
10315
  return None
9797
10316
  raise
@@ -9912,7 +10431,12 @@ class RecordTools(ToolBase):
9912
10431
  )
9913
10432
  normalized = _normalize_data_list_base_info_schema(payload)
9914
10433
  if not isinstance(normalized.get("formQues"), list) or not normalized.get("formQues"):
9915
- return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10434
+ try:
10435
+ return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10436
+ except QingflowApiError as exc:
10437
+ if not _is_optional_schema_permission_error(exc):
10438
+ raise
10439
+ return normalized
9916
10440
  self._form_cache[cache_key] = normalized
9917
10441
  return normalized
9918
10442
 
@@ -9944,8 +10468,16 @@ class RecordTools(ToolBase):
9944
10468
  cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
9945
10469
  if not force_refresh and cache_key in self._form_cache:
9946
10470
  return self._form_cache[cache_key]
9947
- payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
9948
- normalized = _normalize_data_list_base_info_schema(payload)
10471
+ try:
10472
+ payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
10473
+ normalized = _normalize_data_list_base_info_schema(payload)
10474
+ form_ques = normalized.get("formQues")
10475
+ if not isinstance(form_ques, list) or not form_ques:
10476
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
10477
+ except QingflowApiError as exc:
10478
+ if not _is_optional_schema_permission_error(exc):
10479
+ raise
10480
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
9949
10481
  self._form_cache[cache_key] = normalized
9950
10482
  return normalized
9951
10483
 
@@ -10001,22 +10533,6 @@ class RecordTools(ToolBase):
10001
10533
  force_refresh: bool,
10002
10534
  ) -> JSONObject:
10003
10535
  """Build the UI/table-view readable field scope from apply/baseInfo."""
10004
- applicant_index: FieldIndex | None
10005
- applicant_writable_field_ids: set[int]
10006
- try:
10007
- applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10008
- except QingflowApiError as exc:
10009
- if exc.backend_code != 40002:
10010
- raise
10011
- applicant_index = None
10012
- applicant_writable_field_ids = set()
10013
- else:
10014
- applicant_writable_field_ids = {
10015
- field.que_id
10016
- for field in applicant_index.by_id.values()
10017
- if bool(self._schema_write_hints(field)["writable"])
10018
- }
10019
-
10020
10536
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
10021
10537
  schema = self._get_custom_view_browse_schema(
10022
10538
  profile,
@@ -10025,6 +10541,16 @@ class RecordTools(ToolBase):
10025
10541
  force_refresh=force_refresh,
10026
10542
  )
10027
10543
  index = _build_top_level_field_index(schema)
10544
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10545
+ return {
10546
+ "index": index,
10547
+ "writable_field_ids": {
10548
+ field.que_id
10549
+ for field in index.by_id.values()
10550
+ if bool(self._schema_write_hints(field)["writable"])
10551
+ },
10552
+ "visible_question_ids": visible_question_ids,
10553
+ }
10028
10554
  elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
10029
10555
  schema = self._get_system_browse_base_info_schema(
10030
10556
  profile,
@@ -10034,34 +10560,26 @@ class RecordTools(ToolBase):
10034
10560
  force_refresh=force_refresh,
10035
10561
  )
10036
10562
  index = _build_top_level_field_index(schema)
10037
- else:
10038
- index = applicant_index or _build_top_level_field_index(
10039
- self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10040
- )
10041
-
10042
- if applicant_index is not None and index.by_id:
10043
- enriched_fields = [
10044
- _enrich_read_field_from_applicant(field, applicant_index.by_id.get(str(field.que_id)))
10045
- for field in index.by_id.values()
10046
- ]
10047
- index = _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in enriched_fields]]})
10563
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10564
+ return {
10565
+ "index": index,
10566
+ "writable_field_ids": {
10567
+ field.que_id
10568
+ for field in index.by_id.values()
10569
+ if bool(self._schema_write_hints(field)["writable"])
10570
+ },
10571
+ "visible_question_ids": visible_question_ids,
10572
+ }
10048
10573
 
10049
- visible_question_ids = {field.que_id for field in index.by_id.values()}
10050
- if applicant_index is None:
10051
- writable_field_ids = {
10574
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10575
+ visible_question_ids = {field.que_id for field in applicant_index.by_id.values()}
10576
+ return {
10577
+ "index": applicant_index,
10578
+ "writable_field_ids": {
10052
10579
  field.que_id
10053
- for field in index.by_id.values()
10580
+ for field in applicant_index.by_id.values()
10054
10581
  if bool(self._schema_write_hints(field)["writable"])
10055
- }
10056
- else:
10057
- writable_field_ids = {
10058
- field_id
10059
- for field_id in visible_question_ids
10060
- if field_id in applicant_writable_field_ids
10061
- }
10062
- return {
10063
- "index": index,
10064
- "writable_field_ids": writable_field_ids,
10582
+ },
10065
10583
  "visible_question_ids": visible_question_ids,
10066
10584
  }
10067
10585
 
@@ -10075,23 +10593,13 @@ class RecordTools(ToolBase):
10075
10593
  force_refresh: bool,
10076
10594
  ) -> JSONObject:
10077
10595
  """执行内部辅助逻辑。"""
10078
- applicant_index: FieldIndex | None
10079
- applicant_writable_field_ids: set[int]
10080
- try:
10081
- applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10082
- except QingflowApiError as exc:
10083
- if exc.backend_code != 40002:
10084
- raise
10085
- applicant_index = None
10086
- applicant_writable_field_ids = set()
10087
- else:
10088
- applicant_writable_field_ids = {
10089
- field.que_id
10090
- for field in applicant_index.by_id.values()
10091
- if bool(self._schema_write_hints(field)["writable"])
10092
- }
10093
10596
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
10094
- schema = self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
10597
+ schema = self._get_custom_view_browse_schema(
10598
+ profile,
10599
+ context,
10600
+ resolved_view.view_selection.view_key,
10601
+ force_refresh=force_refresh,
10602
+ )
10095
10603
  index = _build_top_level_field_index(schema)
10096
10604
  visible_question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
10097
10605
  if not visible_question_ids:
@@ -10107,6 +10615,12 @@ class RecordTools(ToolBase):
10107
10615
  index = _build_top_level_field_index(schema)
10108
10616
  visible_question_ids = _question_ids_from_schema(schema)
10109
10617
  else:
10618
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10619
+ applicant_writable_field_ids = {
10620
+ field.que_id
10621
+ for field in applicant_index.by_id.values()
10622
+ if bool(self._schema_write_hints(field)["writable"])
10623
+ }
10110
10624
  index = applicant_index or _build_top_level_field_index(
10111
10625
  self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10112
10626
  )
@@ -10125,43 +10639,13 @@ class RecordTools(ToolBase):
10125
10639
  "visible_question_ids": set(visible_question_ids),
10126
10640
  }
10127
10641
 
10128
- if applicant_index is None:
10129
- return {
10130
- "index": index,
10131
- "writable_field_ids": {
10132
- field.que_id
10133
- for field in index.by_id.values()
10134
- if bool(self._schema_write_hints(field)["writable"])
10135
- },
10136
- "visible_question_ids": visible_question_ids,
10137
- }
10138
-
10139
- augmented_fields = [
10140
- _clone_form_field(applicant_index.by_id.get(str(field.que_id)) or field)
10141
- for field in index.by_id.values()
10142
- ]
10143
- augmented_field_ids = {field.que_id for field in augmented_fields}
10144
- writable_field_ids = {
10145
- field_id
10146
- for field_id in visible_question_ids
10147
- if field_id in applicant_writable_field_ids
10148
- }
10149
- for field in applicant_index.by_id.values():
10150
- descendant_ids = _subtable_descendant_ids(field)
10151
- field_visible = field.que_id in visible_question_ids
10152
- descendant_visible = bool(descendant_ids and (descendant_ids & visible_question_ids))
10153
- if not field_visible and not descendant_visible:
10154
- continue
10155
- if field.que_id not in augmented_field_ids:
10156
- augmented_fields.append(_clone_form_field(field))
10157
- augmented_field_ids.add(field.que_id)
10158
- if descendant_visible:
10159
- visible_question_ids.add(field.que_id)
10160
- if field.que_id in applicant_writable_field_ids and (field_visible or descendant_visible):
10161
- writable_field_ids.add(field.que_id)
10162
10642
  return {
10163
- "index": _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in augmented_fields]]}),
10164
- "writable_field_ids": writable_field_ids,
10643
+ "index": index,
10644
+ "writable_field_ids": {
10645
+ field.que_id
10646
+ for field in index.by_id.values()
10647
+ if bool(self._schema_write_hints(field)["writable"])
10648
+ },
10165
10649
  "visible_question_ids": visible_question_ids,
10166
10650
  }
10167
10651
 
@@ -10226,7 +10710,7 @@ class RecordTools(ToolBase):
10226
10710
  try:
10227
10711
  payload = self.backend.request("GET", context, f"/view/{view_key}/question")
10228
10712
  except QingflowApiError as exc:
10229
- if exc.backend_code in {40002, 40027}:
10713
+ if _is_record_permission_denied_error(exc):
10230
10714
  return set()
10231
10715
  raise
10232
10716
  if not isinstance(payload, list):
@@ -10270,7 +10754,7 @@ class RecordTools(ToolBase):
10270
10754
  )
10271
10755
  return True
10272
10756
  except QingflowApiError as exc:
10273
- if exc.backend_code in {40002, 40027}:
10757
+ if _is_record_permission_denied_error(exc):
10274
10758
  return False
10275
10759
  raise
10276
10760
 
@@ -10423,7 +10907,12 @@ class RecordTools(ToolBase):
10423
10907
  requested_name = _normalize_optional_text(view_name)
10424
10908
  if requested_key is None and requested_name is None:
10425
10909
  return None
10426
- views = self._get_view_list(profile, context, app_key)
10910
+ try:
10911
+ views = self._get_view_list(profile, context, app_key)
10912
+ except QingflowApiError as exc:
10913
+ if requested_key is None or not _is_record_permission_denied_error(exc):
10914
+ raise
10915
+ views = []
10427
10916
  selected: JSONObject | None = None
10428
10917
  if requested_key is not None:
10429
10918
  selected = next((item for item in views if _normalize_optional_text(item.get("viewKey")) == requested_key), None)
@@ -10621,9 +11110,11 @@ class RecordTools(ToolBase):
10621
11110
 
10622
11111
  def _should_retry_list_type_fallback(self, error: QingflowApiError) -> bool:
10623
11112
  """执行内部辅助逻辑。"""
10624
- if error.backend_code in {40002, 40027, 404}:
11113
+ if is_auth_like_error(error):
11114
+ return False
11115
+ if backend_code_int(error) in {40002, 40027, 404}:
10625
11116
  return True
10626
- if error.http_status in {404, 500}:
11117
+ if error.http_status == 404:
10627
11118
  return True
10628
11119
  return False
10629
11120
 
@@ -11444,6 +11935,70 @@ class RecordTools(ToolBase):
11444
11935
  )
11445
11936
  )
11446
11937
 
11938
+ def _candidate_lookup_failed_response(
11939
+ self,
11940
+ *,
11941
+ profile: str,
11942
+ session_profile, # type: ignore[no-untyped-def]
11943
+ context, # type: ignore[no-untyped-def]
11944
+ kind: str,
11945
+ error: RecordInputError,
11946
+ field: FormField,
11947
+ app_key: str,
11948
+ record_id_text: str | None,
11949
+ workflow_node_id: int | None,
11950
+ fields_present: bool,
11951
+ keyword: str,
11952
+ scope_source: str,
11953
+ ) -> JSONObject:
11954
+ """Return a structured result when an optional field candidate lookup is unavailable."""
11955
+ error_payload = error.to_dict()
11956
+ error_details = error_payload.get("details") if isinstance(error_payload.get("details"), dict) else {}
11957
+ candidate_error = error_details.get("candidate_error") if isinstance(error_details.get("candidate_error"), dict) else {}
11958
+ warning_transport = {
11959
+ key: candidate_error.get(key)
11960
+ for key in ("backend_code", "http_status", "request_id")
11961
+ if candidate_error.get(key) is not None
11962
+ }
11963
+ selection: JSONObject = {
11964
+ "app_key": app_key,
11965
+ "field_id": field.que_id,
11966
+ "field_title": field.que_title,
11967
+ "record_id": record_id_text,
11968
+ "workflow_node_id": workflow_node_id,
11969
+ "fields_present": fields_present,
11970
+ "keyword": keyword,
11971
+ "permission_scope": "applicant_node",
11972
+ }
11973
+ return {
11974
+ "profile": profile,
11975
+ "ws_id": session_profile.selected_ws_id,
11976
+ "ok": False,
11977
+ "status": "failed",
11978
+ "error_code": error.error_code,
11979
+ "message": error.message,
11980
+ "request_route": self._request_route_payload(context),
11981
+ "warnings": [
11982
+ {
11983
+ "code": error.error_code,
11984
+ "message": error.fix_hint,
11985
+ "kind": kind,
11986
+ "field_id": field.que_id,
11987
+ "field_title": field.que_title,
11988
+ **warning_transport,
11989
+ }
11990
+ ],
11991
+ "output_profile": "normal",
11992
+ "data": {
11993
+ "items": [],
11994
+ "pagination": {"returned_items": 0},
11995
+ "selection": selection,
11996
+ "scope_source": scope_source,
11997
+ "fix_hint": error.fix_hint,
11998
+ },
11999
+ "details": error_details,
12000
+ }
12001
+
11447
12002
  def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
11448
12003
  """执行内部辅助逻辑。"""
11449
12004
  describe_route = getattr(self.backend, "describe_route", None)
@@ -11617,7 +12172,7 @@ class RecordTools(ToolBase):
11617
12172
  selection: JSONObject | None,
11618
12173
  ) -> None:
11619
12174
  """执行内部辅助逻辑。"""
11620
- if exc.backend_code != 40002:
12175
+ if not _is_record_permission_denied_error(exc):
11621
12176
  raise exc
11622
12177
  raise_tool_error(
11623
12178
  QingflowApiError(
@@ -11783,6 +12338,7 @@ class RecordTools(ToolBase):
11783
12338
  response_status = raw_status or "failed"
11784
12339
  update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
11785
12340
  tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
12341
+ expose_tried_routes = output_profile == "verbose" or response_status != "success"
11786
12342
  response: JSONObject = {
11787
12343
  "profile": raw_apply.get("profile"),
11788
12344
  "ws_id": raw_apply.get("ws_id"),
@@ -11795,7 +12351,6 @@ class RecordTools(ToolBase):
11795
12351
  "warnings": warnings,
11796
12352
  "output_profile": output_profile,
11797
12353
  "update_route": update_route,
11798
- "tried_routes": tried_routes,
11799
12354
  "data": {
11800
12355
  "action": {"operation": operation, "executed": True},
11801
12356
  "resource": resource,
@@ -11807,9 +12362,11 @@ class RecordTools(ToolBase):
11807
12362
  "resolved_fields": resolved_fields,
11808
12363
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
11809
12364
  "update_route": update_route,
11810
- "tried_routes": tried_routes,
11811
12365
  },
11812
12366
  }
12367
+ if expose_tried_routes:
12368
+ response["tried_routes"] = tried_routes
12369
+ response["data"]["tried_routes"] = tried_routes
11813
12370
  if record_id is not None:
11814
12371
  response["record_id"] = record_id
11815
12372
  if apply_id is not None:
@@ -11989,7 +12546,7 @@ class RecordTools(ToolBase):
11989
12546
  )
11990
12547
  return errors
11991
12548
 
11992
- def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
12549
+ def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int], list_type: int = DEFAULT_RECORD_LIST_TYPE) -> JSONObject:
11993
12550
  """执行内部辅助逻辑。"""
11994
12551
  if not app_key:
11995
12552
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -12002,14 +12559,14 @@ class RecordTools(ToolBase):
12002
12559
  "DELETE",
12003
12560
  context,
12004
12561
  f"/app/{app_key}/apply",
12005
- json_body={"type": DEFAULT_RECORD_LIST_TYPE, "applyIds": normalized_ids},
12562
+ json_body={"type": list_type, "applyIds": normalized_ids},
12006
12563
  )
12007
12564
  return {
12008
12565
  "profile": profile,
12009
12566
  "ws_id": session_profile.selected_ws_id,
12010
12567
  "request_route": self._request_route_payload(context),
12011
12568
  "result": result,
12012
- "resource": {"type": "record", "apply_ids": normalized_ids},
12569
+ "resource": {"type": "record", "apply_ids": normalized_ids, "list_type": list_type},
12013
12570
  "ok": True,
12014
12571
  }
12015
12572
 
@@ -12583,6 +13140,30 @@ class RecordTools(ToolBase):
12583
13140
  },
12584
13141
  )
12585
13142
 
13143
+ def _candidate_lookup_error(
13144
+ self,
13145
+ *,
13146
+ kind: str,
13147
+ field: FormField,
13148
+ value: JSONValue,
13149
+ error: QingflowApiError,
13150
+ ) -> RecordInputError:
13151
+ """Build the standard candidate lookup failure without raising it."""
13152
+ field_kind = "member" if kind == "member" else "department"
13153
+ return RecordInputError(
13154
+ message=f"{field_kind} candidates for field '{field.que_title}' could not be loaded",
13155
+ error_code=f"{kind.upper()}_CANDIDATE_LOOKUP_FAILED",
13156
+ fix_hint=(
13157
+ f"Run record_{field_kind}_candidates again after the backend error is resolved, "
13158
+ "then choose one returned item exactly."
13159
+ ),
13160
+ details={
13161
+ "field": _field_ref_payload(field),
13162
+ "received_value": value,
13163
+ "candidate_error": error.to_dict(),
13164
+ },
13165
+ )
13166
+
12586
13167
  def _candidate_keyword_from_value(
12587
13168
  self,
12588
13169
  value: JSONValue,
@@ -12847,14 +13428,7 @@ class RecordTools(ToolBase):
12847
13428
 
12848
13429
  def _lookup_department_detail(self, context, keyword: str, *, purpose: str) -> JSONObject: # type: ignore[no-untyped-def]
12849
13430
  """执行内部辅助逻辑。"""
12850
- payload = self.backend.request(
12851
- "GET",
12852
- context,
12853
- "/contact/deptByPage",
12854
- params={"keyword": keyword, "pageNum": 1, "pageSize": 20},
12855
- )
12856
- rows = payload.get("list") if isinstance(payload, dict) else None
12857
- items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
13431
+ items = self._search_workspace_departments(context, keyword=keyword)
12858
13432
  normalized_keyword = keyword.strip()
12859
13433
  exact = [
12860
13434
  item for item in items
@@ -13379,6 +13953,7 @@ class RecordTools(ToolBase):
13379
13953
  normalized_answers: list[JSONObject],
13380
13954
  index: FieldIndex,
13381
13955
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13956
+ verify_role: int | None = None,
13382
13957
  verify_view_key: str | None = None,
13383
13958
  ) -> JSONObject:
13384
13959
  """执行内部辅助逻辑。"""
@@ -13398,14 +13973,20 @@ class RecordTools(ToolBase):
13398
13973
  f"/view/{verify_view_key}/apply/{apply_id}",
13399
13974
  )
13400
13975
  else:
13976
+ role = verify_role if verify_role is not None else 1
13401
13977
  record = self.backend.request(
13402
13978
  "GET",
13403
13979
  context,
13404
13980
  f"/app/{app_key}/apply/{apply_id}",
13405
- params={"role": 1, "listType": verify_list_type},
13981
+ params={"role": role, "listType": verify_list_type},
13406
13982
  )
13407
13983
  except QingflowApiError as exc:
13408
13984
  if verify_view_key:
13985
+ warning: JSONObject = {
13986
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13987
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13988
+ }
13989
+ warning.update(_record_detail_error_warning_fields(exc))
13409
13990
  return {
13410
13991
  "verified": False,
13411
13992
  "verification_mode": "custom_view_record_detail",
@@ -13414,14 +13995,9 @@ class RecordTools(ToolBase):
13414
13995
  "missing_fields": [],
13415
13996
  "empty_fields": [],
13416
13997
  "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
- }],
13998
+ "warnings": [warning],
13423
13999
  }
13424
- if exc.backend_code != 40002:
14000
+ if not _is_record_permission_denied_error(exc):
13425
14001
  raise
13426
14002
  return self._verify_record_write_result_via_initiated_tasks(
13427
14003
  context,
@@ -13715,6 +14291,8 @@ def _normalize_data_list_base_info_schema(payload: JSONValue) -> JSONObject:
13715
14291
  if not isinstance(payload, dict):
13716
14292
  return {}
13717
14293
  que_base_infos = payload.get("queBaseInfos")
14294
+ if not isinstance(que_base_infos, list) and isinstance(payload.get("formQues"), list):
14295
+ que_base_infos = payload.get("formQues")
13718
14296
  if not isinstance(que_base_infos, list):
13719
14297
  return {}
13720
14298
  return {
@@ -14101,6 +14679,44 @@ def _build_answer_backed_field_index(
14101
14679
  )
14102
14680
 
14103
14681
 
14682
+ def _merge_subtable_parent_field(primary: FormField, extra: FormField) -> FormField:
14683
+ if primary.que_type not in SUBTABLE_QUE_TYPES or extra.que_type not in SUBTABLE_QUE_TYPES:
14684
+ return primary
14685
+ primary_raw = dict(primary.raw) if isinstance(primary.raw, dict) else {}
14686
+ extra_raw = dict(extra.raw) if isinstance(extra.raw, dict) else {}
14687
+ primary_subquestions = primary_raw.get("subQuestions")
14688
+ extra_subquestions = extra_raw.get("subQuestions")
14689
+ if not isinstance(primary_subquestions, list) or not isinstance(extra_subquestions, list):
14690
+ return primary
14691
+ merged_subquestions = [item for item in primary_subquestions if isinstance(item, dict)]
14692
+ seen_ids = {
14693
+ _coerce_count(item.get("queId"))
14694
+ for item in merged_subquestions
14695
+ if isinstance(item, dict) and _coerce_count(item.get("queId")) is not None
14696
+ }
14697
+ for item in extra_subquestions:
14698
+ if not isinstance(item, dict):
14699
+ continue
14700
+ que_id = _coerce_count(item.get("queId"))
14701
+ if que_id is not None and que_id in seen_ids:
14702
+ continue
14703
+ merged_subquestions.append(item)
14704
+ if que_id is not None:
14705
+ seen_ids.add(que_id)
14706
+ if len(merged_subquestions) == len(primary_subquestions):
14707
+ return primary
14708
+ merged_raw = dict(primary_raw)
14709
+ merged_raw["subQuestions"] = merged_subquestions
14710
+ merged_field = _clone_form_field(primary)
14711
+ merged_field.raw = merged_raw
14712
+ return merged_field
14713
+
14714
+
14715
+ def _replace_field_in_lookup(index: dict[str, list[FormField]], field: FormField) -> None:
14716
+ for key, fields in list(index.items()):
14717
+ index[key] = [field if existing.que_id == field.que_id else existing for existing in fields]
14718
+
14719
+
14104
14720
  def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
14105
14721
  by_id = dict(primary.by_id)
14106
14722
  by_title = {key: list(value) for key, value in primary.by_title.items()}
@@ -14111,12 +14727,42 @@ def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
14111
14727
 
14112
14728
  for field_id, field in extra.by_id.items():
14113
14729
  if field_id in by_id:
14730
+ merged_field = _merge_subtable_parent_field(by_id[field_id], field)
14731
+ if merged_field is not by_id[field_id]:
14732
+ by_id[field_id] = merged_field
14733
+ _replace_field_in_lookup(by_title, merged_field)
14734
+ _replace_field_in_lookup(by_alias, merged_field)
14114
14735
  continue
14115
14736
  by_id[field_id] = field
14116
14737
  by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
14117
14738
  for alias in field.aliases:
14118
14739
  by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
14119
14740
 
14741
+ for field_id, fields in extra.subtable_leaf_by_id.items():
14742
+ merged = subtable_leaf_by_id.setdefault(field_id, [])
14743
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14744
+ for field in fields:
14745
+ key = (field.field.que_id, field.parent_field.que_id)
14746
+ if key not in existing:
14747
+ merged.append(field)
14748
+ existing.add(key)
14749
+ for title, fields in extra.subtable_leaf_by_title.items():
14750
+ merged = subtable_leaf_by_title.setdefault(title, [])
14751
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14752
+ for field in fields:
14753
+ key = (field.field.que_id, field.parent_field.que_id)
14754
+ if key not in existing:
14755
+ merged.append(field)
14756
+ existing.add(key)
14757
+ for alias, fields in extra.subtable_leaf_by_alias.items():
14758
+ merged = subtable_leaf_by_alias.setdefault(alias, [])
14759
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14760
+ for field in fields:
14761
+ key = (field.field.que_id, field.parent_field.que_id)
14762
+ if key not in existing:
14763
+ merged.append(field)
14764
+ existing.add(key)
14765
+
14120
14766
  return FieldIndex(
14121
14767
  by_id=by_id,
14122
14768
  by_title=by_title,
@@ -15507,6 +16153,28 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
15507
16153
  "message": message,
15508
16154
  "category": exc.category,
15509
16155
  }
16156
+ if is_auth_like_error(exc):
16157
+ payload["auth_like"] = True
16158
+ payload["error_code"] = "AUTH_REQUIRED"
16159
+ if exc.backend_code is not None:
16160
+ payload["backend_code"] = exc.backend_code
16161
+ if exc.http_status is not None:
16162
+ payload["http_status"] = exc.http_status
16163
+ request_id = getattr(exc, "request_id", None)
16164
+ if request_id:
16165
+ payload["request_id"] = request_id
16166
+ details = exc.details if isinstance(exc.details, dict) else {}
16167
+ error_code = details.get("error_code")
16168
+ if error_code and not payload.get("error_code"):
16169
+ payload["error_code"] = error_code
16170
+ return payload
16171
+
16172
+
16173
+ def _record_detail_error_warning_fields(exc: QingflowApiError) -> JSONObject:
16174
+ payload: JSONObject = {"category": exc.category}
16175
+ if is_auth_like_error(exc):
16176
+ payload["auth_like"] = True
16177
+ payload["error_code"] = "AUTH_REQUIRED"
15510
16178
  if exc.backend_code is not None:
15511
16179
  payload["backend_code"] = exc.backend_code
15512
16180
  if exc.http_status is not None:
@@ -15516,11 +16184,35 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
15516
16184
  payload["request_id"] = request_id
15517
16185
  details = exc.details if isinstance(exc.details, dict) else {}
15518
16186
  error_code = details.get("error_code")
15519
- if error_code:
16187
+ if error_code and not payload.get("error_code"):
15520
16188
  payload["error_code"] = error_code
15521
16189
  return payload
15522
16190
 
15523
16191
 
16192
+ def _record_detail_refreshed_source_url(refresh_result: Any) -> str | None:
16193
+ if isinstance(refresh_result, dict):
16194
+ return _normalize_optional_text(refresh_result.get("source_url"))
16195
+ return _normalize_optional_text(refresh_result)
16196
+
16197
+
16198
+ def _record_detail_append_refresh_warning(
16199
+ warnings: list[JSONObject],
16200
+ refresh_result: Any,
16201
+ *,
16202
+ id_key: str,
16203
+ id_value: str,
16204
+ ) -> None:
16205
+ if not isinstance(refresh_result, dict):
16206
+ return
16207
+ warning = refresh_result.get("warning")
16208
+ if not isinstance(warning, dict):
16209
+ return
16210
+ payload: JSONObject = dict(warning)
16211
+ payload.setdefault("code", "ASSET_STORAGE_URL_REFRESH_FAILED")
16212
+ payload.setdefault(id_key, id_value)
16213
+ warnings.append(payload)
16214
+
16215
+
15524
16216
  _RECORD_MEDIA_IMG_SRC_RE = re.compile(r"""<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE)
15525
16217
  _RECORD_MEDIA_MD_IMAGE_RE = re.compile(r"""!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)""")
15526
16218
  _RECORD_MEDIA_URL_RE = re.compile(r"""https?://[^\s<>"')\]]+""", re.IGNORECASE)
@@ -15675,7 +16367,14 @@ def _record_detail_media_assets_payload(
15675
16367
  except QingflowApiError as exc:
15676
16368
  blocked = exc.http_status in {401, 403}
15677
16369
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
15678
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16370
+ refresh_result = refresh_source_url(candidate)
16371
+ _record_detail_append_refresh_warning(
16372
+ warnings,
16373
+ refresh_result,
16374
+ id_key="asset_id",
16375
+ id_value=asset_id,
16376
+ )
16377
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
15679
16378
  if refreshed_url and refreshed_url != source_url:
15680
16379
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
15681
16380
  try:
@@ -15717,14 +16416,13 @@ def _record_detail_media_assets_payload(
15717
16416
  "readable_by_agent": False,
15718
16417
  }
15719
16418
  )
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
- )
16419
+ warning: JSONObject = {
16420
+ "code": warning_code,
16421
+ "asset_id": asset_id,
16422
+ "message": f"record_get could not download image asset {asset_id}: {exc.message}",
16423
+ }
16424
+ warning.update(_record_detail_error_warning_fields(exc))
16425
+ warnings.append(warning)
15728
16426
  continue
15729
16427
 
15730
16428
  if not isinstance(content, bytes):
@@ -15942,7 +16640,14 @@ def _record_detail_file_assets_payload(
15942
16640
  except QingflowApiError as exc:
15943
16641
  blocked = exc.http_status in {401, 403}
15944
16642
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
15945
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16643
+ refresh_result = refresh_source_url(candidate)
16644
+ _record_detail_append_refresh_warning(
16645
+ warnings,
16646
+ refresh_result,
16647
+ id_key="file_asset_id",
16648
+ id_value=file_asset_id,
16649
+ )
16650
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
15946
16651
  if refreshed_url and refreshed_url != source_url:
15947
16652
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
15948
16653
  try:
@@ -15989,14 +16694,13 @@ def _record_detail_file_assets_payload(
15989
16694
  "extraction": {"status": "failed", "text_path": None, "preview": None},
15990
16695
  }
15991
16696
  )
15992
- warnings.append(
15993
- {
15994
- "code": warning_code,
15995
- "file_asset_id": file_asset_id,
15996
- "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
15997
- "http_status": exc.http_status,
15998
- }
15999
- )
16697
+ warning = {
16698
+ "code": warning_code,
16699
+ "file_asset_id": file_asset_id,
16700
+ "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
16701
+ }
16702
+ warning.update(_record_detail_error_warning_fields(exc))
16703
+ warnings.append(warning)
16000
16704
  continue
16001
16705
 
16002
16706
  if not isinstance(content, bytes):