@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
@@ -0,0 +1,106 @@
1
+ # Data Access Playbook
2
+
3
+ This file is the operational state machine for `record_access`.
4
+
5
+ ## Required Sequence
6
+
7
+ 1. `app_get`
8
+ 2. choose `view_id` from `accessible_views`
9
+ 3. `record_browse_schema_get(app_key, view_id)`
10
+ 4. build `record_access.columns / where / order_by` from field ids
11
+ 5. run `record_access`
12
+ 6. read every CSV shard with Python
13
+
14
+ Do not call `record_access` with field titles, guessed ids, page controls, row limits, or profile.
15
+ CSV columns are readable and field-id anchored, for example `项目状态__field_343283094`; do not look for extra `schema.json` or README files.
16
+
17
+ ## Finding `record_access`
18
+
19
+ `record_access` can be available as an MCP tool or as a CLI subcommand. If the MCP surface does not show a top-level `record_access` tool, look under the Qingflow CLI record group before choosing any other path:
20
+
21
+ ```bash
22
+ qingflow record --help
23
+ qingflow record access --help
24
+ ```
25
+
26
+ The CLI call is:
27
+
28
+ ```bash
29
+ qingflow record access \
30
+ --app-key APP_KEY \
31
+ --view-id VIEW_ID \
32
+ --columns-file columns.json \
33
+ --where-file where.json \
34
+ --order-by-file order_by.json \
35
+ --json
36
+ ```
37
+
38
+ This is the same analysis path: it writes CSV shards and metadata for Python. Do not replace it with list browsing, export, QingBI, or aggregate helpers just because the MCP tool is not visible.
39
+
40
+ ## Request Patterns
41
+
42
+ ### Count or distribution
43
+
44
+ Fetch the grouping field and any time/business filter field.
45
+
46
+ ```json
47
+ {
48
+ "app_key": "APP_KEY",
49
+ "view_id": "system:all",
50
+ "columns": [{ "field_id": 18 }],
51
+ "where": [{ "field_id": 2, "op": "between", "value": ["2026-05-01", "2026-05-31"] }],
52
+ "order_by": []
53
+ }
54
+ ```
55
+
56
+ ### Trend
57
+
58
+ Fetch the date/time field plus metric fields.
59
+
60
+ ```json
61
+ {
62
+ "app_key": "APP_KEY",
63
+ "view_id": "system:all",
64
+ "columns": [{ "field_id": 2 }, { "field_id": 18 }],
65
+ "where": [{ "field_id": 2, "op": "between", "value": ["2026-01-01", "2026-12-31"] }],
66
+ "order_by": [{ "field_id": 2, "direction": "asc" }]
67
+ }
68
+ ```
69
+
70
+ ### Ratio
71
+
72
+ If numerator and denominator use different filters, run separate `record_access` calls. Only compute the ratio after both source datasets are complete and compatible.
73
+
74
+ ## Status Decisions
75
+
76
+ | Status | Meaning | Agent action |
77
+ |---|---|---|
78
+ | `success` + `safe_for_final_conclusion=true` | Full retrieved scope is reliable | Give final conclusion |
79
+ | `needs_scope` | Tool refused large unbounded scan, no CSV | Ask for scope or retry with explicit period/business filter |
80
+ | `partial` | Some CSV files written, but not full data | Give only subset observation |
81
+ | `complete=false` | Not all requested data is available | Do not present full-population conclusion |
82
+ | `truncated=true` | Tool had to stop before full scope | Disclose and narrow scope |
83
+
84
+ ## `needs_scope` Recovery
85
+
86
+ Use the returned `scope` object:
87
+
88
+ - `reported_total`: explain why scope is needed
89
+ - `suggested_time_fields`: choose likely date fields
90
+ - `recommended_where_examples`: reuse if they match the user request
91
+
92
+ If the user already provided a concrete month/quarter/year, retry with that period. If no business boundary is available, ask one short clarification.
93
+
94
+ ## `partial` Recovery
95
+
96
+ You may read the files, but must label output as partial:
97
+
98
+ - say which files/rows were analyzed
99
+ - do not use `全部`, `所有`, `整体`, or `全量`
100
+ - suggest narrowing time or business scope before final conclusion
101
+
102
+ ## View Scope
103
+
104
+ For custom views, the result is scoped to that saved view. If `verification.view_filter_verified=false`, disclose that the saved-filter scope could not be fully verified.
105
+
106
+ For board/gantt views, switch to a table-style view or `system:all` plus explicit filters.
@@ -0,0 +1,172 @@
1
+ # Pandas Recipes
2
+
3
+ Use Python to read returned CSV shards. Never paste raw CSV into the model context.
4
+
5
+ ## Load All Shards
6
+
7
+ ```python
8
+ import pandas as pd
9
+
10
+ files = [
11
+ "/absolute/path/records-0001.csv",
12
+ # include every record_access.files[].local_path
13
+ ]
14
+
15
+ frames = [pd.read_csv(path, dtype=str, keep_default_na=False) for path in files]
16
+ df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
17
+
18
+ # Columns are readable and field-id anchored, e.g. 项目状态__field_343283094.
19
+ fields = [] # optionally paste record_access.fields here if you need field-id metadata
20
+ field_by_id = {int(item["field_id"]): item for item in fields if "field_id" in item}
21
+ title_by_col = {item["column_name"]: item["title"] for item in fields if item.get("column_name")}
22
+ ```
23
+
24
+ ## Field Quality Profile
25
+
26
+ Run this before choosing final grouping dimensions.
27
+
28
+ ```python
29
+ def field_quality(frame: pd.DataFrame, *, date_col: str | None = None) -> pd.DataFrame:
30
+ rows = []
31
+ for col in frame.columns:
32
+ blank = frame[col].astype(str).eq("")
33
+ item = {
34
+ "column": col,
35
+ "row_count": len(frame),
36
+ "blank_count": int(blank.sum()),
37
+ "blank_rate": float(blank.mean()) if len(frame) else 0.0,
38
+ "distinct_count": int(frame[col].replace("", pd.NA).nunique(dropna=True)),
39
+ }
40
+ rows.append(item)
41
+ quality = pd.DataFrame(rows).sort_values(["blank_rate", "distinct_count"], ascending=[False, False])
42
+ if date_col and date_col in frame.columns:
43
+ tmp = frame.copy()
44
+ tmp["_period"] = pd.to_datetime(tmp[date_col], errors="coerce").dt.to_period("M").astype(str)
45
+ period_quality = []
46
+ for col in frame.columns:
47
+ if col == date_col:
48
+ continue
49
+ by_period = tmp.groupby("_period")[col].apply(lambda s: float(s.astype(str).eq("").mean()))
50
+ period_quality.append({"column": col, "max_period_blank_rate": float(by_period.max()) if len(by_period) else 0.0})
51
+ quality = quality.merge(pd.DataFrame(period_quality), on="column", how="left")
52
+ return quality
53
+ ```
54
+
55
+ Quality gates:
56
+
57
+ - `blank_rate > 0.4`: do not use as the primary conclusion dimension.
58
+ - `max_period_blank_rate > 0.8`: do not use for period comparison.
59
+ - Very high `distinct_count` fields are usually identifiers or descriptions, not grouping dimensions.
60
+ - High-missing dimensions may still be reported as `已填写样本观察`.
61
+
62
+ ## Column Selection
63
+
64
+ Prefer exact readable CSV columns. Use suffix matching only when you need to address a field id programmatically.
65
+
66
+ ```python
67
+ def col_by_field_id(frame, field_id: int) -> str:
68
+ suffix = f"__field_{field_id}"
69
+ matches = [col for col in frame.columns if col.endswith(suffix)]
70
+ if not matches:
71
+ raise KeyError(f"field_id not in CSV: {field_id}")
72
+ return matches[0]
73
+ ```
74
+
75
+ ## Count Distribution
76
+
77
+ ```python
78
+ col = "项目状态__field_18"
79
+ quality = field_quality(df)
80
+ blank_rate = quality.loc[quality["column"].eq(col), "blank_rate"].iloc[0]
81
+ if blank_rate > 0.4:
82
+ print(f"Use only as filled-sample observation: {col} blank_rate={blank_rate:.1%}")
83
+ dist = (
84
+ df[col]
85
+ .replace("", pd.NA)
86
+ .fillna("未填写")
87
+ .value_counts(dropna=False)
88
+ .rename_axis("group")
89
+ .reset_index(name="count")
90
+ )
91
+ dist["share"] = dist["count"] / dist["count"].sum()
92
+ ```
93
+
94
+ ## Numeric Aggregation
95
+
96
+ ```python
97
+ group_col = "项目状态__field_18"
98
+ amount_col = "金额__field_25"
99
+ tmp = df.copy()
100
+ tmp[amount_col] = (
101
+ tmp[amount_col]
102
+ .str.replace(",", "", regex=False)
103
+ .str.replace("¥", "", regex=False)
104
+ .pipe(pd.to_numeric, errors="coerce")
105
+ )
106
+ summary = (
107
+ tmp.groupby(group_col, dropna=False)[amount_col]
108
+ .agg(count="count", total="sum", avg="mean")
109
+ .reset_index()
110
+ .sort_values("total", ascending=False)
111
+ )
112
+ ```
113
+
114
+ ## Date Trend
115
+
116
+ ```python
117
+ date_col = "申请时间__field_2"
118
+ tmp = df.copy()
119
+ tmp[date_col] = pd.to_datetime(tmp[date_col], errors="coerce")
120
+ tmp = tmp.dropna(subset=[date_col])
121
+ tmp["month"] = tmp[date_col].dt.to_period("M").astype(str)
122
+ trend = tmp.groupby("month").size().reset_index(name="count")
123
+ ```
124
+
125
+ ## Year-Over-Year Month Comparison
126
+
127
+ ```python
128
+ date_col = "申请时间__field_2"
129
+ tmp = df.copy()
130
+ tmp[date_col] = pd.to_datetime(tmp[date_col], errors="coerce")
131
+ tmp = tmp.dropna(subset=[date_col])
132
+ tmp["year"] = tmp[date_col].dt.year
133
+ tmp["month"] = tmp[date_col].dt.month
134
+ monthly = tmp.groupby(["year", "month"]).size().reset_index(name="count")
135
+ ```
136
+
137
+ ## Ratio
138
+
139
+ ```python
140
+ numerator = len(df[df["项目状态__field_18"].eq("已成交")])
141
+ denominator = len(df)
142
+ ratio = numerator / denominator if denominator else None
143
+ ```
144
+
145
+ Always report numerator and denominator.
146
+
147
+ ## Multi-Select Cells
148
+
149
+ If values are serialized with delimiters, inspect samples first. For simple comma-separated values:
150
+
151
+ ```python
152
+ col = "标签__field_30"
153
+ exploded = (
154
+ df.assign(_value=df[col].str.split(","))
155
+ .explode("_value")
156
+ )
157
+ exploded["_value"] = exploded["_value"].str.strip()
158
+ multi_dist = exploded["_value"].value_counts().reset_index(name="count")
159
+ ```
160
+
161
+ ## Business Mapping
162
+
163
+ ```python
164
+ mapping = {
165
+ "烈焰组": "北斗部门",
166
+ "飓风组": "北斗部门",
167
+ }
168
+ department_col = "部门__field_40"
169
+ df["department_normalized"] = df[department_col].replace(mapping)
170
+ ```
171
+
172
+ State the mapping in the final answer.
@@ -0,0 +1,76 @@
1
+ # Report Format
2
+
3
+ Use this for user-facing analysis reports.
4
+
5
+ ## Short Answer
6
+
7
+ ```text
8
+ 结论:
9
+ - ...
10
+
11
+ 关键数据:
12
+ - 指标 A:...
13
+ - 指标 B:...
14
+
15
+ 口径与范围:
16
+ - 应用 / 视图:...
17
+ - 时间范围:...
18
+ - 字段:...
19
+ - 字段质量:...
20
+ - 业务映射:...
21
+ - 数据完整性:...
22
+
23
+ 限制:
24
+ - ...
25
+ ```
26
+
27
+ ## Detailed Report
28
+
29
+ ```text
30
+ 1. 分析范围
31
+ - app / view
32
+ - time range
33
+ - filters
34
+ - rows analyzed
35
+
36
+ 2. 核心结论
37
+ - concrete numbers first
38
+ - no vague adjectives without numbers
39
+
40
+ 3. 分项数据
41
+ - distribution / trend / ranking tables
42
+ - percentages with numerator and denominator
43
+
44
+ 4. 解释与建议
45
+ - separate facts from hypotheses
46
+
47
+ 5. 口径与可信度
48
+ - fields used
49
+ - field-quality gates and downgraded dimensions
50
+ - mapping rules
51
+ - completeness
52
+ - partial or unverified scope warnings
53
+ ```
54
+
55
+ ## Wording Rules
56
+
57
+ - Use `全量可信结论` only when the accessed scope is complete and safe.
58
+ - Use `初步观察` for partial or unverified data.
59
+ - Do not say `全部`, `所有`, `整体`, or `完整` when `safe_for_final_conclusion=false`.
60
+ - For ratios, always show `numerator / denominator`.
61
+ - For comparisons, show both periods' absolute values and the delta.
62
+
63
+ ## Comparison Template
64
+
65
+ ```text
66
+ 今年5月 vs 去年5月:
67
+ - 记录数:今年 X,去年 Y,变化 +Z(+P%)
68
+ - 金额:今年 X,去年 Y,变化 +Z(+P%)
69
+ - 结构变化:...
70
+
71
+ 口径:
72
+ - 时间字段:...
73
+ - 部门字段:...
74
+ - 部门映射:...
75
+ - 数据完整性:...
76
+ ```
@@ -9,7 +9,9 @@ metadata:
9
9
 
10
10
  ## Default Path
11
11
 
12
- `record_insert_schema_get -> (optional candidate lookup / confirmation) -> record_insert`
12
+ `record_insert_schema_get -> record_insert(items) -> optional record_get/readback`
13
+
14
+ Default to batch-shaped insert. A single new record is `items` with one row.
13
15
 
14
16
  ## Core Tools
15
17
 
@@ -25,19 +27,20 @@ metadata:
25
27
  2. Read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`
26
28
  3. Inside every field bucket, read field-level `linkage` first when present; it is the canonical static hint for linked visibility, reference-driven auto fill, or formula-driven fields
27
29
  4. Inside `optional_fields`, pay special attention to any field with `may_become_required=true`; these are writable fields that can become required when linked visibility or option-driven rules activate
28
- 5. Build `fields` from the manually-supplied required/optional fields in that schema
30
+ 5. Build `items` as `[{"fields": {...}}]`, where each `fields` map uses field titles from the insert schema
29
31
  6. Treat `runtime_linked_required_fields` as required-but-not-directly-writable runtime/upstream dependencies, not as fields to hand-fill blindly
30
32
  7. For `linkage.kind=logic_visibility`, read `sources` as upstream trigger fields and treat `role=manual_input_after_activation` as "fill this only after the upstream condition is satisfied"
31
33
  8. For `linkage.kind=reference_fill`, prefer filling the source field first; treat target fields with `role=auto_fill_preferred` or `auto_fill_only` as reference-driven outputs rather than blind manual inputs
32
34
  9. For `linkage.kind=formula_fill`, treat the field as formula/default-auto-fill driven unless the user explicitly asks to override it and the field is still writable
33
- 10. If insert succeeds and readback shape matters, prefer `record_get(..., output_profile="normalized")` or `record_list(..., output_profile="normalized")`
35
+ 10. If insert succeeds and single-record detail/readback matters, prefer `record_get`; use `record_list(..., output_profile="normalized")` only for batch row-shaped normalized readback
34
36
  11. Keep subtable payloads under the parent field as a row array
35
- 12. Member / department / relation fields may accept natural strings directly
37
+ 12. Member / department / relation fields may accept natural strings directly, such as `"张三"`, `"直销部"`, or `"海军军医大学"`; do not pre-query ids by default
36
38
  13. If the write returns `status="needs_confirmation"`, stop and surface the candidates
37
- 14. Retry with explicit ids / objects only after the user confirms
39
+ 14. Retry failed rows only with explicit ids / objects after the user confirms
38
40
  15. Keep `verify_write=true` for production inserts
39
- 16. If post-write readback consistency matters, prefer `record_get(..., output_profile="normalized")` and surface `normalized_ambiguous_fields` instead of pretending same-title columns are unambiguous
41
+ 16. If post-write detail context matters, read `record_get.fields[]`, `media_assets.items[].local_path`, `file_assets.items[].local_path`, `file_assets.items[].extraction.text_path`, and `semantic_context`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, so prefer local paths over remote URLs and do not expect legacy `data.normalized_record`
40
42
  17. Treat nested schema shape as guidance, not a brittle contract; do not hard-code transient implementation details like optional nested `field_id` shape when composing inserts
43
+ 18. For `partial_success`, read `created_record_ids`, then repair only the failed `items[].row_number` using `failed_fields`; never retry the whole batch after any row has `write_executed=true`
41
44
 
42
45
  ## Field Notes
43
46
 
@@ -51,11 +54,29 @@ metadata:
51
54
  - `linkage.affects_fields` lists downstream field titles that may change when this field changes
52
55
  - `linkage.role=auto_fill_only` means "normally do not hand-fill this unless the product explicitly requires it"
53
56
  - `requires_upload=true` means upload the file first, then write the returned value
57
+ - `failed_fields[].next_action` tells the next repair step for that row
58
+
59
+ ## CLI Pattern
60
+
61
+ Use a JSON array file:
62
+
63
+ ```bash
64
+ qingflow record insert --app-key APP_KEY --items-file records.json --json
65
+ ```
66
+
67
+ `records.json`:
68
+
69
+ ```json
70
+ [
71
+ { "fields": { "客户名称": "测试客户", "负责人": "张三" } }
72
+ ]
73
+ ```
54
74
 
55
75
  ## Do Not
56
76
 
57
77
  - Do not skip `record_insert_schema_get`
58
78
  - Do not invent missing required fields
59
79
  - Do not flatten subtable leaf fields to the top level
60
- - Do not silently guess member / department / relation ids
80
+ - Do not pre-query or silently guess member / department / relation ids when a natural string is enough
81
+ - Do not retry a whole batch after `created_record_ids` is non-empty
61
82
  - Do not bind logic to a transient nested schema serialization detail when the field title and parent table already identify the legal payload shape
@@ -34,7 +34,7 @@ metadata:
34
34
  12. Do not assume any arbitrary combination of writable fields will succeed; one single matched accessible view still has to cover the payload
35
35
  13. Do not look for any extra context bucket in update schema; lookup behavior stays inline on the field definitions themselves
36
36
  14. When update context feels unstable, trust `record_update_schema_get`'s route-aware matched-view result over guessed `view_id` or remembered UI scope
37
- 15. If readback consistency matters, prefer `record_get(..., output_profile="normalized")` after the write and surface `normalized_ambiguous_fields` instead of hiding same-title conflicts
37
+ 15. If single-record detail/readback matters, prefer `record_get` after the write and read top-level `fields[]`, `media_assets.items[].local_path`, `file_assets.items[].local_path`, `file_assets.items[].extraction.text_path`, and `semantic_context`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, so prefer local paths over remote URLs; use `record_list(..., output_profile="normalized")` only for batch row-shaped normalized readback
38
38
 
39
39
  ## Do Not
40
40
 
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
  from threading import Event
5
5
  from time import sleep
6
6
  from typing import Any
7
- from urllib.parse import urlsplit, urlunsplit
7
+ from urllib.parse import urljoin, urlsplit, urlunsplit
8
8
  from uuid import uuid4
9
9
 
10
10
  import httpx
@@ -33,6 +33,15 @@ class BackendResponse:
33
33
  qf_response_version: str | None = None
34
34
 
35
35
 
36
+ @dataclass(slots=True)
37
+ class BackendBinaryResponse:
38
+ content: bytes
39
+ headers: dict[str, str]
40
+ http_status: int
41
+ final_url: str
42
+ redirected: bool
43
+
44
+
36
45
  class BackendClient:
37
46
  def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
38
47
  self._owns_client = client is None
@@ -290,6 +299,51 @@ class BackendClient:
290
299
  )
291
300
  return response.content
292
301
 
302
+ def download_binary_with_cookie(
303
+ self,
304
+ context: BackendRequestContext,
305
+ url: str,
306
+ *,
307
+ cookie_name: str,
308
+ headers: dict[str, str] | None = None,
309
+ ) -> BackendBinaryResponse:
310
+ qf_version, _source = self._resolve_qf_version(context.qf_version)
311
+ request_headers = {
312
+ "User-Agent": DEFAULT_USER_AGENT,
313
+ "Qf-Request-Id": context.qf_request_id or str(uuid4()),
314
+ }
315
+ if headers:
316
+ request_headers.update({key: value for key, value in headers.items() if value is not None})
317
+ cookie_parts = [f"{cookie_name}={context.token}"]
318
+ if qf_version:
319
+ cookie_parts.append(f"qfVersion={qf_version}")
320
+ request_headers["Cookie"] = "; ".join(cookie_parts)
321
+ try:
322
+ response = self._client.get(url, headers=request_headers, follow_redirects=False)
323
+ except httpx.RequestError as exc:
324
+ raise QingflowApiError(category="network", message=str(exc))
325
+ redirected = 300 <= response.status_code < 400 and bool(response.headers.get("location"))
326
+ if redirected:
327
+ redirect_headers = {key: value for key, value in request_headers.items() if key.lower() != "cookie"}
328
+ redirect_url = urljoin(str(response.url), response.headers["location"])
329
+ try:
330
+ response = self._client.get(redirect_url, headers=redirect_headers)
331
+ except httpx.RequestError as exc:
332
+ raise QingflowApiError(category="network", message=str(exc))
333
+ if response.status_code >= 400:
334
+ raise QingflowApiError(
335
+ category="http",
336
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
337
+ http_status=response.status_code,
338
+ )
339
+ return BackendBinaryResponse(
340
+ content=response.content,
341
+ headers=dict(response.headers),
342
+ http_status=response.status_code,
343
+ final_url=str(response.url),
344
+ redirected=redirected or bool(response.history) or str(response.url) != url,
345
+ )
346
+
293
347
  def request_multipart(
294
348
  self,
295
349
  method: str,
@@ -10,7 +10,11 @@ from .common import load_list_arg, load_object_arg, raise_config_error, require_
10
10
 
11
11
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
12
12
  parser = subparsers.add_parser("record", help="记录与表结构")
13
- record_subparsers = parser.add_subparsers(dest="record_command", required=True)
13
+ record_subparsers = parser.add_subparsers(
14
+ dest="record_command",
15
+ required=True,
16
+ metavar="{schema,list,access,get,insert,update,delete,code-block-run}",
17
+ )
14
18
 
15
19
  schema = record_subparsers.add_parser("schema", help="读取记录相关表结构")
16
20
  schema.add_argument("--mode", dest="legacy_mode", help=argparse.SUPPRESS)
@@ -47,9 +51,11 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
47
51
  list_parser.add_argument("--app-key", required=True)
48
52
  list_parser.add_argument("--column", dest="columns", action="append", type=int, default=[])
49
53
  list_parser.add_argument("--columns-file")
54
+ list_parser.add_argument("--query")
55
+ list_parser.add_argument("--query-field", dest="query_fields", action="append", type=int, default=[])
56
+ list_parser.add_argument("--query-fields-file")
50
57
  list_parser.add_argument("--where-file")
51
58
  list_parser.add_argument("--order-by-file")
52
- list_parser.add_argument("--limit", type=int, default=20)
53
59
  list_parser.add_argument("--page", type=int, default=1)
54
60
  list_parser.add_argument("--view-id")
55
61
  list_parser.add_argument("--list-type", dest="legacy_list_type", type=int, help=argparse.SUPPRESS)
@@ -57,17 +63,27 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
57
63
  list_parser.add_argument("--view-name", dest="legacy_view_name", help=argparse.SUPPRESS)
58
64
  list_parser.set_defaults(handler=_handle_list, format_hint="record_list")
59
65
 
66
+ access_parser = record_subparsers.add_parser("access", help="访问记录并写入本地 CSV 分片")
67
+ access_parser.add_argument("--app-key", required=True)
68
+ access_parser.add_argument("--column", dest="columns", action="append", type=int, default=[])
69
+ access_parser.add_argument("--columns-file")
70
+ access_parser.add_argument("--where-file")
71
+ access_parser.add_argument("--order-by-file")
72
+ access_parser.add_argument("--view-id", required=True)
73
+ access_parser.set_defaults(handler=_handle_access, format_hint="record_access")
74
+
60
75
  get = record_subparsers.add_parser("get", help="读取单条记录")
61
76
  get.add_argument("--app-key", required=True)
62
77
  get.add_argument("--record-id", required=True)
63
78
  get.add_argument("--column", dest="columns", action="append", type=int, default=[])
64
79
  get.add_argument("--columns-file")
65
80
  get.add_argument("--view-id")
66
- get.set_defaults(handler=_handle_get, format_hint="")
81
+ get.set_defaults(handler=_handle_get, format_hint="record_get")
67
82
 
68
83
  insert = record_subparsers.add_parser("insert", help="新增记录")
69
84
  insert.add_argument("--app-key", required=True)
70
- insert.add_argument("--fields-file", required=True)
85
+ insert.add_argument("--fields-file")
86
+ insert.add_argument("--items-file")
71
87
  insert.add_argument("--verify-write", action=argparse.BooleanOptionalAction, default=True)
72
88
  insert.set_defaults(handler=_handle_insert, format_hint="")
73
89
 
@@ -86,7 +102,12 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
86
102
  delete.add_argument("--record-ids-file")
87
103
  delete.set_defaults(handler=_handle_delete, format_hint="")
88
104
 
89
- analyze = record_subparsers.add_parser("analyze", help="分析记录数据")
105
+ analyze = record_subparsers.add_parser("analyze", help=argparse.SUPPRESS)
106
+ record_subparsers._choices_actions = [ # type: ignore[attr-defined]
107
+ action
108
+ for action in record_subparsers._choices_actions # type: ignore[attr-defined]
109
+ if getattr(action, "dest", None) != "analyze"
110
+ ]
90
111
  analyze.add_argument("--app-key", required=True)
91
112
  analyze.add_argument("--dimensions-file")
92
113
  analyze.add_argument("--metrics-file")
@@ -122,6 +143,13 @@ def _columns(args: argparse.Namespace) -> list[Any]:
122
143
  return columns
123
144
 
124
145
 
146
+ def _query_fields(args: argparse.Namespace) -> list[Any]:
147
+ query_fields: list[Any] = list(getattr(args, "query_fields", None) or [])
148
+ if getattr(args, "query_fields_file", None):
149
+ query_fields.extend(require_list_arg(args.query_fields_file, option_name="--query-fields-file"))
150
+ return query_fields
151
+
152
+
125
153
  def _handle_schema_root(args: argparse.Namespace, _context: CliContext) -> dict:
126
154
  mode = (args.legacy_mode or "").strip()
127
155
  if mode:
@@ -216,14 +244,26 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
216
244
  profile=args.profile,
217
245
  app_key=args.app_key,
218
246
  columns=_columns(args),
247
+ query=args.query,
248
+ query_fields=_query_fields(args),
219
249
  where=load_list_arg(args.where_file, option_name="--where-file"),
220
250
  order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
221
- limit=args.limit,
222
251
  page=args.page,
223
252
  view_id=args.view_id,
224
253
  )
225
254
 
226
255
 
256
+ def _handle_access(args: argparse.Namespace, context: CliContext) -> dict:
257
+ return context.record.record_access(
258
+ profile=args.profile,
259
+ app_key=args.app_key,
260
+ columns=_columns(args),
261
+ where=load_list_arg(args.where_file, option_name="--where-file"),
262
+ order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
263
+ view_id=args.view_id,
264
+ )
265
+
266
+
227
267
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
228
268
  return context.record.record_get_public(
229
269
  profile=args.profile,
@@ -235,6 +275,23 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
235
275
 
236
276
 
237
277
  def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
278
+ if args.items_file:
279
+ if args.fields_file:
280
+ raise_config_error(
281
+ "record insert batch mode does not accept --fields-file.",
282
+ fix_hint="Use `record insert --app-key APP_KEY --items-file ITEMS.json` for batch inserts.",
283
+ )
284
+ return context.record.record_insert_public(
285
+ profile=args.profile,
286
+ app_key=args.app_key,
287
+ items=require_list_arg(args.items_file, option_name="--items-file"),
288
+ verify_write=bool(args.verify_write),
289
+ )
290
+ if not args.fields_file:
291
+ raise_config_error(
292
+ "record insert requires --items-file or --fields-file.",
293
+ fix_hint="Prefer `record insert --app-key APP_KEY --items-file ITEMS.json`; use --fields-file only for legacy single inserts.",
294
+ )
238
295
  return context.record.record_insert_public(
239
296
  profile=args.profile,
240
297
  app_key=args.app_key,