@qingflow-tech/qingflow-cli 1.1.51 → 1.1.53

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 (106) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +8 -2
  5. package/skills/qingflow-cli/SKILL.md +93 -322
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +3 -9
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  17. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
  18. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  19. package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
  20. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  21. package/skills/qingflow-cli/reference/builder/README.md +1 -1
  22. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  27. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  28. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  29. package/skills/qingflow-cli/reference/data/README.md +32 -0
  30. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  31. package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
  32. package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
  33. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  34. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  35. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  36. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  37. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  38. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  39. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  40. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  41. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  42. package/skills/qingflow-common/SKILL.md +7 -4
  43. package/src/qingflow_mcp/app_form_compat.py +3 -0
  44. package/src/qingflow_mcp/app_form_validator.py +69 -17
  45. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  46. package/src/qingflow_mcp/builder_facade/models.py +227 -17
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  70. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  71. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  72. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  73. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
  74. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
  75. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
  76. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
  77. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
  78. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
  79. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
  80. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
  81. package/src/qingflow_mcp/builder_facade/service.py +1666 -509
  82. package/src/qingflow_mcp/builder_facade/views_extract.py +592 -0
  83. package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
  84. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  85. package/src/qingflow_mcp/cli/commands/builder.py +76 -55
  86. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  87. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  88. package/src/qingflow_mcp/cli/context.py +3 -0
  89. package/src/qingflow_mcp/cli/formatters.py +97 -1
  90. package/src/qingflow_mcp/public_surface.py +8 -0
  91. package/src/qingflow_mcp/response_trim.py +11 -2
  92. package/src/qingflow_mcp/server.py +2 -0
  93. package/src/qingflow_mcp/server_app_builder.py +34 -10
  94. package/src/qingflow_mcp/server_app_user.py +4 -0
  95. package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
  96. package/src/qingflow_mcp/solution/executor.py +96 -8
  97. package/src/qingflow_mcp/solution/spec_models.py +1 -0
  98. package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
  99. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  100. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  101. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  102. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
  103. package/src/qingflow_mcp/tools/view_tools.py +0 -6
  104. package/src/qingflow_mcp/tools/view_writer.py +44 -0
  105. package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
  106. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +0 -42
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from ..json_types import JSONObject
10
+
11
+
12
+ PageFetcher = Callable[[int, int], JSONObject]
13
+ RowProjector = Callable[[JSONObject], list[str]]
14
+
15
+
16
+ class CsvPageFailure(RuntimeError):
17
+ """后续分页返回结构化业务错误;已写文件保留为不完整结果。"""
18
+
19
+ def __init__(self, payload: JSONObject) -> None:
20
+ super().__init__(str(payload.get("message") or "分页请求失败"))
21
+ self.payload = payload
22
+
23
+
24
+ class CsvShardWriter:
25
+ """视图无关的 UTF-8 BOM CSV 分片写入器。"""
26
+
27
+ def __init__(self, *, output_dir: Path, file_prefix: str, header: list[str], shard_rows: int) -> None:
28
+ self.output_dir = output_dir
29
+ self.file_prefix = file_prefix
30
+ self.header = header
31
+ self.shard_rows = shard_rows
32
+ self.files: list[JSONObject] = []
33
+ self.row_count = 0
34
+ self._part = 0
35
+ self._current_rows = 0
36
+ self._file: Any | None = None
37
+ self._writer: Any | None = None
38
+ output_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ def write(self, values: list[str]) -> None:
41
+ if self._current_rows >= self.shard_rows:
42
+ self.close_shard()
43
+ self._ensure_writer().writerow(values)
44
+ self._current_rows += 1
45
+ self.row_count += 1
46
+
47
+ def close_shard(self) -> None:
48
+ if self._file is None:
49
+ return
50
+ path = Path(self._file.name)
51
+ self._file.close()
52
+ self.files.append({"local_path": str(path), "row_count": self._current_rows, "part": len(self.files) + 1})
53
+ self._file = None
54
+ self._writer = None
55
+ self._current_rows = 0
56
+
57
+ def _ensure_writer(self) -> Any:
58
+ if self._writer is not None:
59
+ return self._writer
60
+ self._part += 1
61
+ path = self.output_dir / f"{self.file_prefix}-{self._part:04d}.csv"
62
+ self._file = path.open("w", newline="", encoding="utf-8-sig")
63
+ self._writer = csv.writer(self._file)
64
+ self._writer.writerow(self.header)
65
+ return self._writer
66
+
67
+
68
+ def export_csv_pages(
69
+ *,
70
+ fetch_page: PageFetcher,
71
+ header: list[str],
72
+ project_row: RowProjector,
73
+ output_dir: Path,
74
+ file_prefix: str,
75
+ page_size: int,
76
+ shard_rows: int,
77
+ time_budget_seconds: float,
78
+ ) -> JSONObject:
79
+ """执行与业务权限无关的分页、CSV 分片和时间预算控制。"""
80
+ output_dir.mkdir(parents=True, exist_ok=True)
81
+ deadline = time.monotonic() + time_budget_seconds
82
+ files: list[JSONObject] = []
83
+ row_count = 0
84
+ fetched_pages = 0
85
+ current_page = 1
86
+ file_handle: Any | None = None
87
+ writer: Any | None = None
88
+ current_rows = 0
89
+ part = 0
90
+ has_more = False
91
+ stopped_reason: str | None = None
92
+ page_error: JSONObject | None = None
93
+
94
+ def close_file() -> None:
95
+ nonlocal file_handle, writer, current_rows
96
+ if file_handle is None:
97
+ return
98
+ path = Path(file_handle.name)
99
+ file_handle.close()
100
+ files.append({"local_path": str(path), "row_count": current_rows, "part": len(files) + 1})
101
+ file_handle = None
102
+ writer = None
103
+ current_rows = 0
104
+
105
+ def get_writer() -> Any:
106
+ nonlocal file_handle, writer, part
107
+ if writer is not None:
108
+ return writer
109
+ part += 1
110
+ path = output_dir / f"{file_prefix}-{part:04d}.csv"
111
+ file_handle = path.open("w", newline="", encoding="utf-8-sig")
112
+ writer = csv.writer(file_handle)
113
+ writer.writerow(header)
114
+ return writer
115
+
116
+ try:
117
+ while True:
118
+ page = fetch_page(current_page, page_size)
119
+ records = page.get("records") if isinstance(page, dict) else None
120
+ container = records if isinstance(records, dict) else page
121
+ raw_items = container.get("items", container.get("list", [])) if isinstance(container, dict) else []
122
+ items = raw_items if isinstance(raw_items, list) else []
123
+ has_more = bool(container.get("has_more")) if isinstance(container, dict) else False
124
+ for item in items:
125
+ if not isinstance(item, dict):
126
+ continue
127
+ if current_rows >= shard_rows:
128
+ close_file()
129
+ get_writer().writerow(project_row(item))
130
+ current_rows += 1
131
+ row_count += 1
132
+ fetched_pages += 1
133
+ if not has_more:
134
+ break
135
+ if time.monotonic() >= deadline:
136
+ stopped_reason = "TIME_BUDGET_EXCEEDED"
137
+ break
138
+ current_page += 1
139
+ except CsvPageFailure as exception:
140
+ stopped_reason = "PAGE_FETCH_FAILED"
141
+ page_error = exception.payload
142
+ except Exception:
143
+ # 已落盘分片仍是有效的部分结果;调用方据此禁止形成完整性结论。
144
+ stopped_reason = "PAGE_FETCH_FAILED"
145
+ finally:
146
+ close_file()
147
+
148
+ complete = stopped_reason is None and not has_more
149
+ result: JSONObject = {
150
+ "files": files,
151
+ "row_count": row_count,
152
+ "complete": complete,
153
+ "truncated": not complete,
154
+ "safe_for_final_conclusion": complete,
155
+ "fetched_pages": fetched_pages,
156
+ "stopped_reason": stopped_reason,
157
+ }
158
+ if page_error is not None:
159
+ result["page_error"] = page_error
160
+ return result
161
+
162
+
163
+ def csv_cell(value: Any) -> str:
164
+ if value is None:
165
+ return ""
166
+ if isinstance(value, (dict, list)):
167
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
168
+ return str(value)
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from ..backend_client import BackendRequestContext
10
+ from ..config import DEFAULT_PROFILE
11
+ from ..errors import QingflowApiError, raise_tool_error
12
+ from ..json_types import JSONObject, JSONValue
13
+ from .base import ToolBase
14
+ from .csv_export import CsvPageFailure, csv_cell, export_csv_pages
15
+
16
+
17
+ DATA_PAGE_SIZE = 1000
18
+ DATA_CSV_SHARD_ROWS = 20_000
19
+ DATA_UNBOUNDED_ROW_THRESHOLD = 50_000
20
+ DATA_CSV_TIME_BUDGET_SECONDS = 55.0
21
+
22
+ DataFilterOperator = Literal[
23
+ "eq", "ne", "contains", "not_contains", "gt", "gte", "lt", "lte", "in", "not_in"
24
+ ]
25
+ DataSortDirection = Literal["asc", "desc"]
26
+
27
+
28
+ class DataWhereItem(TypedDict):
29
+ """数据面筛选项;in/not_in 使用数组,其余操作符使用单值。"""
30
+
31
+ field_id: int
32
+ op: DataFilterOperator
33
+ value: JSONValue
34
+
35
+
36
+ class DataSortItem(TypedDict):
37
+ """数据面排序项。"""
38
+
39
+ field_id: int
40
+ direction: DataSortDirection
41
+
42
+
43
+ class DataInsertItem(TypedDict):
44
+ """数据面新增项,fields 的键为字符串形式 field_id。"""
45
+
46
+ fields: dict[str, JSONValue]
47
+
48
+
49
+ class DataTools(ToolBase):
50
+ """视图无关的应用数据面工具。"""
51
+
52
+ def register(self, mcp: FastMCP) -> None:
53
+ @mcp.tool()
54
+ def data_query(
55
+ profile: str = DEFAULT_PROFILE,
56
+ app_key: str = "",
57
+ columns: list[int] | None = None,
58
+ where: list[DataWhereItem] | None = None,
59
+ sort: list[DataSortItem] | None = None,
60
+ search: str = "",
61
+ page: int = 1,
62
+ page_size: int = 100,
63
+ include_schema: bool = True,
64
+ all_pages: bool = False,
65
+ output: str = "json",
66
+ ) -> JSONObject:
67
+ """按字段 ID 查询应用数据;先读取 field_capabilities 再构造 where 和 sort。"""
68
+ return self.data_query(
69
+ profile=profile, app_key=app_key, columns=columns or [], where=where or [], sort=sort or [],
70
+ search=search, page=page, page_size=page_size, include_schema=include_schema,
71
+ all_pages=all_pages, output=output,
72
+ )
73
+
74
+ @mcp.tool()
75
+ def data_get(
76
+ profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "", include_schema: bool = True
77
+ ) -> JSONObject:
78
+ return self.data_get(profile=profile, app_key=app_key, record_id=record_id, include_schema=include_schema)
79
+
80
+ @mcp.tool()
81
+ def data_insert(
82
+ profile: str = DEFAULT_PROFILE, app_key: str = "", items: list[DataInsertItem] | None = None,
83
+ include_schema: bool = True,
84
+ ) -> JSONObject:
85
+ """新增应用数据;每项形如 {"fields": {"字段ID": 值}}。"""
86
+ return self.data_insert(profile=profile, app_key=app_key, items=items or [], include_schema=include_schema)
87
+
88
+ @mcp.tool()
89
+ def data_update(
90
+ profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "",
91
+ fields: dict | None = None, include_schema: bool = True,
92
+ ) -> JSONObject:
93
+ return self.data_update(
94
+ profile=profile, app_key=app_key, record_id=record_id, fields=fields or {},
95
+ include_schema=include_schema,
96
+ )
97
+
98
+ @mcp.tool()
99
+ def data_delete(
100
+ profile: str = DEFAULT_PROFILE, app_key: str = "", record_ids: list[str | int] | None = None,
101
+ ) -> JSONObject:
102
+ return self.data_delete(profile=profile, app_key=app_key, record_ids=record_ids or [])
103
+
104
+ def data_query(
105
+ self, *, profile: str, app_key: str, columns: list[int], where: list[DataWhereItem],
106
+ sort: list[DataSortItem],
107
+ search: str = "", page: int = 1, page_size: int = 100, include_schema: bool = True,
108
+ all_pages: bool = False, output: str = "json",
109
+ ) -> JSONObject:
110
+ self._require_app_key(app_key)
111
+ if all_pages and output != "csv":
112
+ raise_tool_error(QingflowApiError.config_error("all_pages 仅支持 output=csv"))
113
+
114
+ def runner(session_profile, context):
115
+ body: JSONObject = {
116
+ "page": page, "page_size": page_size, "columns": columns, "where": where, "sort": sort,
117
+ "search": search, "include_schema": include_schema,
118
+ }
119
+ if not all_pages:
120
+ return self._attach_context(
121
+ self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=body),
122
+ profile, session_profile.selected_ws_id,
123
+ )
124
+ return self._export_all(
125
+ context=context, profile=profile, ws_id=session_profile.selected_ws_id, app_key=app_key,
126
+ body=body, has_scope=bool(where or search),
127
+ )
128
+
129
+ return self._run(profile, runner, tool_name="数据面查询")
130
+
131
+ def data_get(self, *, profile: str, app_key: str, record_id: str | int, include_schema: bool = True) -> JSONObject:
132
+ self._require_app_key(app_key)
133
+ self._require_record_id(record_id)
134
+ return self._run(
135
+ profile,
136
+ lambda session_profile, context: self._attach_context(
137
+ self._request_data_plane(
138
+ context, "GET", f"{self._base_path(app_key)}/{record_id}",
139
+ params={"include_schema": include_schema},
140
+ ), profile, session_profile.selected_ws_id,
141
+ ),
142
+ tool_name="数据面单条读取",
143
+ )
144
+
145
+ def data_insert(
146
+ self, *, profile: str, app_key: str, items: list[DataInsertItem], include_schema: bool = True
147
+ ) -> JSONObject:
148
+ self._require_app_key(app_key)
149
+ return self._write(profile, app_key, "POST", self._base_path(app_key), {"items": items, "include_schema": include_schema}, "数据面新增")
150
+
151
+ def data_update(
152
+ self, *, profile: str, app_key: str, record_id: str | int, fields: dict, include_schema: bool = True
153
+ ) -> JSONObject:
154
+ self._require_app_key(app_key)
155
+ self._require_record_id(record_id)
156
+ return self._write(
157
+ profile, app_key, "POST", f"{self._base_path(app_key)}/{record_id}/update",
158
+ {"fields": fields, "include_schema": include_schema}, "数据面更新",
159
+ )
160
+
161
+ def data_delete(self, *, profile: str, app_key: str, record_ids: list[str | int]) -> JSONObject:
162
+ self._require_app_key(app_key)
163
+ return self._write(profile, app_key, "POST", self._base_path(app_key) + "/delete", {"record_ids": record_ids}, "数据面删除")
164
+
165
+ def _write(self, profile: str, app_key: str, method: str, path: str, body: JSONObject, tool_name: str) -> JSONObject:
166
+ return self._run(
167
+ profile,
168
+ lambda session_profile, context: self._attach_context(
169
+ self._request_data_plane(context, method, path, json_body=body), profile, session_profile.selected_ws_id,
170
+ ),
171
+ tool_name=tool_name,
172
+ )
173
+
174
+ def _request_data_plane(
175
+ self, context: BackendRequestContext, method: str, path: str, *, params: JSONObject | None = None,
176
+ json_body: JSONValue = None,
177
+ ) -> JSONObject:
178
+ raw = self.backend.request(method, context, path, params=params, json_body=json_body, unwrap=False)
179
+ if not isinstance(raw, dict):
180
+ raise QingflowApiError(category="backend", message="数据面返回了非对象响应")
181
+ code = raw.get("code")
182
+ payload = raw.get("data")
183
+ result: JSONObject = dict(payload) if isinstance(payload, dict) else {}
184
+ success = code in (None, 0, 200, 20000) or str(code).lower() in {"success", "ok"}
185
+ result.setdefault("ok", success)
186
+ result.setdefault("status", "success" if success else "failed")
187
+ result.setdefault("hints", [])
188
+ if not success:
189
+ result.setdefault("message", str(raw.get("message") or raw.get("msg") or "数据面请求失败"))
190
+ result.setdefault("backend_code", code)
191
+ return result
192
+
193
+ def _export_all(
194
+ self, *, context: BackendRequestContext, profile: str, ws_id: int | None, app_key: str,
195
+ body: JSONObject, has_scope: bool,
196
+ ) -> JSONObject:
197
+ probe_body = dict(body)
198
+ probe_body.update({"page": 1, "page_size": 1, "include_schema": True})
199
+ probe = self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=probe_body)
200
+ if probe.get("ok") is False:
201
+ # 探测请求没有产生文件,保留后端结构化错误,不把失败误报为空数据成功。
202
+ return self._attach_context(probe, profile, ws_id)
203
+ records = probe.get("records") if isinstance(probe.get("records"), dict) else {}
204
+ total = records.get("total") if isinstance(records, dict) else None
205
+ if isinstance(total, int) and total > DATA_UNBOUNDED_ROW_THRESHOLD and not has_scope:
206
+ return self._attach_context({
207
+ "ok": True, "status": "needs_scope", "app_key": app_key, "format": "csv", "row_count": 0,
208
+ "complete": False, "truncated": True, "safe_for_final_conclusion": False, "files": [],
209
+ "hints": probe.get("hints", []),
210
+ "schema": probe.get("schema", {}),
211
+ "field_capabilities": probe.get("field_capabilities", []),
212
+ "scope": {"reported_total": total, "row_threshold": DATA_UNBOUNDED_ROW_THRESHOLD},
213
+ }, profile, ws_id)
214
+ schema = probe.get("schema") if isinstance(probe.get("schema"), dict) else {}
215
+ fields = schema.get("fields") if isinstance(schema, dict) else None
216
+ field_list = fields if isinstance(fields, list) else []
217
+ business_fields = [field for field in field_list if isinstance(field, dict) and field.get("field_id") is not None]
218
+ columns = [(str(field.get("field_id")), str(field.get("title") or field.get("field_id"))) for field in business_fields]
219
+ header = ["record_id"] + [f"{title}__field_{field_id}" for field_id, title in columns]
220
+ run_dir = Path.home() / ".qingflow" / "data-exports" / datetime.now().strftime("%Y%m%d-%H%M%S-%f")
221
+ latest_hints = probe.get("hints", [])
222
+ latest_schema = schema
223
+ latest_capabilities = probe.get("field_capabilities", [])
224
+
225
+ def fetch_page(page: int, page_size: int) -> JSONObject:
226
+ nonlocal latest_hints, latest_schema, latest_capabilities
227
+ request_body = dict(body)
228
+ # 每页都请求 schema,使最后一页的后端原始 hints 同时准确表达完整性和字段能力。
229
+ request_body.update({"page": page, "page_size": page_size, "include_schema": True})
230
+ response = self._request_data_plane(
231
+ context, "POST", self._base_path(app_key) + "/query", json_body=request_body
232
+ )
233
+ if response.get("ok") is False:
234
+ raise CsvPageFailure(response)
235
+ latest_hints = response.get("hints", [])
236
+ if isinstance(response.get("schema"), dict):
237
+ latest_schema = response["schema"]
238
+ if isinstance(response.get("field_capabilities"), list):
239
+ latest_capabilities = response["field_capabilities"]
240
+ return response
241
+
242
+ def project_row(record: JSONObject) -> list[str]:
243
+ values = record.get("fields") if isinstance(record.get("fields"), dict) else record
244
+ return [str(record.get("record_id") or "")] + [csv_cell(values.get(field_id)) for field_id, _ in columns]
245
+
246
+ exported = export_csv_pages(
247
+ fetch_page=fetch_page, header=header, project_row=project_row, output_dir=run_dir,
248
+ file_prefix="records", page_size=DATA_PAGE_SIZE, shard_rows=DATA_CSV_SHARD_ROWS,
249
+ time_budget_seconds=DATA_CSV_TIME_BUDGET_SECONDS,
250
+ )
251
+ page_error = exported.pop("page_error", None)
252
+ result: JSONObject = {
253
+ "ok": True, "status": "success" if exported["complete"] else "partial", "app_key": app_key,
254
+ "format": "csv", "local_dir": str(run_dir), "fields": business_fields,
255
+ "schema": latest_schema, "field_capabilities": latest_capabilities, "hints": latest_hints,
256
+ **exported,
257
+ }
258
+ if isinstance(page_error, dict):
259
+ # 后续页失败时保留已落盘分片,同时把后端稳定错误字段原样带回。
260
+ result["ok"] = False
261
+ for key in ("error_code", "message", "backend_code", "write_executed", "retry_safe", "field_errors"):
262
+ if key in page_error:
263
+ result[key] = page_error[key]
264
+ if "hints" in page_error:
265
+ result["hints"] = page_error["hints"]
266
+ return self._attach_context(result, profile, ws_id)
267
+
268
+ @staticmethod
269
+ def _base_path(app_key: str) -> str:
270
+ return f"/app/{app_key}/data/v1/records"
271
+
272
+ @staticmethod
273
+ def _attach_context(result: JSONObject, profile: str, ws_id: int | None) -> JSONObject:
274
+ return {"profile": profile, "ws_id": ws_id, **result}
275
+
276
+ @staticmethod
277
+ def _require_app_key(app_key: str) -> None:
278
+ if not str(app_key or "").strip():
279
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
280
+
281
+ @staticmethod
282
+ def _require_record_id(record_id: str | int) -> None:
283
+ if not str(record_id or "").strip():
284
+ raise_tool_error(QingflowApiError.config_error("record_id is required"))
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import csv
4
3
  import html
5
4
  import mimetypes
6
5
  import json
@@ -34,6 +33,7 @@ from ..list_type_labels import (
34
33
  )
35
34
  from .base import ToolBase
36
35
  from .directory_tools import _directory_has_more, _directory_items
36
+ from .csv_export import CsvShardWriter
37
37
 
38
38
 
39
39
  DEFAULT_QUERY_PAGE_SIZE = 50
@@ -2284,57 +2284,22 @@ class RecordTools(ToolBase):
2284
2284
  }
2285
2285
 
2286
2286
  run_dir = _record_access_run_dir()
2287
- run_dir.mkdir(parents=True, exist_ok=True)
2288
2287
  header = ["record_id"] + [str(field["column_name"]) for field in fields_payload]
2289
- files: list[JSONObject] = []
2290
- shard_part = 0
2291
- shard_rows = 0
2292
- row_count = 0
2293
- file_handle: Any | None = None
2294
- writer: Any | None = None
2295
-
2296
- def close_shard() -> None:
2297
- nonlocal file_handle, writer, shard_rows
2298
- if file_handle is None:
2299
- return
2300
- path = Path(file_handle.name)
2301
- file_handle.close()
2302
- files.append(
2303
- {
2304
- "local_path": str(path),
2305
- "row_count": shard_rows,
2306
- "part": len(files) + 1,
2307
- }
2308
- )
2309
- file_handle = None
2310
- writer = None
2311
- shard_rows = 0
2312
-
2313
- def ensure_writer() -> Any:
2314
- nonlocal file_handle, writer, shard_part
2315
- if writer is not None:
2316
- return writer
2317
- shard_part += 1
2318
- path = run_dir / f"records-{shard_part:04d}.csv"
2319
- file_handle = path.open("w", newline="", encoding="utf-8-sig")
2320
- writer = csv.writer(file_handle)
2321
- writer.writerow(header)
2322
- return writer
2288
+ shard_writer = CsvShardWriter(
2289
+ output_dir=run_dir,
2290
+ file_prefix="records",
2291
+ header=header,
2292
+ shard_rows=DEFAULT_RECORD_ACCESS_SHARD_ROWS,
2293
+ )
2323
2294
 
2324
2295
  def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
2325
- nonlocal row_count, shard_rows
2326
2296
  if apply_id is None:
2327
2297
  return
2328
- if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
2329
- close_shard()
2330
- csv_writer = ensure_writer()
2331
2298
  values: list[str] = [_public_record_id_text(apply_id) or ""]
2332
2299
  for field in selected_fields:
2333
2300
  answer = _find_answer_for_field(answer_list, field)
2334
2301
  values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
2335
- csv_writer.writerow(values)
2336
- row_count += 1
2337
- shard_rows += 1
2302
+ shard_writer.write(values)
2338
2303
 
2339
2304
  current_page = 1
2340
2305
  has_more = False
@@ -2374,7 +2339,7 @@ class RecordTools(ToolBase):
2374
2339
  break
2375
2340
  page = fetch_page(current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
2376
2341
  finally:
2377
- close_shard()
2342
+ shard_writer.close_shard()
2378
2343
 
2379
2344
  if stopped_reason == "TIME_BUDGET_EXCEEDED":
2380
2345
  warnings.append(_record_access_time_budget_warning(reported_total=reported_total, fetched_pages=fetched_pages))
@@ -2399,11 +2364,11 @@ class RecordTools(ToolBase):
2399
2364
  "view_id": view_route.view_id,
2400
2365
  "format": "csv",
2401
2366
  "local_dir": str(run_dir),
2402
- "row_count": row_count,
2367
+ "row_count": shard_writer.row_count,
2403
2368
  "complete": complete,
2404
2369
  "truncated": not complete,
2405
2370
  "safe_for_final_conclusion": safe_for_final_conclusion,
2406
- "files": files,
2371
+ "files": shard_writer.files,
2407
2372
  "fields": fields_payload,
2408
2373
  "warnings": warnings,
2409
2374
  "verification": verification_payload,
@@ -38,6 +38,7 @@ from .qingbi_report_tools import QingbiReportTools
38
38
  from .record_tools import RecordTools
39
39
  from .role_tools import RoleTools
40
40
  from .view_tools import ViewTools
41
+ from .view_writer import ViewWriter
41
42
  from .workspace_tools import WorkspaceTools
42
43
 
43
44
  STAGED_BUILD_MODES = {"preflight", "plan", "apply", "repair"}
@@ -1427,6 +1428,7 @@ class SolutionTools(ToolBase):
1427
1428
  app_tools=AppTools(self.sessions, self.backend),
1428
1429
  record_tools=RecordTools(self.sessions, self.backend),
1429
1430
  view_tools=ViewTools(self.sessions, self.backend),
1431
+ view_writer=ViewWriter(self.sessions, self.backend),
1430
1432
  chart_tools=QingbiReportTools(self.sessions, self.backend),
1431
1433
  portal_tools=PortalTools(self.sessions, self.backend),
1432
1434
  navigation_tools=NavigationTools(self.sessions, self.backend),
@@ -111,12 +111,6 @@ class ViewTools(ToolBase):
111
111
  body = self._require_dict(payload)
112
112
  return self._request(profile, "POST", f"/app/{app_key}/view/que/width", app_key=app_key, tool_name="设置视图列宽", json_body=body)
113
113
 
114
- def view_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
115
- """执行视图相关逻辑。"""
116
- body = self._require_dict(payload)
117
- app_key = str(body.get("appKey") or "")
118
- return self._request(profile, "POST", "/view/viewConfig", app_key=app_key, tool_name="创建视图", json_body=body)
119
-
120
114
  def view_get_config(self, *, profile: str, viewgraph_key: str) -> JSONObject:
121
115
  """执行视图相关逻辑。"""
122
116
  self._require_viewgraph_key(viewgraph_key)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..errors import QingflowApiError, raise_tool_error
6
+ from ..json_types import JSONObject, JSONValue
7
+ from .base import ToolBase
8
+
9
+
10
+ class ViewWriter(ToolBase):
11
+ """视图创建写手——POST /view/viewConfig 的唯一落点。
12
+
13
+ 仅限 builder facade 与 solution executor 内部使用;
14
+ 不注册 MCP 工具、不进 public_surface、不暴露 CLI。
15
+ 公开层的视图创建唯一入口是 ``builder views apply`` / ``app_views_apply``。
16
+ """
17
+
18
+ def create_view(self, *, profile: str, payload: JSONObject) -> JSONObject:
19
+ """创建视图(内部 writer;公开入口请走 app_views_apply)。"""
20
+ body = self._require_dict(payload)
21
+ app_key = str(body.get("appKey") or "")
22
+ return self._request(profile, "POST", "/view/viewConfig", app_key=app_key, tool_name="创建视图", json_body=body)
23
+
24
+ def _request(
25
+ self,
26
+ profile: str,
27
+ method: str,
28
+ path: str,
29
+ *,
30
+ json_body: JSONValue = None,
31
+ params: JSONObject | None = None,
32
+ tool_name: str,
33
+ **extra: Any,
34
+ ) -> JSONObject:
35
+ def runner(session_profile, context):
36
+ result = self.backend.request(method, context, path, json_body=json_body, params=params)
37
+ response: JSONObject = {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
38
+ response.update(extra)
39
+ return response
40
+
41
+ return self._run(profile, runner, tool_name=tool_name)
42
+
43
+
44
+ __all__ = ["ViewWriter", "QingflowApiError", "raise_tool_error"]