@qingflow-tech/qingflow-app-user-mcp 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +2 -1
  4. package/skills/qingflow-app-user/SKILL.md +2 -1
  5. package/skills/qingflow-app-user/references/data-gotchas.md +8 -4
  6. package/skills/qingflow-app-user/references/public-surface-sync.md +6 -4
  7. package/skills/qingflow-app-user/references/record-patterns.md +14 -4
  8. package/skills/qingflow-record-analysis/SKILL.md +103 -166
  9. package/skills/qingflow-record-analysis/agents/openai.yaml +2 -2
  10. package/skills/qingflow-record-analysis/references/analysis-gotchas.md +56 -110
  11. package/skills/qingflow-record-analysis/references/analysis-patterns.md +106 -119
  12. package/skills/qingflow-record-analysis/references/business-context.md +74 -0
  13. package/skills/qingflow-record-analysis/references/confidence-reporting.md +49 -72
  14. package/skills/qingflow-record-analysis/references/data-access-playbook.md +106 -0
  15. package/skills/qingflow-record-analysis/references/pandas-recipes.md +172 -0
  16. package/skills/qingflow-record-analysis/references/report-format.md +76 -0
  17. package/skills/qingflow-record-insert/SKILL.md +28 -7
  18. package/skills/qingflow-record-update/SKILL.md +1 -1
  19. package/src/qingflow_mcp/backend_client.py +55 -1
  20. package/src/qingflow_mcp/cli/commands/record.py +63 -6
  21. package/src/qingflow_mcp/cli/formatters.py +101 -1
  22. package/src/qingflow_mcp/public_surface.py +2 -1
  23. package/src/qingflow_mcp/response_trim.py +235 -10
  24. package/src/qingflow_mcp/server.py +19 -12
  25. package/src/qingflow_mcp/server_app_user.py +30 -13
  26. package/src/qingflow_mcp/tools/record_tools.py +13425 -8817
  27. package/skills/qingflow-record-analysis/references/dsl-templates.md +0 -93
@@ -183,7 +183,17 @@ def _format_app_get(result: dict[str, Any]) -> str:
183
183
  def _format_record_list(result: dict[str, Any]) -> str:
184
184
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
185
185
  items = data.get("items") if isinstance(data.get("items"), list) else []
186
- lines = [f"Returned Records: {len(items)}"]
186
+ lines: list[str] = []
187
+ lookup = result.get("lookup") if isinstance(result.get("lookup"), dict) else {}
188
+ if lookup:
189
+ lines.append("Lookup:")
190
+ lines.append(f"- query: {lookup.get('query')}")
191
+ lines.append(f"- confidence: {lookup.get('confidence')}")
192
+ lines.append(f"- next_action: {lookup.get('next_action')}")
193
+ lines.append(f"- total_count: {lookup.get('total_count')}")
194
+ lines.append(f"- returned_count: {lookup.get('returned_count')}")
195
+ lines.append(f"- truncated: {lookup.get('truncated')}")
196
+ lines.append(f"Returned Records: {len(items)}")
187
197
  for item in items[:10]:
188
198
  if isinstance(item, dict):
189
199
  lines.append(json.dumps(item, ensure_ascii=False))
@@ -194,6 +204,94 @@ def _format_record_list(result: dict[str, Any]) -> str:
194
204
  return "\n".join(lines) + "\n"
195
205
 
196
206
 
207
+ def _format_record_access(result: dict[str, Any]) -> str:
208
+ status = result.get("status") or "-"
209
+ lines = [
210
+ f"Status: {status}",
211
+ f"Rows: {result.get('row_count')}",
212
+ f"Complete: {result.get('complete')}",
213
+ f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
214
+ ]
215
+ if result.get("local_dir"):
216
+ lines.append(f"Local dir: {result.get('local_dir')}")
217
+ files = result.get("files") if isinstance(result.get("files"), list) else []
218
+ if files:
219
+ lines.append("Files:")
220
+ for item in files:
221
+ if isinstance(item, dict):
222
+ lines.append(f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)")
223
+ scope = result.get("scope") if isinstance(result.get("scope"), dict) else {}
224
+ if status == "needs_scope" and scope:
225
+ lines.append("Scope required:")
226
+ lines.append(f"- reported_total: {scope.get('reported_total')}")
227
+ lines.append(f"- estimated_pages: {scope.get('estimated_pages')}")
228
+ suggested = scope.get("suggested_time_fields") if isinstance(scope.get("suggested_time_fields"), list) else []
229
+ if suggested:
230
+ names = ", ".join(str(item.get("title") or item.get("field_id")) for item in suggested if isinstance(item, dict))
231
+ lines.append(f"- suggested_time_fields: {names}")
232
+ _append_warnings(lines, result.get("warnings"))
233
+ _append_verification(lines, result.get("verification"))
234
+ return "\n".join(lines) + "\n"
235
+
236
+
237
+ def _format_record_get(result: dict[str, Any]) -> str:
238
+ record = result.get("record") if isinstance(result.get("record"), dict) else {}
239
+ app = result.get("app") if isinstance(result.get("app"), dict) else {}
240
+ view = result.get("view") if isinstance(result.get("view"), dict) else {}
241
+ fields = result.get("fields") if isinstance(result.get("fields"), list) else []
242
+ data_logs = result.get("data_logs") if isinstance(result.get("data_logs"), dict) else {}
243
+ workflow_logs = result.get("workflow_logs") if isinstance(result.get("workflow_logs"), dict) else {}
244
+ media_assets = result.get("media_assets") if isinstance(result.get("media_assets"), dict) else {}
245
+ media_items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
246
+ downloaded_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
247
+ failed_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
248
+ file_assets = result.get("file_assets") if isinstance(result.get("file_assets"), dict) else {}
249
+ file_items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
250
+ downloaded_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
251
+ failed_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
252
+ extracted_files = [
253
+ item
254
+ for item in downloaded_files
255
+ if isinstance(item.get("extraction"), dict) and item["extraction"].get("status") == "ok"
256
+ ]
257
+ associated_resources = result.get("associated_resources") if isinstance(result.get("associated_resources"), list) else []
258
+ unavailable_context = result.get("unavailable_context") if isinstance(result.get("unavailable_context"), list) else []
259
+ lines = [
260
+ f"Status: {result.get('status') or '-'}",
261
+ f"App: {app.get('app_name') or app.get('app_key') or '-'} ({app.get('app_key') or '-'})",
262
+ f"View: {view.get('name') or view.get('view_id') or '-'}",
263
+ f"Record: {record.get('title') or '-'} ({record.get('record_id') or '-'})",
264
+ f"Fields: {len(fields)}",
265
+ f"Data logs: {data_logs.get('status') or '-'} / loaded={data_logs.get('items_loaded')}",
266
+ f"Workflow logs: {workflow_logs.get('status') or '-'} / loaded={workflow_logs.get('items_loaded')}",
267
+ f"Media assets: {media_assets.get('status') or '-'} / downloaded={len(downloaded_media)} / failed={len(failed_media)}",
268
+ f"File assets: {file_assets.get('status') or '-'} / downloaded={len(downloaded_files)} / extracted={len(extracted_files)} / failed={len(failed_files)}",
269
+ f"Associated resources: {len(associated_resources)}",
270
+ f"Unavailable contexts: {len(unavailable_context)}",
271
+ ]
272
+ if media_assets.get("local_dir"):
273
+ lines.append(f"Media dir: {media_assets.get('local_dir')}")
274
+ if file_assets.get("local_dir"):
275
+ lines.append(f"File dir: {file_assets.get('local_dir')}")
276
+ if failed_media:
277
+ failure_counts: dict[str, int] = {}
278
+ for item in failed_media:
279
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
280
+ failure_counts[key] = failure_counts.get(key, 0) + 1
281
+ lines.append("Media failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
282
+ if failed_files:
283
+ failure_counts = {}
284
+ for item in failed_files:
285
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
286
+ failure_counts[key] = failure_counts.get(key, 0) + 1
287
+ lines.append("File failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
288
+ summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
289
+ if summary.get("text"):
290
+ lines.append(f"Summary: {summary.get('text')}")
291
+ _append_warnings(lines, result.get("warnings"))
292
+ return "\n".join(lines) + "\n"
293
+
294
+
197
295
  def _format_task_list(result: dict[str, Any]) -> str:
198
296
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
199
297
  items = data.get("items") if isinstance(data.get("items"), list) else []
@@ -693,6 +791,8 @@ _FORMATTERS = {
693
791
  "app_search": _format_app_items,
694
792
  "app_get": _format_app_get,
695
793
  "record_list": _format_record_list,
794
+ "record_access": _format_record_access,
795
+ "record_get": _format_record_get,
696
796
  "task_list": _format_task_list,
697
797
  "task_workbench": _format_task_workbench,
698
798
  "task_get": _format_task_get,
@@ -80,8 +80,9 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
80
80
  ),
81
81
  PublicToolSpec(USER_DOMAIN, "record_member_candidates", ("record_member_candidates",), cli_public=False),
82
82
  PublicToolSpec(USER_DOMAIN, "record_department_candidates", ("record_department_candidates",), cli_public=False),
83
- PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze")),
83
+ PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze"), mcp_public=False),
84
84
  PublicToolSpec(USER_DOMAIN, "record_list", ("record_list",), ("record", "list"), cli_show_effective_context=True),
85
+ PublicToolSpec(USER_DOMAIN, "record_access", ("record_access",), ("record", "access"), cli_show_effective_context=True),
85
86
  PublicToolSpec(USER_DOMAIN, "record_get", ("record_get_public",), ("record", "get"), cli_show_effective_context=True),
86
87
  PublicToolSpec(USER_DOMAIN, "record_insert", ("record_insert_public",), ("record", "insert"), cli_show_effective_context=True, cli_context_write=True),
87
88
  PublicToolSpec(USER_DOMAIN, "record_update", ("record_update_public",), ("record", "update"), cli_show_effective_context=True, cli_context_write=True),
@@ -57,6 +57,13 @@ def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict
57
57
  if not isinstance(payload, dict):
58
58
  return payload
59
59
  if _looks_like_failure_payload(payload):
60
+ status = str(payload.get("status") or "").lower()
61
+ if tool_name in {"user:record_insert", "user:record_update"} and status in {
62
+ "blocked",
63
+ "needs_confirmation",
64
+ "partial_success",
65
+ }:
66
+ return trim_success_response(tool_name, payload)
60
67
  return _trim_returned_failure(payload)
61
68
  return trim_success_response(tool_name, payload)
62
69
 
@@ -65,7 +72,10 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
65
72
  if not isinstance(payload, dict):
66
73
  return payload
67
74
  trimmed = deepcopy(payload)
68
- _drop_top_keys(trimmed, COMMON_SUCCESS_DROP_TOP)
75
+ drop_keys = COMMON_SUCCESS_DROP_TOP
76
+ if tool_name == "user:record_get":
77
+ drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
78
+ _drop_top_keys(trimmed, drop_keys)
69
79
  transformer = SUCCESS_POLICY_BY_TOOL.get(tool_name or "")
70
80
  if transformer is not None:
71
81
  transformer(trimmed)
@@ -137,7 +147,7 @@ def _looks_like_failure_payload(payload: dict[str, Any]) -> bool:
137
147
  if payload.get("ok") is False:
138
148
  return True
139
149
  status = str(payload.get("status") or "").lower()
140
- return status in {"failed", "blocked", "verification_failed"}
150
+ return status in {"failed", "blocked"}
141
151
 
142
152
 
143
153
  def _trim_returned_failure(payload: dict[str, Any]) -> dict[str, Any]:
@@ -365,6 +375,9 @@ def _trim_record_write(payload: JSONObject) -> None:
365
375
  data = payload.get("data")
366
376
  if not isinstance(data, dict):
367
377
  return
378
+ if payload.get("mode") == "batch" or data.get("mode") == "batch":
379
+ _trim_record_write_batch(payload, data)
380
+ return
368
381
  data.pop("debug", None)
369
382
  data.pop("normalized_payload", None)
370
383
  data.pop("human_review", None)
@@ -394,7 +407,48 @@ def _trim_record_write(payload: JSONObject) -> None:
394
407
  data.pop(key, None)
395
408
 
396
409
 
410
+ def _trim_record_write_batch(payload: JSONObject, data: JSONObject) -> None:
411
+ data.pop("items", None)
412
+ data.pop("debug", None)
413
+ for key in ("summary", "created_record_ids", "app_key", "mode"):
414
+ if data.get(key) in (None, [], {}, ""):
415
+ data.pop(key, None)
416
+ items = payload.get("items")
417
+ if isinstance(items, list):
418
+ payload["items"] = [
419
+ _pick(
420
+ item,
421
+ (
422
+ "index",
423
+ "row_number",
424
+ "status",
425
+ "record_id",
426
+ "apply_id",
427
+ "write_executed",
428
+ "verification_status",
429
+ "safe_to_retry",
430
+ "failed_fields",
431
+ "confirmation_requests",
432
+ "blockers",
433
+ "error",
434
+ "warnings",
435
+ "resource",
436
+ "verification",
437
+ ),
438
+ )
439
+ for item in items
440
+ if isinstance(item, dict)
441
+ ]
442
+ for key in ("items", "created_record_ids"):
443
+ value = payload.get(key)
444
+ if value in (None, [], {}, ""):
445
+ payload.pop(key, None)
446
+
447
+
397
448
  def _trim_record_get(payload: JSONObject) -> None:
449
+ if payload.get("fields") is not None or payload.get("semantic_context") is not None:
450
+ _trim_detail_context_record_get(payload)
451
+ return
398
452
  data = payload.get("data")
399
453
  if not isinstance(data, dict):
400
454
  return
@@ -417,22 +471,142 @@ def _trim_record_get(payload: JSONObject) -> None:
417
471
  payload["data"] = compact
418
472
 
419
473
 
474
+ def _trim_detail_context_record_get(payload: JSONObject) -> None:
475
+ _trim_item_list(
476
+ payload,
477
+ "fields",
478
+ allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids", "file_asset_ids"),
479
+ )
480
+ references = payload.get("references")
481
+ if isinstance(references, list):
482
+ compact_refs: list[JSONObject] = []
483
+ for item in references:
484
+ if not isinstance(item, dict):
485
+ continue
486
+ compact = _pick(
487
+ item,
488
+ (
489
+ "field_id",
490
+ "field_title",
491
+ "target_app_key",
492
+ "target_record_id",
493
+ "target_title",
494
+ "target_detail_completeness",
495
+ "self_reference",
496
+ ),
497
+ )
498
+ target_fields = item.get("target_fields") if isinstance(item.get("target_fields"), list) else []
499
+ if target_fields:
500
+ compact["target_fields"] = [
501
+ _pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids", "file_asset_ids"))
502
+ for field in target_fields
503
+ if isinstance(field, dict)
504
+ ]
505
+ compact_refs.append(compact)
506
+ payload["references"] = compact_refs
507
+ media_assets = payload.get("media_assets")
508
+ if isinstance(media_assets, dict):
509
+ compact_media = _pick(media_assets, ("status", "local_dir", "warnings"))
510
+ items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
511
+ compact_media["items"] = [
512
+ _pick(
513
+ item,
514
+ (
515
+ "asset_id",
516
+ "kind",
517
+ "source",
518
+ "field_id",
519
+ "field_title",
520
+ "local_path",
521
+ "access_status",
522
+ "readable_by_agent",
523
+ "mime_type",
524
+ "size_bytes",
525
+ "download_strategy",
526
+ "storage_auth_type",
527
+ "storage_cookie_prefix",
528
+ "redirected",
529
+ ),
530
+ )
531
+ for item in items
532
+ if isinstance(item, dict)
533
+ ]
534
+ payload["media_assets"] = compact_media
535
+ file_assets = payload.get("file_assets")
536
+ if isinstance(file_assets, dict):
537
+ compact_files = _pick(file_assets, ("status", "local_dir", "warnings"))
538
+ items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
539
+ compact_files["items"] = [
540
+ _pick(
541
+ item,
542
+ (
543
+ "file_asset_id",
544
+ "media_asset_id",
545
+ "kind",
546
+ "source",
547
+ "field_id",
548
+ "field_title",
549
+ "file_name",
550
+ "local_path",
551
+ "access_status",
552
+ "readable_by_agent",
553
+ "mime_type",
554
+ "size_bytes",
555
+ "download_strategy",
556
+ "storage_auth_type",
557
+ "storage_cookie_prefix",
558
+ "redirected",
559
+ "extraction",
560
+ ),
561
+ )
562
+ for item in items
563
+ if isinstance(item, dict)
564
+ ]
565
+ payload["file_assets"] = compact_files
566
+ for key in ("data_logs", "workflow_logs"):
567
+ node = payload.get(key)
568
+ if not isinstance(node, dict):
569
+ continue
570
+ compact_node = _pick(
571
+ node,
572
+ (
573
+ "status",
574
+ "visible",
575
+ "reason",
576
+ "page",
577
+ "page_size",
578
+ "items_loaded",
579
+ "reported_total",
580
+ "has_more",
581
+ "complete",
582
+ "items",
583
+ ),
584
+ )
585
+ payload[key] = compact_node
586
+ associated_resources = payload.get("associated_resources")
587
+ if isinstance(associated_resources, list):
588
+ payload["associated_resources"] = [
589
+ _pick(item, ("type", "name", "app_key", "app_name", "view_key", "chart_key", "view_type", "data_access"))
590
+ for item in associated_resources
591
+ if isinstance(item, dict)
592
+ ]
593
+ _drop_deep_keys(payload, {"raw", "debug"})
594
+
595
+
420
596
  def _trim_record_list(payload: JSONObject) -> None:
421
597
  data = payload.get("data")
422
598
  if not isinstance(data, dict):
423
599
  return
424
600
  pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
425
- returned_items = pagination.get("returned_items")
426
- result_amount = pagination.get("result_amount")
427
- limit = pagination.get("limit")
428
- truncated = False
429
- if isinstance(result_amount, int) and isinstance(returned_items, int):
601
+ returned_items = pagination.get("returned_count", pagination.get("returned_items"))
602
+ result_amount = pagination.get("total_count", pagination.get("result_amount"))
603
+ truncated = bool(pagination.get("truncated"))
604
+ if not truncated and isinstance(result_amount, int) and isinstance(returned_items, int):
430
605
  truncated = result_amount > returned_items
431
606
  compact_pagination = {
432
607
  "loaded": True,
433
- "page_size": limit,
434
- "fetched_pages": 1,
435
- "reported_total": result_amount,
608
+ "returned_count": returned_items,
609
+ "total_count": result_amount,
436
610
  "truncated": truncated,
437
611
  }
438
612
  selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
@@ -445,6 +619,43 @@ def _trim_record_list(payload: JSONObject) -> None:
445
619
  if view:
446
620
  compact["view"] = _pick(view, ("view_id", "name"))
447
621
  payload["data"] = compact
622
+ lookup = payload.get("lookup") if isinstance(payload.get("lookup"), dict) else {}
623
+ if lookup:
624
+ payload["lookup"] = {
625
+ "mode": lookup.get("mode"),
626
+ "query": lookup.get("query"),
627
+ "total_count": lookup.get("total_count", lookup.get("reported_total")),
628
+ "returned_count": lookup.get("returned_count"),
629
+ "truncated": lookup.get("truncated"),
630
+ "confidence": lookup.get("confidence"),
631
+ "next_action": lookup.get("next_action"),
632
+ }
633
+
634
+
635
+ def _trim_record_access(payload: JSONObject) -> None:
636
+ compact: dict[str, Any] = {}
637
+ for key in (
638
+ "ok",
639
+ "status",
640
+ "app_key",
641
+ "view_id",
642
+ "format",
643
+ "local_dir",
644
+ "row_count",
645
+ "complete",
646
+ "truncated",
647
+ "safe_for_final_conclusion",
648
+ "files",
649
+ "fields",
650
+ "warnings",
651
+ "verification",
652
+ "scope",
653
+ ):
654
+ value = payload.get(key)
655
+ if value is not None:
656
+ compact[key] = value
657
+ payload.clear()
658
+ payload.update(compact)
448
659
 
449
660
 
450
661
  def _trim_record_analyze(payload: JSONObject) -> None:
@@ -567,6 +778,19 @@ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) ->
567
778
  compact["required"] = bool(item.get("required"))
568
779
  if template_map is not None and isinstance(title, str) and title in template_map:
569
780
  compact["template"] = template_map.get(title)
781
+ for key in (
782
+ "format_hint",
783
+ "example_value",
784
+ "linkage",
785
+ "may_become_required",
786
+ "activation_sources",
787
+ "requirement_reason",
788
+ "accepts_natural_input",
789
+ "requires_upload",
790
+ ):
791
+ value = item.get(key)
792
+ if value not in (None, [], {}, ""):
793
+ compact[key] = value
570
794
  candidate_hint = item.get("candidate_hint")
571
795
  if isinstance(candidate_hint, dict):
572
796
  compact["candidate_hint"] = candidate_hint
@@ -783,6 +1007,7 @@ _register_policy(
783
1007
  _register_policy((USER_DOMAIN,), ("record_insert", "record_update"), _trim_record_write)
784
1008
  _register_policy((USER_DOMAIN,), ("record_get",), _trim_record_get)
785
1009
  _register_policy((USER_DOMAIN,), ("record_list",), _trim_record_list)
1010
+ _register_policy((USER_DOMAIN,), ("record_access",), _trim_record_access)
786
1011
  _register_policy((USER_DOMAIN,), ("record_analyze",), _trim_record_analyze)
787
1012
  _register_policy((USER_DOMAIN,), ("record_code_block_run",), _trim_code_block_run)
788
1013
  _register_policy((USER_DOMAIN,), ("task_list",), _trim_task_list)
@@ -50,7 +50,7 @@ All resource tools operate with the logged-in user's Qingflow permissions.
50
50
 
51
51
  If `app_key` is unknown, use `app_list` or `app_search` first.
52
52
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
53
- If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
53
+ If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
54
54
  `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
55
55
 
56
56
  ## Schema-First Rule
@@ -58,7 +58,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
58
58
  Call `record_insert_schema_get` before `record_insert`.
59
59
  Call `record_update_schema_get` before `record_update`.
60
60
  Call `record_code_block_schema_get` before `record_code_block_run`.
61
- Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
61
+ Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, or `record_get`.
62
62
  Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
63
63
 
64
64
  - All `field_id` values must come from the schema response.
@@ -68,7 +68,8 @@ Call `record_import_schema_get` when the import field mapping is unclear before
68
68
 
69
69
  `record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
70
70
  `record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
71
- `record_browse_schema_get(view_id=...)` returns browse-schema fields for the selected accessible view.
71
+ `record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
72
+ `record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema.
72
73
  `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
73
74
  `record_import_schema_get` returns import-ready column metadata.
74
75
 
@@ -78,16 +79,17 @@ Call `record_import_schema_get` when the import field mapping is unclear before
78
79
 
79
80
  ## Analytics Path
80
81
 
81
- `app_get -> record_browse_schema_get(view_id=...) -> record_analyze`
82
+ `app_get -> record_browse_schema_get(view_id=...) -> record_access -> Python`
82
83
 
83
84
  Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
84
85
 
86
+ Use `record_access` to write local CSV shard files for analysis, then use Python to compute counts, rankings, ratios, trends, and final conclusions.
87
+
85
88
  Use this DSL shape:
86
89
 
87
- - `dimensions`: `{{field_id, alias, bucket}}`
88
- - `metrics`: `{{op, field_id, alias}}`
89
- - `filters`: `{{field_id, op, value}}`
90
- - `sort`: `{{by, order}}`
90
+ - `columns`: `[{{field_id}}]`
91
+ - `where`: `[{{field_id, op, value}}]`
92
+ - `order_by`: `[{{field_id, direction}}]`
91
93
 
92
94
  Important key rules:
93
95
 
@@ -102,17 +104,18 @@ Analysis answers must include concrete numbers. When applicable, include percent
102
104
  ## Record CRUD Path
103
105
 
104
106
  `app_get -> record_browse_schema_get(view_id=...) -> record_list / record_get`
105
- `record_insert_schema_get -> record_insert`
107
+ `record_insert_schema_get -> record_insert(items)`
106
108
  `record_update_schema_get -> record_update`
107
109
  `record_list / record_get -> record_delete`
108
110
  `record_code_block_schema_get -> record_code_block_run`
109
111
 
110
112
  - Use `columns` as `[{{field_id}}]`
113
+ - Use `record_list(query=..., query_fields=[{{field_id}}])` for fuzzy single-record lookup, then follow `lookup.next_action`; `query_fields` is search scope and `columns` is display shape.
111
114
  - Use `where` items as `{{field_id, op, value}}`
112
115
  - Use `order_by` items as `{{field_id, direction}}`
113
116
  - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
114
117
 
115
- - `record_insert` uses an applicant-node `fields` map keyed by field title.
118
+ - `record_insert` defaults to an applicant-node `items` array; each item contains a field-title keyed `fields` map. A single insert is one item.
116
119
  - `record_update` uses a field-title keyed `fields` map and internally selects the first accessible view that can execute the current payload.
117
120
  - For insert, `runtime_linked_required_fields` means required-but-not-directly-writable fields that are usually supplied by runtime linkage or upstream context.
118
121
  - For insert, fields marked `may_become_required=true` stay in `optional_fields`; they are still directly writable, but linked visibility or option-driven rules can make them required at runtime.
@@ -121,10 +124,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
121
124
  - `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
122
125
  - `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
123
126
  - `record_delete` deletes by `record_id` or `record_ids`.
124
- - When readback shape matters after insert or update, prefer `record_get(..., output_profile="normalized")` or `record_list(..., output_profile="normalized")`.
127
+ - `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
128
+ - Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
129
+ - `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
130
+ - When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
125
131
 
126
132
  - Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
127
- - Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
133
+ - Member, department, and relation fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to candidate tools when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
134
+ - For batch insert `partial_success`, read `created_record_ids`, failed `items[].row_number`, and `failed_fields`; repair only failed rows and never retry the whole batch after any row has `write_executed=true`.
128
135
  - If explicit candidate browsing is needed for default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
129
136
 
130
137
  ## Code Block Path