@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.
@@ -192,155 +192,88 @@ class RecordTools(ToolBase):
192
192
 
193
193
  @mcp.tool(
194
194
  description=(
195
- "Static preflight for record create/update payloads. Supports ergonomic fields{} mapping, resolves field titles, "
196
- "and reports blockers before submit."
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 record_write_plan(
200
+ def record_list(
200
201
  profile: str = DEFAULT_PROFILE,
201
- operation: str = "auto",
202
202
  app_key: str = "",
203
- apply_id: int | None = None,
204
- answers: list[JSONObject] | None = None,
205
- fields: JSONObject | None = None,
206
- force_refresh_form: bool = False,
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.record_write_plan(
212
+ return self.record_list(
209
213
  profile=profile,
210
- operation=operation,
211
214
  app_key=app_key,
212
- apply_id=apply_id,
213
- answers=answers or [],
214
- fields=fields or {},
215
- force_refresh_form=force_refresh_form,
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
- description=(
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
- apply_id: int | None = None,
231
- page_num: int = 1,
232
- query_key: str | None = None,
233
- filters: list[JSONObject] | None = None,
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
- paging = _fixed_list_scan_policy()
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
- apply_id=apply_id,
249
- page_num=page_num,
250
- page_size=int(paging["page_size"]),
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
- "Create one record. Supports explicit answers[] and ergonomic fields{} mapping by exact field title or queId. "
273
- "Use record_write_plan first for complex payloads."
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 record_create(
250
+ def record_write(
277
251
  profile: str = DEFAULT_PROFILE,
278
252
  app_key: str = "",
279
- answers: list[JSONObject] | None = None,
280
- fields: JSONObject | None = None,
281
- submit_type: int = 1,
282
- verify_write: bool = False,
283
- force_refresh_form: bool = False,
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.record_create(
263
+ return self.record_write(
286
264
  profile=profile,
287
265
  app_key=app_key,
288
- answers=answers or [],
289
- fields=fields or {},
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
- force_refresh_form=force_refresh_form,
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 "record_query(list) 适合浏览或导出明细;最终统计结论请改用 record_schema_get -> record_analyze。"
3851
+ return "record_list 适合浏览或导出明细;最终统计结论请改用 record_schema_get -> record_analyze。"
3353
3852
 
3354
3853
 
3355
3854
  def _resolve_query_mode(