@josephyan/qingflow-app-user-mcp 0.2.0-beta.20 → 0.2.0-beta.21
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 +183 -113
- package/skills/qingflow-app-user/references/data-gotchas.md +20 -30
- package/skills/qingflow-app-user/references/environments.md +1 -1
- package/skills/qingflow-app-user/references/record-patterns.md +80 -66
- package/skills/qingflow-app-user/references/workflow-usage.md +10 -8
- package/skills/qingflow-record-analysis/SKILL.md +4 -4
- package/skills/qingflow-record-analysis/agents/openai.yaml +1 -1
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +2 -2
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +2 -2
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +2 -2
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +6 -6
- package/src/qingflow_mcp/server_app_user.py +8 -183
- package/src/qingflow_mcp/tools/approval_tools.py +357 -75
- package/src/qingflow_mcp/tools/directory_tools.py +158 -28
- package/src/qingflow_mcp/tools/record_tools.py +619 -120
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
|
@@ -192,155 +192,88 @@ class RecordTools(ToolBase):
|
|
|
192
192
|
|
|
193
193
|
@mcp.tool(
|
|
194
194
|
description=(
|
|
195
|
-
"
|
|
196
|
-
"
|
|
195
|
+
"Browse Qingflow records with a schema-first list DSL. "
|
|
196
|
+
"Use record_schema_get first, then pass field_id-only columns, where, and order_by clauses. "
|
|
197
|
+
"This route is for browse/export/sample inspection only, not analysis."
|
|
197
198
|
)
|
|
198
199
|
)
|
|
199
|
-
def
|
|
200
|
+
def record_list(
|
|
200
201
|
profile: str = DEFAULT_PROFILE,
|
|
201
|
-
operation: str = "auto",
|
|
202
202
|
app_key: str = "",
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
columns: list[int] | None = None,
|
|
204
|
+
where: list[JSONObject] | None = None,
|
|
205
|
+
order_by: list[JSONObject] | None = None,
|
|
206
|
+
limit: int = 50,
|
|
207
|
+
page: int = 1,
|
|
208
|
+
view_key: str | None = None,
|
|
209
|
+
view_name: str | None = None,
|
|
210
|
+
output_profile: str = "normal",
|
|
207
211
|
) -> JSONObject:
|
|
208
|
-
return self.
|
|
212
|
+
return self.record_list(
|
|
209
213
|
profile=profile,
|
|
210
|
-
operation=operation,
|
|
211
214
|
app_key=app_key,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
columns=columns or [],
|
|
216
|
+
where=where or [],
|
|
217
|
+
order_by=order_by or [],
|
|
218
|
+
limit=limit,
|
|
219
|
+
page=page,
|
|
220
|
+
view_key=view_key,
|
|
221
|
+
view_name=view_name,
|
|
222
|
+
output_profile=output_profile,
|
|
216
223
|
)
|
|
217
224
|
|
|
218
|
-
@mcp.tool(
|
|
219
|
-
|
|
220
|
-
"Unified read entry for record list / record detail. "
|
|
221
|
-
"Use query_mode=auto to route: apply_id -> record, otherwise -> list. "
|
|
222
|
-
"query_mode only supports auto, list, or record. "
|
|
223
|
-
"For statistical analysis use record_schema_get and record_analyze."
|
|
224
|
-
)
|
|
225
|
-
)
|
|
226
|
-
def record_query(
|
|
225
|
+
@mcp.tool(description="Read one Qingflow record by record_id. Use record_schema_get first if columns are ambiguous.")
|
|
226
|
+
def record_get(
|
|
227
227
|
profile: str = DEFAULT_PROFILE,
|
|
228
|
-
query_mode: str = "auto",
|
|
229
228
|
app_key: str = "",
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
sorts: list[JSONObject] | None = None,
|
|
235
|
-
max_rows: int = DEFAULT_ROW_LIMIT,
|
|
236
|
-
max_columns: int | None = None,
|
|
237
|
-
select_columns: list[str | int] | None = None,
|
|
238
|
-
output_profile: str = DEFAULT_OUTPUT_PROFILE,
|
|
239
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
240
|
-
view_key: str | None = None,
|
|
241
|
-
view_name: str | None = None,
|
|
229
|
+
record_id: int = 0,
|
|
230
|
+
columns: list[int] | None = None,
|
|
231
|
+
workflow_node_id: int | None = None,
|
|
232
|
+
output_profile: str = "normal",
|
|
242
233
|
) -> JSONObject:
|
|
243
|
-
|
|
244
|
-
return self.record_query(
|
|
234
|
+
return self.record_get_public(
|
|
245
235
|
profile=profile,
|
|
246
|
-
query_mode=query_mode,
|
|
247
236
|
app_key=app_key,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
requested_pages=int(paging["requested_pages"]),
|
|
252
|
-
scan_max_pages=int(paging["scan_max_pages"]),
|
|
253
|
-
auto_expand_pages=bool(paging["auto_expand_pages"]),
|
|
254
|
-
query_key=query_key,
|
|
255
|
-
filters=filters or [],
|
|
256
|
-
sorts=sorts or [],
|
|
257
|
-
max_rows=max_rows,
|
|
258
|
-
max_columns=max_columns,
|
|
259
|
-
select_columns=select_columns or [],
|
|
260
|
-
amount_column=None,
|
|
261
|
-
time_range={},
|
|
262
|
-
stat_policy={},
|
|
263
|
-
strict_full=False,
|
|
237
|
+
record_id=record_id,
|
|
238
|
+
columns=columns or [],
|
|
239
|
+
workflow_node_id=workflow_node_id,
|
|
264
240
|
output_profile=output_profile,
|
|
265
|
-
list_type=list_type,
|
|
266
|
-
view_key=view_key,
|
|
267
|
-
view_name=view_name,
|
|
268
241
|
)
|
|
269
242
|
|
|
270
243
|
@mcp.tool(
|
|
271
244
|
description=(
|
|
272
|
-
"
|
|
273
|
-
"Use
|
|
245
|
+
"Write Qingflow records with a SQL-like JSON DSL. "
|
|
246
|
+
"Use record_schema_get first, then choose operation=insert|update|delete and mode=plan|apply. "
|
|
247
|
+
"This route does not accept raw SQL strings or free-form WHERE clauses."
|
|
274
248
|
)
|
|
275
249
|
)
|
|
276
|
-
def
|
|
250
|
+
def record_write(
|
|
277
251
|
profile: str = DEFAULT_PROFILE,
|
|
278
252
|
app_key: str = "",
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
253
|
+
operation: str = "insert",
|
|
254
|
+
mode: str = "plan",
|
|
255
|
+
record_id: int | None = None,
|
|
256
|
+
record_ids: list[int] | None = None,
|
|
257
|
+
values: list[JSONObject] | None = None,
|
|
258
|
+
set: list[JSONObject] | None = None,
|
|
259
|
+
submit_type: str | int = "submit",
|
|
260
|
+
verify_write: bool = True,
|
|
261
|
+
output_profile: str = "normal",
|
|
284
262
|
) -> JSONObject:
|
|
285
|
-
return self.
|
|
263
|
+
return self.record_write(
|
|
286
264
|
profile=profile,
|
|
287
265
|
app_key=app_key,
|
|
288
|
-
|
|
289
|
-
|
|
266
|
+
operation=operation,
|
|
267
|
+
mode=mode,
|
|
268
|
+
record_id=record_id,
|
|
269
|
+
record_ids=record_ids or [],
|
|
270
|
+
values=values or [],
|
|
271
|
+
set=set or [],
|
|
290
272
|
submit_type=submit_type,
|
|
291
273
|
verify_write=verify_write,
|
|
292
|
-
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
@mcp.tool()
|
|
296
|
-
def record_get(
|
|
297
|
-
profile: str = DEFAULT_PROFILE,
|
|
298
|
-
app_key: str = "",
|
|
299
|
-
apply_id: int = 0,
|
|
300
|
-
role: int = 1,
|
|
301
|
-
list_type: int | None = None,
|
|
302
|
-
audit_node_id: int | None = None,
|
|
303
|
-
) -> JSONObject:
|
|
304
|
-
return self.record_get(
|
|
305
|
-
profile=profile,
|
|
306
|
-
app_key=app_key,
|
|
307
|
-
apply_id=apply_id,
|
|
308
|
-
role=role,
|
|
309
|
-
list_type=list_type,
|
|
310
|
-
audit_node_id=audit_node_id,
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
@mcp.tool(description=self._high_risk_tool_description(operation="update", target="record data"))
|
|
314
|
-
def record_update(
|
|
315
|
-
profile: str = DEFAULT_PROFILE,
|
|
316
|
-
app_key: str = "",
|
|
317
|
-
apply_id: int = 0,
|
|
318
|
-
answers: list[JSONObject] | None = None,
|
|
319
|
-
fields: JSONObject | None = None,
|
|
320
|
-
role: int = 1,
|
|
321
|
-
verify_write: bool = False,
|
|
322
|
-
force_refresh_form: bool = False,
|
|
323
|
-
) -> JSONObject:
|
|
324
|
-
return self.record_update(
|
|
325
|
-
profile=profile,
|
|
326
|
-
app_key=app_key,
|
|
327
|
-
apply_id=apply_id,
|
|
328
|
-
answers=answers or [],
|
|
329
|
-
fields=fields or {},
|
|
330
|
-
role=role,
|
|
331
|
-
verify_write=verify_write,
|
|
332
|
-
force_refresh_form=force_refresh_form,
|
|
274
|
+
output_profile=output_profile,
|
|
333
275
|
)
|
|
334
276
|
|
|
335
|
-
@mcp.tool(description=self._high_risk_tool_description(operation="delete", target="record data"))
|
|
336
|
-
def record_delete(
|
|
337
|
-
profile: str = DEFAULT_PROFILE,
|
|
338
|
-
app_key: str = "",
|
|
339
|
-
apply_id: int = 0,
|
|
340
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
341
|
-
) -> JSONObject:
|
|
342
|
-
return self.record_delete(profile=profile, app_key=app_key, apply_id=apply_id, list_type=list_type)
|
|
343
|
-
|
|
344
277
|
def record_schema_get(
|
|
345
278
|
self,
|
|
346
279
|
*,
|
|
@@ -445,7 +378,349 @@ class RecordTools(ToolBase):
|
|
|
445
378
|
|
|
446
379
|
return self._run_record_tool(profile, runner)
|
|
447
380
|
|
|
381
|
+
def record_list(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
profile: str,
|
|
385
|
+
app_key: str,
|
|
386
|
+
columns: list[int],
|
|
387
|
+
where: list[JSONObject],
|
|
388
|
+
order_by: list[JSONObject],
|
|
389
|
+
limit: int,
|
|
390
|
+
page: int,
|
|
391
|
+
view_key: str | None,
|
|
392
|
+
view_name: str | None,
|
|
393
|
+
output_profile: str,
|
|
394
|
+
) -> JSONObject:
|
|
395
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
396
|
+
if not app_key:
|
|
397
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
398
|
+
if not columns:
|
|
399
|
+
raise_tool_error(QingflowApiError.config_error("columns is required"))
|
|
400
|
+
if any(not isinstance(item, int) or item < 0 for item in columns):
|
|
401
|
+
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
402
|
+
if limit <= 0:
|
|
403
|
+
raise_tool_error(QingflowApiError.config_error("limit must be positive"))
|
|
404
|
+
if page <= 0:
|
|
405
|
+
raise_tool_error(QingflowApiError.config_error("page must be positive"))
|
|
406
|
+
|
|
407
|
+
raw = self.record_query(
|
|
408
|
+
profile=profile,
|
|
409
|
+
query_mode="list",
|
|
410
|
+
app_key=app_key,
|
|
411
|
+
apply_id=None,
|
|
412
|
+
page_num=page,
|
|
413
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
414
|
+
requested_pages=1,
|
|
415
|
+
scan_max_pages=1,
|
|
416
|
+
auto_expand_pages=False,
|
|
417
|
+
query_key=None,
|
|
418
|
+
filters=self._normalize_record_list_where(where),
|
|
419
|
+
sorts=self._normalize_record_list_order_by(order_by),
|
|
420
|
+
max_rows=limit,
|
|
421
|
+
max_columns=len(columns),
|
|
422
|
+
select_columns=columns,
|
|
423
|
+
amount_column=None,
|
|
424
|
+
time_range={},
|
|
425
|
+
stat_policy={},
|
|
426
|
+
strict_full=False,
|
|
427
|
+
output_profile="verbose" if normalized_output_profile == "verbose" else DEFAULT_OUTPUT_PROFILE,
|
|
428
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
429
|
+
view_key=view_key,
|
|
430
|
+
view_name=view_name,
|
|
431
|
+
)
|
|
432
|
+
list_data = cast(JSONObject, cast(JSONObject, raw["data"])["list"])
|
|
433
|
+
pagination = cast(JSONObject, list_data["pagination"])
|
|
434
|
+
warnings: list[JSONObject] = []
|
|
435
|
+
warning = _normalize_optional_text(list_data.get("analysis_warning"))
|
|
436
|
+
if warning:
|
|
437
|
+
warnings.append({"code": "BROWSE_ONLY", "message": warning})
|
|
438
|
+
response: JSONObject = {
|
|
439
|
+
"profile": profile,
|
|
440
|
+
"ws_id": raw.get("ws_id"),
|
|
441
|
+
"ok": bool(raw.get("ok", True)),
|
|
442
|
+
"request_route": raw.get("request_route"),
|
|
443
|
+
"warnings": warnings,
|
|
444
|
+
"output_profile": normalized_output_profile,
|
|
445
|
+
"data": {
|
|
446
|
+
"app_key": app_key,
|
|
447
|
+
"items": list_data.get("rows", []),
|
|
448
|
+
"pagination": {
|
|
449
|
+
"page": page,
|
|
450
|
+
"limit": limit,
|
|
451
|
+
"returned_items": pagination.get("returned_items"),
|
|
452
|
+
"result_amount": pagination.get("result_amount"),
|
|
453
|
+
},
|
|
454
|
+
"selection": {
|
|
455
|
+
"columns": columns,
|
|
456
|
+
"view": cast(JSONObject, raw["data"]).get("view"),
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
if normalized_output_profile == "verbose":
|
|
461
|
+
response["data"]["debug"] = {
|
|
462
|
+
"completeness": raw.get("completeness"),
|
|
463
|
+
"evidence": raw.get("evidence"),
|
|
464
|
+
"resolved_mappings": raw.get("resolved_mappings"),
|
|
465
|
+
"row_cap_hit": list_data.get("row_cap_hit"),
|
|
466
|
+
"sample_only": list_data.get("sample_only"),
|
|
467
|
+
}
|
|
468
|
+
return response
|
|
469
|
+
|
|
470
|
+
def record_get_public(
|
|
471
|
+
self,
|
|
472
|
+
*,
|
|
473
|
+
profile: str,
|
|
474
|
+
app_key: str,
|
|
475
|
+
record_id: int,
|
|
476
|
+
columns: list[int],
|
|
477
|
+
workflow_node_id: int | None,
|
|
478
|
+
output_profile: str,
|
|
479
|
+
) -> JSONObject:
|
|
480
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
481
|
+
if record_id <= 0:
|
|
482
|
+
raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
|
|
483
|
+
if columns and any(not isinstance(item, int) or item < 0 for item in columns):
|
|
484
|
+
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
485
|
+
|
|
486
|
+
if columns:
|
|
487
|
+
raw = self.record_query(
|
|
488
|
+
profile=profile,
|
|
489
|
+
query_mode="record",
|
|
490
|
+
app_key=app_key,
|
|
491
|
+
apply_id=record_id,
|
|
492
|
+
page_num=1,
|
|
493
|
+
page_size=20,
|
|
494
|
+
requested_pages=1,
|
|
495
|
+
scan_max_pages=1,
|
|
496
|
+
auto_expand_pages=False,
|
|
497
|
+
query_key=None,
|
|
498
|
+
filters=[],
|
|
499
|
+
sorts=[],
|
|
500
|
+
max_rows=1,
|
|
501
|
+
max_columns=len(columns),
|
|
502
|
+
select_columns=columns,
|
|
503
|
+
amount_column=None,
|
|
504
|
+
time_range={},
|
|
505
|
+
stat_policy={},
|
|
506
|
+
strict_full=False,
|
|
507
|
+
output_profile="verbose" if normalized_output_profile == "verbose" else DEFAULT_OUTPUT_PROFILE,
|
|
508
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
509
|
+
)
|
|
510
|
+
record_data = cast(JSONObject, cast(JSONObject, raw["data"])["record"])
|
|
511
|
+
response: JSONObject = {
|
|
512
|
+
"profile": profile,
|
|
513
|
+
"ws_id": raw.get("ws_id"),
|
|
514
|
+
"ok": bool(raw.get("ok", True)),
|
|
515
|
+
"request_route": raw.get("request_route"),
|
|
516
|
+
"warnings": [],
|
|
517
|
+
"output_profile": normalized_output_profile,
|
|
518
|
+
"data": {
|
|
519
|
+
"app_key": app_key,
|
|
520
|
+
"record_id": record_id,
|
|
521
|
+
"record": record_data.get("row"),
|
|
522
|
+
"selection": {
|
|
523
|
+
"columns": columns,
|
|
524
|
+
"workflow_node_id": workflow_node_id,
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
}
|
|
528
|
+
if normalized_output_profile == "verbose":
|
|
529
|
+
response["data"]["debug"] = {
|
|
530
|
+
"evidence": raw.get("evidence"),
|
|
531
|
+
"resolved_mappings": raw.get("resolved_mappings"),
|
|
532
|
+
}
|
|
533
|
+
return response
|
|
534
|
+
|
|
535
|
+
raw = self.record_get(
|
|
536
|
+
profile=profile,
|
|
537
|
+
app_key=app_key,
|
|
538
|
+
apply_id=record_id,
|
|
539
|
+
role=1,
|
|
540
|
+
list_type=None,
|
|
541
|
+
audit_node_id=workflow_node_id,
|
|
542
|
+
)
|
|
543
|
+
return {
|
|
544
|
+
"profile": profile,
|
|
545
|
+
"ws_id": raw.get("ws_id"),
|
|
546
|
+
"ok": bool(raw.get("ok", True)),
|
|
547
|
+
"request_route": raw.get("request_route"),
|
|
548
|
+
"warnings": [],
|
|
549
|
+
"output_profile": normalized_output_profile,
|
|
550
|
+
"data": {
|
|
551
|
+
"app_key": app_key,
|
|
552
|
+
"record_id": record_id,
|
|
553
|
+
"record": raw.get("result"),
|
|
554
|
+
"selection": {
|
|
555
|
+
"columns": columns,
|
|
556
|
+
"workflow_node_id": workflow_node_id,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
def record_write(
|
|
562
|
+
self,
|
|
563
|
+
*,
|
|
564
|
+
profile: str,
|
|
565
|
+
app_key: str,
|
|
566
|
+
operation: str,
|
|
567
|
+
mode: str,
|
|
568
|
+
record_id: int | None,
|
|
569
|
+
record_ids: list[int],
|
|
570
|
+
values: list[JSONObject],
|
|
571
|
+
set: list[JSONObject],
|
|
572
|
+
submit_type: str | int,
|
|
573
|
+
verify_write: bool,
|
|
574
|
+
output_profile: str,
|
|
575
|
+
) -> JSONObject:
|
|
576
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
577
|
+
normalized_operation = operation.strip().lower()
|
|
578
|
+
normalized_mode = mode.strip().lower()
|
|
579
|
+
if normalized_operation not in {"insert", "update", "delete"}:
|
|
580
|
+
raise_tool_error(QingflowApiError.config_error("operation must be insert, update, or delete"))
|
|
581
|
+
if normalized_mode not in {"plan", "apply"}:
|
|
582
|
+
raise_tool_error(QingflowApiError.config_error("mode must be plan or apply"))
|
|
583
|
+
if not app_key:
|
|
584
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
585
|
+
normalized_record_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
|
|
586
|
+
submit_type_value = self._normalize_record_write_submit_type(submit_type)
|
|
587
|
+
|
|
588
|
+
if normalized_operation == "insert":
|
|
589
|
+
if record_id is not None or normalized_record_ids:
|
|
590
|
+
raise_tool_error(QingflowApiError.config_error("insert must not include record_id or record_ids"))
|
|
591
|
+
if set:
|
|
592
|
+
raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
|
|
593
|
+
normalized_answers = self._normalize_record_write_clauses(values, location="values")
|
|
594
|
+
normalized_payload: JSONObject = {
|
|
595
|
+
"operation": "insert",
|
|
596
|
+
"record_id": None,
|
|
597
|
+
"record_ids": [],
|
|
598
|
+
"answers": normalized_answers,
|
|
599
|
+
"submit_type": submit_type_value,
|
|
600
|
+
}
|
|
601
|
+
if normalized_mode == "plan":
|
|
602
|
+
raw_plan = self.record_write_plan(
|
|
603
|
+
profile=profile,
|
|
604
|
+
operation="create",
|
|
605
|
+
app_key=app_key,
|
|
606
|
+
apply_id=None,
|
|
607
|
+
answers=normalized_answers,
|
|
608
|
+
fields={},
|
|
609
|
+
force_refresh_form=False,
|
|
610
|
+
)
|
|
611
|
+
return self._record_write_plan_response(
|
|
612
|
+
raw_plan,
|
|
613
|
+
operation="insert",
|
|
614
|
+
normalized_payload=normalized_payload,
|
|
615
|
+
output_profile=normalized_output_profile,
|
|
616
|
+
human_review=False,
|
|
617
|
+
)
|
|
618
|
+
raw_apply = self.record_create(
|
|
619
|
+
profile=profile,
|
|
620
|
+
app_key=app_key,
|
|
621
|
+
answers=normalized_answers,
|
|
622
|
+
fields={},
|
|
623
|
+
submit_type=submit_type_value,
|
|
624
|
+
verify_write=verify_write,
|
|
625
|
+
force_refresh_form=False,
|
|
626
|
+
)
|
|
627
|
+
return self._record_write_apply_response(
|
|
628
|
+
raw_apply,
|
|
629
|
+
operation="insert",
|
|
630
|
+
normalized_payload=normalized_payload,
|
|
631
|
+
output_profile=normalized_output_profile,
|
|
632
|
+
human_review=False,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if normalized_operation == "update":
|
|
636
|
+
if record_id is None or record_id <= 0:
|
|
637
|
+
raise_tool_error(QingflowApiError.config_error("update requires record_id"))
|
|
638
|
+
if normalized_record_ids:
|
|
639
|
+
raise_tool_error(QingflowApiError.config_error("update does not support record_ids"))
|
|
640
|
+
if values:
|
|
641
|
+
raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
|
|
642
|
+
normalized_answers = self._normalize_record_write_clauses(set, location="set")
|
|
643
|
+
normalized_payload = {
|
|
644
|
+
"operation": "update",
|
|
645
|
+
"record_id": record_id,
|
|
646
|
+
"record_ids": [],
|
|
647
|
+
"answers": normalized_answers,
|
|
648
|
+
"submit_type": submit_type_value,
|
|
649
|
+
}
|
|
650
|
+
if normalized_mode == "plan":
|
|
651
|
+
raw_plan = self.record_write_plan(
|
|
652
|
+
profile=profile,
|
|
653
|
+
operation="update",
|
|
654
|
+
app_key=app_key,
|
|
655
|
+
apply_id=record_id,
|
|
656
|
+
answers=normalized_answers,
|
|
657
|
+
fields={},
|
|
658
|
+
force_refresh_form=False,
|
|
659
|
+
)
|
|
660
|
+
return self._record_write_plan_response(
|
|
661
|
+
raw_plan,
|
|
662
|
+
operation="update",
|
|
663
|
+
normalized_payload=normalized_payload,
|
|
664
|
+
output_profile=normalized_output_profile,
|
|
665
|
+
human_review=True,
|
|
666
|
+
)
|
|
667
|
+
raw_apply = self.record_update(
|
|
668
|
+
profile=profile,
|
|
669
|
+
app_key=app_key,
|
|
670
|
+
apply_id=record_id,
|
|
671
|
+
answers=normalized_answers,
|
|
672
|
+
fields={},
|
|
673
|
+
role=1,
|
|
674
|
+
verify_write=verify_write,
|
|
675
|
+
force_refresh_form=False,
|
|
676
|
+
)
|
|
677
|
+
return self._record_write_apply_response(
|
|
678
|
+
raw_apply,
|
|
679
|
+
operation="update",
|
|
680
|
+
normalized_payload=normalized_payload,
|
|
681
|
+
output_profile=normalized_output_profile,
|
|
682
|
+
human_review=True,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if values or set:
|
|
686
|
+
raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
|
|
687
|
+
delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
|
|
688
|
+
if not delete_ids:
|
|
689
|
+
raise_tool_error(QingflowApiError.config_error("delete requires record_id or record_ids"))
|
|
690
|
+
normalized_payload = {
|
|
691
|
+
"operation": "delete",
|
|
692
|
+
"record_id": record_id,
|
|
693
|
+
"record_ids": delete_ids,
|
|
694
|
+
"answers": [],
|
|
695
|
+
"submit_type": submit_type_value,
|
|
696
|
+
}
|
|
697
|
+
if normalized_mode == "plan":
|
|
698
|
+
return {
|
|
699
|
+
"profile": profile,
|
|
700
|
+
"ok": True,
|
|
701
|
+
"request_route": None,
|
|
702
|
+
"warnings": [],
|
|
703
|
+
"output_profile": normalized_output_profile,
|
|
704
|
+
"data": {
|
|
705
|
+
"action": {"operation": "delete", "mode": "plan"},
|
|
706
|
+
"resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": delete_ids},
|
|
707
|
+
"verification": None,
|
|
708
|
+
"normalized_payload": normalized_payload,
|
|
709
|
+
"blockers": [],
|
|
710
|
+
"human_review": self._record_write_human_review_payload("delete", enabled=True),
|
|
711
|
+
},
|
|
712
|
+
}
|
|
713
|
+
raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
|
|
714
|
+
return self._record_write_apply_response(
|
|
715
|
+
raw_apply,
|
|
716
|
+
operation="delete",
|
|
717
|
+
normalized_payload=normalized_payload,
|
|
718
|
+
output_profile=normalized_output_profile,
|
|
719
|
+
human_review=True,
|
|
720
|
+
)
|
|
721
|
+
|
|
448
722
|
def _schema_field_payload(self, field: FormField) -> JSONObject:
|
|
723
|
+
write_hints = self._schema_write_hints(field)
|
|
449
724
|
return {
|
|
450
725
|
"field_id": field.que_id,
|
|
451
726
|
"title": field.que_title,
|
|
@@ -455,6 +730,15 @@ class RecordTools(ToolBase):
|
|
|
455
730
|
"options": field.options,
|
|
456
731
|
"aliases": field.aliases,
|
|
457
732
|
"role_hints": self._schema_role_hints(field),
|
|
733
|
+
"readable": True,
|
|
734
|
+
"writable": write_hints["writable"],
|
|
735
|
+
"write_kind": write_hints["write_kind"],
|
|
736
|
+
"supported_read_ops": write_hints["supported_read_ops"],
|
|
737
|
+
"supported_write_ops": write_hints["supported_write_ops"],
|
|
738
|
+
"requires_lookup": write_hints["requires_lookup"],
|
|
739
|
+
"requires_upload": write_hints["requires_upload"],
|
|
740
|
+
"requires_existing_row_id": write_hints["requires_existing_row_id"],
|
|
741
|
+
"unsupported_reason": write_hints["unsupported_reason"],
|
|
458
742
|
}
|
|
459
743
|
|
|
460
744
|
def _schema_role_hints(self, field: FormField) -> JSONObject:
|
|
@@ -475,6 +759,49 @@ class RecordTools(ToolBase):
|
|
|
475
759
|
"semantic_hints": self._schema_semantic_hint(field, field_family=field_family),
|
|
476
760
|
}
|
|
477
761
|
|
|
762
|
+
def _schema_write_hints(self, field: FormField) -> JSONObject:
|
|
763
|
+
write_format = _write_format_for_field(field)
|
|
764
|
+
kind = _normalize_optional_text(write_format.get("kind")) or "scalar_text"
|
|
765
|
+
support_level = _normalize_optional_text(write_format.get("support_level")) or "full"
|
|
766
|
+
write_kind = self._schema_write_kind(kind)
|
|
767
|
+
writable = bool(not field.system and not field.readonly and support_level != "unsupported")
|
|
768
|
+
supported_write_ops = ["insert", "update"] if writable else []
|
|
769
|
+
requires_lookup = write_kind in {"member", "department", "relation"}
|
|
770
|
+
requires_upload = write_kind == "attachment"
|
|
771
|
+
requires_existing_row_id = write_kind == "subtable"
|
|
772
|
+
unsupported_reason = _normalize_optional_text(write_format.get("reason"))
|
|
773
|
+
supported_read_ops = ["select"]
|
|
774
|
+
if field.que_type not in ATTACHMENT_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
775
|
+
supported_read_ops.append("filter")
|
|
776
|
+
if field.que_type not in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
777
|
+
supported_read_ops.append("sort")
|
|
778
|
+
return {
|
|
779
|
+
"writable": writable,
|
|
780
|
+
"write_kind": write_kind,
|
|
781
|
+
"supported_read_ops": supported_read_ops,
|
|
782
|
+
"supported_write_ops": supported_write_ops,
|
|
783
|
+
"requires_lookup": requires_lookup,
|
|
784
|
+
"requires_upload": requires_upload,
|
|
785
|
+
"requires_existing_row_id": requires_existing_row_id,
|
|
786
|
+
"unsupported_reason": unsupported_reason,
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
def _schema_write_kind(self, kind: str) -> str:
|
|
790
|
+
mapping = {
|
|
791
|
+
"single_select": "select",
|
|
792
|
+
"multi_select": "select",
|
|
793
|
+
"member_list": "member",
|
|
794
|
+
"department_list": "department",
|
|
795
|
+
"relation_record": "relation",
|
|
796
|
+
"attachment_list": "attachment",
|
|
797
|
+
"subtable_rows": "subtable",
|
|
798
|
+
"unsupported_direct_write": "unsupported",
|
|
799
|
+
"boolean_label": "scalar",
|
|
800
|
+
"date_string": "scalar",
|
|
801
|
+
"scalar_text": "scalar",
|
|
802
|
+
}
|
|
803
|
+
return mapping.get(kind, "scalar")
|
|
804
|
+
|
|
478
805
|
def _schema_field_family(self, field: FormField) -> str:
|
|
479
806
|
if self._schema_is_identifier_like(field):
|
|
480
807
|
return "text"
|
|
@@ -2553,6 +2880,178 @@ class RecordTools(ToolBase):
|
|
|
2553
2880
|
"qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
|
|
2554
2881
|
}
|
|
2555
2882
|
|
|
2883
|
+
def _normalize_public_output_profile(self, output_profile: str) -> str:
|
|
2884
|
+
normalized = (output_profile or "normal").strip().lower()
|
|
2885
|
+
if normalized not in {"normal", "verbose"}:
|
|
2886
|
+
raise_tool_error(QingflowApiError.config_error("output_profile must be normal or verbose"))
|
|
2887
|
+
return normalized
|
|
2888
|
+
|
|
2889
|
+
def _normalize_record_list_where(self, where: list[JSONObject]) -> list[JSONObject]:
|
|
2890
|
+
normalized: list[JSONObject] = []
|
|
2891
|
+
for idx, item in enumerate(where):
|
|
2892
|
+
if not isinstance(item, dict):
|
|
2893
|
+
raise_tool_error(QingflowApiError.config_error(f"where[{idx}] must be an object"))
|
|
2894
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2895
|
+
if field_id is None:
|
|
2896
|
+
raise_tool_error(QingflowApiError.config_error(f"where[{idx}] requires field_id"))
|
|
2897
|
+
payload: JSONObject = {"field_id": field_id}
|
|
2898
|
+
if "op" in item:
|
|
2899
|
+
payload["op"] = item["op"]
|
|
2900
|
+
if "operator" in item:
|
|
2901
|
+
payload["operator"] = item["operator"]
|
|
2902
|
+
if "value" in item:
|
|
2903
|
+
payload["value"] = item["value"]
|
|
2904
|
+
elif "values" in item:
|
|
2905
|
+
payload["values"] = item["values"]
|
|
2906
|
+
normalized.append(payload)
|
|
2907
|
+
return normalized
|
|
2908
|
+
|
|
2909
|
+
def _normalize_record_list_order_by(self, order_by: list[JSONObject]) -> list[JSONObject]:
|
|
2910
|
+
normalized: list[JSONObject] = []
|
|
2911
|
+
for idx, item in enumerate(order_by):
|
|
2912
|
+
if not isinstance(item, dict):
|
|
2913
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}] must be an object"))
|
|
2914
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2915
|
+
if field_id is None:
|
|
2916
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}] requires field_id"))
|
|
2917
|
+
direction = _normalize_optional_text(item.get("direction", item.get("order"))) or "asc"
|
|
2918
|
+
if direction not in {"asc", "desc"}:
|
|
2919
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}].direction must be asc or desc"))
|
|
2920
|
+
normalized.append({"field_id": field_id, "direction": direction})
|
|
2921
|
+
return normalized
|
|
2922
|
+
|
|
2923
|
+
def _normalize_record_write_submit_type(self, submit_type: str | int) -> int:
|
|
2924
|
+
if isinstance(submit_type, int):
|
|
2925
|
+
if submit_type in {0, 1}:
|
|
2926
|
+
return submit_type
|
|
2927
|
+
raise_tool_error(QingflowApiError.config_error("submit_type must be 0, 1, save, or submit"))
|
|
2928
|
+
normalized = (submit_type or "submit").strip().lower()
|
|
2929
|
+
if normalized == "submit":
|
|
2930
|
+
return 1
|
|
2931
|
+
if normalized in {"save", "draft"}:
|
|
2932
|
+
return 0
|
|
2933
|
+
raise_tool_error(QingflowApiError.config_error("submit_type must be save or submit"))
|
|
2934
|
+
|
|
2935
|
+
def _normalize_record_write_clauses(self, clauses: list[JSONObject], *, location: str) -> list[JSONObject]:
|
|
2936
|
+
normalized: list[JSONObject] = []
|
|
2937
|
+
for idx, item in enumerate(clauses):
|
|
2938
|
+
if not isinstance(item, dict):
|
|
2939
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] must be an object"))
|
|
2940
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2941
|
+
if field_id is None:
|
|
2942
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] requires field_id"))
|
|
2943
|
+
payload: JSONObject = {"field_id": field_id}
|
|
2944
|
+
if "value" in item:
|
|
2945
|
+
payload["value"] = item["value"]
|
|
2946
|
+
elif "values" in item:
|
|
2947
|
+
payload["values"] = item["values"]
|
|
2948
|
+
else:
|
|
2949
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] requires value"))
|
|
2950
|
+
normalized.append(payload)
|
|
2951
|
+
return normalized
|
|
2952
|
+
|
|
2953
|
+
def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
|
|
2954
|
+
if not enabled:
|
|
2955
|
+
return None
|
|
2956
|
+
return {
|
|
2957
|
+
"required": True,
|
|
2958
|
+
"operation": operation,
|
|
2959
|
+
"message": "Read the current record first and confirm the exact target before applying this high-risk write.",
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
def _record_write_plan_response(
|
|
2963
|
+
self,
|
|
2964
|
+
raw_plan: JSONObject,
|
|
2965
|
+
*,
|
|
2966
|
+
operation: str,
|
|
2967
|
+
normalized_payload: JSONObject,
|
|
2968
|
+
output_profile: str,
|
|
2969
|
+
human_review: bool,
|
|
2970
|
+
) -> JSONObject:
|
|
2971
|
+
plan_data = cast(JSONObject, raw_plan.get("data", {}))
|
|
2972
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
2973
|
+
warnings_payload = validation.get("warnings", [])
|
|
2974
|
+
warnings = [{"code": "PLAN_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
2975
|
+
response: JSONObject = {
|
|
2976
|
+
"profile": raw_plan.get("profile"),
|
|
2977
|
+
"ws_id": raw_plan.get("ws_id"),
|
|
2978
|
+
"ok": bool(raw_plan.get("ok", True)),
|
|
2979
|
+
"request_route": raw_plan.get("request_route"),
|
|
2980
|
+
"warnings": warnings,
|
|
2981
|
+
"output_profile": output_profile,
|
|
2982
|
+
"data": {
|
|
2983
|
+
"action": {"operation": operation, "mode": "plan"},
|
|
2984
|
+
"resource": {"type": "record", "app_key": plan_data.get("app_key"), "record_id": plan_data.get("apply_id")},
|
|
2985
|
+
"verification": None,
|
|
2986
|
+
"normalized_payload": normalized_payload,
|
|
2987
|
+
"blockers": plan_data.get("blockers", []),
|
|
2988
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
2989
|
+
},
|
|
2990
|
+
}
|
|
2991
|
+
if output_profile == "verbose":
|
|
2992
|
+
response["data"]["debug"] = {
|
|
2993
|
+
"legacy_plan": plan_data,
|
|
2994
|
+
}
|
|
2995
|
+
return response
|
|
2996
|
+
|
|
2997
|
+
def _record_write_apply_response(
|
|
2998
|
+
self,
|
|
2999
|
+
raw_apply: JSONObject,
|
|
3000
|
+
*,
|
|
3001
|
+
operation: str,
|
|
3002
|
+
normalized_payload: JSONObject,
|
|
3003
|
+
output_profile: str,
|
|
3004
|
+
human_review: bool,
|
|
3005
|
+
) -> JSONObject:
|
|
3006
|
+
response: JSONObject = {
|
|
3007
|
+
"profile": raw_apply.get("profile"),
|
|
3008
|
+
"ws_id": raw_apply.get("ws_id"),
|
|
3009
|
+
"ok": bool(raw_apply.get("ok", True)),
|
|
3010
|
+
"request_route": raw_apply.get("request_route"),
|
|
3011
|
+
"warnings": [],
|
|
3012
|
+
"output_profile": output_profile,
|
|
3013
|
+
"data": {
|
|
3014
|
+
"action": {"operation": operation, "mode": "apply"},
|
|
3015
|
+
"resource": raw_apply.get("resource"),
|
|
3016
|
+
"verification": raw_apply.get("verification"),
|
|
3017
|
+
"normalized_payload": normalized_payload,
|
|
3018
|
+
"blockers": [],
|
|
3019
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
3020
|
+
},
|
|
3021
|
+
}
|
|
3022
|
+
if output_profile == "verbose":
|
|
3023
|
+
response["data"]["debug"] = {
|
|
3024
|
+
"legacy_result": raw_apply.get("result"),
|
|
3025
|
+
"status": raw_apply.get("status"),
|
|
3026
|
+
"write_verified": raw_apply.get("write_verified"),
|
|
3027
|
+
}
|
|
3028
|
+
return response
|
|
3029
|
+
|
|
3030
|
+
def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
|
|
3031
|
+
if not app_key:
|
|
3032
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3033
|
+
normalized_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
|
|
3034
|
+
if not normalized_ids:
|
|
3035
|
+
raise_tool_error(QingflowApiError.config_error("record_ids must contain at least one positive id"))
|
|
3036
|
+
|
|
3037
|
+
def runner(session_profile, context):
|
|
3038
|
+
result = self.backend.request(
|
|
3039
|
+
"DELETE",
|
|
3040
|
+
context,
|
|
3041
|
+
f"/app/{app_key}/apply",
|
|
3042
|
+
json_body={"type": DEFAULT_RECORD_LIST_TYPE, "applyIds": normalized_ids},
|
|
3043
|
+
)
|
|
3044
|
+
return {
|
|
3045
|
+
"profile": profile,
|
|
3046
|
+
"ws_id": session_profile.selected_ws_id,
|
|
3047
|
+
"request_route": self._request_route_payload(context),
|
|
3048
|
+
"result": result,
|
|
3049
|
+
"resource": {"type": "record", "apply_ids": normalized_ids},
|
|
3050
|
+
"ok": True,
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
return self._run_record_tool(profile, runner)
|
|
3054
|
+
|
|
2556
3055
|
def _resolve_field_selector(self, selector: str | int | None, index: FieldIndex, *, location: str) -> FormField:
|
|
2557
3056
|
if selector is None:
|
|
2558
3057
|
raise RecordInputError(
|
|
@@ -3349,7 +3848,7 @@ def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int |
|
|
|
3349
3848
|
def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
|
|
3350
3849
|
if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
|
|
3351
3850
|
return "当前仅返回样本,不适合最终统计结论。"
|
|
3352
|
-
return "
|
|
3851
|
+
return "record_list 适合浏览或导出明细;最终统计结论请改用 record_schema_get -> record_analyze。"
|
|
3353
3852
|
|
|
3354
3853
|
|
|
3355
3854
|
def _resolve_query_mode(
|