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

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 (88) 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/models.py +11 -0
  50. package/src/qingflow_mcp/builder_facade/service.py +1488 -288
  51. package/src/qingflow_mcp/cli/commands/builder.py +2 -2
  52. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  53. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  54. package/src/qingflow_mcp/cli/commands/record.py +91 -19
  55. package/src/qingflow_mcp/cli/context.py +0 -3
  56. package/src/qingflow_mcp/cli/formatters.py +206 -7
  57. package/src/qingflow_mcp/cli/main.py +47 -3
  58. package/src/qingflow_mcp/errors.py +43 -2
  59. package/src/qingflow_mcp/public_surface.py +21 -15
  60. package/src/qingflow_mcp/response_trim.py +74 -13
  61. package/src/qingflow_mcp/server.py +11 -9
  62. package/src/qingflow_mcp/server_app_builder.py +3 -2
  63. package/src/qingflow_mcp/server_app_user.py +19 -13
  64. package/src/qingflow_mcp/session_store.py +11 -7
  65. package/src/qingflow_mcp/solution/executor.py +112 -15
  66. package/src/qingflow_mcp/tools/ai_builder_tools.py +36 -11
  67. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  68. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  69. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  70. package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
  71. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  72. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  73. package/src/qingflow_mcp/tools/export_tools.py +244 -34
  74. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  75. package/src/qingflow_mcp/tools/import_tools.py +336 -49
  76. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  77. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  78. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  79. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  80. package/src/qingflow_mcp/tools/record_tools.py +1067 -349
  81. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  82. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  83. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  84. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  85. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  86. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  87. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  88. 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
@@ -322,11 +350,12 @@ class RecordTools(ToolBase):
322
350
  """注册当前工具到 MCP 服务。"""
323
351
  @mcp.tool()
324
352
  def record_insert_schema_get(
353
+ profile: str = DEFAULT_PROFILE,
325
354
  app_key: str = "",
326
355
  output_profile: str = "normal",
327
356
  ) -> JSONObject:
328
357
  return self.record_insert_schema_get_public(
329
- profile=DEFAULT_PROFILE,
358
+ profile=profile,
330
359
  app_key=app_key,
331
360
  output_profile=output_profile,
332
361
  )
@@ -436,6 +465,7 @@ class RecordTools(ToolBase):
436
465
  )
437
466
  )
438
467
  def record_access(
468
+ profile: str = DEFAULT_PROFILE,
439
469
  app_key: str = "",
440
470
  view_id: str = "",
441
471
  columns: list[JSONObject | int] | None = None,
@@ -443,7 +473,7 @@ class RecordTools(ToolBase):
443
473
  order_by: list[JSONObject] | None = None,
444
474
  ) -> JSONObject:
445
475
  return self.record_access(
446
- profile=DEFAULT_PROFILE,
476
+ profile=profile,
447
477
  app_key=app_key,
448
478
  view_id=view_id,
449
479
  columns=columns or [],
@@ -471,7 +501,7 @@ class RecordTools(ToolBase):
471
501
  output_profile=output_profile,
472
502
  )
473
503
 
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.")
504
+ @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
505
  def record_logs_get(
476
506
  profile: str = DEFAULT_PROFILE,
477
507
  app_key: str = "",
@@ -487,12 +517,13 @@ class RecordTools(ToolBase):
487
517
 
488
518
  @mcp.tool()
489
519
  def record_browse_schema_get(
520
+ profile: str = DEFAULT_PROFILE,
490
521
  app_key: str = "",
491
522
  view_id: str = "",
492
523
  output_profile: str = "normal",
493
524
  ) -> JSONObject:
494
525
  return self.record_browse_schema_get_public(
495
- profile=DEFAULT_PROFILE,
526
+ profile=profile,
496
527
  app_key=app_key,
497
528
  view_id=view_id,
498
529
  output_profile=output_profile,
@@ -500,14 +531,17 @@ class RecordTools(ToolBase):
500
531
 
501
532
  @mcp.tool()
502
533
  def record_update_schema_get(
534
+ profile: str = DEFAULT_PROFILE,
503
535
  app_key: str = "",
504
536
  record_id: str = "",
537
+ view_id: str | None = None,
505
538
  output_profile: str = "normal",
506
539
  ) -> JSONObject:
507
540
  return self.record_update_schema_get_public(
508
- profile=DEFAULT_PROFILE,
541
+ profile=profile,
509
542
  app_key=app_key,
510
543
  record_id=record_id,
544
+ view_id=view_id,
511
545
  output_profile=output_profile,
512
546
  )
513
547
 
@@ -520,13 +554,14 @@ class RecordTools(ToolBase):
520
554
  )
521
555
  )
522
556
  def record_insert(
557
+ profile: str = DEFAULT_PROFILE,
523
558
  app_key: str = "",
524
559
  items: list[JSONObject] | None = None,
525
560
  verify_write: bool = True,
526
561
  output_profile: str = "normal",
527
562
  ) -> JSONObject:
528
563
  return self.record_insert_public(
529
- profile=DEFAULT_PROFILE,
564
+ profile=profile,
530
565
  app_key=app_key,
531
566
  items=items,
532
567
  verify_write=verify_write,
@@ -537,25 +572,29 @@ class RecordTools(ToolBase):
537
572
  description=(
538
573
  "Update one Qingflow record using a field map. "
539
574
  "For simple field changes, call this tool directly after the target record is clear. "
575
+ "Pass view_id when the frontend detail view is known; the tool will try that view first. "
540
576
  "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
541
577
  "Use record_update_schema_get for diagnostics or complex field-scope inspection."
542
578
  )
543
579
  )
544
580
  def record_update(
581
+ profile: str = DEFAULT_PROFILE,
545
582
  app_key: str = "",
546
583
  record_id: str | None = None,
547
584
  fields: JSONObject | None = None,
548
585
  items: list[JSONObject] | None = None,
586
+ view_id: str | None = None,
549
587
  dry_run: bool = False,
550
588
  verify_write: bool = True,
551
589
  output_profile: str = "normal",
552
590
  ) -> JSONObject:
553
591
  return self.record_update_public(
554
- profile=DEFAULT_PROFILE,
592
+ profile=profile,
555
593
  app_key=app_key,
556
594
  record_id=record_id,
557
595
  fields=fields,
558
596
  items=items,
597
+ view_id=view_id,
559
598
  dry_run=dry_run,
560
599
  verify_write=verify_write,
561
600
  output_profile=output_profile,
@@ -564,20 +603,23 @@ class RecordTools(ToolBase):
564
603
  @mcp.tool(
565
604
  description=(
566
605
  "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."
606
+ "Pass view_id when deleting from a known system list route; custom view deletion is not supported by this backend route."
568
607
  )
569
608
  )
570
609
  def record_delete(
610
+ profile: str = DEFAULT_PROFILE,
571
611
  app_key: str = "",
572
612
  record_id: str | None = None,
573
613
  record_ids: list[str] | None = None,
614
+ view_id: str | None = None,
574
615
  output_profile: str = "normal",
575
616
  ) -> JSONObject:
576
617
  return self.record_delete_public(
577
- profile=DEFAULT_PROFILE,
618
+ profile=profile,
578
619
  app_key=app_key,
579
620
  record_id=record_id,
580
621
  record_ids=record_ids or [],
622
+ view_id=view_id,
581
623
  output_profile=output_profile,
582
624
  )
583
625
 
@@ -849,6 +891,7 @@ class RecordTools(ToolBase):
849
891
  profile: str = DEFAULT_PROFILE,
850
892
  app_key: str,
851
893
  record_id: Any,
894
+ view_id: str | None = None,
852
895
  output_profile: str = "normal",
853
896
  ) -> JSONObject:
854
897
  """执行记录相关逻辑。"""
@@ -860,21 +903,44 @@ class RecordTools(ToolBase):
860
903
  def runner(session_profile, context):
861
904
  request_route = self._request_route_payload(context)
862
905
  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
- )
906
+ linkage_payloads_by_field_id: dict[str, JSONObject] = {}
907
+ try:
908
+ app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
909
+ except QingflowApiError as exc:
910
+ if not _is_optional_schema_permission_error(exc):
911
+ raise
912
+ else:
913
+ question_relations = _collect_question_relations(app_schema)
914
+ app_index = _build_applicant_top_level_field_index(app_schema)
915
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
916
+ linked_field_ids.update(_collect_option_linked_field_ids(app_index))
917
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
918
+ app_schema,
919
+ linked_field_ids=linked_field_ids,
920
+ )
921
+ app_index = _merge_field_indexes(app_index, linked_hidden_index)
922
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
923
+ index=app_index,
924
+ question_relations=question_relations,
925
+ )
926
+ preferred_view_id = _normalize_optional_text(view_id)
877
927
  candidate_routes = self._candidate_update_views(profile, context, app_key)
928
+ if preferred_view_id:
929
+ preferred_route = next(
930
+ (
931
+ route
932
+ for route in candidate_routes
933
+ if route.view_id == preferred_view_id
934
+ ),
935
+ None,
936
+ )
937
+ if preferred_route is None:
938
+ raise_tool_error(
939
+ QingflowApiError.config_error(
940
+ f"view_id '{preferred_view_id}' is not an accessible update candidate"
941
+ )
942
+ )
943
+ candidate_routes = [preferred_route]
878
944
  probes = self._probe_candidate_record_contexts(
879
945
  context,
880
946
  app_key=app_key,
@@ -976,6 +1042,7 @@ class RecordTools(ToolBase):
976
1042
  output_profile=normalized_output_profile,
977
1043
  view_probe_summary=probe_summary,
978
1044
  ambiguous_fields=[],
1045
+ preferred_view_id=preferred_view_id,
979
1046
  )
980
1047
 
981
1048
  ambiguous_field_ids: set[int] = set()
@@ -1022,6 +1089,7 @@ class RecordTools(ToolBase):
1022
1089
  output_profile=normalized_output_profile,
1023
1090
  view_probe_summary=probe_summary,
1024
1091
  ambiguous_fields=ambiguous_fields,
1092
+ preferred_view_id=preferred_view_id,
1025
1093
  )
1026
1094
 
1027
1095
  response: JSONObject = {
@@ -1046,6 +1114,8 @@ class RecordTools(ToolBase):
1046
1114
  "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
1115
  },
1048
1116
  }
1117
+ if preferred_view_id:
1118
+ response["preferred_view_id"] = preferred_view_id
1049
1119
  if normalized_output_profile == "verbose":
1050
1120
  response["view_probe_summary"] = probe_summary
1051
1121
  response["record_context_probe"] = probe_summary
@@ -1106,6 +1176,7 @@ class RecordTools(ToolBase):
1106
1176
  output_profile: str,
1107
1177
  view_probe_summary: list[JSONObject],
1108
1178
  ambiguous_fields: list[JSONObject],
1179
+ preferred_view_id: str | None = None,
1109
1180
  ) -> JSONObject:
1110
1181
  """执行内部辅助逻辑。"""
1111
1182
  response: JSONObject = {
@@ -1123,6 +1194,8 @@ class RecordTools(ToolBase):
1123
1194
  "payload_template": {},
1124
1195
  "recommended_next_actions": recommended_next_actions,
1125
1196
  }
1197
+ if preferred_view_id:
1198
+ response["preferred_view_id"] = preferred_view_id
1126
1199
  if output_profile == "verbose":
1127
1200
  response["view_probe_summary"] = view_probe_summary
1128
1201
  response["ambiguous_fields"] = ambiguous_fields
@@ -1440,24 +1513,58 @@ class RecordTools(ToolBase):
1440
1513
  )
1441
1514
  warnings: list[JSONObject] = []
1442
1515
  scope_source = "static_applicant_scope"
1443
- if runtime_lookup:
1444
- state = self._build_candidate_lookup_state(
1445
- profile,
1446
- context,
1516
+ try:
1517
+ if runtime_lookup:
1518
+ state = self._build_candidate_lookup_state(
1519
+ profile,
1520
+ context,
1521
+ app_key=app_key,
1522
+ record_id=record_id_int,
1523
+ workflow_node_id=workflow_node_id,
1524
+ fields=normalized_fields,
1525
+ )
1526
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1527
+ scope_source = "backend_runtime_scope"
1528
+ else:
1529
+ items: list[JSONObject] | None = None
1530
+ if self._member_candidate_static_preview_should_use_backend(field):
1531
+ state = self._build_candidate_lookup_state(
1532
+ profile,
1533
+ context,
1534
+ app_key=app_key,
1535
+ record_id=None,
1536
+ workflow_node_id=None,
1537
+ fields={},
1538
+ )
1539
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1540
+ scope_source = "backend_applicant_scope"
1541
+ if items is None:
1542
+ items = self._resolve_member_candidates(context, field, keyword=keyword)
1543
+ warnings.append(
1544
+ {
1545
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1546
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1547
+ }
1548
+ )
1549
+ except (RecordInputError, QingflowApiError) as error:
1550
+ record_error = (
1551
+ error
1552
+ if isinstance(error, RecordInputError)
1553
+ else self._candidate_lookup_error(kind="member", field=field, value=keyword, error=error)
1554
+ )
1555
+ return self._candidate_lookup_failed_response(
1556
+ profile=profile,
1557
+ session_profile=session_profile,
1558
+ context=context,
1559
+ kind="member",
1560
+ error=record_error,
1561
+ field=field,
1447
1562
  app_key=app_key,
1448
- record_id=record_id_int,
1563
+ record_id_text=record_id_text,
1449
1564
  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
- }
1565
+ fields_present=bool(normalized_fields),
1566
+ keyword=keyword,
1567
+ scope_source=scope_source,
1461
1568
  )
1462
1569
  total = len(items)
1463
1570
  start = (page_num - 1) * page_size
@@ -1550,41 +1657,75 @@ class RecordTools(ToolBase):
1550
1657
  )
1551
1658
  warnings: list[JSONObject] = []
1552
1659
  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
- ):
1660
+ try:
1661
+ if runtime_lookup:
1573
1662
  state = self._build_candidate_lookup_state(
1574
1663
  profile,
1575
1664
  context,
1576
1665
  app_key=app_key,
1577
- record_id=None,
1578
- workflow_node_id=None,
1579
- fields={},
1666
+ record_id=record_id_int,
1667
+ workflow_node_id=workflow_node_id,
1668
+ fields=normalized_fields,
1580
1669
  )
1581
1670
  items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1582
1671
  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
- }
1672
+ else:
1673
+ items: list[JSONObject] | None = None
1674
+ if self._department_candidate_static_preview_should_use_backend(field):
1675
+ state = self._build_candidate_lookup_state(
1676
+ profile,
1677
+ context,
1678
+ app_key=app_key,
1679
+ record_id=None,
1680
+ workflow_node_id=None,
1681
+ fields={},
1682
+ )
1683
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1684
+ scope_source = "backend_applicant_scope"
1685
+ if items is None:
1686
+ items = self._resolve_department_candidates(context, field, keyword=keyword)
1687
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1688
+ if (
1689
+ not items
1690
+ and field.dept_select_scope_type == 2
1691
+ and not _scope_has_dynamic_or_external(scope)
1692
+ and not list(scope.get("depart") or [])
1693
+ ):
1694
+ state = self._build_candidate_lookup_state(
1695
+ profile,
1696
+ context,
1697
+ app_key=app_key,
1698
+ record_id=None,
1699
+ workflow_node_id=None,
1700
+ fields={},
1701
+ )
1702
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1703
+ scope_source = "backend_applicant_scope"
1704
+ warnings.append(
1705
+ {
1706
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1707
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1708
+ }
1709
+ )
1710
+ except (RecordInputError, QingflowApiError) as error:
1711
+ record_error = (
1712
+ error
1713
+ if isinstance(error, RecordInputError)
1714
+ else self._candidate_lookup_error(kind="department", field=field, value=keyword, error=error)
1715
+ )
1716
+ return self._candidate_lookup_failed_response(
1717
+ profile=profile,
1718
+ session_profile=session_profile,
1719
+ context=context,
1720
+ kind="department",
1721
+ error=record_error,
1722
+ field=field,
1723
+ app_key=app_key,
1724
+ record_id_text=record_id_text,
1725
+ workflow_node_id=workflow_node_id,
1726
+ fields_present=bool(normalized_fields),
1727
+ keyword=keyword,
1728
+ scope_source=scope_source,
1588
1729
  )
1589
1730
  total = len(items)
1590
1731
  start = (page_num - 1) * page_size
@@ -1646,6 +1787,21 @@ class RecordTools(ToolBase):
1646
1787
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1647
1788
  if limit <= 0:
1648
1789
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1790
+ if not (
1791
+ _normalize_optional_text(view_id)
1792
+ or list_type is not None
1793
+ or _normalize_optional_text(view_key)
1794
+ or _normalize_optional_text(view_name)
1795
+ ):
1796
+ raise_tool_error(
1797
+ QingflowApiError.config_error(
1798
+ "record_analyze requires view_id. Call app_get first and pass accessible_views[].view_id.",
1799
+ details={
1800
+ "error_code": "RECORD_ANALYZE_VIEW_REQUIRED",
1801
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_analyze with view_id.",
1802
+ },
1803
+ )
1804
+ )
1649
1805
  legacy_warnings = _detect_analyze_legacy_warnings(
1650
1806
  dimensions=dimensions,
1651
1807
  metrics=metrics,
@@ -1662,7 +1818,7 @@ class RecordTools(ToolBase):
1662
1818
  list_type=list_type,
1663
1819
  view_key=view_key,
1664
1820
  view_name=view_name,
1665
- allow_default=True,
1821
+ allow_default=False,
1666
1822
  )
1667
1823
  if not _view_type_supports_analysis(resolved_view.view_type):
1668
1824
  raise_tool_error(
@@ -1743,6 +1899,21 @@ class RecordTools(ToolBase):
1743
1899
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1744
1900
  if page <= 0:
1745
1901
  raise_tool_error(QingflowApiError.config_error("page must be positive"))
1902
+ if not (
1903
+ _normalize_optional_text(view_id)
1904
+ or list_type is not None
1905
+ or _normalize_optional_text(view_key)
1906
+ or _normalize_optional_text(view_name)
1907
+ ):
1908
+ raise_tool_error(
1909
+ QingflowApiError.config_error(
1910
+ "record_list requires view_id. Call app_get first and pass accessible_views[].view_id.",
1911
+ details={
1912
+ "error_code": "RECORD_LIST_VIEW_REQUIRED",
1913
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_list with view_id.",
1914
+ },
1915
+ )
1916
+ )
1746
1917
  view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
1747
1918
  profile=profile,
1748
1919
  app_key=app_key,
@@ -1750,7 +1921,7 @@ class RecordTools(ToolBase):
1750
1921
  list_type=list_type,
1751
1922
  view_key=view_key,
1752
1923
  view_name=view_name,
1753
- allow_default=True,
1924
+ allow_default=False,
1754
1925
  )
1755
1926
  if not _view_type_supports_analysis(view_route.view_type):
1756
1927
  raise_tool_error(
@@ -2224,6 +2395,7 @@ class RecordTools(ToolBase):
2224
2395
  requested_output_profile = _normalize_record_get_detail_output_profile(output_profile)
2225
2396
  record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2226
2397
  normalized_columns = _normalize_public_column_selectors(columns)
2398
+ explicit_view_id = _normalize_optional_text(view_id)
2227
2399
 
2228
2400
  def runner(session_profile, context):
2229
2401
  resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
@@ -2249,17 +2421,45 @@ class RecordTools(ToolBase):
2249
2421
  "code": "OUTPUT_PROFILE_DEPRECATED_FOR_DETAIL_CONTEXT",
2250
2422
  "message": f"output_profile={requested_output_profile!r} is deprecated for record_get; detail_context is always returned.",
2251
2423
  })
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
- )
2424
+ def get_detail_for_route(route: AccessibleViewRoute, route_warnings: list[JSONObject]) -> JSONObject:
2425
+ return self._record_get_detail_context(
2426
+ profile=profile,
2427
+ session_profile=session_profile,
2428
+ context=context,
2429
+ app_key=app_key,
2430
+ record_id_int=record_id_int,
2431
+ resolved_view=route,
2432
+ requested_focus_field_ids=normalized_columns,
2433
+ workflow_node_id=workflow_node_id,
2434
+ warnings=route_warnings,
2435
+ )
2436
+
2437
+ try:
2438
+ return get_detail_for_route(resolved_view, warnings)
2439
+ except QingflowApiError as exc:
2440
+ if explicit_view_id is not None:
2441
+ raise
2442
+ if not self._is_record_context_route_miss(exc):
2443
+ raise
2444
+ fallback_warnings = list(warnings)
2445
+ fallback_warnings.append(
2446
+ {
2447
+ "code": "DEFAULT_DETAIL_ROUTE_DENIED",
2448
+ "message": "record_get default system:all route was not readable; trying accessible views that match the frontend route model.",
2449
+ "backend_code": exc.backend_code,
2450
+ }
2451
+ )
2452
+ last_error = exc
2453
+ for candidate in self._candidate_update_views(profile, context, app_key):
2454
+ if candidate.view_id == resolved_view.view_id:
2455
+ continue
2456
+ try:
2457
+ return get_detail_for_route(candidate, fallback_warnings)
2458
+ except QingflowApiError as candidate_exc:
2459
+ if not self._is_record_context_route_miss(candidate_exc):
2460
+ raise
2461
+ last_error = candidate_exc
2462
+ raise last_error
2263
2463
 
2264
2464
  return self._run_record_tool(profile, runner)
2265
2465
 
@@ -2274,6 +2474,16 @@ class RecordTools(ToolBase):
2274
2474
  ) -> JSONObject:
2275
2475
  """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2276
2476
  record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2477
+ if not _normalize_optional_text(view_id):
2478
+ raise_tool_error(
2479
+ QingflowApiError.config_error(
2480
+ "record_logs_get requires view_id. Call app_get first and pass accessible_views[].view_id.",
2481
+ details={
2482
+ "error_code": "RECORD_LOGS_VIEW_REQUIRED",
2483
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_logs_get with view_id.",
2484
+ },
2485
+ )
2486
+ )
2277
2487
 
2278
2488
  def runner(session_profile, context):
2279
2489
  resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
@@ -2284,21 +2494,45 @@ class RecordTools(ToolBase):
2284
2494
  list_type=None,
2285
2495
  view_key=None,
2286
2496
  view_name=None,
2287
- allow_default=True,
2497
+ allow_default=False,
2288
2498
  )
2289
2499
  warnings: list[JSONObject] = []
2290
2500
  warnings.extend(compatibility_warnings)
2291
2501
  warnings.extend(_view_filter_trust_warnings(resolved_view))
2292
2502
  unavailable_context: list[JSONObject] = []
2293
2503
 
2294
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2504
+ schema: JSONObject = {}
2505
+ try:
2506
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2507
+ except QingflowApiError as exc:
2508
+ if not _is_optional_schema_permission_error(exc):
2509
+ raise
2510
+ unavailable_context.append(
2511
+ _record_detail_unavailable_context(
2512
+ "detail_schema",
2513
+ "记录日志字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2514
+ exc,
2515
+ )
2516
+ )
2295
2517
  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
- )
2518
+ try:
2519
+ audit_info = self._record_get_audit_info(
2520
+ context,
2521
+ app_key=app_key,
2522
+ record_id=record_id_int,
2523
+ resolved_view=resolved_view,
2524
+ )
2525
+ except QingflowApiError as exc:
2526
+ if not _is_optional_schema_permission_error(exc):
2527
+ raise
2528
+ audit_info = {}
2529
+ unavailable_context.append(
2530
+ _record_detail_unavailable_context(
2531
+ "audit_info",
2532
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据和日志。",
2533
+ exc,
2534
+ )
2535
+ )
2302
2536
  audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2303
2537
  detail_result, used_list_type, used_role = self._record_get_apply_detail(
2304
2538
  context,
@@ -2308,6 +2542,17 @@ class RecordTools(ToolBase):
2308
2542
  audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2309
2543
  )
2310
2544
  answer_list = _record_detail_answers(detail_result)
2545
+ if not index.by_id:
2546
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2547
+ if answer_index.by_id:
2548
+ index = answer_index
2549
+ unavailable_context.append(
2550
+ {
2551
+ "section": "detail_schema",
2552
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2553
+ "category": "partial_context",
2554
+ }
2555
+ )
2311
2556
  selected_fields = list(index.by_id.values())
2312
2557
  fields = [
2313
2558
  _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
@@ -2401,14 +2646,41 @@ class RecordTools(ToolBase):
2401
2646
  warnings: list[JSONObject],
2402
2647
  ) -> JSONObject:
2403
2648
  """执行内部辅助逻辑。"""
2404
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2649
+ unavailable_context: list[JSONObject] = []
2650
+ schema: JSONObject = {}
2651
+ schema_available = True
2652
+ try:
2653
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2654
+ except QingflowApiError as exc:
2655
+ if not _is_optional_schema_permission_error(exc):
2656
+ raise
2657
+ schema_available = False
2658
+ unavailable_context.append(
2659
+ _record_detail_unavailable_context(
2660
+ "detail_schema",
2661
+ "记录详情字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2662
+ exc,
2663
+ )
2664
+ )
2405
2665
  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
- )
2666
+ try:
2667
+ audit_info = self._record_get_audit_info(
2668
+ context,
2669
+ app_key=app_key,
2670
+ record_id=record_id_int,
2671
+ resolved_view=resolved_view,
2672
+ )
2673
+ except QingflowApiError as exc:
2674
+ if not _is_optional_schema_permission_error(exc):
2675
+ raise
2676
+ audit_info = {}
2677
+ unavailable_context.append(
2678
+ _record_detail_unavailable_context(
2679
+ "audit_info",
2680
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据。",
2681
+ exc,
2682
+ )
2683
+ )
2412
2684
  audit_context = _record_detail_audit_context(audit_info, workflow_node_id=workflow_node_id)
2413
2685
  detail_result, used_list_type, used_role = self._record_get_apply_detail(
2414
2686
  context,
@@ -2418,13 +2690,24 @@ class RecordTools(ToolBase):
2418
2690
  audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2419
2691
  )
2420
2692
  answer_list = _record_detail_answers(detail_result)
2693
+ if not index.by_id:
2694
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2695
+ if answer_index.by_id:
2696
+ index = answer_index
2697
+ unavailable_context.append(
2698
+ {
2699
+ "section": "detail_schema",
2700
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2701
+ "category": "partial_context",
2702
+ }
2703
+ )
2421
2704
  selected_fields = list(index.by_id.values())
2422
2705
  row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id_int)
2423
2706
  normalized_record, _normalized_ambiguous_fields = _build_normalized_row_from_answers(
2424
2707
  cast(list[JSONValue], answer_list),
2425
2708
  selected_fields,
2426
2709
  )
2427
- if self._record_get_needs_schema_refresh(
2710
+ if schema_available and self._record_get_needs_schema_refresh(
2428
2711
  answer_list=cast(list[JSONValue], answer_list),
2429
2712
  selected_fields=selected_fields,
2430
2713
  record=row,
@@ -2440,7 +2723,6 @@ class RecordTools(ToolBase):
2440
2723
  index = _build_top_level_field_index(schema)
2441
2724
  selected_fields = list(index.by_id.values())
2442
2725
 
2443
- unavailable_context: list[JSONObject] = []
2444
2726
  dynamic_reference_answers, dynamic_reference_unavailable = self._record_get_dynamic_reference_answers(
2445
2727
  context,
2446
2728
  app_key=app_key,
@@ -2599,7 +2881,20 @@ class RecordTools(ToolBase):
2599
2881
  ) -> JSONObject:
2600
2882
  """执行内部辅助逻辑。"""
2601
2883
  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)
2884
+ return self._get_custom_view_browse_schema(
2885
+ profile,
2886
+ context,
2887
+ resolved_view.view_selection.view_key,
2888
+ force_refresh=force_refresh,
2889
+ )
2890
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
2891
+ return self._get_system_browse_schema(
2892
+ profile,
2893
+ context,
2894
+ app_key,
2895
+ list_type=resolved_view.list_type,
2896
+ force_refresh=force_refresh,
2897
+ )
2603
2898
  return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
2604
2899
 
2605
2900
  def _record_get_audit_info(
@@ -2660,7 +2955,7 @@ class RecordTools(ToolBase):
2660
2955
  )
2661
2956
  return result if isinstance(result, dict) else {"value": result}, list_type, role
2662
2957
  except QingflowApiError as exc:
2663
- if resolved_view.list_type is not None or exc.backend_code != 40002:
2958
+ if resolved_view.list_type is not None or not _is_record_permission_denied_error(exc):
2664
2959
  raise
2665
2960
  last_error: QingflowApiError = exc
2666
2961
  for fallback_list_type in (14, 1, 2, 12):
@@ -2678,7 +2973,7 @@ class RecordTools(ToolBase):
2678
2973
  return result if isinstance(result, dict) else {"value": result}, fallback_list_type, role
2679
2974
  except QingflowApiError as fallback_exc:
2680
2975
  last_error = fallback_exc
2681
- if fallback_exc.backend_code == 40002:
2976
+ if _is_record_permission_denied_error(fallback_exc):
2682
2977
  continue
2683
2978
  raise
2684
2979
  raise last_error
@@ -2776,6 +3071,8 @@ class RecordTools(ToolBase):
2776
3071
  if target_app_key == app_key and str(target_record_id) == str(source_record_id):
2777
3072
  reference_payload["self_reference"] = True
2778
3073
  except QingflowApiError as exc:
3074
+ if is_auth_like_error(exc):
3075
+ raise
2779
3076
  unavailable = _record_detail_unavailable_context(
2780
3077
  "reference_detail",
2781
3078
  f"引用字段「{field.que_title}」的目标记录详情获取失败。",
@@ -2873,6 +3170,8 @@ class RecordTools(ToolBase):
2873
3170
  json_body=body,
2874
3171
  )
2875
3172
  except QingflowApiError as exc:
3173
+ if is_auth_like_error(exc):
3174
+ raise
2876
3175
  unavailable = _record_detail_unavailable_context(
2877
3176
  "reference_runtime_match",
2878
3177
  "动态引用字段匹配数据获取失败。",
@@ -2927,6 +3226,8 @@ class RecordTools(ToolBase):
2927
3226
  },
2928
3227
  )
2929
3228
  except QingflowApiError as exc:
3229
+ if is_auth_like_error(exc):
3230
+ raise
2930
3231
  unavailable_context.append(_record_detail_unavailable_context("log_visibility", "侧边栏日志可见性获取失败。", exc))
2931
3232
  return {"status": "unavailable", "channel": channel, "data_log_visible": None, "workflow_log_visible": None}
2932
3233
  payload = visibility if isinstance(visibility, dict) else {}
@@ -2980,6 +3281,8 @@ class RecordTools(ToolBase):
2980
3281
  source="data_logs",
2981
3282
  )
2982
3283
  except QingflowApiError as exc:
3284
+ if is_auth_like_error(exc):
3285
+ raise
2983
3286
  unavailable_context.append(_record_detail_unavailable_context("data_logs", "最近数据日志获取失败。", exc))
2984
3287
  return _record_detail_log_unavailable_payload("data_logs", "fetch_unavailable")
2985
3288
 
@@ -3033,6 +3336,8 @@ class RecordTools(ToolBase):
3033
3336
  source="workflow_logs",
3034
3337
  )
3035
3338
  except QingflowApiError as exc:
3339
+ if is_auth_like_error(exc):
3340
+ raise
3036
3341
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
3037
3342
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
3038
3343
 
@@ -3076,6 +3381,8 @@ class RecordTools(ToolBase):
3076
3381
  deadline=deadline,
3077
3382
  )
3078
3383
  except QingflowApiError as exc:
3384
+ if is_auth_like_error(exc):
3385
+ raise
3079
3386
  unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3080
3387
  return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
3081
3388
 
@@ -3135,6 +3442,8 @@ class RecordTools(ToolBase):
3135
3442
  deadline=deadline,
3136
3443
  )
3137
3444
  except QingflowApiError as exc:
3445
+ if is_auth_like_error(exc):
3446
+ raise
3138
3447
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3139
3448
  return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3140
3449
 
@@ -3167,6 +3476,8 @@ class RecordTools(ToolBase):
3167
3476
  params["auditNodeId"] = audit_node_id
3168
3477
  payload = self.backend.request("GET", context, f"/app/{app_key}/asosChart", params=params)
3169
3478
  except QingflowApiError as exc:
3479
+ if is_auth_like_error(exc):
3480
+ raise
3170
3481
  unavailable_context.append(_record_detail_unavailable_context("associated_resources", "关联资源获取失败。", exc))
3171
3482
  return []
3172
3483
  return [_record_detail_associated_resource(item) for item in _record_detail_associated_resource_items(payload)]
@@ -3204,16 +3515,17 @@ class RecordTools(ToolBase):
3204
3515
  refresh_source_url=refresh_source_url,
3205
3516
  )
3206
3517
  except Exception as exc: # defensive: media should never break the core record detail.
3518
+ warning: JSONObject = {
3519
+ "code": "MEDIA_ASSETS_UNAVAILABLE",
3520
+ "message": f"record_get could not collect media assets: {exc}",
3521
+ }
3522
+ if isinstance(exc, QingflowApiError):
3523
+ warning.update(_record_detail_error_warning_fields(exc))
3207
3524
  return {
3208
3525
  "status": "unavailable",
3209
3526
  "local_dir": None,
3210
3527
  "items": [],
3211
- "warnings": [
3212
- {
3213
- "code": "MEDIA_ASSETS_UNAVAILABLE",
3214
- "message": f"record_get could not collect media assets: {exc}",
3215
- }
3216
- ],
3528
+ "warnings": [warning],
3217
3529
  }
3218
3530
 
3219
3531
  def _record_get_file_assets(
@@ -3251,16 +3563,17 @@ class RecordTools(ToolBase):
3251
3563
  refresh_source_url=refresh_source_url,
3252
3564
  )
3253
3565
  except Exception as exc: # defensive: file assets should never break the core record detail.
3566
+ warning = {
3567
+ "code": "FILE_ASSETS_UNAVAILABLE",
3568
+ "message": f"record_get could not collect file assets: {exc}",
3569
+ }
3570
+ if isinstance(exc, QingflowApiError):
3571
+ warning.update(_record_detail_error_warning_fields(exc))
3254
3572
  return {
3255
3573
  "status": "unavailable",
3256
3574
  "local_dir": None,
3257
3575
  "items": [],
3258
- "warnings": [
3259
- {
3260
- "code": "FILE_ASSETS_UNAVAILABLE",
3261
- "message": f"record_get could not collect file assets: {exc}",
3262
- }
3263
- ],
3576
+ "warnings": [warning],
3264
3577
  }
3265
3578
 
3266
3579
  def _record_get_refreshed_media_source_url(
@@ -3272,7 +3585,7 @@ class RecordTools(ToolBase):
3272
3585
  resolved_view: AccessibleViewRoute,
3273
3586
  audit_node_id: int | None,
3274
3587
  candidate: JSONObject,
3275
- ) -> str | None:
3588
+ ) -> JSONValue | None:
3276
3589
  """Refresh the detail payload once to recover an expired attachment storage signature."""
3277
3590
  if candidate.get("source") not in {"attachment", "image_field", "subtable"}:
3278
3591
  return None
@@ -3288,8 +3601,15 @@ class RecordTools(ToolBase):
3288
3601
  resolved_view=resolved_view,
3289
3602
  audit_node_id=audit_node_id,
3290
3603
  )
3291
- except QingflowApiError:
3292
- return None
3604
+ except QingflowApiError as exc:
3605
+ return {
3606
+ "source_url": None,
3607
+ "warning": _record_detail_unavailable_context(
3608
+ "asset_url_refresh",
3609
+ "record_get could not refresh the record detail before downloading a private asset.",
3610
+ exc,
3611
+ ),
3612
+ }
3293
3613
  for answer in _record_detail_answers(detail_result):
3294
3614
  if not isinstance(answer, dict) or _coerce_count(answer.get("queId")) != field_id:
3295
3615
  continue
@@ -3804,6 +4124,7 @@ class RecordTools(ToolBase):
3804
4124
  record_id: Any | None,
3805
4125
  fields: JSONObject | None = None,
3806
4126
  items: list[JSONObject] | None = None,
4127
+ view_id: str | None = None,
3807
4128
  dry_run: bool = False,
3808
4129
  verify_write: bool = True,
3809
4130
  output_profile: str = "normal",
@@ -3824,6 +4145,7 @@ class RecordTools(ToolBase):
3824
4145
  profile=profile,
3825
4146
  app_key=app_key,
3826
4147
  items=normalized_items,
4148
+ view_id=view_id,
3827
4149
  dry_run=dry_run,
3828
4150
  verify_write=verify_write,
3829
4151
  output_profile=normalized_output_profile,
@@ -3840,6 +4162,7 @@ class RecordTools(ToolBase):
3840
4162
  app_key=app_key,
3841
4163
  record_id=record_id_int,
3842
4164
  fields=cast(JSONObject, fields or {}),
4165
+ view_id=view_id,
3843
4166
  verify_write=verify_write,
3844
4167
  output_profile=normalized_output_profile,
3845
4168
  )
@@ -3851,17 +4174,61 @@ class RecordTools(ToolBase):
3851
4174
  app_key: str,
3852
4175
  record_id: int,
3853
4176
  fields: JSONObject,
4177
+ view_id: str | None,
3854
4178
  verify_write: bool,
3855
4179
  output_profile: str,
4180
+ capture_exceptions: bool = False,
3856
4181
  ) -> JSONObject:
3857
4182
  """执行内部辅助逻辑。"""
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
- )
4183
+ write_state = {"attempted": False}
4184
+ try:
4185
+ return self._record_update_public_single_impl(
4186
+ profile=profile,
4187
+ app_key=app_key,
4188
+ record_id=record_id,
4189
+ fields=fields,
4190
+ view_id=view_id,
4191
+ verify_write=verify_write,
4192
+ output_profile=output_profile,
4193
+ write_attempted_ref=lambda value: write_state.__setitem__("attempted", value),
4194
+ )
4195
+ except (QingflowApiError, RuntimeError) as exc:
4196
+ if not capture_exceptions:
4197
+ raise
4198
+ return self._record_write_exception_response(
4199
+ exc,
4200
+ operation="update",
4201
+ profile=profile,
4202
+ app_key=app_key,
4203
+ record_id=record_id,
4204
+ output_profile=output_profile,
4205
+ human_review=True,
4206
+ write_executed=write_state["attempted"],
4207
+ )
4208
+
4209
+ def _record_update_public_single_impl(
4210
+ self,
4211
+ *,
4212
+ profile: str,
4213
+ app_key: str,
4214
+ record_id: int,
4215
+ fields: JSONObject,
4216
+ view_id: str | None,
4217
+ verify_write: bool,
4218
+ output_profile: str,
4219
+ write_attempted_ref: Callable[[bool], None],
4220
+ ) -> JSONObject:
4221
+ """执行内部辅助逻辑。"""
4222
+ preflight_kwargs: dict[str, Any] = {
4223
+ "profile": profile,
4224
+ "app_key": app_key,
4225
+ "record_id": record_id,
4226
+ "fields": fields,
4227
+ "force_refresh_form": False,
4228
+ }
4229
+ if view_id is not None:
4230
+ preflight_kwargs["preferred_view_id"] = view_id
4231
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
3865
4232
  preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3866
4233
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3867
4234
  normalized_payload = self._record_write_normalized_payload(
@@ -3881,6 +4248,7 @@ class RecordTools(ToolBase):
3881
4248
  human_review=True,
3882
4249
  target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
3883
4250
  )
4251
+ write_attempted_ref(True)
3884
4252
  route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
3885
4253
  profile=profile,
3886
4254
  app_key=app_key,
@@ -4161,7 +4529,9 @@ class RecordTools(ToolBase):
4161
4529
  )
4162
4530
 
4163
4531
  def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4164
- if exc.backend_code in {40002, 40027, 40038, 404}:
4532
+ if is_auth_like_error(exc):
4533
+ return False
4534
+ if backend_code_int(exc) in {40002, 40027, 40038, 404}:
4165
4535
  return True
4166
4536
  if exc.http_status == 404:
4167
4537
  return True
@@ -4265,6 +4635,8 @@ class RecordTools(ToolBase):
4265
4635
  },
4266
4636
  )
4267
4637
  except QingflowApiError as exc:
4638
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4639
+ raise
4268
4640
  return unavailable(
4269
4641
  error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4270
4642
  reason="current-user todo task list is unavailable",
@@ -4312,6 +4684,8 @@ class RecordTools(ToolBase):
4312
4684
  f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4313
4685
  )
4314
4686
  except QingflowApiError as exc:
4687
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4688
+ raise
4315
4689
  return unavailable(
4316
4690
  error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4317
4691
  reason="workflow node editable field list is unavailable; record_update will not guess task editability",
@@ -4462,7 +4836,7 @@ class RecordTools(ToolBase):
4462
4836
  raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4463
4837
 
4464
4838
  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
4839
+ index = self._get_view_field_index(profile, context, normalized_view_key, force_refresh=force_refresh_form) if verify_write else None
4466
4840
  normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4467
4841
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4468
4842
  result = self.backend.request(
@@ -4565,6 +4939,7 @@ class RecordTools(ToolBase):
4565
4939
  profile: str,
4566
4940
  app_key: str,
4567
4941
  items: list[JSONObject],
4942
+ view_id: str | None,
4568
4943
  dry_run: bool,
4569
4944
  verify_write: bool,
4570
4945
  output_profile: str,
@@ -4576,6 +4951,7 @@ class RecordTools(ToolBase):
4576
4951
  app_key=app_key,
4577
4952
  record_id=cast(int, item["record_id"]),
4578
4953
  fields=cast(JSONObject, item["fields"]),
4954
+ view_id=view_id,
4579
4955
  output_profile=output_profile,
4580
4956
  )
4581
4957
  for item in items
@@ -4604,8 +4980,10 @@ class RecordTools(ToolBase):
4604
4980
  app_key=app_key,
4605
4981
  record_id=record_id,
4606
4982
  fields=fields,
4983
+ view_id=view_id,
4607
4984
  verify_write=verify_write,
4608
4985
  output_profile=output_profile,
4986
+ capture_exceptions=True,
4609
4987
  )
4610
4988
  )
4611
4989
  except (QingflowApiError, RuntimeError) as exc:
@@ -4636,16 +5014,20 @@ class RecordTools(ToolBase):
4636
5014
  app_key: str,
4637
5015
  record_id: int,
4638
5016
  fields: JSONObject,
5017
+ view_id: str | None,
4639
5018
  output_profile: str,
4640
5019
  ) -> JSONObject:
4641
5020
  """执行内部辅助逻辑。"""
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
- )
5021
+ preflight_kwargs: dict[str, Any] = {
5022
+ "profile": profile,
5023
+ "app_key": app_key,
5024
+ "record_id": record_id,
5025
+ "fields": fields,
5026
+ "force_refresh_form": False,
5027
+ }
5028
+ if view_id is not None:
5029
+ preflight_kwargs["preferred_view_id"] = view_id
5030
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
4649
5031
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
4650
5032
  normalized_payload = self._record_write_normalized_payload(
4651
5033
  operation="update",
@@ -4851,6 +5233,9 @@ class RecordTools(ToolBase):
4851
5233
  item: JSONObject = {
4852
5234
  "resource": data.get("resource"),
4853
5235
  "status": response.get("status"),
5236
+ "write_executed": bool(response.get("write_executed")),
5237
+ "safe_to_retry": bool(response.get("safe_to_retry", True)),
5238
+ "verification_status": response.get("verification_status", "not_requested"),
4854
5239
  "verification": data.get("verification"),
4855
5240
  "field_errors": cast(list[JSONObject], data.get("field_errors", [])),
4856
5241
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
@@ -4860,7 +5245,7 @@ class RecordTools(ToolBase):
4860
5245
  if isinstance(update_route, dict):
4861
5246
  item["update_route"] = update_route
4862
5247
  tried_routes = response.get("tried_routes")
4863
- if isinstance(tried_routes, list):
5248
+ if isinstance(tried_routes, list) and (output_profile == "verbose" or response.get("status") != "success"):
4864
5249
  item["tried_routes"] = tried_routes
4865
5250
  blockers = data.get("blockers")
4866
5251
  if isinstance(blockers, list) and blockers:
@@ -4882,6 +5267,7 @@ class RecordTools(ToolBase):
4882
5267
  app_key: str,
4883
5268
  record_id: int,
4884
5269
  fields: JSONObject,
5270
+ preferred_view_id: str | None = None,
4885
5271
  force_refresh_form: bool,
4886
5272
  ) -> JSONObject:
4887
5273
  """执行内部辅助逻辑。"""
@@ -4889,6 +5275,25 @@ class RecordTools(ToolBase):
4889
5275
  request_route = self._request_route_payload(context)
4890
5276
  def build_once(*, effective_force_refresh: bool) -> JSONObject:
4891
5277
  candidate_routes = self._candidate_update_views(profile, context, app_key)
5278
+ normalized_preferred_view_id = _normalize_optional_text(preferred_view_id)
5279
+ if normalized_preferred_view_id:
5280
+ preferred_route = next(
5281
+ (
5282
+ route
5283
+ for route in candidate_routes
5284
+ if route.view_id == normalized_preferred_view_id
5285
+ ),
5286
+ None,
5287
+ )
5288
+ if preferred_route is None:
5289
+ raise_tool_error(
5290
+ QingflowApiError.config_error(
5291
+ f"view_id '{normalized_preferred_view_id}' is not an accessible update candidate"
5292
+ )
5293
+ )
5294
+ candidate_routes = [preferred_route]
5295
+ else:
5296
+ candidate_routes = _prefer_custom_update_routes(candidate_routes)
4892
5297
  probes = self._probe_candidate_record_contexts(
4893
5298
  context,
4894
5299
  app_key=app_key,
@@ -5102,41 +5507,24 @@ class RecordTools(ToolBase):
5102
5507
  "data": first_confirmation_plan,
5103
5508
  }
5104
5509
 
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
5510
+ if normalized_preferred_view_id and first_blocked_plan is not None:
5511
+ first_blocked_plan["view_probe_summary"] = probe_summary
5512
+ first_blocked_plan["record_context_probe"] = probe_summary
5127
5513
  return {
5128
5514
  "profile": profile,
5129
5515
  "ws_id": session_profile.selected_ws_id,
5130
5516
  "ok": True,
5131
5517
  "request_route": request_route,
5132
- "data": union_plan,
5518
+ "data": first_blocked_plan,
5133
5519
  }
5134
5520
 
5135
5521
  blocked_data = self._build_auto_view_blocked_preflight_data(
5136
5522
  app_key=app_key,
5137
5523
  record_id=record_id,
5138
5524
  blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
5139
- warnings=[],
5525
+ warnings=[
5526
+ "record_update requires one executable frontend route for the full payload; it does not merge writable fields across multiple views."
5527
+ ],
5140
5528
  recommended_next_actions=[
5141
5529
  "Call record_update_schema_get first to inspect the overall writable field set for this record.",
5142
5530
  "Reduce the update payload until all requested fields fit inside one matched accessible view.",
@@ -5183,6 +5571,7 @@ class RecordTools(ToolBase):
5183
5571
  union_writable_field_ids: set[int] = set()
5184
5572
  union_visible_question_ids: set[int] = set()
5185
5573
  matched_view_payloads: list[JSONObject] = []
5574
+ union_index: FieldIndex | None = None
5186
5575
 
5187
5576
  for candidate in matched_routes:
5188
5577
  browse_scope = self._build_browse_write_scope(
@@ -5192,11 +5581,13 @@ class RecordTools(ToolBase):
5192
5581
  candidate,
5193
5582
  force_refresh=force_refresh_form,
5194
5583
  )
5584
+ browse_index = cast(FieldIndex, browse_scope["index"])
5585
+ union_index = browse_index if union_index is None else _merge_field_indexes(union_index, browse_index)
5195
5586
  union_writable_field_ids.update(cast(set[int], browse_scope["writable_field_ids"]))
5196
5587
  union_visible_question_ids.update(cast(set[int], browse_scope["visible_question_ids"]))
5197
5588
  matched_view_payloads.append(_accessible_view_payload(candidate))
5198
5589
 
5199
- if not union_writable_field_ids and not union_visible_question_ids:
5590
+ if union_index is None or (not union_writable_field_ids and not union_visible_question_ids):
5200
5591
  return None
5201
5592
 
5202
5593
  plan_data = self._build_record_write_preflight(
@@ -5213,10 +5604,9 @@ class RecordTools(ToolBase):
5213
5604
  view_key=None,
5214
5605
  view_name=None,
5215
5606
  existing_answers_override=current_answers,
5607
+ field_index_override=union_index,
5216
5608
  )
5217
5609
 
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
5610
  validation = cast(JSONObject, plan_data.get("validation", {}))
5221
5611
  invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
5222
5612
  missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
@@ -5227,12 +5617,21 @@ class RecordTools(ToolBase):
5227
5617
  invalid_fields.extend(
5228
5618
  self._validate_view_scoped_subtable_answers(
5229
5619
  normalized_answers=cast(list[JSONObject], plan_data.get("normalized_answers", [])),
5230
- full_index=app_index,
5231
- selector_index=app_index,
5620
+ full_index=union_index,
5621
+ selector_index=union_index,
5232
5622
  visible_question_ids=union_visible_question_ids,
5233
5623
  )
5234
5624
  )
5235
5625
 
5626
+ readonly_or_system_fields = [
5627
+ item
5628
+ for item in readonly_or_system_fields
5629
+ if not (
5630
+ isinstance(item, dict)
5631
+ and (que_id := _coerce_count(item.get("que_id"))) is not None
5632
+ and que_id in union_writable_field_ids
5633
+ )
5634
+ ]
5236
5635
  existing_readonly_ids = {
5237
5636
  str(_coerce_count(item.get("que_id")))
5238
5637
  for item in readonly_or_system_fields
@@ -5396,7 +5795,13 @@ class RecordTools(ToolBase):
5396
5795
  view_type=None,
5397
5796
  )
5398
5797
  )
5399
- for item in self._get_view_list(profile, context, app_key):
5798
+ try:
5799
+ view_items = self._get_view_list(profile, context, app_key)
5800
+ except QingflowApiError as exc:
5801
+ if not _is_record_permission_denied_error(exc):
5802
+ raise
5803
+ view_items = []
5804
+ for item in view_items:
5400
5805
  if not isinstance(item, dict):
5401
5806
  continue
5402
5807
  view_key = _normalize_optional_text(item.get("viewKey"))
@@ -5453,7 +5858,9 @@ class RecordTools(ToolBase):
5453
5858
  return payload
5454
5859
 
5455
5860
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
5456
- if error.backend_code in {40002, 40023, 40027, 40038, 404}:
5861
+ if is_auth_like_error(error):
5862
+ return False
5863
+ if backend_code_int(error) in {40002, 40023, 40027, 40038, 404}:
5457
5864
  return True
5458
5865
  if error.http_status == 404:
5459
5866
  return True
@@ -5483,11 +5890,12 @@ class RecordTools(ToolBase):
5483
5890
  used_list_type = None
5484
5891
  else:
5485
5892
  used_list_type = resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE
5893
+ role = _record_detail_role_for_list_type(used_list_type)
5486
5894
  record = self.backend.request(
5487
5895
  "GET",
5488
5896
  context,
5489
5897
  f"/app/{app_key}/apply/{apply_id}",
5490
- params={"role": 1, "listType": used_list_type},
5898
+ params={"role": role, "listType": used_list_type},
5491
5899
  )
5492
5900
  answers = record.get("answers") if isinstance(record, dict) else None
5493
5901
  normalized_answers = [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
@@ -5517,6 +5925,8 @@ class RecordTools(ToolBase):
5517
5925
  error_payload=None,
5518
5926
  )
5519
5927
  except QingflowApiError as exc:
5928
+ if not self._is_record_context_route_miss(exc):
5929
+ raise
5520
5930
  return RecordContextRouteProbe(
5521
5931
  route=resolved_view,
5522
5932
  answer_list=None,
@@ -5588,7 +5998,7 @@ class RecordTools(ToolBase):
5588
5998
  ]
5589
5999
 
5590
6000
  def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
5591
- if exc.backend_code == 500:
6001
+ if backend_code_int(exc) == 500:
5592
6002
  return True
5593
6003
  if exc.http_status is not None and exc.http_status >= 500:
5594
6004
  return True
@@ -5713,12 +6123,15 @@ class RecordTools(ToolBase):
5713
6123
  app_key: str,
5714
6124
  record_id: Any | None = None,
5715
6125
  record_ids: list[Any] | None = None,
6126
+ view_id: str | None = None,
6127
+ list_type: int | None = None,
5716
6128
  output_profile: str = "normal",
5717
6129
  ) -> JSONObject:
5718
6130
  """执行记录相关逻辑。"""
5719
6131
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
5720
6132
  if not app_key:
5721
6133
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
6134
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
5722
6135
  normalized_record_ids: list[int] = []
5723
6136
  for index, item in enumerate(record_ids or []):
5724
6137
  normalized_record_ids.append(normalize_positive_id_int(item, field_name=f"record_ids[{index}]"))
@@ -5738,21 +6151,72 @@ class RecordTools(ToolBase):
5738
6151
  "record_ids": [stringify_backend_id(item) for item in delete_ids],
5739
6152
  "answers": [],
5740
6153
  "submit_type": 1,
6154
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
5741
6155
  }
5742
6156
  return self._record_delete_public_batch(
5743
6157
  profile=profile,
5744
6158
  app_key=app_key,
5745
6159
  delete_ids=delete_ids,
6160
+ list_type=delete_list_type,
5746
6161
  normalized_payload=normalized_payload,
5747
6162
  output_profile=normalized_output_profile,
5748
6163
  )
5749
6164
 
6165
+ def _resolve_record_delete_list_type(self, *, view_id: str | None, list_type: int | None) -> int:
6166
+ normalized_view_id = _normalize_optional_text(view_id)
6167
+ if normalized_view_id:
6168
+ if normalized_view_id.startswith("custom:"):
6169
+ raise_tool_error(
6170
+ QingflowApiError.config_error(
6171
+ "record_delete does not support custom view deletion; the backend delete route accepts system listType only",
6172
+ details={
6173
+ "error_code": "RECORD_DELETE_CUSTOM_VIEW_UNSUPPORTED",
6174
+ "view_id": normalized_view_id,
6175
+ "fix_hint": (
6176
+ "Use a system view_id from app_get.accessible_views, or resolve target record_ids with "
6177
+ "record list/get first and retry delete without a custom view selector."
6178
+ ),
6179
+ },
6180
+ )
6181
+ )
6182
+ if not normalized_view_id.startswith("system:"):
6183
+ raise_tool_error(QingflowApiError.config_error("view_id must start with system: or custom:"))
6184
+ mapped_list_type = SYSTEM_VIEW_ID_TO_LIST_TYPE.get(normalized_view_id)
6185
+ if mapped_list_type is None:
6186
+ raise_tool_error(QingflowApiError.config_error(f"unsupported view_id '{normalized_view_id}'"))
6187
+ return mapped_list_type
6188
+ if list_type is not None:
6189
+ normalized_list_type = int(list_type)
6190
+ if normalized_list_type not in SYSTEM_VIEW_LIST_TYPES:
6191
+ raise_tool_error(
6192
+ QingflowApiError.config_error(
6193
+ "record_delete list_type must map to a supported system view",
6194
+ details={
6195
+ "error_code": "RECORD_DELETE_SYSTEM_VIEW_REQUIRED",
6196
+ "list_type": normalized_list_type,
6197
+ "supported_list_types": sorted(SYSTEM_VIEW_LIST_TYPES),
6198
+ "fix_hint": "Pass a system view_id from app_get.accessible_views instead of an arbitrary list_type.",
6199
+ },
6200
+ )
6201
+ )
6202
+ return normalized_list_type
6203
+ raise_tool_error(
6204
+ QingflowApiError.config_error(
6205
+ "record_delete requires a system view_id or list_type; deleting without frontend list context is ambiguous",
6206
+ details={
6207
+ "error_code": "RECORD_DELETE_VIEW_REQUIRED",
6208
+ "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.",
6209
+ },
6210
+ )
6211
+ )
6212
+
5750
6213
  def _record_delete_public_batch(
5751
6214
  self,
5752
6215
  *,
5753
6216
  profile: str,
5754
6217
  app_key: str,
5755
6218
  delete_ids: list[int],
6219
+ list_type: int,
5756
6220
  normalized_payload: JSONObject,
5757
6221
  output_profile: str,
5758
6222
  ) -> JSONObject:
@@ -5762,7 +6226,7 @@ class RecordTools(ToolBase):
5762
6226
  for index, delete_id in enumerate(delete_ids):
5763
6227
  record_id_text = stringify_backend_id(delete_id)
5764
6228
  try:
5765
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
6229
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id], list_type=list_type)
5766
6230
  request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
5767
6231
  ws_id = raw_apply.get("ws_id", ws_id)
5768
6232
  single_payload = {
@@ -5771,6 +6235,7 @@ class RecordTools(ToolBase):
5771
6235
  "record_ids": [record_id_text],
5772
6236
  "answers": [],
5773
6237
  "submit_type": 1,
6238
+ "selection": normalized_payload.get("selection"),
5774
6239
  }
5775
6240
  single_response = self._record_write_apply_response(
5776
6241
  raw_apply,
@@ -6053,12 +6518,13 @@ class RecordTools(ToolBase):
6053
6518
  preflight=raw_preflight,
6054
6519
  )
6055
6520
 
6056
- if uses_view_scope:
6521
+ if view_key is not None or view_name is not None:
6057
6522
  raise_tool_error(
6058
6523
  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"
6524
+ "delete does not support custom view selectors; use a system view_id/list_type or resolve target record_ids first"
6060
6525
  )
6061
6526
  )
6527
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
6062
6528
  if normalized_values or normalized_set:
6063
6529
  raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
6064
6530
  delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
@@ -6070,8 +6536,9 @@ class RecordTools(ToolBase):
6070
6536
  "record_ids": delete_ids,
6071
6537
  "answers": [],
6072
6538
  "submit_type": submit_type_value,
6539
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
6073
6540
  }
6074
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
6541
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids, list_type=delete_list_type)
6075
6542
  return self._record_write_apply_response(
6076
6543
  raw_apply,
6077
6544
  operation="delete",
@@ -6221,7 +6688,9 @@ class RecordTools(ToolBase):
6221
6688
  or _normalize_optional_text(payload.get("appName"))
6222
6689
  or _normalize_optional_text(payload.get("appTitle"))
6223
6690
  )
6224
- except QingflowApiError:
6691
+ except QingflowApiError as exc:
6692
+ if is_auth_like_error(exc):
6693
+ raise
6225
6694
  name = None
6226
6695
  self._app_name_cache[cache_key] = name
6227
6696
  return name
@@ -6375,7 +6844,9 @@ class RecordTools(ToolBase):
6375
6844
  try:
6376
6845
  result = self.backend.request("GET", context, "/data/baseInfo", params={"queId": field.que_id})
6377
6846
  payload = result if isinstance(result, dict) else None
6378
- except QingflowApiError:
6847
+ except QingflowApiError as exc:
6848
+ if is_auth_like_error(exc):
6849
+ raise
6379
6850
  payload = None
6380
6851
  self._relation_base_info_cache[cache_key] = payload or {}
6381
6852
  return payload
@@ -6648,6 +7119,26 @@ class RecordTools(ToolBase):
6648
7119
  or bool(fields)
6649
7120
  )
6650
7121
 
7122
+ def _member_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7123
+ """Return true when the frontend field-scope endpoint is safer than directory expansion."""
7124
+ scope = field.member_select_scope if isinstance(field.member_select_scope, dict) else {}
7125
+ if field.member_select_scope_type != 2:
7126
+ return False
7127
+ return bool(
7128
+ _scope_has_dynamic_or_external(scope)
7129
+ or list(scope.get("depart") or [])
7130
+ or list(scope.get("role") or [])
7131
+ )
7132
+
7133
+ def _department_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7134
+ """Return true when static preview would otherwise need ContactAuth-only directory APIs."""
7135
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
7136
+ if field.dept_select_scope_type != 2:
7137
+ return False
7138
+ if _scope_has_dynamic_or_external(scope):
7139
+ return True
7140
+ return bool(_normalize_bool(scope.get("includeSubDeparts")) or not list(scope.get("depart") or []))
7141
+
6651
7142
  def _build_candidate_lookup_state(
6652
7143
  self,
6653
7144
  profile: str,
@@ -6666,7 +7157,9 @@ class RecordTools(ToolBase):
6666
7157
  if apply_id is not None:
6667
7158
  try:
6668
7159
  base_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
6669
- except QingflowApiError:
7160
+ except QingflowApiError as exc:
7161
+ if not _is_optional_record_auxiliary_lookup_error(exc):
7162
+ raise
6670
7163
  context_complete = False
6671
7164
  state = LookupResolutionState(
6672
7165
  operation="update" if apply_id is not None else "insert",
@@ -7156,15 +7649,16 @@ class RecordTools(ToolBase):
7156
7649
  )
7157
7650
  if configured_candidate is not None:
7158
7651
  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)
7652
+ if include_sub:
7653
+ for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=True):
7654
+ normalized = _normalize_candidate_department(
7655
+ dept,
7656
+ source_kind="department",
7657
+ source_id=dept_id,
7658
+ source_value=dept_name,
7659
+ )
7660
+ if normalized is not None:
7661
+ self._merge_department_candidate(merged, normalized)
7168
7662
  filtered = _filter_department_candidates(list(merged.values()), keyword)
7169
7663
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
7170
7664
  return filtered
@@ -8305,22 +8799,10 @@ class RecordTools(ToolBase):
8305
8799
  field_index_override: FieldIndex | None = None,
8306
8800
  ) -> JSONObject:
8307
8801
  """执行内部辅助逻辑。"""
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
8802
  normalized_fields = fields or {}
8321
8803
  normalized_answers_input = answers or []
8322
8804
  resolved_view: AccessibleViewRoute | None = None
8323
- selector_index = index
8805
+ selector_index: FieldIndex | None = field_index_override
8324
8806
  browse_writable_field_ids: set[int] = set()
8325
8807
  visible_question_ids: set[int] = set()
8326
8808
  if any(item is not None for item in (view_id, list_type, view_key, view_name)):
@@ -8346,6 +8828,31 @@ class RecordTools(ToolBase):
8346
8828
  visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
8347
8829
  else:
8348
8830
  compatibility_warnings = []
8831
+ if field_index_override is not None:
8832
+ base_index = field_index_override
8833
+ question_relations: list[JSONObject] = []
8834
+ runtime_linked_field_ids: set[int] = set()
8835
+ index = base_index
8836
+ elif operation == "update" and resolved_view is not None:
8837
+ base_index = cast(FieldIndex, selector_index)
8838
+ question_relations = []
8839
+ runtime_linked_field_ids = set()
8840
+ index = base_index
8841
+ else:
8842
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
8843
+ base_index = _build_applicant_top_level_field_index(schema)
8844
+ question_relations = _collect_question_relations(schema)
8845
+ runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
8846
+ runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
8847
+ index = base_index
8848
+ if operation == "create":
8849
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
8850
+ schema,
8851
+ linked_field_ids=runtime_linked_field_ids,
8852
+ )
8853
+ index = _merge_field_indexes(base_index, linked_hidden_index)
8854
+ if selector_index is None:
8855
+ selector_index = index
8349
8856
  resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=selector_index)
8350
8857
  support_matrix = _summarize_write_support(resolved_fields)
8351
8858
  invalid_fields: list[JSONObject] = []
@@ -8389,7 +8896,9 @@ class RecordTools(ToolBase):
8389
8896
  apply_id=apply_id,
8390
8897
  )
8391
8898
  existing_answers_loaded = True
8392
- except QingflowApiError:
8899
+ except QingflowApiError as exc:
8900
+ if not _is_optional_record_auxiliary_lookup_error(exc):
8901
+ raise
8393
8902
  validation_warnings.append(
8394
8903
  "update preflight could not load the current record; required-field completeness and dynamic lookup context were not fully revalidated."
8395
8904
  )
@@ -8978,7 +9487,7 @@ class RecordTools(ToolBase):
8978
9487
  break
8979
9488
  except QingflowApiError as exc:
8980
9489
  last_error = exc
8981
- if exc.backend_code == 40002:
9490
+ if _is_record_permission_denied_error(exc):
8982
9491
  continue
8983
9492
  raise
8984
9493
  if result is None:
@@ -9081,7 +9590,21 @@ class RecordTools(ToolBase):
9081
9590
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
9082
9591
 
9083
9592
  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
9593
+ needs_index = verify_write or bool(fields) or _answers_need_resolution(answers or [])
9594
+ update_index = None
9595
+ if needs_index:
9596
+ update_index = (
9597
+ self._get_system_browse_field_index(
9598
+ profile,
9599
+ context,
9600
+ app_key,
9601
+ list_type=DEFAULT_RECORD_LIST_TYPE,
9602
+ force_refresh=force_refresh_form,
9603
+ )
9604
+ if role == 1
9605
+ else self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
9606
+ )
9607
+ index = update_index if verify_write else None
9085
9608
  normalized_answers = self._resolve_answers(
9086
9609
  profile,
9087
9610
  context,
@@ -9089,6 +9612,7 @@ class RecordTools(ToolBase):
9089
9612
  answers=answers or [],
9090
9613
  fields=fields or {},
9091
9614
  force_refresh_form=force_refresh_form,
9615
+ field_index_override=update_index,
9092
9616
  )
9093
9617
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
9094
9618
  try:
@@ -9142,13 +9666,14 @@ class RecordTools(ToolBase):
9142
9666
  def record_delete(self, *, profile: str, app_key: str, apply_id: int, list_type: int) -> JSONObject:
9143
9667
  """执行记录相关逻辑。"""
9144
9668
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
9669
+ delete_list_type = self._resolve_record_delete_list_type(view_id=None, list_type=list_type)
9145
9670
 
9146
9671
  def runner(session_profile, context):
9147
9672
  result = self.backend.request(
9148
9673
  "DELETE",
9149
9674
  context,
9150
9675
  f"/app/{app_key}/apply",
9151
- json_body={"type": list_type, "applyIds": [normalized_apply_id]},
9676
+ json_body={"type": delete_list_type, "applyIds": [normalized_apply_id]},
9152
9677
  )
9153
9678
  return self._attach_human_review_notice(
9154
9679
  {
@@ -9157,6 +9682,7 @@ class RecordTools(ToolBase):
9157
9682
  "request_route": self._request_route_payload(context),
9158
9683
  "app_key": app_key,
9159
9684
  "apply_id": normalized_apply_id,
9685
+ "list_type": delete_list_type,
9160
9686
  "result": result,
9161
9687
  },
9162
9688
  operation="delete",
@@ -9201,7 +9727,7 @@ class RecordTools(ToolBase):
9201
9727
  "GET",
9202
9728
  context,
9203
9729
  f"/app/{app_key}/apply/{apply_id}",
9204
- params={"role": 1, "listType": list_type},
9730
+ params={"role": _record_detail_role_for_list_type(list_type), "listType": list_type},
9205
9731
  )
9206
9732
  answers = result.get("answers") if isinstance(result, dict) else None
9207
9733
  answer_list = answers if isinstance(answers, list) else []
@@ -9560,7 +10086,7 @@ class RecordTools(ToolBase):
9560
10086
  used_list_type: int | None = None
9561
10087
  if view_selection is not None:
9562
10088
  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:
10089
+ elif view_route.list_type is not None:
9564
10090
  fallback_list_types = [view_route.list_type]
9565
10091
  else:
9566
10092
  fallback_list_types = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
@@ -9791,7 +10317,7 @@ class RecordTools(ToolBase):
9791
10317
  try:
9792
10318
  payload = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
9793
10319
  except QingflowApiError as exc:
9794
- if exc.backend_code in {40002, 40027, 404} or exc.http_status == 404:
10320
+ if _is_optional_schema_permission_error(exc):
9795
10321
  self._view_config_cache[cache_key] = None
9796
10322
  return None
9797
10323
  raise
@@ -9912,7 +10438,12 @@ class RecordTools(ToolBase):
9912
10438
  )
9913
10439
  normalized = _normalize_data_list_base_info_schema(payload)
9914
10440
  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)
10441
+ try:
10442
+ return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10443
+ except QingflowApiError as exc:
10444
+ if not _is_optional_schema_permission_error(exc):
10445
+ raise
10446
+ return normalized
9916
10447
  self._form_cache[cache_key] = normalized
9917
10448
  return normalized
9918
10449
 
@@ -9944,8 +10475,16 @@ class RecordTools(ToolBase):
9944
10475
  cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
9945
10476
  if not force_refresh and cache_key in self._form_cache:
9946
10477
  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)
10478
+ try:
10479
+ payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
10480
+ normalized = _normalize_data_list_base_info_schema(payload)
10481
+ form_ques = normalized.get("formQues")
10482
+ if not isinstance(form_ques, list) or not form_ques:
10483
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
10484
+ except QingflowApiError as exc:
10485
+ if not _is_optional_schema_permission_error(exc):
10486
+ raise
10487
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
9949
10488
  self._form_cache[cache_key] = normalized
9950
10489
  return normalized
9951
10490
 
@@ -10001,22 +10540,6 @@ class RecordTools(ToolBase):
10001
10540
  force_refresh: bool,
10002
10541
  ) -> JSONObject:
10003
10542
  """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
10543
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
10021
10544
  schema = self._get_custom_view_browse_schema(
10022
10545
  profile,
@@ -10025,6 +10548,16 @@ class RecordTools(ToolBase):
10025
10548
  force_refresh=force_refresh,
10026
10549
  )
10027
10550
  index = _build_top_level_field_index(schema)
10551
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10552
+ return {
10553
+ "index": index,
10554
+ "writable_field_ids": {
10555
+ field.que_id
10556
+ for field in index.by_id.values()
10557
+ if bool(self._schema_write_hints(field)["writable"])
10558
+ },
10559
+ "visible_question_ids": visible_question_ids,
10560
+ }
10028
10561
  elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
10029
10562
  schema = self._get_system_browse_base_info_schema(
10030
10563
  profile,
@@ -10034,34 +10567,26 @@ class RecordTools(ToolBase):
10034
10567
  force_refresh=force_refresh,
10035
10568
  )
10036
10569
  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]]})
10570
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10571
+ return {
10572
+ "index": index,
10573
+ "writable_field_ids": {
10574
+ field.que_id
10575
+ for field in index.by_id.values()
10576
+ if bool(self._schema_write_hints(field)["writable"])
10577
+ },
10578
+ "visible_question_ids": visible_question_ids,
10579
+ }
10048
10580
 
10049
- visible_question_ids = {field.que_id for field in index.by_id.values()}
10050
- if applicant_index is None:
10051
- writable_field_ids = {
10581
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10582
+ visible_question_ids = {field.que_id for field in applicant_index.by_id.values()}
10583
+ return {
10584
+ "index": applicant_index,
10585
+ "writable_field_ids": {
10052
10586
  field.que_id
10053
- for field in index.by_id.values()
10587
+ for field in applicant_index.by_id.values()
10054
10588
  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,
10589
+ },
10065
10590
  "visible_question_ids": visible_question_ids,
10066
10591
  }
10067
10592
 
@@ -10075,23 +10600,13 @@ class RecordTools(ToolBase):
10075
10600
  force_refresh: bool,
10076
10601
  ) -> JSONObject:
10077
10602
  """执行内部辅助逻辑。"""
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
10603
  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)
10604
+ schema = self._get_custom_view_browse_schema(
10605
+ profile,
10606
+ context,
10607
+ resolved_view.view_selection.view_key,
10608
+ force_refresh=force_refresh,
10609
+ )
10095
10610
  index = _build_top_level_field_index(schema)
10096
10611
  visible_question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
10097
10612
  if not visible_question_ids:
@@ -10107,6 +10622,12 @@ class RecordTools(ToolBase):
10107
10622
  index = _build_top_level_field_index(schema)
10108
10623
  visible_question_ids = _question_ids_from_schema(schema)
10109
10624
  else:
10625
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10626
+ applicant_writable_field_ids = {
10627
+ field.que_id
10628
+ for field in applicant_index.by_id.values()
10629
+ if bool(self._schema_write_hints(field)["writable"])
10630
+ }
10110
10631
  index = applicant_index or _build_top_level_field_index(
10111
10632
  self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10112
10633
  )
@@ -10125,43 +10646,13 @@ class RecordTools(ToolBase):
10125
10646
  "visible_question_ids": set(visible_question_ids),
10126
10647
  }
10127
10648
 
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
10649
  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,
10650
+ "index": index,
10651
+ "writable_field_ids": {
10652
+ field.que_id
10653
+ for field in index.by_id.values()
10654
+ if bool(self._schema_write_hints(field)["writable"])
10655
+ },
10165
10656
  "visible_question_ids": visible_question_ids,
10166
10657
  }
10167
10658
 
@@ -10226,7 +10717,7 @@ class RecordTools(ToolBase):
10226
10717
  try:
10227
10718
  payload = self.backend.request("GET", context, f"/view/{view_key}/question")
10228
10719
  except QingflowApiError as exc:
10229
- if exc.backend_code in {40002, 40027}:
10720
+ if _is_record_permission_denied_error(exc):
10230
10721
  return set()
10231
10722
  raise
10232
10723
  if not isinstance(payload, list):
@@ -10270,7 +10761,7 @@ class RecordTools(ToolBase):
10270
10761
  )
10271
10762
  return True
10272
10763
  except QingflowApiError as exc:
10273
- if exc.backend_code in {40002, 40027}:
10764
+ if _is_record_permission_denied_error(exc):
10274
10765
  return False
10275
10766
  raise
10276
10767
 
@@ -10423,7 +10914,12 @@ class RecordTools(ToolBase):
10423
10914
  requested_name = _normalize_optional_text(view_name)
10424
10915
  if requested_key is None and requested_name is None:
10425
10916
  return None
10426
- views = self._get_view_list(profile, context, app_key)
10917
+ try:
10918
+ views = self._get_view_list(profile, context, app_key)
10919
+ except QingflowApiError as exc:
10920
+ if requested_key is None or not _is_record_permission_denied_error(exc):
10921
+ raise
10922
+ views = []
10427
10923
  selected: JSONObject | None = None
10428
10924
  if requested_key is not None:
10429
10925
  selected = next((item for item in views if _normalize_optional_text(item.get("viewKey")) == requested_key), None)
@@ -10621,9 +11117,11 @@ class RecordTools(ToolBase):
10621
11117
 
10622
11118
  def _should_retry_list_type_fallback(self, error: QingflowApiError) -> bool:
10623
11119
  """执行内部辅助逻辑。"""
10624
- if error.backend_code in {40002, 40027, 404}:
11120
+ if is_auth_like_error(error):
11121
+ return False
11122
+ if backend_code_int(error) in {40002, 40027, 404}:
10625
11123
  return True
10626
- if error.http_status in {404, 500}:
11124
+ if error.http_status == 404:
10627
11125
  return True
10628
11126
  return False
10629
11127
 
@@ -11405,6 +11903,8 @@ class RecordTools(ToolBase):
11405
11903
  schema: JSONObject = {}
11406
11904
  if isinstance(raw.get("subQuestions"), list):
11407
11905
  schema["formQues"] = [raw["subQuestions"]]
11906
+ elif isinstance(raw.get("subQues"), list):
11907
+ schema["formQues"] = [raw["subQues"]]
11408
11908
  elif isinstance(raw.get("innerQuestions"), list):
11409
11909
  schema["formQues"] = raw["innerQuestions"]
11410
11910
  index = _build_field_index(schema)
@@ -11444,6 +11944,70 @@ class RecordTools(ToolBase):
11444
11944
  )
11445
11945
  )
11446
11946
 
11947
+ def _candidate_lookup_failed_response(
11948
+ self,
11949
+ *,
11950
+ profile: str,
11951
+ session_profile, # type: ignore[no-untyped-def]
11952
+ context, # type: ignore[no-untyped-def]
11953
+ kind: str,
11954
+ error: RecordInputError,
11955
+ field: FormField,
11956
+ app_key: str,
11957
+ record_id_text: str | None,
11958
+ workflow_node_id: int | None,
11959
+ fields_present: bool,
11960
+ keyword: str,
11961
+ scope_source: str,
11962
+ ) -> JSONObject:
11963
+ """Return a structured result when an optional field candidate lookup is unavailable."""
11964
+ error_payload = error.to_dict()
11965
+ error_details = error_payload.get("details") if isinstance(error_payload.get("details"), dict) else {}
11966
+ candidate_error = error_details.get("candidate_error") if isinstance(error_details.get("candidate_error"), dict) else {}
11967
+ warning_transport = {
11968
+ key: candidate_error.get(key)
11969
+ for key in ("backend_code", "http_status", "request_id")
11970
+ if candidate_error.get(key) is not None
11971
+ }
11972
+ selection: JSONObject = {
11973
+ "app_key": app_key,
11974
+ "field_id": field.que_id,
11975
+ "field_title": field.que_title,
11976
+ "record_id": record_id_text,
11977
+ "workflow_node_id": workflow_node_id,
11978
+ "fields_present": fields_present,
11979
+ "keyword": keyword,
11980
+ "permission_scope": "applicant_node",
11981
+ }
11982
+ return {
11983
+ "profile": profile,
11984
+ "ws_id": session_profile.selected_ws_id,
11985
+ "ok": False,
11986
+ "status": "failed",
11987
+ "error_code": error.error_code,
11988
+ "message": error.message,
11989
+ "request_route": self._request_route_payload(context),
11990
+ "warnings": [
11991
+ {
11992
+ "code": error.error_code,
11993
+ "message": error.fix_hint,
11994
+ "kind": kind,
11995
+ "field_id": field.que_id,
11996
+ "field_title": field.que_title,
11997
+ **warning_transport,
11998
+ }
11999
+ ],
12000
+ "output_profile": "normal",
12001
+ "data": {
12002
+ "items": [],
12003
+ "pagination": {"returned_items": 0},
12004
+ "selection": selection,
12005
+ "scope_source": scope_source,
12006
+ "fix_hint": error.fix_hint,
12007
+ },
12008
+ "details": error_details,
12009
+ }
12010
+
11447
12011
  def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
11448
12012
  """执行内部辅助逻辑。"""
11449
12013
  describe_route = getattr(self.backend, "describe_route", None)
@@ -11617,7 +12181,7 @@ class RecordTools(ToolBase):
11617
12181
  selection: JSONObject | None,
11618
12182
  ) -> None:
11619
12183
  """执行内部辅助逻辑。"""
11620
- if exc.backend_code != 40002:
12184
+ if not _is_record_permission_denied_error(exc):
11621
12185
  raise exc
11622
12186
  raise_tool_error(
11623
12187
  QingflowApiError(
@@ -11783,6 +12347,7 @@ class RecordTools(ToolBase):
11783
12347
  response_status = raw_status or "failed"
11784
12348
  update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
11785
12349
  tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
12350
+ expose_tried_routes = output_profile == "verbose" or response_status != "success"
11786
12351
  response: JSONObject = {
11787
12352
  "profile": raw_apply.get("profile"),
11788
12353
  "ws_id": raw_apply.get("ws_id"),
@@ -11795,7 +12360,6 @@ class RecordTools(ToolBase):
11795
12360
  "warnings": warnings,
11796
12361
  "output_profile": output_profile,
11797
12362
  "update_route": update_route,
11798
- "tried_routes": tried_routes,
11799
12363
  "data": {
11800
12364
  "action": {"operation": operation, "executed": True},
11801
12365
  "resource": resource,
@@ -11807,9 +12371,11 @@ class RecordTools(ToolBase):
11807
12371
  "resolved_fields": resolved_fields,
11808
12372
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
11809
12373
  "update_route": update_route,
11810
- "tried_routes": tried_routes,
11811
12374
  },
11812
12375
  }
12376
+ if expose_tried_routes:
12377
+ response["tried_routes"] = tried_routes
12378
+ response["data"]["tried_routes"] = tried_routes
11813
12379
  if record_id is not None:
11814
12380
  response["record_id"] = record_id
11815
12381
  if apply_id is not None:
@@ -11989,7 +12555,7 @@ class RecordTools(ToolBase):
11989
12555
  )
11990
12556
  return errors
11991
12557
 
11992
- def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
12558
+ def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int], list_type: int = DEFAULT_RECORD_LIST_TYPE) -> JSONObject:
11993
12559
  """执行内部辅助逻辑。"""
11994
12560
  if not app_key:
11995
12561
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -12002,14 +12568,14 @@ class RecordTools(ToolBase):
12002
12568
  "DELETE",
12003
12569
  context,
12004
12570
  f"/app/{app_key}/apply",
12005
- json_body={"type": DEFAULT_RECORD_LIST_TYPE, "applyIds": normalized_ids},
12571
+ json_body={"type": list_type, "applyIds": normalized_ids},
12006
12572
  )
12007
12573
  return {
12008
12574
  "profile": profile,
12009
12575
  "ws_id": session_profile.selected_ws_id,
12010
12576
  "request_route": self._request_route_payload(context),
12011
12577
  "result": result,
12012
- "resource": {"type": "record", "apply_ids": normalized_ids},
12578
+ "resource": {"type": "record", "apply_ids": normalized_ids, "list_type": list_type},
12013
12579
  "ok": True,
12014
12580
  }
12015
12581
 
@@ -12583,6 +13149,30 @@ class RecordTools(ToolBase):
12583
13149
  },
12584
13150
  )
12585
13151
 
13152
+ def _candidate_lookup_error(
13153
+ self,
13154
+ *,
13155
+ kind: str,
13156
+ field: FormField,
13157
+ value: JSONValue,
13158
+ error: QingflowApiError,
13159
+ ) -> RecordInputError:
13160
+ """Build the standard candidate lookup failure without raising it."""
13161
+ field_kind = "member" if kind == "member" else "department"
13162
+ return RecordInputError(
13163
+ message=f"{field_kind} candidates for field '{field.que_title}' could not be loaded",
13164
+ error_code=f"{kind.upper()}_CANDIDATE_LOOKUP_FAILED",
13165
+ fix_hint=(
13166
+ f"Run record_{field_kind}_candidates again after the backend error is resolved, "
13167
+ "then choose one returned item exactly."
13168
+ ),
13169
+ details={
13170
+ "field": _field_ref_payload(field),
13171
+ "received_value": value,
13172
+ "candidate_error": error.to_dict(),
13173
+ },
13174
+ )
13175
+
12586
13176
  def _candidate_keyword_from_value(
12587
13177
  self,
12588
13178
  value: JSONValue,
@@ -12847,14 +13437,7 @@ class RecordTools(ToolBase):
12847
13437
 
12848
13438
  def _lookup_department_detail(self, context, keyword: str, *, purpose: str) -> JSONObject: # type: ignore[no-untyped-def]
12849
13439
  """执行内部辅助逻辑。"""
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 []
13440
+ items = self._search_workspace_departments(context, keyword=keyword)
12858
13441
  normalized_keyword = keyword.strip()
12859
13442
  exact = [
12860
13443
  item for item in items
@@ -13379,6 +13962,7 @@ class RecordTools(ToolBase):
13379
13962
  normalized_answers: list[JSONObject],
13380
13963
  index: FieldIndex,
13381
13964
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13965
+ verify_role: int | None = None,
13382
13966
  verify_view_key: str | None = None,
13383
13967
  ) -> JSONObject:
13384
13968
  """执行内部辅助逻辑。"""
@@ -13398,14 +13982,20 @@ class RecordTools(ToolBase):
13398
13982
  f"/view/{verify_view_key}/apply/{apply_id}",
13399
13983
  )
13400
13984
  else:
13985
+ role = verify_role if verify_role is not None else 1
13401
13986
  record = self.backend.request(
13402
13987
  "GET",
13403
13988
  context,
13404
13989
  f"/app/{app_key}/apply/{apply_id}",
13405
- params={"role": 1, "listType": verify_list_type},
13990
+ params={"role": role, "listType": verify_list_type},
13406
13991
  )
13407
13992
  except QingflowApiError as exc:
13408
13993
  if verify_view_key:
13994
+ warning: JSONObject = {
13995
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13996
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13997
+ }
13998
+ warning.update(_record_detail_error_warning_fields(exc))
13409
13999
  return {
13410
14000
  "verified": False,
13411
14001
  "verification_mode": "custom_view_record_detail",
@@ -13414,14 +14004,9 @@ class RecordTools(ToolBase):
13414
14004
  "missing_fields": [],
13415
14005
  "empty_fields": [],
13416
14006
  "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
- }],
14007
+ "warnings": [warning],
13423
14008
  }
13424
- if exc.backend_code != 40002:
14009
+ if not _is_record_permission_denied_error(exc):
13425
14010
  raise
13426
14011
  return self._verify_record_write_result_via_initiated_tasks(
13427
14012
  context,
@@ -13469,6 +14054,7 @@ class RecordTools(ToolBase):
13469
14054
  or len(count_mismatches) > mismatch_before
13470
14055
  ):
13471
14056
  continue
14057
+ continue
13472
14058
  expected_value = _canonicalize_answer_value_for_compare(answer, field)
13473
14059
  actual_value = _canonicalize_answer_value_for_compare(actual, field)
13474
14060
  if not _canonical_value_is_empty(expected_value) and _canonical_value_is_empty(actual_value):
@@ -13715,6 +14301,8 @@ def _normalize_data_list_base_info_schema(payload: JSONValue) -> JSONObject:
13715
14301
  if not isinstance(payload, dict):
13716
14302
  return {}
13717
14303
  que_base_infos = payload.get("queBaseInfos")
14304
+ if not isinstance(que_base_infos, list) and isinstance(payload.get("formQues"), list):
14305
+ que_base_infos = payload.get("formQues")
13718
14306
  if not isinstance(que_base_infos, list):
13719
14307
  return {}
13720
14308
  return {
@@ -14101,6 +14689,44 @@ def _build_answer_backed_field_index(
14101
14689
  )
14102
14690
 
14103
14691
 
14692
+ def _merge_subtable_parent_field(primary: FormField, extra: FormField) -> FormField:
14693
+ if primary.que_type not in SUBTABLE_QUE_TYPES or extra.que_type not in SUBTABLE_QUE_TYPES:
14694
+ return primary
14695
+ primary_raw = dict(primary.raw) if isinstance(primary.raw, dict) else {}
14696
+ extra_raw = dict(extra.raw) if isinstance(extra.raw, dict) else {}
14697
+ primary_subquestions = primary_raw.get("subQuestions")
14698
+ extra_subquestions = extra_raw.get("subQuestions")
14699
+ if not isinstance(primary_subquestions, list) or not isinstance(extra_subquestions, list):
14700
+ return primary
14701
+ merged_subquestions = [item for item in primary_subquestions if isinstance(item, dict)]
14702
+ seen_ids = {
14703
+ _coerce_count(item.get("queId"))
14704
+ for item in merged_subquestions
14705
+ if isinstance(item, dict) and _coerce_count(item.get("queId")) is not None
14706
+ }
14707
+ for item in extra_subquestions:
14708
+ if not isinstance(item, dict):
14709
+ continue
14710
+ que_id = _coerce_count(item.get("queId"))
14711
+ if que_id is not None and que_id in seen_ids:
14712
+ continue
14713
+ merged_subquestions.append(item)
14714
+ if que_id is not None:
14715
+ seen_ids.add(que_id)
14716
+ if len(merged_subquestions) == len(primary_subquestions):
14717
+ return primary
14718
+ merged_raw = dict(primary_raw)
14719
+ merged_raw["subQuestions"] = merged_subquestions
14720
+ merged_field = _clone_form_field(primary)
14721
+ merged_field.raw = merged_raw
14722
+ return merged_field
14723
+
14724
+
14725
+ def _replace_field_in_lookup(index: dict[str, list[FormField]], field: FormField) -> None:
14726
+ for key, fields in list(index.items()):
14727
+ index[key] = [field if existing.que_id == field.que_id else existing for existing in fields]
14728
+
14729
+
14104
14730
  def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
14105
14731
  by_id = dict(primary.by_id)
14106
14732
  by_title = {key: list(value) for key, value in primary.by_title.items()}
@@ -14111,12 +14737,42 @@ def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
14111
14737
 
14112
14738
  for field_id, field in extra.by_id.items():
14113
14739
  if field_id in by_id:
14740
+ merged_field = _merge_subtable_parent_field(by_id[field_id], field)
14741
+ if merged_field is not by_id[field_id]:
14742
+ by_id[field_id] = merged_field
14743
+ _replace_field_in_lookup(by_title, merged_field)
14744
+ _replace_field_in_lookup(by_alias, merged_field)
14114
14745
  continue
14115
14746
  by_id[field_id] = field
14116
14747
  by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
14117
14748
  for alias in field.aliases:
14118
14749
  by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
14119
14750
 
14751
+ for field_id, fields in extra.subtable_leaf_by_id.items():
14752
+ merged = subtable_leaf_by_id.setdefault(field_id, [])
14753
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14754
+ for field in fields:
14755
+ key = (field.field.que_id, field.parent_field.que_id)
14756
+ if key not in existing:
14757
+ merged.append(field)
14758
+ existing.add(key)
14759
+ for title, fields in extra.subtable_leaf_by_title.items():
14760
+ merged = subtable_leaf_by_title.setdefault(title, [])
14761
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14762
+ for field in fields:
14763
+ key = (field.field.que_id, field.parent_field.que_id)
14764
+ if key not in existing:
14765
+ merged.append(field)
14766
+ existing.add(key)
14767
+ for alias, fields in extra.subtable_leaf_by_alias.items():
14768
+ merged = subtable_leaf_by_alias.setdefault(alias, [])
14769
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14770
+ for field in fields:
14771
+ key = (field.field.que_id, field.parent_field.que_id)
14772
+ if key not in existing:
14773
+ merged.append(field)
14774
+ existing.add(key)
14775
+
14120
14776
  return FieldIndex(
14121
14777
  by_id=by_id,
14122
14778
  by_title=by_title,
@@ -15507,6 +16163,28 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
15507
16163
  "message": message,
15508
16164
  "category": exc.category,
15509
16165
  }
16166
+ if is_auth_like_error(exc):
16167
+ payload["auth_like"] = True
16168
+ payload["error_code"] = "AUTH_REQUIRED"
16169
+ if exc.backend_code is not None:
16170
+ payload["backend_code"] = exc.backend_code
16171
+ if exc.http_status is not None:
16172
+ payload["http_status"] = exc.http_status
16173
+ request_id = getattr(exc, "request_id", None)
16174
+ if request_id:
16175
+ payload["request_id"] = request_id
16176
+ details = exc.details if isinstance(exc.details, dict) else {}
16177
+ error_code = details.get("error_code")
16178
+ if error_code and not payload.get("error_code"):
16179
+ payload["error_code"] = error_code
16180
+ return payload
16181
+
16182
+
16183
+ def _record_detail_error_warning_fields(exc: QingflowApiError) -> JSONObject:
16184
+ payload: JSONObject = {"category": exc.category}
16185
+ if is_auth_like_error(exc):
16186
+ payload["auth_like"] = True
16187
+ payload["error_code"] = "AUTH_REQUIRED"
15510
16188
  if exc.backend_code is not None:
15511
16189
  payload["backend_code"] = exc.backend_code
15512
16190
  if exc.http_status is not None:
@@ -15516,11 +16194,35 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
15516
16194
  payload["request_id"] = request_id
15517
16195
  details = exc.details if isinstance(exc.details, dict) else {}
15518
16196
  error_code = details.get("error_code")
15519
- if error_code:
16197
+ if error_code and not payload.get("error_code"):
15520
16198
  payload["error_code"] = error_code
15521
16199
  return payload
15522
16200
 
15523
16201
 
16202
+ def _record_detail_refreshed_source_url(refresh_result: Any) -> str | None:
16203
+ if isinstance(refresh_result, dict):
16204
+ return _normalize_optional_text(refresh_result.get("source_url"))
16205
+ return _normalize_optional_text(refresh_result)
16206
+
16207
+
16208
+ def _record_detail_append_refresh_warning(
16209
+ warnings: list[JSONObject],
16210
+ refresh_result: Any,
16211
+ *,
16212
+ id_key: str,
16213
+ id_value: str,
16214
+ ) -> None:
16215
+ if not isinstance(refresh_result, dict):
16216
+ return
16217
+ warning = refresh_result.get("warning")
16218
+ if not isinstance(warning, dict):
16219
+ return
16220
+ payload: JSONObject = dict(warning)
16221
+ payload.setdefault("code", "ASSET_STORAGE_URL_REFRESH_FAILED")
16222
+ payload.setdefault(id_key, id_value)
16223
+ warnings.append(payload)
16224
+
16225
+
15524
16226
  _RECORD_MEDIA_IMG_SRC_RE = re.compile(r"""<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE)
15525
16227
  _RECORD_MEDIA_MD_IMAGE_RE = re.compile(r"""!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)""")
15526
16228
  _RECORD_MEDIA_URL_RE = re.compile(r"""https?://[^\s<>"')\]]+""", re.IGNORECASE)
@@ -15675,7 +16377,14 @@ def _record_detail_media_assets_payload(
15675
16377
  except QingflowApiError as exc:
15676
16378
  blocked = exc.http_status in {401, 403}
15677
16379
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
15678
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16380
+ refresh_result = refresh_source_url(candidate)
16381
+ _record_detail_append_refresh_warning(
16382
+ warnings,
16383
+ refresh_result,
16384
+ id_key="asset_id",
16385
+ id_value=asset_id,
16386
+ )
16387
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
15679
16388
  if refreshed_url and refreshed_url != source_url:
15680
16389
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
15681
16390
  try:
@@ -15717,14 +16426,13 @@ def _record_detail_media_assets_payload(
15717
16426
  "readable_by_agent": False,
15718
16427
  }
15719
16428
  )
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
- )
16429
+ warning: JSONObject = {
16430
+ "code": warning_code,
16431
+ "asset_id": asset_id,
16432
+ "message": f"record_get could not download image asset {asset_id}: {exc.message}",
16433
+ }
16434
+ warning.update(_record_detail_error_warning_fields(exc))
16435
+ warnings.append(warning)
15728
16436
  continue
15729
16437
 
15730
16438
  if not isinstance(content, bytes):
@@ -15942,7 +16650,14 @@ def _record_detail_file_assets_payload(
15942
16650
  except QingflowApiError as exc:
15943
16651
  blocked = exc.http_status in {401, 403}
15944
16652
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
15945
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16653
+ refresh_result = refresh_source_url(candidate)
16654
+ _record_detail_append_refresh_warning(
16655
+ warnings,
16656
+ refresh_result,
16657
+ id_key="file_asset_id",
16658
+ id_value=file_asset_id,
16659
+ )
16660
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
15946
16661
  if refreshed_url and refreshed_url != source_url:
15947
16662
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
15948
16663
  try:
@@ -15989,14 +16704,13 @@ def _record_detail_file_assets_payload(
15989
16704
  "extraction": {"status": "failed", "text_path": None, "preview": None},
15990
16705
  }
15991
16706
  )
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
- )
16707
+ warning = {
16708
+ "code": warning_code,
16709
+ "file_asset_id": file_asset_id,
16710
+ "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
16711
+ }
16712
+ warning.update(_record_detail_error_warning_fields(exc))
16713
+ warnings.append(warning)
16000
16714
  continue
16001
16715
 
16002
16716
  if not isinstance(content, bytes):
@@ -18115,12 +18829,14 @@ def _extract_sort_selector(item: JSONObject) -> JSONValue:
18115
18829
  return None
18116
18830
 
18117
18831
 
18118
- def _normalize_public_column_selectors(columns: list[JSONObject | int]) -> list[int]:
18832
+ def _normalize_public_column_selectors(columns: list[JSONObject | int | str]) -> list[int]:
18119
18833
  normalized: list[int] = []
18120
18834
  for item in columns:
18121
18835
  field_id: int | None = None
18122
18836
  if isinstance(item, int):
18123
18837
  field_id = item
18838
+ elif isinstance(item, str):
18839
+ field_id = _coerce_count(item)
18124
18840
  elif isinstance(item, dict):
18125
18841
  _ensure_allowed_record_list_keys(
18126
18842
  item,
@@ -18132,19 +18848,21 @@ def _normalize_public_column_selectors(columns: list[JSONObject | int]) -> list[
18132
18848
  if field_id is None or field_id < 0:
18133
18849
  raise_tool_error(
18134
18850
  QingflowApiError.config_error(
18135
- "columns must be a list of field_id integers or {field_id} objects"
18851
+ "columns must be a list of field_id integers, integer strings, or {field_id} objects"
18136
18852
  )
18137
18853
  )
18138
18854
  normalized.append(field_id)
18139
18855
  return normalized
18140
18856
 
18141
18857
 
18142
- def _normalize_public_query_field_selectors(query_fields: list[JSONObject | int]) -> list[int]:
18858
+ def _normalize_public_query_field_selectors(query_fields: list[JSONObject | int | str]) -> list[int]:
18143
18859
  normalized: list[int] = []
18144
18860
  for item in query_fields:
18145
18861
  field_id: int | None = None
18146
18862
  if isinstance(item, int):
18147
18863
  field_id = item
18864
+ elif isinstance(item, str):
18865
+ field_id = _coerce_count(item)
18148
18866
  elif isinstance(item, dict):
18149
18867
  _ensure_allowed_record_list_keys(
18150
18868
  item,
@@ -18156,7 +18874,7 @@ def _normalize_public_query_field_selectors(query_fields: list[JSONObject | int]
18156
18874
  if field_id is None or field_id < 0:
18157
18875
  raise_tool_error(
18158
18876
  QingflowApiError.config_error(
18159
- "query_fields must be a list of field_id integers or {field_id} objects"
18877
+ "query_fields must be a list of field_id integers, integer strings, or {field_id} objects"
18160
18878
  )
18161
18879
  )
18162
18880
  normalized.append(field_id)