@josephyan/qingflow-app-user-mcp 0.2.0-beta.46 → 0.2.0-beta.48
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/SKILL.md +1 -1
- package/skills/qingflow-record-crud/SKILL.md +38 -6
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +52 -11
- package/src/qingflow_mcp/server.py +13 -2
- package/src/qingflow_mcp/server_app_user.py +13 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +584 -0
- package/src/qingflow_mcp/tools/import_tools.py +43 -5
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +34 -0
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.48
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.48 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -32,7 +32,7 @@ Route to exactly one of these specialized paths:
|
|
|
32
32
|
|
|
33
33
|
- If the user does not know the target `app_key`, discover apps first with `app_list` or `app_search`, then route to the specialized skill
|
|
34
34
|
- If the app is known but the available data range is unclear, call `app_get` first and inspect `accessible_views`
|
|
35
|
-
- If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, member/department-field candidate lookup, import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-crud`
|
|
35
|
+
- If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, member/department-field candidate lookup, code-block field execution, import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-crud`
|
|
36
36
|
- If the task is about todo discovery, task context, approval actions, rollback or transfer, associated report review, or workflow log review, switch to `$qingflow-task-ops`
|
|
37
37
|
- If the task is about grouped distributions, ratios, rankings, trends, insights, or any final statistical conclusion, switch to `$qingflow-record-analysis`
|
|
38
38
|
- If the MCP is not connected, authenticated, or bound to the right workspace, switch to `$qingflow-mcp-setup`
|
|
@@ -19,7 +19,8 @@ Use exactly one of these default paths:
|
|
|
19
19
|
1. Browse records: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list`
|
|
20
20
|
2. Read one record: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_get`
|
|
21
21
|
3. Write records: `record_schema_get(schema_mode="applicant") -> record_write`
|
|
22
|
-
4.
|
|
22
|
+
4. Run a code-block field: `record_schema_get(schema_mode="applicant") -> record_code_block_run`
|
|
23
|
+
5. Import records: `record_import_template_get -> record_import_verify -> (optional authorized file repair) -> record_import_start -> record_import_status_get`
|
|
23
24
|
|
|
24
25
|
## Core Tools
|
|
25
26
|
|
|
@@ -27,6 +28,7 @@ Use exactly one of these default paths:
|
|
|
27
28
|
- `record_list`
|
|
28
29
|
- `record_get`
|
|
29
30
|
- `record_write`
|
|
31
|
+
- `record_code_block_run`
|
|
30
32
|
- `record_import_template_get`
|
|
31
33
|
- `record_import_verify`
|
|
32
34
|
- `record_import_repair_local`
|
|
@@ -63,11 +65,12 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
|
|
|
63
65
|
6. Run `record_schema_get(schema_mode="browse", view_id=...)` before browse/read
|
|
64
66
|
7. Run `record_schema_get(schema_mode="applicant")` before write
|
|
65
67
|
8. If the request is import-like, decide whether the user needs template download, file verification, file repair, import execution, or import status before changing any file
|
|
66
|
-
9. If the request is
|
|
67
|
-
10. If the request is
|
|
68
|
-
11. If
|
|
69
|
-
12.
|
|
70
|
-
13.
|
|
68
|
+
9. If the request is code-block-like, inspect applicant schema first and confirm the exact `code_block_field`
|
|
69
|
+
10. If the request is analysis-like, switch to [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-record-analysis/SKILL.md)
|
|
70
|
+
11. If the request is write-like, decide `insert / update / delete` before building any payload
|
|
71
|
+
12. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
|
|
72
|
+
13. For high-risk writes or production changes, read the current state first whenever practical
|
|
73
|
+
14. After actions, report the affected `record_id`, counts, or returned item count
|
|
71
74
|
|
|
72
75
|
## Record Read Rules
|
|
73
76
|
|
|
@@ -131,6 +134,35 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
|
|
|
131
134
|
- `relation`: `✅ {"field_id":25,"value":{"apply_id":5001}}` / `❌ {"field_id":25,"value":"客户A"}`
|
|
132
135
|
- `attachment`: upload first, then `✅ {"field_id":13,"value":{"value":"https://.../a.pdf","name":"a.pdf"}}` / `❌ {"field_id":13,"value":"/tmp/a.pdf"}`
|
|
133
136
|
|
|
137
|
+
## Code Block Rules
|
|
138
|
+
|
|
139
|
+
Use `record_code_block_run` when the user wants to run a form code-block field against an existing record and optionally let Qingflow auto-write bound relation outputs back into the same record.
|
|
140
|
+
|
|
141
|
+
### Code block workflow
|
|
142
|
+
|
|
143
|
+
1. Run `record_schema_get(schema_mode="applicant")`
|
|
144
|
+
2. Select the exact code-block field from schema
|
|
145
|
+
3. Confirm the target `record_id`
|
|
146
|
+
4. Choose the right execution context:
|
|
147
|
+
- default record context: `role=1`
|
|
148
|
+
- workflow task context: pass `role=3` and `workflow_node_id`
|
|
149
|
+
5. Run `record_code_block_run`
|
|
150
|
+
6. Check:
|
|
151
|
+
- `outputs.alias_results`
|
|
152
|
+
- `relation.target_fields`
|
|
153
|
+
- `writeback.applied`
|
|
154
|
+
- `writeback.verification`
|
|
155
|
+
|
|
156
|
+
### Code block discipline
|
|
157
|
+
|
|
158
|
+
- `code_block_field` must resolve to a real code-block field; do not guess by approximate title
|
|
159
|
+
- Prefer the current stored record answers as inputs
|
|
160
|
+
- Only pass temporary `answers` or `fields` overrides when the user explicitly wants a what-if execution or the current record data is known to be incomplete
|
|
161
|
+
- Treat this tool as a write-capable operation, not a pure read
|
|
162
|
+
- If the code block is bound to relation outputs, MCP will trigger Qingflow's relation-calculation chain and may auto-write calculated answers back to the record
|
|
163
|
+
- If `verify_writeback=true`, read `writeback.write_verified` and `writeback.verification` before claiming success
|
|
164
|
+
- In workflow context, `workflow_node_id` is required when `role=3`
|
|
165
|
+
|
|
134
166
|
## Import Rules
|
|
135
167
|
|
|
136
168
|
Use the import tools for file-based bulk data loading, not `record_write`.
|
|
@@ -1250,7 +1250,7 @@ class AiBuilderFacade:
|
|
|
1250
1250
|
if app_result.get("status") != "success":
|
|
1251
1251
|
return app_result
|
|
1252
1252
|
resolved_app_key = str(app_result.get("app_key") or app_key)
|
|
1253
|
-
items = self.
|
|
1253
|
+
items, list_source = self._load_chart_list_for_builder(profile=profile, app_key=resolved_app_key)
|
|
1254
1254
|
except (QingflowApiError, RuntimeError) as error:
|
|
1255
1255
|
api_error = _coerce_api_error(error)
|
|
1256
1256
|
return _failed_from_api_error(
|
|
@@ -1278,12 +1278,22 @@ class AiBuilderFacade:
|
|
|
1278
1278
|
"request_id": None,
|
|
1279
1279
|
"suggested_next_call": None,
|
|
1280
1280
|
"noop": False,
|
|
1281
|
-
"warnings": [],
|
|
1282
|
-
"verification": {"app_exists": True},
|
|
1281
|
+
"warnings": [] if list_source == "sorted" else [_warning("CHART_ORDER_UNVERIFIED", "chart summary order uses fallback listing and may not reflect saved chart sort order")],
|
|
1282
|
+
"verification": {"app_exists": True, "chart_order_verified": list_source == "sorted", "chart_list_source": list_source},
|
|
1283
1283
|
"verified": True,
|
|
1284
1284
|
**response.model_dump(mode="json"),
|
|
1285
1285
|
}
|
|
1286
1286
|
|
|
1287
|
+
def _load_chart_list_for_builder(self, *, profile: str, app_key: str) -> tuple[list[dict[str, Any]], str]:
|
|
1288
|
+
try:
|
|
1289
|
+
sorted_items = self.charts.qingbi_report_list_sorted(profile=profile, app_key=app_key, page_num=1, page_size=500).get("items") or []
|
|
1290
|
+
if isinstance(sorted_items, list):
|
|
1291
|
+
return sorted_items, "sorted"
|
|
1292
|
+
except (QingflowApiError, RuntimeError):
|
|
1293
|
+
pass
|
|
1294
|
+
fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
|
|
1295
|
+
return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
|
|
1296
|
+
|
|
1287
1297
|
def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
|
|
1288
1298
|
try:
|
|
1289
1299
|
result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
|
|
@@ -3231,7 +3241,7 @@ class AiBuilderFacade:
|
|
|
3231
3241
|
parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
|
|
3232
3242
|
fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
|
|
3233
3243
|
qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
|
|
3234
|
-
existing_chart_items = self.
|
|
3244
|
+
existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
3235
3245
|
except (QingflowApiError, RuntimeError) as error:
|
|
3236
3246
|
api_error = _coerce_api_error(error)
|
|
3237
3247
|
return _failed_from_api_error(
|
|
@@ -3305,7 +3315,7 @@ class AiBuilderFacade:
|
|
|
3305
3315
|
create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
|
|
3306
3316
|
created_chart_id = _extract_chart_identifier(create_result or {})
|
|
3307
3317
|
if not created_chart_id:
|
|
3308
|
-
refreshed_items = self.
|
|
3318
|
+
refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
3309
3319
|
refreshed_matches = _find_charts_by_name(
|
|
3310
3320
|
refreshed_items,
|
|
3311
3321
|
chart_name=patch.name,
|
|
@@ -3331,10 +3341,23 @@ class AiBuilderFacade:
|
|
|
3331
3341
|
existing_by_id[chart_id] = deepcopy(created_chart)
|
|
3332
3342
|
existing_by_name.setdefault(patch.name, []).append(deepcopy(created_chart))
|
|
3333
3343
|
elif existing_name != patch.name or existing_type != target_type:
|
|
3344
|
+
base_info = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
|
|
3334
3345
|
self.charts.qingbi_report_update_base(
|
|
3335
3346
|
profile=profile,
|
|
3336
3347
|
chart_id=chart_id,
|
|
3337
|
-
payload={
|
|
3348
|
+
payload={
|
|
3349
|
+
"chartId": chart_id,
|
|
3350
|
+
"chartName": patch.name,
|
|
3351
|
+
"chartType": target_type,
|
|
3352
|
+
"dataSourceType": base_info.get("dataSourceType") or (existing or {}).get("dataSourceType") or "qingflow",
|
|
3353
|
+
"tagId": "0",
|
|
3354
|
+
"parentId": "0",
|
|
3355
|
+
"dataSourceId": base_info.get("dataSourceId") or app_key,
|
|
3356
|
+
"visibleAuth": deepcopy(base_info.get("visibleAuth") or qingbi_workspace_visible_auth()),
|
|
3357
|
+
"editAuthList": deepcopy(base_info.get("editAuthList") or []),
|
|
3358
|
+
"editAuthType": base_info.get("editAuthType") or "ws",
|
|
3359
|
+
"editAuthIncludeSubDept": bool(base_info.get("editAuthIncludeSubDept", True)),
|
|
3360
|
+
},
|
|
3338
3361
|
)
|
|
3339
3362
|
updated_ids.append(chart_id)
|
|
3340
3363
|
updated_chart = deepcopy(existing or {})
|
|
@@ -3417,7 +3440,14 @@ class AiBuilderFacade:
|
|
|
3417
3440
|
reordered = False
|
|
3418
3441
|
if request.reorder_chart_ids:
|
|
3419
3442
|
try:
|
|
3420
|
-
|
|
3443
|
+
current_order = [
|
|
3444
|
+
_extract_chart_identifier(item)
|
|
3445
|
+
for item in existing_chart_items
|
|
3446
|
+
if isinstance(item, dict) and _extract_chart_identifier(item)
|
|
3447
|
+
]
|
|
3448
|
+
desired_display_order = list(request.reorder_chart_ids) + [chart_id for chart_id in current_order if chart_id not in request.reorder_chart_ids]
|
|
3449
|
+
backend_reorder_ids = list(reversed(desired_display_order))
|
|
3450
|
+
self.charts.qingbi_report_reorder(profile=profile, app_key=app_key, chart_ids=backend_reorder_ids)
|
|
3421
3451
|
reordered = True
|
|
3422
3452
|
except (QingflowApiError, RuntimeError) as error:
|
|
3423
3453
|
api_error = _coerce_api_error(error)
|
|
@@ -3434,7 +3464,7 @@ class AiBuilderFacade:
|
|
|
3434
3464
|
|
|
3435
3465
|
noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
|
|
3436
3466
|
try:
|
|
3437
|
-
readback_items = self.
|
|
3467
|
+
readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
3438
3468
|
readback_ids = {
|
|
3439
3469
|
_extract_chart_identifier(item)
|
|
3440
3470
|
for item in readback_items
|
|
@@ -3451,11 +3481,12 @@ class AiBuilderFacade:
|
|
|
3451
3481
|
if isinstance(item, dict) and _extract_chart_identifier(item)
|
|
3452
3482
|
]
|
|
3453
3483
|
requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
|
|
3454
|
-
verified = verified and ordered_readback[: len(requested_existing)] == requested_existing
|
|
3484
|
+
verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
|
|
3455
3485
|
readback_unavailable = False
|
|
3456
3486
|
except (QingflowApiError, RuntimeError):
|
|
3457
3487
|
verified = False
|
|
3458
3488
|
readback_unavailable = True
|
|
3489
|
+
readback_list_source = None
|
|
3459
3490
|
|
|
3460
3491
|
if failed_items:
|
|
3461
3492
|
successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
|
|
@@ -3478,7 +3509,12 @@ class AiBuilderFacade:
|
|
|
3478
3509
|
readback_unavailable=readback_unavailable,
|
|
3479
3510
|
verified=False if failed_items else verified,
|
|
3480
3511
|
),
|
|
3481
|
-
"verification": {
|
|
3512
|
+
"verification": {
|
|
3513
|
+
"charts_verified": False if failed_items else verified,
|
|
3514
|
+
"readback_unavailable": readback_unavailable,
|
|
3515
|
+
"chart_order_verified": False if request.reorder_chart_ids else (readback_list_source == "sorted"),
|
|
3516
|
+
"chart_list_source": readback_list_source or existing_chart_list_source,
|
|
3517
|
+
},
|
|
3482
3518
|
"app_key": app_key,
|
|
3483
3519
|
"chart_results": chart_results,
|
|
3484
3520
|
"verified": False if failed_items else verified,
|
|
@@ -3501,7 +3537,12 @@ class AiBuilderFacade:
|
|
|
3501
3537
|
readback_unavailable=False if noop else readback_unavailable,
|
|
3502
3538
|
verified=result_verified,
|
|
3503
3539
|
),
|
|
3504
|
-
"verification": {
|
|
3540
|
+
"verification": {
|
|
3541
|
+
"charts_verified": result_verified,
|
|
3542
|
+
"readback_unavailable": False if noop else readback_unavailable,
|
|
3543
|
+
"chart_order_verified": True if noop and not request.reorder_chart_ids else (readback_list_source == "sorted" and result_verified if request.reorder_chart_ids else readback_list_source == "sorted"),
|
|
3544
|
+
"chart_list_source": existing_chart_list_source if noop else readback_list_source,
|
|
3545
|
+
},
|
|
3505
3546
|
"app_key": app_key,
|
|
3506
3547
|
"chart_results": chart_results,
|
|
3507
3548
|
"verified": result_verified,
|
|
@@ -8,6 +8,7 @@ from .backend_client import BackendClient
|
|
|
8
8
|
from .session_store import SessionStore
|
|
9
9
|
from .tools.app_tools import AppTools
|
|
10
10
|
from .tools.auth_tools import AuthTools
|
|
11
|
+
from .tools.code_block_tools import CodeBlockTools
|
|
11
12
|
from .tools.feedback_tools import FeedbackTools
|
|
12
13
|
from .tools.file_tools import FileTools
|
|
13
14
|
from .tools.import_tools import ImportTools
|
|
@@ -16,7 +17,6 @@ from .tools.navigation_tools import NavigationTools
|
|
|
16
17
|
from .tools.directory_tools import DirectoryTools
|
|
17
18
|
from .tools.portal_tools import PortalTools
|
|
18
19
|
from .tools.qingbi_report_tools import QingbiReportTools
|
|
19
|
-
from .tools.record_tools import RecordTools
|
|
20
20
|
from .tools.role_tools import RoleTools
|
|
21
21
|
from .tools.solution_tools import SolutionTools
|
|
22
22
|
from .tools.task_context_tools import TaskContextTools
|
|
@@ -95,6 +95,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
95
95
|
|
|
96
96
|
`app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
|
|
97
97
|
`record_schema_get(schema_mode="applicant") -> record_write`
|
|
98
|
+
`record_schema_get(schema_mode="applicant") -> record_code_block_run`
|
|
98
99
|
|
|
99
100
|
- Use `columns` as `[{{field_id}}]`
|
|
100
101
|
- Use `where` items as `{{field_id, op, value}}`
|
|
@@ -111,6 +112,16 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
111
112
|
- If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
|
|
112
113
|
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
113
114
|
|
|
115
|
+
## Code Block Path
|
|
116
|
+
|
|
117
|
+
Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
|
|
118
|
+
|
|
119
|
+
- Always resolve the exact code-block field from `record_schema_get(schema_mode="applicant")` first.
|
|
120
|
+
- Treat code-block execution as write-capable, not read-only.
|
|
121
|
+
- If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
|
|
122
|
+
- In workflow context, pass `role=3` and the exact `workflow_node_id`.
|
|
123
|
+
- After execution, inspect `outputs.alias_results`, `relation.target_fields`, and `writeback.verification` before claiming success.
|
|
124
|
+
|
|
114
125
|
## Import Path
|
|
115
126
|
|
|
116
127
|
`app_get -> record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
|
|
@@ -161,7 +172,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
161
172
|
WorkspaceTools(sessions, backend).register(server)
|
|
162
173
|
FileTools(sessions, backend).register(server)
|
|
163
174
|
ImportTools(sessions, backend).register(server)
|
|
164
|
-
|
|
175
|
+
CodeBlockTools(sessions, backend).register(server)
|
|
165
176
|
TaskContextTools(sessions, backend).register(server)
|
|
166
177
|
RoleTools(sessions, backend).register(server)
|
|
167
178
|
AppTools(sessions, backend).register(server)
|
|
@@ -9,11 +9,11 @@ from .config import DEFAULT_PROFILE
|
|
|
9
9
|
from .session_store import SessionStore
|
|
10
10
|
from .tools.app_tools import AppTools
|
|
11
11
|
from .tools.auth_tools import AuthTools
|
|
12
|
+
from .tools.code_block_tools import CodeBlockTools
|
|
12
13
|
from .tools.directory_tools import DirectoryTools
|
|
13
14
|
from .tools.feedback_tools import FeedbackTools
|
|
14
15
|
from .tools.file_tools import FileTools
|
|
15
16
|
from .tools.import_tools import ImportTools
|
|
16
|
-
from .tools.record_tools import RecordTools
|
|
17
17
|
from .tools.task_context_tools import TaskContextTools
|
|
18
18
|
from .tools.workspace_tools import WorkspaceTools
|
|
19
19
|
|
|
@@ -83,6 +83,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
83
83
|
|
|
84
84
|
`app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
|
|
85
85
|
`record_schema_get(schema_mode="applicant") -> record_write`
|
|
86
|
+
`record_schema_get(schema_mode="applicant") -> record_code_block_run`
|
|
86
87
|
|
|
87
88
|
- Use `columns` as `[{{field_id}}]`
|
|
88
89
|
- Use `where` items as `{{field_id, op, value}}`
|
|
@@ -99,6 +100,16 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
99
100
|
- If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
|
|
100
101
|
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
101
102
|
|
|
103
|
+
## Code Block Path
|
|
104
|
+
|
|
105
|
+
Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
|
|
106
|
+
|
|
107
|
+
- Always resolve the exact code-block field from `record_schema_get(schema_mode="applicant")` first.
|
|
108
|
+
- Treat code-block execution as write-capable, not read-only.
|
|
109
|
+
- If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
|
|
110
|
+
- In workflow context, pass `role=3` and the exact `workflow_node_id`.
|
|
111
|
+
- After execution, inspect `outputs.alias_results`, `relation.target_fields`, and `writeback.verification` before claiming success.
|
|
112
|
+
|
|
102
113
|
## Import Path
|
|
103
114
|
|
|
104
115
|
`app_get -> record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
|
|
@@ -274,7 +285,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
274
285
|
imports.register(server)
|
|
275
286
|
|
|
276
287
|
feedback.register(server)
|
|
277
|
-
|
|
288
|
+
CodeBlockTools(sessions, backend).register(server)
|
|
278
289
|
TaskContextTools(sessions, backend).register(server)
|
|
279
290
|
DirectoryTools(sessions, backend).register(server)
|
|
280
291
|
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
|
|
8
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
9
|
+
from ..json_types import JSONObject, JSONValue
|
|
10
|
+
from .record_tools import (
|
|
11
|
+
ATTACHMENT_QUE_TYPES,
|
|
12
|
+
DEPARTMENT_QUE_TYPES,
|
|
13
|
+
MEMBER_QUE_TYPES,
|
|
14
|
+
MULTI_SELECT_QUE_TYPES,
|
|
15
|
+
RELATION_QUE_TYPES,
|
|
16
|
+
SINGLE_SELECT_QUE_TYPES,
|
|
17
|
+
SUBTABLE_QUE_TYPES,
|
|
18
|
+
FieldIndex,
|
|
19
|
+
FormField,
|
|
20
|
+
RecordTools,
|
|
21
|
+
_coerce_count,
|
|
22
|
+
_collect_question_relations,
|
|
23
|
+
_field_ref_payload,
|
|
24
|
+
_normalize_optional_text,
|
|
25
|
+
_relation_ids_from_answer,
|
|
26
|
+
_stringify_json,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CODE_BLOCK_QUE_TYPE = 26
|
|
31
|
+
CODE_BLOCK_RELATION_TYPE = 3
|
|
32
|
+
SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodeBlockTools(RecordTools):
|
|
36
|
+
def register(self, mcp: FastMCP) -> None:
|
|
37
|
+
super().register(mcp)
|
|
38
|
+
|
|
39
|
+
@mcp.tool(
|
|
40
|
+
description=(
|
|
41
|
+
"Run a form code-block field against the current record data, then reuse Qingflow's existing "
|
|
42
|
+
"relation-calculation chain to compute bound outputs and write them back automatically. "
|
|
43
|
+
"Use an exact code-block field selector from the form schema."
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
def record_code_block_run(
|
|
47
|
+
profile: str = DEFAULT_PROFILE,
|
|
48
|
+
app_key: str = "",
|
|
49
|
+
record_id: int = 0,
|
|
50
|
+
code_block_field: str = "",
|
|
51
|
+
role: int = 1,
|
|
52
|
+
workflow_node_id: int | None = None,
|
|
53
|
+
answers: list[JSONObject] | None = None,
|
|
54
|
+
fields: JSONObject | None = None,
|
|
55
|
+
manual: bool = True,
|
|
56
|
+
verify_writeback: bool = True,
|
|
57
|
+
force_refresh_form: bool = False,
|
|
58
|
+
output_profile: str = "normal",
|
|
59
|
+
) -> JSONObject:
|
|
60
|
+
return self.record_code_block_run(
|
|
61
|
+
profile=profile,
|
|
62
|
+
app_key=app_key,
|
|
63
|
+
record_id=record_id,
|
|
64
|
+
code_block_field=code_block_field,
|
|
65
|
+
role=role,
|
|
66
|
+
workflow_node_id=workflow_node_id,
|
|
67
|
+
answers=answers or [],
|
|
68
|
+
fields=fields or {},
|
|
69
|
+
manual=manual,
|
|
70
|
+
verify_writeback=verify_writeback,
|
|
71
|
+
force_refresh_form=force_refresh_form,
|
|
72
|
+
output_profile=output_profile,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def record_code_block_run(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
profile: str,
|
|
79
|
+
app_key: str,
|
|
80
|
+
record_id: int,
|
|
81
|
+
code_block_field: str,
|
|
82
|
+
role: int = 1,
|
|
83
|
+
workflow_node_id: int | None = None,
|
|
84
|
+
answers: list[JSONObject] | None = None,
|
|
85
|
+
fields: JSONObject | None = None,
|
|
86
|
+
manual: bool = True,
|
|
87
|
+
verify_writeback: bool = True,
|
|
88
|
+
force_refresh_form: bool = False,
|
|
89
|
+
output_profile: str = "normal",
|
|
90
|
+
) -> JSONObject:
|
|
91
|
+
normalized_record_id = self._validate_app_and_record(app_key, record_id)
|
|
92
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
93
|
+
if role not in SUPPORTED_CODE_BLOCK_ROLES:
|
|
94
|
+
raise_tool_error(QingflowApiError.config_error("role must be one of 1, 2, 3, or 5"))
|
|
95
|
+
if role == 3 and (workflow_node_id is None or workflow_node_id <= 0):
|
|
96
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id is required when role=3"))
|
|
97
|
+
if not code_block_field:
|
|
98
|
+
raise_tool_error(QingflowApiError.config_error("code_block_field is required"))
|
|
99
|
+
|
|
100
|
+
def runner(session_profile, context):
|
|
101
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
|
|
102
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
|
|
103
|
+
code_block = self._resolve_field_selector(code_block_field, index, location="code_block_field")
|
|
104
|
+
if code_block.que_type != CODE_BLOCK_QUE_TYPE:
|
|
105
|
+
raise_tool_error(
|
|
106
|
+
QingflowApiError(
|
|
107
|
+
category="config",
|
|
108
|
+
message=f"field '{code_block.que_title}' is not a code-block field",
|
|
109
|
+
backend_code="CODE_BLOCK_FIELD_REQUIRED",
|
|
110
|
+
details={
|
|
111
|
+
"error_code": "CODE_BLOCK_FIELD_REQUIRED",
|
|
112
|
+
"field": _field_ref_payload(code_block),
|
|
113
|
+
"expected_que_type": CODE_BLOCK_QUE_TYPE,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
current_answers = self._load_record_answers_for_code_block(
|
|
119
|
+
context,
|
|
120
|
+
app_key=app_key,
|
|
121
|
+
apply_id=normalized_record_id,
|
|
122
|
+
role=role,
|
|
123
|
+
audit_node_id=workflow_node_id,
|
|
124
|
+
)
|
|
125
|
+
override_answers = (
|
|
126
|
+
self._resolve_answers(
|
|
127
|
+
profile,
|
|
128
|
+
context,
|
|
129
|
+
app_key,
|
|
130
|
+
answers=answers or [],
|
|
131
|
+
fields=fields or {},
|
|
132
|
+
force_refresh_form=force_refresh_form,
|
|
133
|
+
)
|
|
134
|
+
if answers or fields
|
|
135
|
+
else []
|
|
136
|
+
)
|
|
137
|
+
merged_answers = self._merge_record_answers(current_answers, override_answers) if override_answers else current_answers
|
|
138
|
+
key_que_values = self._answers_to_open_match_values(merged_answers, index)
|
|
139
|
+
run_body: JSONObject = {
|
|
140
|
+
"role": role,
|
|
141
|
+
"manual": bool(manual),
|
|
142
|
+
"applyId": normalized_record_id,
|
|
143
|
+
"appKey": app_key,
|
|
144
|
+
"queryQuestions": [{"queId": code_block.que_id, "ordinal": None}],
|
|
145
|
+
"keyQueValues": key_que_values,
|
|
146
|
+
}
|
|
147
|
+
if workflow_node_id is not None:
|
|
148
|
+
run_body["auditNodeId"] = workflow_node_id
|
|
149
|
+
run_result = self.backend.request(
|
|
150
|
+
"POST",
|
|
151
|
+
context,
|
|
152
|
+
f"/app/{app_key}/codeBlock/working",
|
|
153
|
+
json_body=run_body,
|
|
154
|
+
)
|
|
155
|
+
alias_results = _normalize_code_block_alias_results(run_result)
|
|
156
|
+
relation_target_fields = _collect_code_block_relation_targets(
|
|
157
|
+
_collect_question_relations(schema),
|
|
158
|
+
code_block_que_id=code_block.que_id,
|
|
159
|
+
)
|
|
160
|
+
relation_errors: list[JSONObject] = []
|
|
161
|
+
relation_items: list[JSONObject] = []
|
|
162
|
+
calculated_answers: list[JSONObject] = []
|
|
163
|
+
relation_result: JSONObject | None = None
|
|
164
|
+
if relation_target_fields:
|
|
165
|
+
relation_body: JSONObject = {
|
|
166
|
+
"role": role,
|
|
167
|
+
"manual": bool(manual),
|
|
168
|
+
"applyId": normalized_record_id,
|
|
169
|
+
"appKey": app_key,
|
|
170
|
+
"queryQuestions": [{"queId": target["que_id"], "ordinal": None} for target in relation_target_fields],
|
|
171
|
+
"keyQueValues": key_que_values,
|
|
172
|
+
"codeBlockValues": [{"queId": code_block.que_id, "values": alias_results}],
|
|
173
|
+
}
|
|
174
|
+
if workflow_node_id is not None:
|
|
175
|
+
relation_body["auditNodeId"] = workflow_node_id
|
|
176
|
+
relation_result = self.backend.request("POST", context, "/que/actuator", json_body=relation_body)
|
|
177
|
+
relation_items = _relation_result_items(relation_result)
|
|
178
|
+
relation_errors = _relation_result_errors(relation_items)
|
|
179
|
+
calculated_answers = _relation_result_answers(relation_items)
|
|
180
|
+
write_result: JSONObject | None = None
|
|
181
|
+
verification: JSONObject | None = None
|
|
182
|
+
writeback_attempted = False
|
|
183
|
+
writeback_applied = False
|
|
184
|
+
status = "completed"
|
|
185
|
+
ok = True
|
|
186
|
+
if relation_target_fields and calculated_answers:
|
|
187
|
+
write_body: JSONObject = {"role": role, "answers": calculated_answers}
|
|
188
|
+
if workflow_node_id is not None:
|
|
189
|
+
write_body["auditNodeId"] = workflow_node_id
|
|
190
|
+
writeback_attempted = True
|
|
191
|
+
write_result = cast(
|
|
192
|
+
JSONObject,
|
|
193
|
+
self.backend.request(
|
|
194
|
+
"POST",
|
|
195
|
+
context,
|
|
196
|
+
f"/app/{app_key}/apply/{normalized_record_id}",
|
|
197
|
+
json_body=write_body,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
writeback_applied = True
|
|
201
|
+
if verify_writeback:
|
|
202
|
+
verification = self._verify_code_block_writeback_result(
|
|
203
|
+
context,
|
|
204
|
+
app_key=app_key,
|
|
205
|
+
apply_id=normalized_record_id,
|
|
206
|
+
expected_answers=calculated_answers,
|
|
207
|
+
index=index,
|
|
208
|
+
role=role,
|
|
209
|
+
audit_node_id=workflow_node_id,
|
|
210
|
+
)
|
|
211
|
+
if not bool(verification.get("verified")):
|
|
212
|
+
status = "verification_failed"
|
|
213
|
+
ok = False
|
|
214
|
+
elif relation_errors:
|
|
215
|
+
status = "relation_failed"
|
|
216
|
+
ok = False
|
|
217
|
+
else:
|
|
218
|
+
status = "no_writeback"
|
|
219
|
+
response: JSONObject = {
|
|
220
|
+
"profile": profile,
|
|
221
|
+
"ws_id": session_profile.selected_ws_id,
|
|
222
|
+
"request_route": self._request_route_payload(context),
|
|
223
|
+
"app_key": app_key,
|
|
224
|
+
"record_id": normalized_record_id,
|
|
225
|
+
"apply_id": normalized_record_id,
|
|
226
|
+
"status": status,
|
|
227
|
+
"ok": ok,
|
|
228
|
+
"code_block_field": _field_ref_payload(code_block),
|
|
229
|
+
"execution": {
|
|
230
|
+
"executed": True,
|
|
231
|
+
"role": role,
|
|
232
|
+
"workflow_node_id": workflow_node_id,
|
|
233
|
+
"manual": bool(manual),
|
|
234
|
+
"result_count": len(alias_results),
|
|
235
|
+
},
|
|
236
|
+
"outputs": {
|
|
237
|
+
"alias_results": alias_results,
|
|
238
|
+
"alias_map": _build_alias_result_map(alias_results),
|
|
239
|
+
},
|
|
240
|
+
"relation": {
|
|
241
|
+
"target_fields": relation_target_fields,
|
|
242
|
+
"result_item_count": len(relation_items),
|
|
243
|
+
"calculated_answer_count": len(calculated_answers),
|
|
244
|
+
"errors": relation_errors,
|
|
245
|
+
},
|
|
246
|
+
"writeback": {
|
|
247
|
+
"attempted": writeback_attempted,
|
|
248
|
+
"applied": writeback_applied,
|
|
249
|
+
"verify_writeback": verify_writeback,
|
|
250
|
+
"write_verified": bool(verification.get("verified")) if verification is not None else None,
|
|
251
|
+
"result": write_result,
|
|
252
|
+
"verification": verification,
|
|
253
|
+
},
|
|
254
|
+
"resource": {"apply_id": normalized_record_id},
|
|
255
|
+
}
|
|
256
|
+
if normalized_output_profile == "verbose":
|
|
257
|
+
response["debug"] = {
|
|
258
|
+
"run_body": run_body,
|
|
259
|
+
"relation_result": relation_result,
|
|
260
|
+
"calculated_answers": calculated_answers,
|
|
261
|
+
"merged_answers": merged_answers,
|
|
262
|
+
"key_que_values": key_que_values,
|
|
263
|
+
}
|
|
264
|
+
return response
|
|
265
|
+
|
|
266
|
+
return self._run_record_tool(profile, runner)
|
|
267
|
+
|
|
268
|
+
def _load_record_answers_for_code_block(
|
|
269
|
+
self,
|
|
270
|
+
context, # type: ignore[no-untyped-def]
|
|
271
|
+
*,
|
|
272
|
+
app_key: str,
|
|
273
|
+
apply_id: int,
|
|
274
|
+
role: int,
|
|
275
|
+
audit_node_id: int | None,
|
|
276
|
+
) -> list[JSONObject]:
|
|
277
|
+
last_error: QingflowApiError | None = None
|
|
278
|
+
for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
|
|
279
|
+
params: JSONObject = {"role": role, "listType": list_type}
|
|
280
|
+
if audit_node_id is not None:
|
|
281
|
+
params["auditNodeId"] = audit_node_id
|
|
282
|
+
try:
|
|
283
|
+
record = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}", params=params)
|
|
284
|
+
answers = record.get("answers") if isinstance(record, dict) else None
|
|
285
|
+
return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
|
|
286
|
+
except QingflowApiError as exc:
|
|
287
|
+
last_error = exc
|
|
288
|
+
if exc.backend_code == 40002:
|
|
289
|
+
continue
|
|
290
|
+
raise
|
|
291
|
+
if last_error is not None:
|
|
292
|
+
raise last_error
|
|
293
|
+
raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
|
|
294
|
+
|
|
295
|
+
def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
|
|
296
|
+
values: list[JSONObject] = []
|
|
297
|
+
for answer in answers:
|
|
298
|
+
if not isinstance(answer, dict):
|
|
299
|
+
continue
|
|
300
|
+
open_match = self._answer_to_open_match_value(answer, index)
|
|
301
|
+
if open_match is None:
|
|
302
|
+
continue
|
|
303
|
+
values.append(open_match)
|
|
304
|
+
return values
|
|
305
|
+
|
|
306
|
+
def _answer_to_open_match_value(self, answer: JSONObject, index: FieldIndex) -> JSONObject | None:
|
|
307
|
+
que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
|
|
308
|
+
if que_id is None or que_id <= 0:
|
|
309
|
+
return None
|
|
310
|
+
field = index.by_id.get(str(que_id))
|
|
311
|
+
if field is None:
|
|
312
|
+
return None
|
|
313
|
+
ordinal = _coerce_count(answer.get("ordinal"))
|
|
314
|
+
if field.que_type in SUBTABLE_QUE_TYPES:
|
|
315
|
+
rows = answer.get("tableValues")
|
|
316
|
+
row_values: list[list[JSONObject]] = []
|
|
317
|
+
subtable_index = self._subtable_field_index_optional(field)
|
|
318
|
+
if isinstance(rows, list) and subtable_index is not None:
|
|
319
|
+
for row in rows:
|
|
320
|
+
if not isinstance(row, list):
|
|
321
|
+
continue
|
|
322
|
+
normalized_row: list[JSONObject] = []
|
|
323
|
+
for cell in row:
|
|
324
|
+
if not isinstance(cell, dict):
|
|
325
|
+
continue
|
|
326
|
+
converted = self._answer_to_open_match_value(cell, subtable_index)
|
|
327
|
+
if converted is not None:
|
|
328
|
+
normalized_row.append(converted)
|
|
329
|
+
row_values.append(normalized_row)
|
|
330
|
+
payload: JSONObject = {"keyQueId": field.que_id, "ordinal": ordinal, "values": [], "tableValues": row_values}
|
|
331
|
+
return payload
|
|
332
|
+
return {
|
|
333
|
+
"keyQueId": field.que_id,
|
|
334
|
+
"ordinal": ordinal,
|
|
335
|
+
"values": self._answer_values_to_code_block_values(answer, field),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def _answer_values_to_code_block_values(self, answer: JSONObject, field: FormField) -> list[str]:
|
|
339
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
340
|
+
return _relation_ids_from_answer(answer)
|
|
341
|
+
raw_values = answer.get("values")
|
|
342
|
+
if not isinstance(raw_values, list):
|
|
343
|
+
return []
|
|
344
|
+
normalized: list[str] = []
|
|
345
|
+
for item in raw_values:
|
|
346
|
+
normalized_value = _normalize_code_block_value_item(item, field)
|
|
347
|
+
if normalized_value is None:
|
|
348
|
+
continue
|
|
349
|
+
normalized.append(normalized_value)
|
|
350
|
+
return normalized
|
|
351
|
+
|
|
352
|
+
def _verify_code_block_writeback_result(
|
|
353
|
+
self,
|
|
354
|
+
context, # type: ignore[no-untyped-def]
|
|
355
|
+
*,
|
|
356
|
+
app_key: str,
|
|
357
|
+
apply_id: int,
|
|
358
|
+
expected_answers: list[JSONObject],
|
|
359
|
+
index: FieldIndex,
|
|
360
|
+
role: int,
|
|
361
|
+
audit_node_id: int | None,
|
|
362
|
+
) -> JSONObject:
|
|
363
|
+
if role == 1 and audit_node_id is None:
|
|
364
|
+
return self._verify_record_write_result(
|
|
365
|
+
context,
|
|
366
|
+
app_key=app_key,
|
|
367
|
+
apply_id=apply_id,
|
|
368
|
+
normalized_answers=expected_answers,
|
|
369
|
+
index=index,
|
|
370
|
+
verify_list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
371
|
+
)
|
|
372
|
+
actual_answers = self._load_record_answers_for_code_block(
|
|
373
|
+
context,
|
|
374
|
+
app_key=app_key,
|
|
375
|
+
apply_id=apply_id,
|
|
376
|
+
role=role,
|
|
377
|
+
audit_node_id=audit_node_id,
|
|
378
|
+
)
|
|
379
|
+
actual_by_id = {
|
|
380
|
+
que_id: item
|
|
381
|
+
for item in actual_answers
|
|
382
|
+
if isinstance(item, dict) and (que_id := _coerce_count(item.get("queId"))) is not None
|
|
383
|
+
}
|
|
384
|
+
missing_fields: list[JSONObject] = []
|
|
385
|
+
empty_fields: list[JSONObject] = []
|
|
386
|
+
count_mismatches: list[JSONObject] = []
|
|
387
|
+
for answer in expected_answers:
|
|
388
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
389
|
+
if que_id is None or que_id <= 0:
|
|
390
|
+
continue
|
|
391
|
+
actual = actual_by_id.get(que_id)
|
|
392
|
+
field = index.by_id.get(str(que_id))
|
|
393
|
+
field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
|
|
394
|
+
if actual is None:
|
|
395
|
+
missing_fields.append(field_payload)
|
|
396
|
+
continue
|
|
397
|
+
expected_rows = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
|
|
398
|
+
if expected_rows:
|
|
399
|
+
actual_rows = actual.get("tableValues") if isinstance(actual.get("tableValues"), list) else []
|
|
400
|
+
self._verify_subtable_write_result(
|
|
401
|
+
field=field,
|
|
402
|
+
expected_rows=expected_rows,
|
|
403
|
+
actual_rows=actual_rows,
|
|
404
|
+
missing_fields=missing_fields,
|
|
405
|
+
empty_fields=empty_fields,
|
|
406
|
+
count_mismatches=count_mismatches,
|
|
407
|
+
)
|
|
408
|
+
continue
|
|
409
|
+
if field is not None and field.que_type in RELATION_QUE_TYPES:
|
|
410
|
+
expected_relation_ids = _relation_ids_from_answer(answer)
|
|
411
|
+
actual_relation_ids = _relation_ids_from_answer(actual)
|
|
412
|
+
if expected_relation_ids and not actual_relation_ids:
|
|
413
|
+
empty_fields.append(field_payload)
|
|
414
|
+
continue
|
|
415
|
+
if expected_relation_ids:
|
|
416
|
+
actual_id_set = set(actual_relation_ids)
|
|
417
|
+
missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
|
|
418
|
+
if missing_ids:
|
|
419
|
+
count_mismatches.append(
|
|
420
|
+
{
|
|
421
|
+
**field_payload,
|
|
422
|
+
"expected_ids": expected_relation_ids,
|
|
423
|
+
"actual_ids": actual_relation_ids,
|
|
424
|
+
"missing_ids": missing_ids,
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
continue
|
|
428
|
+
actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
|
|
429
|
+
if not actual_values:
|
|
430
|
+
empty_fields.append(field_payload)
|
|
431
|
+
continue
|
|
432
|
+
expected_values = answer.get("values") if isinstance(answer.get("values"), list) else []
|
|
433
|
+
if expected_values and len(actual_values) < len(expected_values):
|
|
434
|
+
count_mismatches.append(
|
|
435
|
+
{
|
|
436
|
+
**field_payload,
|
|
437
|
+
"expected_count": len(expected_values),
|
|
438
|
+
"actual_count": len(actual_values),
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
return {
|
|
442
|
+
"verified": not missing_fields and not empty_fields and not count_mismatches,
|
|
443
|
+
"verification_mode": "role_record_view",
|
|
444
|
+
"field_level_verified": True,
|
|
445
|
+
"missing_fields": missing_fields,
|
|
446
|
+
"empty_fields": empty_fields,
|
|
447
|
+
"count_mismatches": count_mismatches,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _normalize_code_block_value_item(value: JSONValue, field: FormField) -> str | None:
|
|
452
|
+
if field.que_type in SINGLE_SELECT_QUE_TYPES | MULTI_SELECT_QUE_TYPES:
|
|
453
|
+
return _selector_numeric_or_text(value, ("optionId", "optId", "id"), allow_text=False)
|
|
454
|
+
if field.que_type in MEMBER_QUE_TYPES:
|
|
455
|
+
return _selector_numeric_or_text(value, ("id", "uid"), allow_text=False)
|
|
456
|
+
if field.que_type in DEPARTMENT_QUE_TYPES:
|
|
457
|
+
return _selector_numeric_or_text(value, ("id", "deptId"), allow_text=False)
|
|
458
|
+
if field.que_type in ATTACHMENT_QUE_TYPES:
|
|
459
|
+
return _selector_numeric_or_text(value, ("value", "url", "otherInfo", "name", "fileName"), allow_text=True)
|
|
460
|
+
if isinstance(value, dict):
|
|
461
|
+
scalar = value.get("value")
|
|
462
|
+
return _stringify_json(scalar) if scalar is not None else None
|
|
463
|
+
text = _normalize_optional_text(value)
|
|
464
|
+
return text if text is not None else None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _selector_numeric_or_text(value: JSONValue, keys: tuple[str, ...], *, allow_text: bool) -> str | None:
|
|
468
|
+
numeric = _coerce_count(value)
|
|
469
|
+
if numeric is not None:
|
|
470
|
+
return str(numeric)
|
|
471
|
+
if not isinstance(value, dict):
|
|
472
|
+
if not allow_text:
|
|
473
|
+
return None
|
|
474
|
+
text = _normalize_optional_text(value)
|
|
475
|
+
return text if text is not None else None
|
|
476
|
+
for key in keys:
|
|
477
|
+
if key not in value:
|
|
478
|
+
continue
|
|
479
|
+
candidate = value.get(key)
|
|
480
|
+
if candidate is None:
|
|
481
|
+
continue
|
|
482
|
+
numeric = _coerce_count(candidate)
|
|
483
|
+
if numeric is not None:
|
|
484
|
+
return str(numeric)
|
|
485
|
+
if allow_text:
|
|
486
|
+
text = _normalize_optional_text(candidate)
|
|
487
|
+
if text is not None:
|
|
488
|
+
return text
|
|
489
|
+
return _normalize_optional_text(value.get("value")) if allow_text and isinstance(value.get("value"), (str, int, float)) else None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _normalize_code_block_alias_results(payload: JSONValue) -> list[JSONObject]:
|
|
493
|
+
if not isinstance(payload, dict):
|
|
494
|
+
return []
|
|
495
|
+
raw_results = payload.get("result")
|
|
496
|
+
if not isinstance(raw_results, list):
|
|
497
|
+
return []
|
|
498
|
+
results: list[JSONObject] = []
|
|
499
|
+
for item in raw_results:
|
|
500
|
+
if not isinstance(item, dict):
|
|
501
|
+
continue
|
|
502
|
+
values = item.get("value")
|
|
503
|
+
result: JSONObject = {
|
|
504
|
+
"parentAliasId": _coerce_count(item.get("parentAliasId")),
|
|
505
|
+
"parentAlias": _normalize_optional_text(item.get("parentAlias")),
|
|
506
|
+
"aliasId": _coerce_count(item.get("aliasId")),
|
|
507
|
+
"alias": _normalize_optional_text(item.get("alias")),
|
|
508
|
+
"value": [_stringify_json(entry) for entry in values] if isinstance(values, list) else [],
|
|
509
|
+
}
|
|
510
|
+
results.append(result)
|
|
511
|
+
return results
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _build_alias_result_map(alias_results: list[JSONObject]) -> JSONObject:
|
|
515
|
+
alias_map: JSONObject = {}
|
|
516
|
+
for item in alias_results:
|
|
517
|
+
alias = _normalize_optional_text(item.get("alias"))
|
|
518
|
+
if alias is None:
|
|
519
|
+
continue
|
|
520
|
+
parent_alias = _normalize_optional_text(item.get("parentAlias"))
|
|
521
|
+
key = f"{parent_alias}.{alias}" if parent_alias else alias
|
|
522
|
+
values = item.get("value")
|
|
523
|
+
alias_map[key] = values if isinstance(values, list) else []
|
|
524
|
+
return alias_map
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _collect_code_block_relation_targets(question_relations: list[JSONObject], *, code_block_que_id: int) -> list[JSONObject]:
|
|
528
|
+
targets: list[JSONObject] = []
|
|
529
|
+
seen: set[int] = set()
|
|
530
|
+
for relation in question_relations:
|
|
531
|
+
if _coerce_count(relation.get("relationType")) != CODE_BLOCK_RELATION_TYPE:
|
|
532
|
+
continue
|
|
533
|
+
if _coerce_count(relation.get("qlinkerQueId")) != code_block_que_id:
|
|
534
|
+
continue
|
|
535
|
+
target_id = _coerce_count(
|
|
536
|
+
relation.get("queId", relation.get("targetQueId", relation.get("displayedQueId")))
|
|
537
|
+
)
|
|
538
|
+
if target_id is None or target_id in seen:
|
|
539
|
+
continue
|
|
540
|
+
seen.add(target_id)
|
|
541
|
+
targets.append(
|
|
542
|
+
{
|
|
543
|
+
"que_id": target_id,
|
|
544
|
+
"alias_id": _coerce_count(relation.get("aliasId")),
|
|
545
|
+
"qlinker_que_id": _coerce_count(relation.get("qlinkerQueId")),
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
return targets
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _relation_result_items(payload: JSONValue) -> list[JSONObject]:
|
|
552
|
+
if not isinstance(payload, dict):
|
|
553
|
+
return []
|
|
554
|
+
result = payload.get("result")
|
|
555
|
+
return [item for item in result if isinstance(item, dict)] if isinstance(result, list) else []
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _relation_result_answers(items: list[JSONObject]) -> list[JSONObject]:
|
|
559
|
+
answers: list[JSONObject] = []
|
|
560
|
+
for item in items:
|
|
561
|
+
raw_answers = item.get("answers")
|
|
562
|
+
if not isinstance(raw_answers, list):
|
|
563
|
+
continue
|
|
564
|
+
for answer in raw_answers:
|
|
565
|
+
if isinstance(answer, dict):
|
|
566
|
+
answers.append(answer)
|
|
567
|
+
return answers
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _relation_result_errors(items: list[JSONObject]) -> list[JSONObject]:
|
|
571
|
+
errors: list[JSONObject] = []
|
|
572
|
+
for item in items:
|
|
573
|
+
message = _normalize_optional_text(item.get("errorMsg"))
|
|
574
|
+
if message is None:
|
|
575
|
+
continue
|
|
576
|
+
errors.append(
|
|
577
|
+
{
|
|
578
|
+
"que_id": _coerce_count(item.get("queId")),
|
|
579
|
+
"que_title": _normalize_optional_text(item.get("queTitle")),
|
|
580
|
+
"ordinal": _coerce_count(item.get("ordinal")),
|
|
581
|
+
"message": message,
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
return errors
|
|
@@ -1132,11 +1132,35 @@ def _analyze_headers(
|
|
|
1132
1132
|
issues: list[JSONObject] = []
|
|
1133
1133
|
repair_suggestions: list[str] = []
|
|
1134
1134
|
if missing:
|
|
1135
|
-
issues.append(
|
|
1135
|
+
issues.append(
|
|
1136
|
+
_issue(
|
|
1137
|
+
"MISSING_COLUMNS",
|
|
1138
|
+
f"Missing expected columns: {', '.join(missing)}",
|
|
1139
|
+
severity="error",
|
|
1140
|
+
repairable=True,
|
|
1141
|
+
repair_code="normalize_headers",
|
|
1142
|
+
)
|
|
1143
|
+
)
|
|
1136
1144
|
if extra:
|
|
1137
|
-
issues.append(
|
|
1145
|
+
issues.append(
|
|
1146
|
+
_issue(
|
|
1147
|
+
"EXTRA_COLUMNS",
|
|
1148
|
+
f"Unexpected columns: {', '.join(extra)}",
|
|
1149
|
+
severity="error",
|
|
1150
|
+
repairable=True,
|
|
1151
|
+
repair_code="normalize_headers",
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1138
1154
|
if duplicates:
|
|
1139
|
-
issues.append(
|
|
1155
|
+
issues.append(
|
|
1156
|
+
_issue(
|
|
1157
|
+
"DUPLICATE_COLUMNS",
|
|
1158
|
+
f"Duplicate columns: {', '.join(sorted(set(duplicates)))}",
|
|
1159
|
+
severity="error",
|
|
1160
|
+
repairable=True,
|
|
1161
|
+
repair_code="normalize_headers",
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1140
1164
|
normalized_changes = []
|
|
1141
1165
|
for text in actual_headers:
|
|
1142
1166
|
if not text:
|
|
@@ -1144,7 +1168,7 @@ def _analyze_headers(
|
|
|
1144
1168
|
canonical = allowed_by_key.get(_normalize_header_key(text))
|
|
1145
1169
|
if canonical and canonical != text:
|
|
1146
1170
|
normalized_changes.append((text, canonical))
|
|
1147
|
-
if normalized_changes:
|
|
1171
|
+
if missing or extra or duplicates or normalized_changes:
|
|
1148
1172
|
repair_suggestions.append("normalize_headers")
|
|
1149
1173
|
return {"issues": issues, "repair_suggestions": repair_suggestions}
|
|
1150
1174
|
|
|
@@ -1194,7 +1218,8 @@ def _sheet_header_map(sheet) -> dict[str, int]: # type: ignore[no-untyped-def]
|
|
|
1194
1218
|
def _repair_headers(sheet, expected_columns: list[JSONObject]) -> bool: # type: ignore[no-untyped-def]
|
|
1195
1219
|
changed = False
|
|
1196
1220
|
expected_by_key = {_normalize_header_key(item["title"]): item["title"] for item in expected_columns}
|
|
1197
|
-
|
|
1221
|
+
header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), []))
|
|
1222
|
+
for cell in header_cells:
|
|
1198
1223
|
text = _normalize_optional_text(cell.value)
|
|
1199
1224
|
if text is None:
|
|
1200
1225
|
continue
|
|
@@ -1202,6 +1227,19 @@ def _repair_headers(sheet, expected_columns: list[JSONObject]) -> bool: # type:
|
|
|
1202
1227
|
if canonical and canonical != text:
|
|
1203
1228
|
cell.value = canonical
|
|
1204
1229
|
changed = True
|
|
1230
|
+
if changed:
|
|
1231
|
+
return True
|
|
1232
|
+
|
|
1233
|
+
# Fallback for template-based files where headers were edited into non-canonical
|
|
1234
|
+
# values but column order is still intact. Keep any extra trailing system columns.
|
|
1235
|
+
for index, column in enumerate(expected_columns, start=1):
|
|
1236
|
+
if index > len(header_cells):
|
|
1237
|
+
break
|
|
1238
|
+
expected_title = str(column["title"]).strip()
|
|
1239
|
+
current_title = _normalize_optional_text(header_cells[index - 1].value)
|
|
1240
|
+
if current_title != expected_title:
|
|
1241
|
+
header_cells[index - 1].value = expected_title
|
|
1242
|
+
changed = True
|
|
1205
1243
|
return changed
|
|
1206
1244
|
|
|
1207
1245
|
|
|
@@ -95,6 +95,32 @@ class QingbiReportTools(ToolBase):
|
|
|
95
95
|
self._require_app_key(app_key)
|
|
96
96
|
return self._request(profile, "GET", f"/qingbi/charts/data/bichart/{app_key}", app_key=app_key, items_key="items")
|
|
97
97
|
|
|
98
|
+
def qingbi_report_list_sorted(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
profile: str,
|
|
102
|
+
app_key: str,
|
|
103
|
+
page_num: int = 1,
|
|
104
|
+
page_size: int = 500,
|
|
105
|
+
search_key: str | None = None,
|
|
106
|
+
) -> JSONObject:
|
|
107
|
+
self._require_app_key(app_key)
|
|
108
|
+
body: JSONObject = {"appKey": app_key, "pageNum": page_num, "pageSize": page_size}
|
|
109
|
+
if search_key:
|
|
110
|
+
body["searchKey"] = search_key
|
|
111
|
+
return self._request(
|
|
112
|
+
profile,
|
|
113
|
+
"POST",
|
|
114
|
+
"/qingbi/charts/chart/list",
|
|
115
|
+
app_key=app_key,
|
|
116
|
+
page_num=page_num,
|
|
117
|
+
page_size=page_size,
|
|
118
|
+
search_key=search_key,
|
|
119
|
+
items_key="items",
|
|
120
|
+
result_transform=_extract_sorted_chart_items,
|
|
121
|
+
json_body=body,
|
|
122
|
+
)
|
|
123
|
+
|
|
98
124
|
def qingbi_report_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
|
|
99
125
|
body = self._require_dict(payload)
|
|
100
126
|
return self._request(profile, "POST", "/qingbi/charts", json_body=body)
|
|
@@ -233,3 +259,11 @@ def _extract_dataset_fields(result: JSONValue) -> list[JSONObject]:
|
|
|
233
259
|
if isinstance(result, list):
|
|
234
260
|
return result
|
|
235
261
|
return []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _extract_sorted_chart_items(result: JSONValue) -> list[JSONObject]:
|
|
265
|
+
if isinstance(result, dict) and isinstance(result.get("list"), list):
|
|
266
|
+
return result["list"]
|
|
267
|
+
if isinstance(result, list):
|
|
268
|
+
return result
|
|
269
|
+
return []
|