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