@qingflow-tech/qingflow-app-user-mcp 1.0.5 → 1.0.6
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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/references/data-gotchas.md +3 -2
- package/skills/qingflow-app-user/references/public-surface-sync.md +1 -1
- package/skills/qingflow-app-user/references/record-patterns.md +5 -4
- package/skills/qingflow-record-insert/SKILL.md +26 -5
- package/src/qingflow_mcp/cli/commands/record.py +19 -1
- package/src/qingflow_mcp/response_trim.py +62 -1
- package/src/qingflow_mcp/server.py +4 -3
- package/src/qingflow_mcp/server_app_user.py +4 -3
- package/src/qingflow_mcp/tools/record_tools.py +614 -55
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @qingflow-tech/qingflow-app-user-mcp@1.0.
|
|
6
|
+
npm install @qingflow-tech/qingflow-app-user-mcp@1.0.6
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @qingflow-tech/qingflow-app-user-mcp@1.0.
|
|
12
|
+
npx -y -p @qingflow-tech/qingflow-app-user-mcp@1.0.6 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -13,10 +13,11 @@ For final statistics, grouped distributions, rankings, trends, or insight-style
|
|
|
13
13
|
|
|
14
14
|
## Direct Writes
|
|
15
15
|
|
|
16
|
-
- `record_insert` is schema-first through `record_insert_schema_get`
|
|
16
|
+
- `record_insert` is schema-first through `record_insert_schema_get`; default to `items=[{"fields": {...}}]`
|
|
17
17
|
- `record_update` is schema-first through `record_update_schema_get`
|
|
18
18
|
- `record_delete` does not need a schema-get step
|
|
19
|
-
-
|
|
19
|
+
- For batch insert, `partial_success` means some rows were created; use `created_record_ids`, failed `row_number`, and `failed_fields` to repair only failed rows
|
|
20
|
+
- If a direct-write tool returns `write_executed=false`, the write was blocked and not executed for that item
|
|
20
21
|
- Prefer `verify_write=true` for complex, relation-heavy, subtable, or production writes
|
|
21
22
|
|
|
22
23
|
## Lookup Fields
|
|
@@ -13,7 +13,7 @@ It is not a user-facing product spec. It exists to prevent skill drift.
|
|
|
13
13
|
- analyze: `app_get -> record_browse_schema_get -> record_access -> Python`
|
|
14
14
|
- browse detail: `app_get -> record_browse_schema_get -> record_list / record_get`
|
|
15
15
|
- explicit export/download/Excel: `view_get -> record_export_*` or `record_export_direct`
|
|
16
|
-
- insert: `record_insert_schema_get -> record_insert`
|
|
16
|
+
- insert: `record_insert_schema_get -> record_insert(items)`
|
|
17
17
|
- update: `record_update_schema_get -> record_update`
|
|
18
18
|
|
|
19
19
|
### Tasks
|
|
@@ -30,13 +30,14 @@ Use `record_browse_schema_get -> record_get` when:
|
|
|
30
30
|
|
|
31
31
|
## Insert Pattern
|
|
32
32
|
|
|
33
|
-
Use `record_insert_schema_get -> record_insert`.
|
|
33
|
+
Use `record_insert_schema_get -> record_insert(items)`.
|
|
34
34
|
|
|
35
35
|
1. Confirm the target app
|
|
36
36
|
2. Read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`
|
|
37
|
-
3. Build
|
|
38
|
-
4.
|
|
39
|
-
5.
|
|
37
|
+
3. Build `items` as `[{"fields": {...}}]`; a single record is one item
|
|
38
|
+
4. Write member, department, and relation fields with natural strings first when the user provided names
|
|
39
|
+
5. If lookup fields are ambiguous, stop and ask for confirmation
|
|
40
|
+
6. On `partial_success`, keep `created_record_ids` and only repair failed `row_number` / `failed_fields`
|
|
40
41
|
|
|
41
42
|
## Update Pattern
|
|
42
43
|
|
|
@@ -9,7 +9,9 @@ metadata:
|
|
|
9
9
|
|
|
10
10
|
## Default Path
|
|
11
11
|
|
|
12
|
-
`record_insert_schema_get -> (
|
|
12
|
+
`record_insert_schema_get -> record_insert(items) -> optional record_get/readback`
|
|
13
|
+
|
|
14
|
+
Default to batch-shaped insert. A single new record is `items` with one row.
|
|
13
15
|
|
|
14
16
|
## Core Tools
|
|
15
17
|
|
|
@@ -25,19 +27,20 @@ metadata:
|
|
|
25
27
|
2. Read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`
|
|
26
28
|
3. Inside every field bucket, read field-level `linkage` first when present; it is the canonical static hint for linked visibility, reference-driven auto fill, or formula-driven fields
|
|
27
29
|
4. Inside `optional_fields`, pay special attention to any field with `may_become_required=true`; these are writable fields that can become required when linked visibility or option-driven rules activate
|
|
28
|
-
5. Build `
|
|
30
|
+
5. Build `items` as `[{"fields": {...}}]`, where each `fields` map uses field titles from the insert schema
|
|
29
31
|
6. Treat `runtime_linked_required_fields` as required-but-not-directly-writable runtime/upstream dependencies, not as fields to hand-fill blindly
|
|
30
32
|
7. For `linkage.kind=logic_visibility`, read `sources` as upstream trigger fields and treat `role=manual_input_after_activation` as "fill this only after the upstream condition is satisfied"
|
|
31
33
|
8. For `linkage.kind=reference_fill`, prefer filling the source field first; treat target fields with `role=auto_fill_preferred` or `auto_fill_only` as reference-driven outputs rather than blind manual inputs
|
|
32
34
|
9. For `linkage.kind=formula_fill`, treat the field as formula/default-auto-fill driven unless the user explicitly asks to override it and the field is still writable
|
|
33
35
|
10. If insert succeeds and single-record detail/readback matters, prefer `record_get`; use `record_list(..., output_profile="normalized")` only for batch row-shaped normalized readback
|
|
34
36
|
11. Keep subtable payloads under the parent field as a row array
|
|
35
|
-
12. Member / department / relation fields may accept natural strings directly
|
|
37
|
+
12. Member / department / relation fields may accept natural strings directly, such as `"张三"`, `"直销部"`, or `"海军军医大学"`; do not pre-query ids by default
|
|
36
38
|
13. If the write returns `status="needs_confirmation"`, stop and surface the candidates
|
|
37
|
-
14. Retry with explicit ids / objects
|
|
39
|
+
14. Retry failed rows only with explicit ids / objects after the user confirms
|
|
38
40
|
15. Keep `verify_write=true` for production inserts
|
|
39
41
|
16. If post-write detail context matters, read `record_get.fields[]`, `media_assets.items[].local_path`, `file_assets.items[].local_path`, `file_assets.items[].extraction.text_path`, and `semantic_context`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, so prefer local paths over remote URLs and do not expect legacy `data.normalized_record`
|
|
40
42
|
17. Treat nested schema shape as guidance, not a brittle contract; do not hard-code transient implementation details like optional nested `field_id` shape when composing inserts
|
|
43
|
+
18. For `partial_success`, read `created_record_ids`, then repair only the failed `items[].row_number` using `failed_fields`; never retry the whole batch after any row has `write_executed=true`
|
|
41
44
|
|
|
42
45
|
## Field Notes
|
|
43
46
|
|
|
@@ -51,11 +54,29 @@ metadata:
|
|
|
51
54
|
- `linkage.affects_fields` lists downstream field titles that may change when this field changes
|
|
52
55
|
- `linkage.role=auto_fill_only` means "normally do not hand-fill this unless the product explicitly requires it"
|
|
53
56
|
- `requires_upload=true` means upload the file first, then write the returned value
|
|
57
|
+
- `failed_fields[].next_action` tells the next repair step for that row
|
|
58
|
+
|
|
59
|
+
## CLI Pattern
|
|
60
|
+
|
|
61
|
+
Use a JSON array file:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
qingflow record insert --app-key APP_KEY --items-file records.json --json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`records.json`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
[
|
|
71
|
+
{ "fields": { "客户名称": "测试客户", "负责人": "张三" } }
|
|
72
|
+
]
|
|
73
|
+
```
|
|
54
74
|
|
|
55
75
|
## Do Not
|
|
56
76
|
|
|
57
77
|
- Do not skip `record_insert_schema_get`
|
|
58
78
|
- Do not invent missing required fields
|
|
59
79
|
- Do not flatten subtable leaf fields to the top level
|
|
60
|
-
- Do not silently guess member / department / relation ids
|
|
80
|
+
- Do not pre-query or silently guess member / department / relation ids when a natural string is enough
|
|
81
|
+
- Do not retry a whole batch after `created_record_ids` is non-empty
|
|
61
82
|
- Do not bind logic to a transient nested schema serialization detail when the field title and parent table already identify the legal payload shape
|
|
@@ -82,7 +82,8 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
82
82
|
|
|
83
83
|
insert = record_subparsers.add_parser("insert", help="新增记录")
|
|
84
84
|
insert.add_argument("--app-key", required=True)
|
|
85
|
-
insert.add_argument("--fields-file"
|
|
85
|
+
insert.add_argument("--fields-file")
|
|
86
|
+
insert.add_argument("--items-file")
|
|
86
87
|
insert.add_argument("--verify-write", action=argparse.BooleanOptionalAction, default=True)
|
|
87
88
|
insert.set_defaults(handler=_handle_insert, format_hint="")
|
|
88
89
|
|
|
@@ -274,6 +275,23 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
274
275
|
|
|
275
276
|
|
|
276
277
|
def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
|
|
278
|
+
if args.items_file:
|
|
279
|
+
if args.fields_file:
|
|
280
|
+
raise_config_error(
|
|
281
|
+
"record insert batch mode does not accept --fields-file.",
|
|
282
|
+
fix_hint="Use `record insert --app-key APP_KEY --items-file ITEMS.json` for batch inserts.",
|
|
283
|
+
)
|
|
284
|
+
return context.record.record_insert_public(
|
|
285
|
+
profile=args.profile,
|
|
286
|
+
app_key=args.app_key,
|
|
287
|
+
items=require_list_arg(args.items_file, option_name="--items-file"),
|
|
288
|
+
verify_write=bool(args.verify_write),
|
|
289
|
+
)
|
|
290
|
+
if not args.fields_file:
|
|
291
|
+
raise_config_error(
|
|
292
|
+
"record insert requires --items-file or --fields-file.",
|
|
293
|
+
fix_hint="Prefer `record insert --app-key APP_KEY --items-file ITEMS.json`; use --fields-file only for legacy single inserts.",
|
|
294
|
+
)
|
|
277
295
|
return context.record.record_insert_public(
|
|
278
296
|
profile=args.profile,
|
|
279
297
|
app_key=args.app_key,
|
|
@@ -57,6 +57,13 @@ def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict
|
|
|
57
57
|
if not isinstance(payload, dict):
|
|
58
58
|
return payload
|
|
59
59
|
if _looks_like_failure_payload(payload):
|
|
60
|
+
status = str(payload.get("status") or "").lower()
|
|
61
|
+
if tool_name in {"user:record_insert", "user:record_update"} and status in {
|
|
62
|
+
"blocked",
|
|
63
|
+
"needs_confirmation",
|
|
64
|
+
"partial_success",
|
|
65
|
+
}:
|
|
66
|
+
return trim_success_response(tool_name, payload)
|
|
60
67
|
return _trim_returned_failure(payload)
|
|
61
68
|
return trim_success_response(tool_name, payload)
|
|
62
69
|
|
|
@@ -140,7 +147,7 @@ def _looks_like_failure_payload(payload: dict[str, Any]) -> bool:
|
|
|
140
147
|
if payload.get("ok") is False:
|
|
141
148
|
return True
|
|
142
149
|
status = str(payload.get("status") or "").lower()
|
|
143
|
-
return status in {"failed", "blocked"
|
|
150
|
+
return status in {"failed", "blocked"}
|
|
144
151
|
|
|
145
152
|
|
|
146
153
|
def _trim_returned_failure(payload: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -368,6 +375,9 @@ def _trim_record_write(payload: JSONObject) -> None:
|
|
|
368
375
|
data = payload.get("data")
|
|
369
376
|
if not isinstance(data, dict):
|
|
370
377
|
return
|
|
378
|
+
if payload.get("mode") == "batch" or data.get("mode") == "batch":
|
|
379
|
+
_trim_record_write_batch(payload, data)
|
|
380
|
+
return
|
|
371
381
|
data.pop("debug", None)
|
|
372
382
|
data.pop("normalized_payload", None)
|
|
373
383
|
data.pop("human_review", None)
|
|
@@ -397,6 +407,44 @@ def _trim_record_write(payload: JSONObject) -> None:
|
|
|
397
407
|
data.pop(key, None)
|
|
398
408
|
|
|
399
409
|
|
|
410
|
+
def _trim_record_write_batch(payload: JSONObject, data: JSONObject) -> None:
|
|
411
|
+
data.pop("items", None)
|
|
412
|
+
data.pop("debug", None)
|
|
413
|
+
for key in ("summary", "created_record_ids", "app_key", "mode"):
|
|
414
|
+
if data.get(key) in (None, [], {}, ""):
|
|
415
|
+
data.pop(key, None)
|
|
416
|
+
items = payload.get("items")
|
|
417
|
+
if isinstance(items, list):
|
|
418
|
+
payload["items"] = [
|
|
419
|
+
_pick(
|
|
420
|
+
item,
|
|
421
|
+
(
|
|
422
|
+
"index",
|
|
423
|
+
"row_number",
|
|
424
|
+
"status",
|
|
425
|
+
"record_id",
|
|
426
|
+
"apply_id",
|
|
427
|
+
"write_executed",
|
|
428
|
+
"verification_status",
|
|
429
|
+
"safe_to_retry",
|
|
430
|
+
"failed_fields",
|
|
431
|
+
"confirmation_requests",
|
|
432
|
+
"blockers",
|
|
433
|
+
"error",
|
|
434
|
+
"warnings",
|
|
435
|
+
"resource",
|
|
436
|
+
"verification",
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
for item in items
|
|
440
|
+
if isinstance(item, dict)
|
|
441
|
+
]
|
|
442
|
+
for key in ("items", "created_record_ids"):
|
|
443
|
+
value = payload.get(key)
|
|
444
|
+
if value in (None, [], {}, ""):
|
|
445
|
+
payload.pop(key, None)
|
|
446
|
+
|
|
447
|
+
|
|
400
448
|
def _trim_record_get(payload: JSONObject) -> None:
|
|
401
449
|
if payload.get("fields") is not None or payload.get("semantic_context") is not None:
|
|
402
450
|
_trim_detail_context_record_get(payload)
|
|
@@ -730,6 +778,19 @@ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) ->
|
|
|
730
778
|
compact["required"] = bool(item.get("required"))
|
|
731
779
|
if template_map is not None and isinstance(title, str) and title in template_map:
|
|
732
780
|
compact["template"] = template_map.get(title)
|
|
781
|
+
for key in (
|
|
782
|
+
"format_hint",
|
|
783
|
+
"example_value",
|
|
784
|
+
"linkage",
|
|
785
|
+
"may_become_required",
|
|
786
|
+
"activation_sources",
|
|
787
|
+
"requirement_reason",
|
|
788
|
+
"accepts_natural_input",
|
|
789
|
+
"requires_upload",
|
|
790
|
+
):
|
|
791
|
+
value = item.get(key)
|
|
792
|
+
if value not in (None, [], {}, ""):
|
|
793
|
+
compact[key] = value
|
|
733
794
|
candidate_hint = item.get("candidate_hint")
|
|
734
795
|
if isinstance(candidate_hint, dict):
|
|
735
796
|
compact["candidate_hint"] = candidate_hint
|
|
@@ -104,7 +104,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
104
104
|
## Record CRUD Path
|
|
105
105
|
|
|
106
106
|
`app_get -> record_browse_schema_get(view_id=...) -> record_list / record_get`
|
|
107
|
-
`record_insert_schema_get -> record_insert`
|
|
107
|
+
`record_insert_schema_get -> record_insert(items)`
|
|
108
108
|
`record_update_schema_get -> record_update`
|
|
109
109
|
`record_list / record_get -> record_delete`
|
|
110
110
|
`record_code_block_schema_get -> record_code_block_run`
|
|
@@ -115,7 +115,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
115
115
|
- Use `order_by` items as `{{field_id, direction}}`
|
|
116
116
|
- Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
|
|
117
117
|
|
|
118
|
-
- `record_insert`
|
|
118
|
+
- `record_insert` defaults to an applicant-node `items` array; each item contains a field-title keyed `fields` map. A single insert is one item.
|
|
119
119
|
- `record_update` uses a field-title keyed `fields` map and internally selects the first accessible view that can execute the current payload.
|
|
120
120
|
- For insert, `runtime_linked_required_fields` means required-but-not-directly-writable fields that are usually supplied by runtime linkage or upstream context.
|
|
121
121
|
- For insert, fields marked `may_become_required=true` stay in `optional_fields`; they are still directly writable, but linked visibility or option-driven rules can make them required at runtime.
|
|
@@ -130,7 +130,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
130
130
|
- When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
|
|
131
131
|
|
|
132
132
|
- Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
|
|
133
|
-
- Member and
|
|
133
|
+
- Member, department, and relation fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to candidate tools when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
|
|
134
|
+
- For batch insert `partial_success`, read `created_record_ids`, failed `items[].row_number`, and `failed_fields`; repair only failed rows and never retry the whole batch after any row has `write_executed=true`.
|
|
134
135
|
- If explicit candidate browsing is needed for default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
135
136
|
|
|
136
137
|
## Code Block Path
|
|
@@ -103,7 +103,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
103
103
|
## Record CRUD Path
|
|
104
104
|
|
|
105
105
|
`app_get -> record_browse_schema_get(view_id=...) -> record_list / record_get`
|
|
106
|
-
`record_insert_schema_get -> record_insert`
|
|
106
|
+
`record_insert_schema_get -> record_insert(items)`
|
|
107
107
|
`record_update_schema_get -> record_update`
|
|
108
108
|
`record_list / record_get -> record_delete`
|
|
109
109
|
`record_code_block_schema_get -> record_code_block_run`
|
|
@@ -116,7 +116,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
116
116
|
- Use `order_by` items as `{{field_id, direction}}`
|
|
117
117
|
- Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
|
|
118
118
|
|
|
119
|
-
- `record_insert`
|
|
119
|
+
- `record_insert` defaults to an applicant-node `items` array; each item contains a field-title keyed `fields` map. A single insert is one item.
|
|
120
120
|
- `record_update` uses a field-title keyed `fields` map and internally selects the first accessible view that can execute the current payload.
|
|
121
121
|
- For insert, `runtime_linked_required_fields` means required-but-not-directly-writable fields that are usually supplied by runtime linkage or upstream context.
|
|
122
122
|
- For insert, fields marked `may_become_required=true` stay in `optional_fields`; they are still directly writable, but linked visibility or option-driven rules can make them required at runtime.
|
|
@@ -131,7 +131,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
131
131
|
- When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
|
|
132
132
|
|
|
133
133
|
- Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
|
|
134
|
-
- Member and
|
|
134
|
+
- Member, department, and relation fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to candidate tools when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
|
|
135
|
+
- For batch insert `partial_success`, read `created_record_ids`, failed `items[].row_number`, and `failed_fields`; repair only failed rows and never retry the whole batch after any row has `write_executed=true`.
|
|
135
136
|
- When candidate browsing must match a real update/write scope, pass `record_id`, `workflow_node_id`, and any pending `fields` context to the candidate tool; otherwise the candidate result is only a static applicant-node preview.
|
|
136
137
|
- If explicit candidate browsing is needed for default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
137
138
|
|
|
@@ -470,21 +470,24 @@ class RecordTools(ToolBase):
|
|
|
470
470
|
|
|
471
471
|
@mcp.tool(
|
|
472
472
|
description=(
|
|
473
|
-
"Insert
|
|
473
|
+
"Insert Qingflow records using applicant-node field maps. "
|
|
474
474
|
"Use record_insert_schema_get first. "
|
|
475
|
-
"
|
|
475
|
+
"Prefer items=[{'fields': {...}}]; a single insert is one item. "
|
|
476
|
+
"Each item performs internal preflight validation before that item is written."
|
|
476
477
|
)
|
|
477
478
|
)
|
|
478
479
|
def record_insert(
|
|
479
480
|
app_key: str = "",
|
|
480
481
|
fields: JSONObject | None = None,
|
|
482
|
+
items: list[JSONObject] | None = None,
|
|
481
483
|
verify_write: bool = True,
|
|
482
484
|
output_profile: str = "normal",
|
|
483
485
|
) -> JSONObject:
|
|
484
486
|
return self.record_insert_public(
|
|
485
487
|
profile=DEFAULT_PROFILE,
|
|
486
488
|
app_key=app_key,
|
|
487
|
-
fields=fields
|
|
489
|
+
fields=fields,
|
|
490
|
+
items=items,
|
|
488
491
|
verify_write=verify_write,
|
|
489
492
|
output_profile=output_profile,
|
|
490
493
|
)
|
|
@@ -1064,11 +1067,16 @@ class RecordTools(ToolBase):
|
|
|
1064
1067
|
required = bool(required_override) if required_override is not None else bool(field.required or any(item.get("required") for item in row_fields))
|
|
1065
1068
|
else:
|
|
1066
1069
|
required = bool(required_override) if required_override is not None else bool(field.required)
|
|
1070
|
+
write_format = _write_format_for_field(field)
|
|
1067
1071
|
payload: JSONObject = {
|
|
1068
1072
|
"title": field.que_title,
|
|
1069
1073
|
"kind": kind,
|
|
1070
1074
|
"required": required,
|
|
1075
|
+
"format_hint": _ready_schema_format_hint(kind, write_format),
|
|
1071
1076
|
}
|
|
1077
|
+
example_value = _ready_schema_example_value(kind, field, write_format, row_fields=row_fields)
|
|
1078
|
+
if example_value is not None:
|
|
1079
|
+
payload["example_value"] = example_value
|
|
1072
1080
|
if include_field_id:
|
|
1073
1081
|
payload["field_id"] = field.que_id
|
|
1074
1082
|
if kind in {"single_select", "multi_select"} and field.options:
|
|
@@ -2995,6 +3003,7 @@ class RecordTools(ToolBase):
|
|
|
2995
3003
|
profile: str = DEFAULT_PROFILE,
|
|
2996
3004
|
app_key: str,
|
|
2997
3005
|
fields: JSONObject | None = None,
|
|
3006
|
+
items: list[JSONObject] | None = None,
|
|
2998
3007
|
verify_write: bool = True,
|
|
2999
3008
|
output_profile: str = "normal",
|
|
3000
3009
|
) -> JSONObject:
|
|
@@ -3002,69 +3011,478 @@ class RecordTools(ToolBase):
|
|
|
3002
3011
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
3003
3012
|
if not app_key:
|
|
3004
3013
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3014
|
+
if items is not None:
|
|
3015
|
+
normalized_items = self._normalize_public_record_insert_batch_items(fields=fields, items=items)
|
|
3016
|
+
return self._record_insert_public_batch(
|
|
3017
|
+
profile=profile,
|
|
3018
|
+
app_key=app_key,
|
|
3019
|
+
items=normalized_items,
|
|
3020
|
+
verify_write=verify_write,
|
|
3021
|
+
output_profile=normalized_output_profile,
|
|
3022
|
+
)
|
|
3005
3023
|
if fields is not None and not isinstance(fields, dict):
|
|
3006
3024
|
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3007
|
-
|
|
3008
|
-
raw_preflight = self._preflight_record_write(
|
|
3025
|
+
return self._record_insert_public_single(
|
|
3009
3026
|
profile=profile,
|
|
3010
|
-
operation="create",
|
|
3011
3027
|
app_key=app_key,
|
|
3012
|
-
apply_id=None,
|
|
3013
|
-
answers=[],
|
|
3014
3028
|
fields=cast(JSONObject, fields or {}),
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
view_key=None,
|
|
3019
|
-
view_name=None,
|
|
3020
|
-
)
|
|
3021
|
-
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3022
|
-
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3023
|
-
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
3024
|
-
operation="insert",
|
|
3025
|
-
record_id=None,
|
|
3026
|
-
record_ids=[],
|
|
3027
|
-
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3028
|
-
submit_type=submit_type_value,
|
|
3029
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3029
|
+
verify_write=verify_write,
|
|
3030
|
+
output_profile=normalized_output_profile,
|
|
3031
|
+
capture_exceptions=False,
|
|
3030
3032
|
)
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3033
|
+
|
|
3034
|
+
def _record_insert_public_single(
|
|
3035
|
+
self,
|
|
3036
|
+
*,
|
|
3037
|
+
profile: str,
|
|
3038
|
+
app_key: str,
|
|
3039
|
+
fields: JSONObject,
|
|
3040
|
+
verify_write: bool,
|
|
3041
|
+
output_profile: str,
|
|
3042
|
+
capture_exceptions: bool,
|
|
3043
|
+
) -> JSONObject:
|
|
3044
|
+
"""执行内部辅助逻辑。"""
|
|
3045
|
+
submit_type_value = self._normalize_record_write_submit_type("submit")
|
|
3046
|
+
write_attempted = False
|
|
3040
3047
|
try:
|
|
3041
|
-
|
|
3048
|
+
raw_preflight = self._preflight_record_write(
|
|
3042
3049
|
profile=profile,
|
|
3050
|
+
operation="create",
|
|
3043
3051
|
app_key=app_key,
|
|
3044
|
-
|
|
3045
|
-
|
|
3052
|
+
apply_id=None,
|
|
3053
|
+
answers=[],
|
|
3054
|
+
fields=fields,
|
|
3055
|
+
force_refresh_form=False,
|
|
3056
|
+
view_id=None,
|
|
3057
|
+
list_type=None,
|
|
3058
|
+
view_key=None,
|
|
3059
|
+
view_name=None,
|
|
3060
|
+
)
|
|
3061
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3062
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3063
|
+
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
3064
|
+
operation="insert",
|
|
3065
|
+
record_id=None,
|
|
3066
|
+
record_ids=[],
|
|
3067
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3046
3068
|
submit_type=submit_type_value,
|
|
3047
|
-
|
|
3048
|
-
force_refresh_form=preflight_used_force_refresh,
|
|
3069
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3049
3070
|
)
|
|
3050
|
-
|
|
3051
|
-
|
|
3071
|
+
if preflight_data.get("blockers"):
|
|
3072
|
+
return self._record_write_blocked_response(
|
|
3073
|
+
raw_preflight,
|
|
3074
|
+
operation="insert",
|
|
3075
|
+
normalized_payload=normalized_payload,
|
|
3076
|
+
output_profile=output_profile,
|
|
3077
|
+
human_review=False,
|
|
3078
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
|
|
3079
|
+
)
|
|
3080
|
+
try:
|
|
3081
|
+
write_attempted = True
|
|
3082
|
+
raw_apply = self.record_create(
|
|
3083
|
+
profile=profile,
|
|
3084
|
+
app_key=app_key,
|
|
3085
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3086
|
+
fields={},
|
|
3087
|
+
submit_type=submit_type_value,
|
|
3088
|
+
verify_write=verify_write,
|
|
3089
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
3090
|
+
)
|
|
3091
|
+
except QingflowApiError as exc:
|
|
3092
|
+
self._raise_record_write_permission_error(
|
|
3093
|
+
exc,
|
|
3094
|
+
operation="insert",
|
|
3095
|
+
app_key=app_key,
|
|
3096
|
+
record_id=None,
|
|
3097
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3098
|
+
)
|
|
3099
|
+
raise
|
|
3100
|
+
return self._record_write_apply_response(
|
|
3101
|
+
raw_apply,
|
|
3102
|
+
operation="insert",
|
|
3103
|
+
normalized_payload=normalized_payload,
|
|
3104
|
+
output_profile=output_profile,
|
|
3105
|
+
human_review=False,
|
|
3106
|
+
preflight=raw_preflight,
|
|
3107
|
+
)
|
|
3108
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3109
|
+
if not capture_exceptions:
|
|
3110
|
+
raise
|
|
3111
|
+
return self._record_write_exception_response(
|
|
3052
3112
|
exc,
|
|
3053
3113
|
operation="insert",
|
|
3114
|
+
profile=profile,
|
|
3054
3115
|
app_key=app_key,
|
|
3055
3116
|
record_id=None,
|
|
3056
|
-
|
|
3117
|
+
output_profile=output_profile,
|
|
3118
|
+
human_review=False,
|
|
3119
|
+
write_executed=write_attempted,
|
|
3057
3120
|
)
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3121
|
+
|
|
3122
|
+
def _normalize_public_record_insert_batch_items(
|
|
3123
|
+
self,
|
|
3124
|
+
*,
|
|
3125
|
+
fields: JSONObject | None,
|
|
3126
|
+
items: list[JSONObject] | None,
|
|
3127
|
+
) -> list[JSONObject]:
|
|
3128
|
+
"""执行内部辅助逻辑。"""
|
|
3129
|
+
if fields is not None:
|
|
3130
|
+
raise_tool_error(QingflowApiError.config_error("record_insert batch mode does not accept fields"))
|
|
3131
|
+
if not isinstance(items, list) or not items:
|
|
3132
|
+
raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
|
|
3133
|
+
normalized_items: list[JSONObject] = []
|
|
3134
|
+
for index, item in enumerate(items):
|
|
3135
|
+
if not isinstance(item, dict):
|
|
3136
|
+
raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
|
|
3137
|
+
item_fields = item.get("fields")
|
|
3138
|
+
if not isinstance(item_fields, dict):
|
|
3139
|
+
raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
|
|
3140
|
+
normalized_items.append({"fields": cast(JSONObject, item_fields)})
|
|
3141
|
+
return normalized_items
|
|
3142
|
+
|
|
3143
|
+
def _record_insert_public_batch(
|
|
3144
|
+
self,
|
|
3145
|
+
*,
|
|
3146
|
+
profile: str,
|
|
3147
|
+
app_key: str,
|
|
3148
|
+
items: list[JSONObject],
|
|
3149
|
+
verify_write: bool,
|
|
3150
|
+
output_profile: str,
|
|
3151
|
+
) -> JSONObject:
|
|
3152
|
+
"""执行内部辅助逻辑。"""
|
|
3153
|
+
responses: list[JSONObject] = []
|
|
3154
|
+
for item in items:
|
|
3155
|
+
responses.append(
|
|
3156
|
+
self._record_insert_public_single(
|
|
3157
|
+
profile=profile,
|
|
3158
|
+
app_key=app_key,
|
|
3159
|
+
fields=cast(JSONObject, item["fields"]),
|
|
3160
|
+
verify_write=verify_write,
|
|
3161
|
+
output_profile=output_profile,
|
|
3162
|
+
capture_exceptions=True,
|
|
3163
|
+
)
|
|
3164
|
+
)
|
|
3165
|
+
return self._record_insert_batch_response(
|
|
3166
|
+
profile=profile,
|
|
3167
|
+
app_key=app_key,
|
|
3168
|
+
responses=responses,
|
|
3169
|
+
output_profile=output_profile,
|
|
3066
3170
|
)
|
|
3067
3171
|
|
|
3172
|
+
def _record_insert_batch_response(
|
|
3173
|
+
self,
|
|
3174
|
+
*,
|
|
3175
|
+
profile: str,
|
|
3176
|
+
app_key: str,
|
|
3177
|
+
responses: list[JSONObject],
|
|
3178
|
+
output_profile: str,
|
|
3179
|
+
) -> JSONObject:
|
|
3180
|
+
"""执行内部辅助逻辑。"""
|
|
3181
|
+
items = [
|
|
3182
|
+
self._record_insert_batch_item_from_response(index=index, response=response, output_profile=output_profile)
|
|
3183
|
+
for index, response in enumerate(responses)
|
|
3184
|
+
]
|
|
3185
|
+
summary = self._record_insert_batch_summary(items)
|
|
3186
|
+
status, ok, message = self._record_insert_batch_envelope_status(summary=summary)
|
|
3187
|
+
first_response = responses[0] if responses else {}
|
|
3188
|
+
created_record_ids = [
|
|
3189
|
+
cast(str, item["record_id"])
|
|
3190
|
+
for item in items
|
|
3191
|
+
if isinstance(item.get("record_id"), str) and item.get("record_id")
|
|
3192
|
+
]
|
|
3193
|
+
write_executed = any(bool(item.get("write_executed")) for item in items)
|
|
3194
|
+
verification_status = self._record_insert_batch_verification_status(items)
|
|
3195
|
+
return {
|
|
3196
|
+
"profile": first_response.get("profile", profile),
|
|
3197
|
+
"ws_id": first_response.get("ws_id"),
|
|
3198
|
+
"ok": ok,
|
|
3199
|
+
"status": status,
|
|
3200
|
+
"mode": "batch",
|
|
3201
|
+
"total": summary["total"],
|
|
3202
|
+
"succeeded": summary["succeeded"],
|
|
3203
|
+
"failed": summary["failed"],
|
|
3204
|
+
"created_record_ids": created_record_ids,
|
|
3205
|
+
"write_executed": write_executed,
|
|
3206
|
+
"verification_status": verification_status,
|
|
3207
|
+
"safe_to_retry": not write_executed,
|
|
3208
|
+
"request_route": first_response.get("request_route"),
|
|
3209
|
+
"warnings": [],
|
|
3210
|
+
"output_profile": output_profile,
|
|
3211
|
+
"items": items,
|
|
3212
|
+
"data": {
|
|
3213
|
+
"app_key": app_key,
|
|
3214
|
+
"mode": "batch",
|
|
3215
|
+
"summary": summary,
|
|
3216
|
+
"created_record_ids": created_record_ids,
|
|
3217
|
+
"items": items,
|
|
3218
|
+
},
|
|
3219
|
+
"message": message,
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
def _record_insert_batch_summary(self, items: list[JSONObject]) -> JSONObject:
|
|
3223
|
+
"""执行内部辅助逻辑。"""
|
|
3224
|
+
created = [item for item in items if isinstance(item.get("record_id"), str) and item.get("record_id")]
|
|
3225
|
+
failed = [item for item in items if item.get("status") not in {"success", "verification_failed"}]
|
|
3226
|
+
return {
|
|
3227
|
+
"total": len(items),
|
|
3228
|
+
"succeeded": len(created),
|
|
3229
|
+
"failed": len(failed),
|
|
3230
|
+
"created_count": len(created),
|
|
3231
|
+
"blocked_count": sum(1 for item in items if item.get("status") == "blocked"),
|
|
3232
|
+
"confirmation_count": sum(1 for item in items if item.get("status") == "needs_confirmation"),
|
|
3233
|
+
"verification_failed_count": sum(1 for item in items if item.get("status") == "verification_failed"),
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
def _record_insert_batch_envelope_status(self, *, summary: JSONObject) -> tuple[str, bool, str]:
|
|
3237
|
+
"""执行内部辅助逻辑。"""
|
|
3238
|
+
succeeded = int(summary["succeeded"])
|
|
3239
|
+
failed = int(summary["failed"])
|
|
3240
|
+
if succeeded and failed:
|
|
3241
|
+
return "partial_success", False, "batch insert completed with partial failures"
|
|
3242
|
+
if succeeded and int(summary["verification_failed_count"]):
|
|
3243
|
+
return "verification_failed", True, "batch insert completed but verification failed for some created records"
|
|
3244
|
+
if succeeded:
|
|
3245
|
+
return "success", True, "batch insert completed"
|
|
3246
|
+
if int(summary["confirmation_count"]):
|
|
3247
|
+
return "needs_confirmation", False, "batch insert requires confirmation before retrying failed rows"
|
|
3248
|
+
if int(summary["blocked_count"]):
|
|
3249
|
+
return "blocked", False, "batch insert preflight blocked all rows"
|
|
3250
|
+
return "failed", False, "batch insert failed"
|
|
3251
|
+
|
|
3252
|
+
def _record_insert_batch_verification_status(self, items: list[JSONObject]) -> str:
|
|
3253
|
+
"""执行内部辅助逻辑。"""
|
|
3254
|
+
statuses = {str(item.get("verification_status") or "not_requested") for item in items}
|
|
3255
|
+
if "failed" in statuses:
|
|
3256
|
+
return "failed"
|
|
3257
|
+
if "verified" in statuses:
|
|
3258
|
+
return "verified"
|
|
3259
|
+
return "not_requested"
|
|
3260
|
+
|
|
3261
|
+
def _record_insert_batch_item_from_response(
|
|
3262
|
+
self,
|
|
3263
|
+
*,
|
|
3264
|
+
index: int,
|
|
3265
|
+
response: JSONObject,
|
|
3266
|
+
output_profile: str,
|
|
3267
|
+
) -> JSONObject:
|
|
3268
|
+
"""执行内部辅助逻辑。"""
|
|
3269
|
+
data = cast(JSONObject, response.get("data", {})) if isinstance(response.get("data"), dict) else {}
|
|
3270
|
+
resource = _public_record_resource(data.get("resource"))
|
|
3271
|
+
record_id = _public_record_id_text(response.get("record_id"))
|
|
3272
|
+
apply_id = _public_record_id_text(response.get("apply_id"))
|
|
3273
|
+
if record_id is None and isinstance(resource, dict):
|
|
3274
|
+
record_id = _public_record_id_text(resource.get("record_id"))
|
|
3275
|
+
if apply_id is None and isinstance(resource, dict):
|
|
3276
|
+
apply_id = _public_record_id_text(resource.get("apply_id"))
|
|
3277
|
+
item: JSONObject = {
|
|
3278
|
+
"index": index,
|
|
3279
|
+
"row_number": index + 1,
|
|
3280
|
+
"status": response.get("status"),
|
|
3281
|
+
"write_executed": bool(response.get("write_executed")),
|
|
3282
|
+
"verification_status": response.get("verification_status", "not_requested"),
|
|
3283
|
+
"safe_to_retry": bool(response.get("safe_to_retry", True)),
|
|
3284
|
+
}
|
|
3285
|
+
if record_id is not None:
|
|
3286
|
+
item["record_id"] = record_id
|
|
3287
|
+
if apply_id is not None:
|
|
3288
|
+
item["apply_id"] = apply_id
|
|
3289
|
+
if resource:
|
|
3290
|
+
item["resource"] = resource
|
|
3291
|
+
verification = data.get("verification")
|
|
3292
|
+
if isinstance(verification, dict):
|
|
3293
|
+
compact_verification = {
|
|
3294
|
+
key: verification[key]
|
|
3295
|
+
for key in ("verified", "verification_mode", "field_level_verified")
|
|
3296
|
+
if key in verification
|
|
3297
|
+
}
|
|
3298
|
+
if compact_verification:
|
|
3299
|
+
item["verification"] = compact_verification
|
|
3300
|
+
field_errors = cast(list[JSONObject], data.get("field_errors", [])) if isinstance(data.get("field_errors"), list) else []
|
|
3301
|
+
confirmation_requests = cast(list[JSONObject], data.get("confirmation_requests", [])) if isinstance(data.get("confirmation_requests"), list) else []
|
|
3302
|
+
failed_fields = self._record_write_failed_fields(field_errors=field_errors, confirmation_requests=confirmation_requests)
|
|
3303
|
+
if failed_fields:
|
|
3304
|
+
item["failed_fields"] = failed_fields
|
|
3305
|
+
if confirmation_requests:
|
|
3306
|
+
item["confirmation_requests"] = [
|
|
3307
|
+
self._record_write_semantic_confirmation_request(request)
|
|
3308
|
+
for request in confirmation_requests
|
|
3309
|
+
if isinstance(request, dict)
|
|
3310
|
+
]
|
|
3311
|
+
blockers = data.get("blockers")
|
|
3312
|
+
if isinstance(blockers, list) and blockers:
|
|
3313
|
+
item["blockers"] = blockers
|
|
3314
|
+
warnings = response.get("warnings")
|
|
3315
|
+
if isinstance(warnings, list) and warnings:
|
|
3316
|
+
item["warnings"] = warnings
|
|
3317
|
+
error = data.get("error")
|
|
3318
|
+
if isinstance(error, dict):
|
|
3319
|
+
item["error"] = error
|
|
3320
|
+
if output_profile == "verbose" and isinstance(data.get("debug"), dict):
|
|
3321
|
+
item["debug"] = data.get("debug")
|
|
3322
|
+
return item
|
|
3323
|
+
|
|
3324
|
+
def _record_write_failed_fields(
|
|
3325
|
+
self,
|
|
3326
|
+
*,
|
|
3327
|
+
field_errors: list[JSONObject],
|
|
3328
|
+
confirmation_requests: list[JSONObject],
|
|
3329
|
+
) -> list[JSONObject]:
|
|
3330
|
+
"""执行内部辅助逻辑。"""
|
|
3331
|
+
failed_fields = [
|
|
3332
|
+
self._record_write_semantic_field_error(error)
|
|
3333
|
+
for error in field_errors
|
|
3334
|
+
if isinstance(error, dict)
|
|
3335
|
+
]
|
|
3336
|
+
failed_fields.extend(
|
|
3337
|
+
self._record_write_failed_field_from_confirmation(request)
|
|
3338
|
+
for request in confirmation_requests
|
|
3339
|
+
if isinstance(request, dict)
|
|
3340
|
+
)
|
|
3341
|
+
return failed_fields
|
|
3342
|
+
|
|
3343
|
+
def _record_write_semantic_field_error(self, error: JSONObject) -> JSONObject:
|
|
3344
|
+
"""执行内部辅助逻辑。"""
|
|
3345
|
+
field = error.get("field")
|
|
3346
|
+
field_payload = cast(JSONObject, field) if isinstance(field, dict) else {}
|
|
3347
|
+
error_code = _normalize_optional_text(error.get("error_code")) or "INVALID_FIELD_VALUE"
|
|
3348
|
+
title = (
|
|
3349
|
+
_normalize_optional_text(field_payload.get("que_title"))
|
|
3350
|
+
or _normalize_optional_text(field_payload.get("title"))
|
|
3351
|
+
or _normalize_optional_text(error.get("location"))
|
|
3352
|
+
or "unknown field"
|
|
3353
|
+
)
|
|
3354
|
+
field_id = (
|
|
3355
|
+
field_payload.get("que_id")
|
|
3356
|
+
if field_payload.get("que_id") is not None
|
|
3357
|
+
else field_payload.get("field_id")
|
|
3358
|
+
)
|
|
3359
|
+
expected_format = error.get("expected_format") if isinstance(error.get("expected_format"), dict) else None
|
|
3360
|
+
if expected_format is None:
|
|
3361
|
+
expected_format = self._record_write_expected_format_from_field_payload(field_payload)
|
|
3362
|
+
payload: JSONObject = {
|
|
3363
|
+
"title": title,
|
|
3364
|
+
"field_id": field_id,
|
|
3365
|
+
"error_code": error_code,
|
|
3366
|
+
"message": self._record_write_semantic_error_message(error_code, error.get("message")),
|
|
3367
|
+
"next_action": self._record_write_next_action_for_error(error_code),
|
|
3368
|
+
}
|
|
3369
|
+
if expected_format is not None:
|
|
3370
|
+
payload["expected_format"] = expected_format
|
|
3371
|
+
payload["example_value"] = self._record_write_example_value_for_format(expected_format, field_payload)
|
|
3372
|
+
if error.get("received_value") is not None:
|
|
3373
|
+
payload["received_value"] = error.get("received_value")
|
|
3374
|
+
if error.get("fix_hint") is not None:
|
|
3375
|
+
payload["fix_hint"] = error.get("fix_hint")
|
|
3376
|
+
if error.get("details") is not None:
|
|
3377
|
+
payload["details"] = error.get("details")
|
|
3378
|
+
return payload
|
|
3379
|
+
|
|
3380
|
+
def _record_write_semantic_confirmation_request(self, request: JSONObject) -> JSONObject:
|
|
3381
|
+
"""执行内部辅助逻辑。"""
|
|
3382
|
+
field_ref = request.get("field_ref")
|
|
3383
|
+
field_payload = cast(JSONObject, field_ref) if isinstance(field_ref, dict) else {}
|
|
3384
|
+
payload: JSONObject = {
|
|
3385
|
+
"field": request.get("field"),
|
|
3386
|
+
"title": _normalize_optional_text(request.get("field")) or _normalize_optional_text(field_payload.get("que_title")),
|
|
3387
|
+
"field_id": field_payload.get("que_id"),
|
|
3388
|
+
"kind": request.get("kind"),
|
|
3389
|
+
"input": request.get("input"),
|
|
3390
|
+
"candidates": request.get("candidates", []),
|
|
3391
|
+
"next_action": "让用户确认候选,或用显式 id/object 只重试本行。",
|
|
3392
|
+
}
|
|
3393
|
+
if request.get("parent_field") is not None:
|
|
3394
|
+
payload["parent_field"] = request.get("parent_field")
|
|
3395
|
+
if request.get("row_ordinal") is not None:
|
|
3396
|
+
payload["row_ordinal"] = request.get("row_ordinal")
|
|
3397
|
+
return payload
|
|
3398
|
+
|
|
3399
|
+
def _record_write_failed_field_from_confirmation(self, request: JSONObject) -> JSONObject:
|
|
3400
|
+
"""执行内部辅助逻辑。"""
|
|
3401
|
+
semantic = self._record_write_semantic_confirmation_request(request)
|
|
3402
|
+
return {
|
|
3403
|
+
"title": semantic.get("title") or semantic.get("field"),
|
|
3404
|
+
"field_id": semantic.get("field_id"),
|
|
3405
|
+
"error_code": "LOOKUP_NEEDS_CONFIRMATION",
|
|
3406
|
+
"message": "候选不唯一,需要用户确认。",
|
|
3407
|
+
"kind": semantic.get("kind"),
|
|
3408
|
+
"input": semantic.get("input"),
|
|
3409
|
+
"candidates": semantic.get("candidates", []),
|
|
3410
|
+
"next_action": semantic.get("next_action"),
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
def _record_write_expected_format_from_field_payload(self, field_payload: JSONObject) -> JSONObject | None:
|
|
3414
|
+
"""执行内部辅助逻辑。"""
|
|
3415
|
+
que_type = _coerce_count(field_payload.get("que_type"))
|
|
3416
|
+
if que_type is None:
|
|
3417
|
+
return None
|
|
3418
|
+
synthetic_field = FormField(
|
|
3419
|
+
que_id=_coerce_count(field_payload.get("que_id")) or 0,
|
|
3420
|
+
que_title=_normalize_optional_text(field_payload.get("que_title")) or _normalize_optional_text(field_payload.get("title")) or "",
|
|
3421
|
+
que_type=que_type,
|
|
3422
|
+
required=False,
|
|
3423
|
+
readonly=False,
|
|
3424
|
+
system=False,
|
|
3425
|
+
options=[],
|
|
3426
|
+
aliases=[],
|
|
3427
|
+
target_app_key=None,
|
|
3428
|
+
target_app_name_hint=None,
|
|
3429
|
+
member_select_scope_type=None,
|
|
3430
|
+
member_select_scope=None,
|
|
3431
|
+
dept_select_scope_type=None,
|
|
3432
|
+
dept_select_scope=None,
|
|
3433
|
+
raw={},
|
|
3434
|
+
)
|
|
3435
|
+
return _write_format_for_field(synthetic_field)
|
|
3436
|
+
|
|
3437
|
+
def _record_write_example_value_for_format(self, expected_format: JSONObject, field_payload: JSONObject) -> JSONValue:
|
|
3438
|
+
"""执行内部辅助逻辑。"""
|
|
3439
|
+
examples = expected_format.get("examples")
|
|
3440
|
+
if isinstance(examples, list) and examples:
|
|
3441
|
+
return cast(JSONValue, examples[0])
|
|
3442
|
+
kind = _normalize_optional_text(expected_format.get("kind"))
|
|
3443
|
+
if kind == "member_list":
|
|
3444
|
+
return "张三"
|
|
3445
|
+
if kind == "department_list":
|
|
3446
|
+
return "直销部"
|
|
3447
|
+
if kind == "relation_record":
|
|
3448
|
+
return {"apply_id": "5001"}
|
|
3449
|
+
if kind == "attachment_list":
|
|
3450
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
3451
|
+
if kind == "subtable_rows":
|
|
3452
|
+
return {"rows": [{"子字段": "值"}]}
|
|
3453
|
+
if kind == "date_string":
|
|
3454
|
+
return "2026-03-13 10:00:00"
|
|
3455
|
+
if kind == "boolean_label":
|
|
3456
|
+
return "是"
|
|
3457
|
+
if kind in {"single_select", "multi_select"}:
|
|
3458
|
+
options = expected_format.get("options")
|
|
3459
|
+
if isinstance(options, list) and options:
|
|
3460
|
+
return cast(JSONValue, options[0])
|
|
3461
|
+
que_type = _coerce_count(field_payload.get("que_type"))
|
|
3462
|
+
if que_type in NUMBER_QUE_TYPES:
|
|
3463
|
+
return 100
|
|
3464
|
+
return "文本"
|
|
3465
|
+
|
|
3466
|
+
def _record_write_semantic_error_message(self, error_code: str, fallback: JSONValue) -> str:
|
|
3467
|
+
"""执行内部辅助逻辑。"""
|
|
3468
|
+
if error_code == "MISSING_REQUIRED_FIELD":
|
|
3469
|
+
return "缺少必填字段。"
|
|
3470
|
+
if error_code == "FIELD_NOT_FOUND":
|
|
3471
|
+
return "字段不存在或字段标题不匹配。"
|
|
3472
|
+
if error_code == "AMBIGUOUS_FIELD":
|
|
3473
|
+
return "字段标题存在歧义。"
|
|
3474
|
+
if error_code in {"INVALID_FIELD_VALUE", "INVALID_MEMBER_VALUE", "INVALID_DEPARTMENT_VALUE", "INVALID_RELATION_VALUE"}:
|
|
3475
|
+
return _normalize_optional_text(fallback) or "字段值格式不正确。"
|
|
3476
|
+
return _normalize_optional_text(fallback) or "字段写入失败。"
|
|
3477
|
+
|
|
3478
|
+
def _record_write_next_action_for_error(self, error_code: str) -> str:
|
|
3479
|
+
"""执行内部辅助逻辑。"""
|
|
3480
|
+
if error_code == "MISSING_REQUIRED_FIELD":
|
|
3481
|
+
return "补充该字段后只重试本行。"
|
|
3482
|
+
if error_code in {"FIELD_NOT_FOUND", "AMBIGUOUS_FIELD"}:
|
|
3483
|
+
return "重新调用 schema 工具确认字段标题或 field_id 后,只重试本行。"
|
|
3484
|
+
return "修正该字段值后只重试本行。"
|
|
3485
|
+
|
|
3068
3486
|
@tool_cn_name("更新记录")
|
|
3069
3487
|
def record_update_public(
|
|
3070
3488
|
self,
|
|
@@ -7357,7 +7775,7 @@ class RecordTools(ToolBase):
|
|
|
7357
7775
|
"result": result,
|
|
7358
7776
|
"normalized_answers": normalized_answers,
|
|
7359
7777
|
"status": "completed" if verified else "verification_failed",
|
|
7360
|
-
"ok":
|
|
7778
|
+
"ok": True,
|
|
7361
7779
|
"apply_id": apply_id,
|
|
7362
7780
|
"record_id": apply_id,
|
|
7363
7781
|
"verify_write": verify_write,
|
|
@@ -7561,7 +7979,7 @@ class RecordTools(ToolBase):
|
|
|
7561
7979
|
"result": result,
|
|
7562
7980
|
"normalized_answers": normalized_answers,
|
|
7563
7981
|
"status": "completed" if verified else "verification_failed",
|
|
7564
|
-
"ok":
|
|
7982
|
+
"ok": True,
|
|
7565
7983
|
"verify_write": verify_write,
|
|
7566
7984
|
"write_verified": verified if verify_write else None,
|
|
7567
7985
|
"verification": verification,
|
|
@@ -10100,6 +10518,9 @@ class RecordTools(ToolBase):
|
|
|
10100
10518
|
"ws_id": raw_preflight.get("ws_id"),
|
|
10101
10519
|
"ok": False,
|
|
10102
10520
|
"status": status,
|
|
10521
|
+
"write_executed": False,
|
|
10522
|
+
"verification_status": "not_requested",
|
|
10523
|
+
"safe_to_retry": True,
|
|
10103
10524
|
"request_route": raw_preflight.get("request_route"),
|
|
10104
10525
|
"warnings": warnings,
|
|
10105
10526
|
"output_profile": output_profile,
|
|
@@ -10143,6 +10564,9 @@ class RecordTools(ToolBase):
|
|
|
10143
10564
|
"ws_id": raw_preflight.get("ws_id"),
|
|
10144
10565
|
"ok": True,
|
|
10145
10566
|
"status": "ready",
|
|
10567
|
+
"write_executed": False,
|
|
10568
|
+
"verification_status": "not_requested",
|
|
10569
|
+
"safe_to_retry": True,
|
|
10146
10570
|
"request_route": raw_preflight.get("request_route"),
|
|
10147
10571
|
"warnings": warnings,
|
|
10148
10572
|
"output_profile": output_profile,
|
|
@@ -10185,17 +10609,45 @@ class RecordTools(ToolBase):
|
|
|
10185
10609
|
resolved_fields = cast(list[JSONObject], preflight_data.get("lookup_resolved_fields", []))
|
|
10186
10610
|
if isinstance(verification_warnings, list):
|
|
10187
10611
|
warnings.extend(cast(list[JSONObject], [item for item in verification_warnings if isinstance(item, dict)]))
|
|
10612
|
+
resource = _public_record_resource(raw_apply.get("resource"))
|
|
10613
|
+
record_id = _public_record_id_text(resource.get("record_id")) if isinstance(resource, dict) else None
|
|
10614
|
+
apply_id = _public_record_id_text(resource.get("apply_id")) if isinstance(resource, dict) else None
|
|
10615
|
+
if record_id is None:
|
|
10616
|
+
record_id = _public_record_id_text(raw_apply.get("record_id"))
|
|
10617
|
+
if apply_id is None:
|
|
10618
|
+
apply_id = _public_record_id_text(raw_apply.get("apply_id"))
|
|
10619
|
+
if apply_id is None:
|
|
10620
|
+
apply_id = record_id
|
|
10621
|
+
if record_id is None:
|
|
10622
|
+
record_id = apply_id
|
|
10623
|
+
write_executed = True
|
|
10624
|
+
verification_requested = (
|
|
10625
|
+
raw_apply.get("verify_write") is True
|
|
10626
|
+
or raw_apply.get("write_verified") is not None
|
|
10627
|
+
or isinstance(raw_apply.get("verification"), dict)
|
|
10628
|
+
)
|
|
10629
|
+
if verification_requested:
|
|
10630
|
+
verification_status = "verified" if bool(verification.get("verified")) else "failed"
|
|
10631
|
+
else:
|
|
10632
|
+
verification_status = "not_requested"
|
|
10633
|
+
raw_status = _normalize_optional_text(raw_apply.get("status"))
|
|
10634
|
+
response_status = "verification_failed" if verification_status == "failed" else "success"
|
|
10635
|
+
if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
|
|
10636
|
+
response_status = raw_status or "failed"
|
|
10188
10637
|
response: JSONObject = {
|
|
10189
10638
|
"profile": raw_apply.get("profile"),
|
|
10190
10639
|
"ws_id": raw_apply.get("ws_id"),
|
|
10191
|
-
"ok": bool(raw_apply.get("ok", True)),
|
|
10192
|
-
"status":
|
|
10640
|
+
"ok": True if verification_status == "failed" and write_executed else bool(raw_apply.get("ok", True)),
|
|
10641
|
+
"status": response_status,
|
|
10642
|
+
"write_executed": write_executed,
|
|
10643
|
+
"verification_status": verification_status,
|
|
10644
|
+
"safe_to_retry": False,
|
|
10193
10645
|
"request_route": raw_apply.get("request_route"),
|
|
10194
10646
|
"warnings": warnings,
|
|
10195
10647
|
"output_profile": output_profile,
|
|
10196
10648
|
"data": {
|
|
10197
10649
|
"action": {"operation": operation, "executed": True},
|
|
10198
|
-
"resource":
|
|
10650
|
+
"resource": resource,
|
|
10199
10651
|
"verification": raw_apply.get("verification"),
|
|
10200
10652
|
"normalized_payload": normalized_payload,
|
|
10201
10653
|
"blockers": [],
|
|
@@ -10205,6 +10657,10 @@ class RecordTools(ToolBase):
|
|
|
10205
10657
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
10206
10658
|
},
|
|
10207
10659
|
}
|
|
10660
|
+
if record_id is not None:
|
|
10661
|
+
response["record_id"] = record_id
|
|
10662
|
+
if apply_id is not None:
|
|
10663
|
+
response["apply_id"] = apply_id
|
|
10208
10664
|
if output_profile == "verbose":
|
|
10209
10665
|
debug: JSONObject = {
|
|
10210
10666
|
"legacy_result": raw_apply.get("result"),
|
|
@@ -10223,9 +10679,10 @@ class RecordTools(ToolBase):
|
|
|
10223
10679
|
operation: str,
|
|
10224
10680
|
profile: str,
|
|
10225
10681
|
app_key: str,
|
|
10226
|
-
record_id:
|
|
10682
|
+
record_id: Any | None,
|
|
10227
10683
|
output_profile: str,
|
|
10228
10684
|
human_review: bool,
|
|
10685
|
+
write_executed: bool = True,
|
|
10229
10686
|
) -> JSONObject:
|
|
10230
10687
|
"""执行内部辅助逻辑。"""
|
|
10231
10688
|
error_payload: JSONObject = {
|
|
@@ -10266,11 +10723,14 @@ class RecordTools(ToolBase):
|
|
|
10266
10723
|
"ws_id": None,
|
|
10267
10724
|
"ok": False,
|
|
10268
10725
|
"status": "failed",
|
|
10726
|
+
"write_executed": write_executed,
|
|
10727
|
+
"verification_status": "failed" if write_executed else "not_requested",
|
|
10728
|
+
"safe_to_retry": not write_executed,
|
|
10269
10729
|
"request_route": request_route,
|
|
10270
10730
|
"warnings": [],
|
|
10271
10731
|
"output_profile": output_profile,
|
|
10272
10732
|
"data": {
|
|
10273
|
-
"action": {"operation": operation, "executed":
|
|
10733
|
+
"action": {"operation": operation, "executed": write_executed},
|
|
10274
10734
|
"resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
10275
10735
|
"verification": None,
|
|
10276
10736
|
"normalized_payload": None,
|
|
@@ -18316,6 +18776,105 @@ def _write_format_for_field(field: FormField) -> JSONObject:
|
|
|
18316
18776
|
return _write_support_payload(support_level="full", kind="scalar_text")
|
|
18317
18777
|
|
|
18318
18778
|
|
|
18779
|
+
def _ready_schema_format_hint(kind: str, write_format: JSONObject) -> str:
|
|
18780
|
+
if kind == "member":
|
|
18781
|
+
return "可直接填成员姓名;唯一匹配会自动解析,重名时会返回候选确认。也可传成员 id/value 对象。"
|
|
18782
|
+
if kind == "department":
|
|
18783
|
+
return "可直接填部门名称;唯一匹配会自动解析,重名时会返回候选确认。也可传部门 id/value 对象。"
|
|
18784
|
+
if kind == "relation":
|
|
18785
|
+
return "可传目标记录 apply_id/record_id 对象,也可填目标记录的可搜索文本;多候选时会返回确认。"
|
|
18786
|
+
if kind == "attachment":
|
|
18787
|
+
return "先调用 file_upload_local 上传文件,再写入上传返回的附件对象或 value/name。"
|
|
18788
|
+
if kind == "subtable":
|
|
18789
|
+
return "传 {'rows': [{...}]} 或直接传行对象数组;每行 key 使用子字段标题。"
|
|
18790
|
+
if kind == "address":
|
|
18791
|
+
return "传省/市/区/详细地址对象、地址明细字符串,或后端地址 parts 数组。"
|
|
18792
|
+
if kind == "single_select":
|
|
18793
|
+
return "传 options 中的一个选项文本。"
|
|
18794
|
+
if kind == "multi_select":
|
|
18795
|
+
return "传 options 中的多个选项文本数组。"
|
|
18796
|
+
if kind == "boolean":
|
|
18797
|
+
return "传 '是' 或 '否'。"
|
|
18798
|
+
if kind == "date":
|
|
18799
|
+
return "推荐传 'YYYY-MM-DD HH:MM:SS';只有日期时可传 'YYYY-MM-DD'。"
|
|
18800
|
+
if kind == "number":
|
|
18801
|
+
return "传数字或数字字符串。"
|
|
18802
|
+
if kind == "unsupported":
|
|
18803
|
+
reason = _normalize_optional_text(write_format.get("reason"))
|
|
18804
|
+
return reason or "该字段不支持直接写入。"
|
|
18805
|
+
return "传文本值。"
|
|
18806
|
+
|
|
18807
|
+
|
|
18808
|
+
def _ready_schema_example_value(
|
|
18809
|
+
kind: str,
|
|
18810
|
+
field: FormField,
|
|
18811
|
+
write_format: JSONObject,
|
|
18812
|
+
*,
|
|
18813
|
+
row_fields: list[JSONObject],
|
|
18814
|
+
) -> JSONValue:
|
|
18815
|
+
if kind == "member":
|
|
18816
|
+
return "张三"
|
|
18817
|
+
if kind == "department":
|
|
18818
|
+
return "直销部"
|
|
18819
|
+
if kind == "relation":
|
|
18820
|
+
return {"apply_id": "5001"}
|
|
18821
|
+
if kind == "attachment":
|
|
18822
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
18823
|
+
if kind == "subtable":
|
|
18824
|
+
row: JSONObject = {}
|
|
18825
|
+
for item in row_fields:
|
|
18826
|
+
if not isinstance(item, dict):
|
|
18827
|
+
continue
|
|
18828
|
+
title = _normalize_optional_text(item.get("title"))
|
|
18829
|
+
if not title:
|
|
18830
|
+
continue
|
|
18831
|
+
row[title] = item.get("example_value", _ready_schema_template_scalar(item.get("kind")))
|
|
18832
|
+
if not row:
|
|
18833
|
+
row = {"子字段": "值"}
|
|
18834
|
+
return {"rows": [row]}
|
|
18835
|
+
if kind == "address":
|
|
18836
|
+
examples = write_format.get("examples")
|
|
18837
|
+
if isinstance(examples, list) and examples:
|
|
18838
|
+
return deepcopy(cast(JSONValue, examples[0]))
|
|
18839
|
+
return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
|
|
18840
|
+
if kind == "single_select":
|
|
18841
|
+
return field.options[0] if field.options else "选项A"
|
|
18842
|
+
if kind == "multi_select":
|
|
18843
|
+
return [field.options[0]] if field.options else ["选项A"]
|
|
18844
|
+
if kind == "boolean":
|
|
18845
|
+
return "是"
|
|
18846
|
+
if kind == "date":
|
|
18847
|
+
return "2026-03-13 10:00:00"
|
|
18848
|
+
if kind == "number":
|
|
18849
|
+
return 100
|
|
18850
|
+
if kind == "unsupported":
|
|
18851
|
+
return None
|
|
18852
|
+
return "示例文本"
|
|
18853
|
+
|
|
18854
|
+
|
|
18855
|
+
def _ready_schema_template_scalar(kind: Any) -> JSONValue:
|
|
18856
|
+
normalized = _normalize_optional_text(kind)
|
|
18857
|
+
if normalized == "number":
|
|
18858
|
+
return 100
|
|
18859
|
+
if normalized == "date":
|
|
18860
|
+
return "2026-03-13 10:00:00"
|
|
18861
|
+
if normalized == "boolean":
|
|
18862
|
+
return "是"
|
|
18863
|
+
if normalized == "member":
|
|
18864
|
+
return "张三"
|
|
18865
|
+
if normalized == "department":
|
|
18866
|
+
return "直销部"
|
|
18867
|
+
if normalized == "relation":
|
|
18868
|
+
return {"apply_id": "5001"}
|
|
18869
|
+
if normalized == "multi_select":
|
|
18870
|
+
return ["选项A"]
|
|
18871
|
+
if normalized == "attachment":
|
|
18872
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
18873
|
+
if normalized == "address":
|
|
18874
|
+
return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
|
|
18875
|
+
return "值"
|
|
18876
|
+
|
|
18877
|
+
|
|
18319
18878
|
def _summarize_write_support(resolved_fields: list[JSONObject]) -> JSONObject:
|
|
18320
18879
|
summary: JSONObject = {
|
|
18321
18880
|
"full": [],
|