@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/npm/lib/runtime.mjs +37 -0
- package/npm/scripts/postinstall.mjs +5 -1
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +230 -0
- package/skills/qingflow-app-user/agents/openai.yaml +4 -0
- package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
- package/skills/qingflow-app-user/references/environments.md +63 -0
- package/skills/qingflow-app-user/references/record-patterns.md +110 -0
- package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
- package/skills/qingflow-record-analysis/SKILL.md +253 -0
- package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +141 -0
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +113 -0
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +294 -1
- package/src/qingflow_mcp/builder_facade/service.py +2727 -235
- package/src/qingflow_mcp/server.py +7 -5
- package/src/qingflow_mcp/server_app_builder.py +80 -4
- package/src/qingflow_mcp/server_app_user.py +8 -182
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
- package/src/qingflow_mcp/solution/executor.py +34 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
- package/src/qingflow_mcp/tools/app_tools.py +1 -2
- package/src/qingflow_mcp/tools/approval_tools.py +357 -75
- package/src/qingflow_mcp/tools/directory_tools.py +158 -28
- package/src/qingflow_mcp/tools/record_tools.py +1954 -973
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
+
import time
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from datetime import UTC, datetime
|
|
7
8
|
from typing import cast
|
|
@@ -16,14 +17,17 @@ from .base import ToolBase
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
20
|
+
DEFAULT_LIST_PAGE_SIZE = 200
|
|
21
|
+
DEFAULT_ANALYSIS_PAGE_SIZE = 1000
|
|
19
22
|
DEFAULT_SCAN_MAX_PAGES = 10
|
|
23
|
+
DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
|
|
24
|
+
DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP = 100
|
|
20
25
|
DEFAULT_ROW_LIMIT = 200
|
|
21
26
|
DEFAULT_OUTPUT_PROFILE = "compact"
|
|
22
27
|
MAX_LIST_COLUMN_LIMIT = 20
|
|
23
28
|
MAX_RECORD_COLUMN_LIMIT = 20
|
|
24
29
|
MAX_SUMMARY_PREVIEW_COLUMN_LIMIT = 6
|
|
25
30
|
BACKEND_LIST_SEARCH_FIELD_LIMIT = 10
|
|
26
|
-
SUPPORTED_QUERY_TOOLS = {"record_query", "record_aggregate", "record_get"}
|
|
27
31
|
MEMBER_QUE_TYPES = {5}
|
|
28
32
|
DEPARTMENT_QUE_TYPES = {22}
|
|
29
33
|
DATE_QUE_TYPES = {4}
|
|
@@ -136,332 +140,1439 @@ class RecordTools(ToolBase):
|
|
|
136
140
|
|
|
137
141
|
def register(self, mcp: FastMCP) -> None:
|
|
138
142
|
@mcp.tool()
|
|
139
|
-
def
|
|
143
|
+
def record_schema_get(
|
|
140
144
|
profile: str = DEFAULT_PROFILE,
|
|
141
145
|
app_key: str = "",
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
fuzzy: bool = True,
|
|
146
|
+
view_key: str | None = None,
|
|
147
|
+
view_name: str | None = None,
|
|
148
|
+
output_profile: str = "normal",
|
|
146
149
|
) -> JSONObject:
|
|
147
|
-
return self.
|
|
150
|
+
return self.record_schema_get(
|
|
148
151
|
profile=profile,
|
|
149
152
|
app_key=app_key,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
fuzzy=fuzzy,
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
@mcp.tool(
|
|
157
|
-
description=(
|
|
158
|
-
"Preflight complex read requests before actual execution. Resolves field selectors, validates required arguments, "
|
|
159
|
-
"and estimates scan scope. Prefer this when the agent is unsure about query shape."
|
|
153
|
+
view_key=view_key,
|
|
154
|
+
view_name=view_name,
|
|
155
|
+
output_profile=output_profile,
|
|
160
156
|
)
|
|
161
|
-
)
|
|
162
|
-
def record_query_plan(
|
|
163
|
-
profile: str = DEFAULT_PROFILE,
|
|
164
|
-
tool: str = "record_query",
|
|
165
|
-
arguments: JSONObject | None = None,
|
|
166
|
-
resolve_fields: bool = True,
|
|
167
|
-
) -> JSONObject:
|
|
168
|
-
return self.record_query_plan(profile=profile, tool=tool, arguments=arguments or {}, resolve_fields=resolve_fields)
|
|
169
157
|
|
|
170
158
|
@mcp.tool(
|
|
171
159
|
description=(
|
|
172
|
-
"
|
|
173
|
-
"
|
|
160
|
+
"Run schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
161
|
+
"Use record_schema_get first, then let the model build a DSL with field_id references only. "
|
|
162
|
+
"dimensions=[] means whole-table summary; dimensions!=[] means grouped analysis. "
|
|
163
|
+
"This route hides paging and scan-budget controls from callers."
|
|
174
164
|
)
|
|
175
165
|
)
|
|
176
|
-
def
|
|
166
|
+
def record_analyze(
|
|
177
167
|
profile: str = DEFAULT_PROFILE,
|
|
178
|
-
operation: str = "auto",
|
|
179
168
|
app_key: str = "",
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
169
|
+
dimensions: list[JSONObject] | None = None,
|
|
170
|
+
metrics: list[JSONObject] | None = None,
|
|
171
|
+
filters: list[JSONObject] | None = None,
|
|
172
|
+
sort: list[JSONObject] | None = None,
|
|
173
|
+
limit: int = 50,
|
|
174
|
+
strict_full: bool = True,
|
|
175
|
+
view_key: str | None = None,
|
|
176
|
+
view_name: str | None = None,
|
|
177
|
+
output_profile: str = "normal",
|
|
184
178
|
) -> JSONObject:
|
|
185
|
-
return self.
|
|
179
|
+
return self.record_analyze(
|
|
186
180
|
profile=profile,
|
|
187
|
-
operation=operation,
|
|
188
181
|
app_key=app_key,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
182
|
+
dimensions=dimensions or [],
|
|
183
|
+
metrics=metrics or [],
|
|
184
|
+
filters=filters or [],
|
|
185
|
+
sort=sort or [],
|
|
186
|
+
limit=limit,
|
|
187
|
+
strict_full=strict_full,
|
|
188
|
+
view_key=view_key,
|
|
189
|
+
view_name=view_name,
|
|
190
|
+
output_profile=output_profile,
|
|
193
191
|
)
|
|
194
192
|
|
|
195
193
|
@mcp.tool(
|
|
196
194
|
description=(
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
195
|
+
"Browse Qingflow records with a schema-first list DSL. "
|
|
196
|
+
"Use record_schema_get first, then pass field_id-only columns, where, and order_by clauses. "
|
|
197
|
+
"This route is for browse/export/sample inspection only, not analysis."
|
|
200
198
|
)
|
|
201
199
|
)
|
|
202
|
-
def
|
|
200
|
+
def record_list(
|
|
203
201
|
profile: str = DEFAULT_PROFILE,
|
|
204
|
-
query_mode: str = "auto",
|
|
205
202
|
app_key: str = "",
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
query_key: str | None = None,
|
|
212
|
-
filters: list[JSONObject] | None = None,
|
|
213
|
-
sorts: list[JSONObject] | None = None,
|
|
214
|
-
max_rows: int = DEFAULT_ROW_LIMIT,
|
|
215
|
-
max_columns: int | None = None,
|
|
216
|
-
select_columns: list[str | int] | None = None,
|
|
217
|
-
amount_column: str | int | None = None,
|
|
218
|
-
time_range: JSONObject | None = None,
|
|
219
|
-
stat_policy: JSONObject | None = None,
|
|
220
|
-
strict_full: bool = False,
|
|
221
|
-
output_profile: str = DEFAULT_OUTPUT_PROFILE,
|
|
222
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
203
|
+
columns: list[int] | None = None,
|
|
204
|
+
where: list[JSONObject] | None = None,
|
|
205
|
+
order_by: list[JSONObject] | None = None,
|
|
206
|
+
limit: int = 50,
|
|
207
|
+
page: int = 1,
|
|
223
208
|
view_key: str | None = None,
|
|
224
209
|
view_name: str | None = None,
|
|
210
|
+
output_profile: str = "normal",
|
|
225
211
|
) -> JSONObject:
|
|
226
|
-
return self.
|
|
212
|
+
return self.record_list(
|
|
227
213
|
profile=profile,
|
|
228
|
-
query_mode=query_mode,
|
|
229
214
|
app_key=app_key,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
query_key=query_key,
|
|
236
|
-
filters=filters or [],
|
|
237
|
-
sorts=sorts or [],
|
|
238
|
-
max_rows=max_rows,
|
|
239
|
-
max_columns=max_columns,
|
|
240
|
-
select_columns=select_columns or [],
|
|
241
|
-
amount_column=amount_column,
|
|
242
|
-
time_range=time_range or {},
|
|
243
|
-
stat_policy=stat_policy or {},
|
|
244
|
-
strict_full=strict_full,
|
|
245
|
-
output_profile=output_profile,
|
|
246
|
-
list_type=list_type,
|
|
215
|
+
columns=columns or [],
|
|
216
|
+
where=where or [],
|
|
217
|
+
order_by=order_by or [],
|
|
218
|
+
limit=limit,
|
|
219
|
+
page=page,
|
|
247
220
|
view_key=view_key,
|
|
248
221
|
view_name=view_name,
|
|
222
|
+
output_profile=output_profile,
|
|
249
223
|
)
|
|
250
224
|
|
|
251
|
-
@mcp.tool(
|
|
252
|
-
|
|
253
|
-
"Grouped record analysis endpoint. Aggregates record counts and numeric metrics by selected dimensions. "
|
|
254
|
-
"Use strict_full=true when the result will be used as a final conclusion."
|
|
255
|
-
)
|
|
256
|
-
)
|
|
257
|
-
def record_aggregate(
|
|
225
|
+
@mcp.tool(description="Read one Qingflow record by record_id. Use record_schema_get first if columns are ambiguous.")
|
|
226
|
+
def record_get(
|
|
258
227
|
profile: str = DEFAULT_PROFILE,
|
|
259
228
|
app_key: str = "",
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
page_size: int = DEFAULT_QUERY_PAGE_SIZE,
|
|
265
|
-
requested_pages: int = 1,
|
|
266
|
-
scan_max_pages: int = DEFAULT_SCAN_MAX_PAGES,
|
|
267
|
-
query_key: str | None = None,
|
|
268
|
-
filters: list[JSONObject] | None = None,
|
|
269
|
-
sorts: list[JSONObject] | None = None,
|
|
270
|
-
time_range: JSONObject | None = None,
|
|
271
|
-
time_bucket: str | None = None,
|
|
272
|
-
max_groups: int = 200,
|
|
273
|
-
strict_full: bool = False,
|
|
274
|
-
output_profile: str = DEFAULT_OUTPUT_PROFILE,
|
|
275
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
276
|
-
view_key: str | None = None,
|
|
277
|
-
view_name: str | None = None,
|
|
229
|
+
record_id: int = 0,
|
|
230
|
+
columns: list[int] | None = None,
|
|
231
|
+
workflow_node_id: int | None = None,
|
|
232
|
+
output_profile: str = "normal",
|
|
278
233
|
) -> JSONObject:
|
|
279
|
-
return self.
|
|
234
|
+
return self.record_get_public(
|
|
280
235
|
profile=profile,
|
|
281
236
|
app_key=app_key,
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
page_num=page_num,
|
|
286
|
-
page_size=page_size,
|
|
287
|
-
requested_pages=requested_pages,
|
|
288
|
-
scan_max_pages=scan_max_pages,
|
|
289
|
-
query_key=query_key,
|
|
290
|
-
filters=filters or [],
|
|
291
|
-
sorts=sorts or [],
|
|
292
|
-
time_range=time_range or {},
|
|
293
|
-
time_bucket=time_bucket,
|
|
294
|
-
max_groups=max_groups,
|
|
295
|
-
strict_full=strict_full,
|
|
237
|
+
record_id=record_id,
|
|
238
|
+
columns=columns or [],
|
|
239
|
+
workflow_node_id=workflow_node_id,
|
|
296
240
|
output_profile=output_profile,
|
|
297
|
-
list_type=list_type,
|
|
298
|
-
view_key=view_key,
|
|
299
|
-
view_name=view_name,
|
|
300
241
|
)
|
|
301
242
|
|
|
302
243
|
@mcp.tool(
|
|
303
244
|
description=(
|
|
304
|
-
"
|
|
305
|
-
"Use
|
|
245
|
+
"Write Qingflow records with a SQL-like JSON DSL. "
|
|
246
|
+
"Use record_schema_get first, then choose operation=insert|update|delete and mode=plan|apply. "
|
|
247
|
+
"This route does not accept raw SQL strings or free-form WHERE clauses."
|
|
306
248
|
)
|
|
307
249
|
)
|
|
308
|
-
def
|
|
250
|
+
def record_write(
|
|
309
251
|
profile: str = DEFAULT_PROFILE,
|
|
310
252
|
app_key: str = "",
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
253
|
+
operation: str = "insert",
|
|
254
|
+
mode: str = "plan",
|
|
255
|
+
record_id: int | None = None,
|
|
256
|
+
record_ids: list[int] | None = None,
|
|
257
|
+
values: list[JSONObject] | None = None,
|
|
258
|
+
set: list[JSONObject] | None = None,
|
|
259
|
+
submit_type: str | int = "submit",
|
|
260
|
+
verify_write: bool = True,
|
|
261
|
+
output_profile: str = "normal",
|
|
316
262
|
) -> JSONObject:
|
|
317
|
-
return self.
|
|
263
|
+
return self.record_write(
|
|
318
264
|
profile=profile,
|
|
319
265
|
app_key=app_key,
|
|
320
|
-
|
|
321
|
-
|
|
266
|
+
operation=operation,
|
|
267
|
+
mode=mode,
|
|
268
|
+
record_id=record_id,
|
|
269
|
+
record_ids=record_ids or [],
|
|
270
|
+
values=values or [],
|
|
271
|
+
set=set or [],
|
|
322
272
|
submit_type=submit_type,
|
|
323
273
|
verify_write=verify_write,
|
|
324
|
-
|
|
274
|
+
output_profile=output_profile,
|
|
325
275
|
)
|
|
326
276
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
277
|
+
def record_schema_get(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
profile: str,
|
|
281
|
+
app_key: str,
|
|
282
|
+
view_key: str | None,
|
|
283
|
+
view_name: str | None,
|
|
284
|
+
output_profile: str,
|
|
285
|
+
) -> JSONObject:
|
|
286
|
+
if not app_key:
|
|
287
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
288
|
+
|
|
289
|
+
def runner(session_profile, context):
|
|
290
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
291
|
+
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
292
|
+
fields = [self._schema_field_payload(field) for field in index.by_id.values()]
|
|
293
|
+
suggested_dimensions = [
|
|
294
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
295
|
+
for item in fields
|
|
296
|
+
if bool(cast(JSONObject, item["role_hints"]).get("dimension_candidate"))
|
|
297
|
+
]
|
|
298
|
+
suggested_metrics = [
|
|
299
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
300
|
+
for item in fields
|
|
301
|
+
if bool(cast(JSONObject, item["role_hints"]).get("metric_candidate"))
|
|
302
|
+
]
|
|
303
|
+
suggested_time_fields = [
|
|
304
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
305
|
+
for item in fields
|
|
306
|
+
if bool(cast(JSONObject, item["role_hints"]).get("time_candidate"))
|
|
307
|
+
]
|
|
308
|
+
response: JSONObject = {
|
|
309
|
+
"profile": profile,
|
|
310
|
+
"ws_id": session_profile.selected_ws_id,
|
|
311
|
+
"ok": True,
|
|
312
|
+
"status": "success",
|
|
313
|
+
"request_route": self._request_route_payload(context),
|
|
314
|
+
"data": {
|
|
315
|
+
"app_key": app_key,
|
|
316
|
+
"view_resolution": _view_selection_payload(view_selection),
|
|
317
|
+
"fields": fields,
|
|
318
|
+
"suggested_dimensions": suggested_dimensions,
|
|
319
|
+
"suggested_metrics": suggested_metrics,
|
|
320
|
+
"suggested_time_fields": suggested_time_fields,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
if output_profile == "verbose":
|
|
324
|
+
response["data"]["field_count"] = len(fields)
|
|
325
|
+
return response
|
|
326
|
+
|
|
327
|
+
return self._run_record_tool(profile, runner)
|
|
328
|
+
|
|
329
|
+
def record_analyze(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
profile: str,
|
|
333
|
+
app_key: str,
|
|
334
|
+
dimensions: list[JSONObject],
|
|
335
|
+
metrics: list[JSONObject],
|
|
336
|
+
filters: list[JSONObject],
|
|
337
|
+
sort: list[JSONObject],
|
|
338
|
+
limit: int,
|
|
339
|
+
strict_full: bool,
|
|
340
|
+
view_key: str | None,
|
|
341
|
+
view_name: str | None,
|
|
342
|
+
output_profile: str,
|
|
343
|
+
) -> JSONObject:
|
|
344
|
+
if not app_key:
|
|
345
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
346
|
+
if limit <= 0:
|
|
347
|
+
raise_tool_error(QingflowApiError.config_error("limit must be positive"))
|
|
348
|
+
|
|
349
|
+
def runner(session_profile, context):
|
|
350
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
351
|
+
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
352
|
+
compiled_dimensions = self._compile_analyze_dimensions(index, dimensions)
|
|
353
|
+
compiled_metrics = self._compile_analyze_metrics(index, metrics)
|
|
354
|
+
duplicate_aliases = {str(item["alias"]) for item in compiled_dimensions} & {str(item["alias"]) for item in compiled_metrics}
|
|
355
|
+
if duplicate_aliases:
|
|
356
|
+
raise RecordInputError(
|
|
357
|
+
message=f"dimensions and metrics cannot share aliases: {sorted(duplicate_aliases)}",
|
|
358
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
359
|
+
fix_hint="Use distinct aliases across dimensions and metrics.",
|
|
360
|
+
)
|
|
361
|
+
compiled_filters = self._compile_analyze_filters(index, filters)
|
|
362
|
+
compiled_sort = self._compile_analyze_sort(sort, compiled_dimensions, compiled_metrics)
|
|
363
|
+
return self._execute_record_analyze(
|
|
337
364
|
profile=profile,
|
|
365
|
+
session_profile=session_profile,
|
|
366
|
+
context=context,
|
|
338
367
|
app_key=app_key,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
368
|
+
index=index,
|
|
369
|
+
view_selection=view_selection,
|
|
370
|
+
dimensions=compiled_dimensions,
|
|
371
|
+
metrics=compiled_metrics,
|
|
372
|
+
filters=compiled_filters,
|
|
373
|
+
sort=compiled_sort,
|
|
374
|
+
limit=limit,
|
|
375
|
+
strict_full=strict_full,
|
|
376
|
+
output_profile=output_profile,
|
|
343
377
|
)
|
|
344
378
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
379
|
+
return self._run_record_tool(profile, runner)
|
|
380
|
+
|
|
381
|
+
def record_list(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
profile: str,
|
|
385
|
+
app_key: str,
|
|
386
|
+
columns: list[int],
|
|
387
|
+
where: list[JSONObject],
|
|
388
|
+
order_by: list[JSONObject],
|
|
389
|
+
limit: int,
|
|
390
|
+
page: int,
|
|
391
|
+
view_key: str | None,
|
|
392
|
+
view_name: str | None,
|
|
393
|
+
output_profile: str,
|
|
394
|
+
) -> JSONObject:
|
|
395
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
396
|
+
if not app_key:
|
|
397
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
398
|
+
if not columns:
|
|
399
|
+
raise_tool_error(QingflowApiError.config_error("columns is required"))
|
|
400
|
+
if any(not isinstance(item, int) or item < 0 for item in columns):
|
|
401
|
+
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
402
|
+
if limit <= 0:
|
|
403
|
+
raise_tool_error(QingflowApiError.config_error("limit must be positive"))
|
|
404
|
+
if page <= 0:
|
|
405
|
+
raise_tool_error(QingflowApiError.config_error("page must be positive"))
|
|
406
|
+
|
|
407
|
+
raw = self.record_query(
|
|
408
|
+
profile=profile,
|
|
409
|
+
query_mode="list",
|
|
410
|
+
app_key=app_key,
|
|
411
|
+
apply_id=None,
|
|
412
|
+
page_num=page,
|
|
413
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
414
|
+
requested_pages=1,
|
|
415
|
+
scan_max_pages=1,
|
|
416
|
+
auto_expand_pages=False,
|
|
417
|
+
query_key=None,
|
|
418
|
+
filters=self._normalize_record_list_where(where),
|
|
419
|
+
sorts=self._normalize_record_list_order_by(order_by),
|
|
420
|
+
max_rows=limit,
|
|
421
|
+
max_columns=len(columns),
|
|
422
|
+
select_columns=columns,
|
|
423
|
+
amount_column=None,
|
|
424
|
+
time_range={},
|
|
425
|
+
stat_policy={},
|
|
426
|
+
strict_full=False,
|
|
427
|
+
output_profile="verbose" if normalized_output_profile == "verbose" else DEFAULT_OUTPUT_PROFILE,
|
|
428
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
429
|
+
view_key=view_key,
|
|
430
|
+
view_name=view_name,
|
|
431
|
+
)
|
|
432
|
+
list_data = cast(JSONObject, cast(JSONObject, raw["data"])["list"])
|
|
433
|
+
pagination = cast(JSONObject, list_data["pagination"])
|
|
434
|
+
warnings: list[JSONObject] = []
|
|
435
|
+
warning = _normalize_optional_text(list_data.get("analysis_warning"))
|
|
436
|
+
if warning:
|
|
437
|
+
warnings.append({"code": "BROWSE_ONLY", "message": warning})
|
|
438
|
+
response: JSONObject = {
|
|
439
|
+
"profile": profile,
|
|
440
|
+
"ws_id": raw.get("ws_id"),
|
|
441
|
+
"ok": bool(raw.get("ok", True)),
|
|
442
|
+
"request_route": raw.get("request_route"),
|
|
443
|
+
"warnings": warnings,
|
|
444
|
+
"output_profile": normalized_output_profile,
|
|
445
|
+
"data": {
|
|
446
|
+
"app_key": app_key,
|
|
447
|
+
"items": list_data.get("rows", []),
|
|
448
|
+
"pagination": {
|
|
449
|
+
"page": page,
|
|
450
|
+
"limit": limit,
|
|
451
|
+
"returned_items": pagination.get("returned_items"),
|
|
452
|
+
"result_amount": pagination.get("result_amount"),
|
|
453
|
+
},
|
|
454
|
+
"selection": {
|
|
455
|
+
"columns": columns,
|
|
456
|
+
"view": cast(JSONObject, raw["data"]).get("view"),
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
if normalized_output_profile == "verbose":
|
|
461
|
+
response["data"]["debug"] = {
|
|
462
|
+
"completeness": raw.get("completeness"),
|
|
463
|
+
"evidence": raw.get("evidence"),
|
|
464
|
+
"resolved_mappings": raw.get("resolved_mappings"),
|
|
465
|
+
"row_cap_hit": list_data.get("row_cap_hit"),
|
|
466
|
+
"sample_only": list_data.get("sample_only"),
|
|
467
|
+
}
|
|
468
|
+
return response
|
|
469
|
+
|
|
470
|
+
def record_get_public(
|
|
471
|
+
self,
|
|
472
|
+
*,
|
|
473
|
+
profile: str,
|
|
474
|
+
app_key: str,
|
|
475
|
+
record_id: int,
|
|
476
|
+
columns: list[int],
|
|
477
|
+
workflow_node_id: int | None,
|
|
478
|
+
output_profile: str,
|
|
479
|
+
) -> JSONObject:
|
|
480
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
481
|
+
if record_id <= 0:
|
|
482
|
+
raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
|
|
483
|
+
if columns and any(not isinstance(item, int) or item < 0 for item in columns):
|
|
484
|
+
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
485
|
+
|
|
486
|
+
if columns:
|
|
487
|
+
raw = self.record_query(
|
|
357
488
|
profile=profile,
|
|
489
|
+
query_mode="record",
|
|
358
490
|
app_key=app_key,
|
|
359
|
-
apply_id=
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
491
|
+
apply_id=record_id,
|
|
492
|
+
page_num=1,
|
|
493
|
+
page_size=20,
|
|
494
|
+
requested_pages=1,
|
|
495
|
+
scan_max_pages=1,
|
|
496
|
+
auto_expand_pages=False,
|
|
497
|
+
query_key=None,
|
|
498
|
+
filters=[],
|
|
499
|
+
sorts=[],
|
|
500
|
+
max_rows=1,
|
|
501
|
+
max_columns=len(columns),
|
|
502
|
+
select_columns=columns,
|
|
503
|
+
amount_column=None,
|
|
504
|
+
time_range={},
|
|
505
|
+
stat_policy={},
|
|
506
|
+
strict_full=False,
|
|
507
|
+
output_profile="verbose" if normalized_output_profile == "verbose" else DEFAULT_OUTPUT_PROFILE,
|
|
508
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
509
|
+
)
|
|
510
|
+
record_data = cast(JSONObject, cast(JSONObject, raw["data"])["record"])
|
|
511
|
+
response: JSONObject = {
|
|
512
|
+
"profile": profile,
|
|
513
|
+
"ws_id": raw.get("ws_id"),
|
|
514
|
+
"ok": bool(raw.get("ok", True)),
|
|
515
|
+
"request_route": raw.get("request_route"),
|
|
516
|
+
"warnings": [],
|
|
517
|
+
"output_profile": normalized_output_profile,
|
|
518
|
+
"data": {
|
|
519
|
+
"app_key": app_key,
|
|
520
|
+
"record_id": record_id,
|
|
521
|
+
"record": record_data.get("row"),
|
|
522
|
+
"selection": {
|
|
523
|
+
"columns": columns,
|
|
524
|
+
"workflow_node_id": workflow_node_id,
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
}
|
|
528
|
+
if normalized_output_profile == "verbose":
|
|
529
|
+
response["data"]["debug"] = {
|
|
530
|
+
"evidence": raw.get("evidence"),
|
|
531
|
+
"resolved_mappings": raw.get("resolved_mappings"),
|
|
532
|
+
}
|
|
533
|
+
return response
|
|
534
|
+
|
|
535
|
+
raw = self.record_get(
|
|
536
|
+
profile=profile,
|
|
537
|
+
app_key=app_key,
|
|
538
|
+
apply_id=record_id,
|
|
539
|
+
role=1,
|
|
540
|
+
list_type=None,
|
|
541
|
+
audit_node_id=workflow_node_id,
|
|
542
|
+
)
|
|
543
|
+
return {
|
|
544
|
+
"profile": profile,
|
|
545
|
+
"ws_id": raw.get("ws_id"),
|
|
546
|
+
"ok": bool(raw.get("ok", True)),
|
|
547
|
+
"request_route": raw.get("request_route"),
|
|
548
|
+
"warnings": [],
|
|
549
|
+
"output_profile": normalized_output_profile,
|
|
550
|
+
"data": {
|
|
551
|
+
"app_key": app_key,
|
|
552
|
+
"record_id": record_id,
|
|
553
|
+
"record": raw.get("result"),
|
|
554
|
+
"selection": {
|
|
555
|
+
"columns": columns,
|
|
556
|
+
"workflow_node_id": workflow_node_id,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
def record_write(
|
|
562
|
+
self,
|
|
563
|
+
*,
|
|
564
|
+
profile: str,
|
|
565
|
+
app_key: str,
|
|
566
|
+
operation: str,
|
|
567
|
+
mode: str,
|
|
568
|
+
record_id: int | None,
|
|
569
|
+
record_ids: list[int],
|
|
570
|
+
values: list[JSONObject],
|
|
571
|
+
set: list[JSONObject],
|
|
572
|
+
submit_type: str | int,
|
|
573
|
+
verify_write: bool,
|
|
574
|
+
output_profile: str,
|
|
575
|
+
) -> JSONObject:
|
|
576
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
577
|
+
normalized_operation = operation.strip().lower()
|
|
578
|
+
normalized_mode = mode.strip().lower()
|
|
579
|
+
if normalized_operation not in {"insert", "update", "delete"}:
|
|
580
|
+
raise_tool_error(QingflowApiError.config_error("operation must be insert, update, or delete"))
|
|
581
|
+
if normalized_mode not in {"plan", "apply"}:
|
|
582
|
+
raise_tool_error(QingflowApiError.config_error("mode must be plan or apply"))
|
|
583
|
+
if not app_key:
|
|
584
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
585
|
+
normalized_record_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
|
|
586
|
+
submit_type_value = self._normalize_record_write_submit_type(submit_type)
|
|
587
|
+
|
|
588
|
+
if normalized_operation == "insert":
|
|
589
|
+
if record_id is not None or normalized_record_ids:
|
|
590
|
+
raise_tool_error(QingflowApiError.config_error("insert must not include record_id or record_ids"))
|
|
591
|
+
if set:
|
|
592
|
+
raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
|
|
593
|
+
normalized_answers = self._normalize_record_write_clauses(values, location="values")
|
|
594
|
+
normalized_payload: JSONObject = {
|
|
595
|
+
"operation": "insert",
|
|
596
|
+
"record_id": None,
|
|
597
|
+
"record_ids": [],
|
|
598
|
+
"answers": normalized_answers,
|
|
599
|
+
"submit_type": submit_type_value,
|
|
600
|
+
}
|
|
601
|
+
if normalized_mode == "plan":
|
|
602
|
+
raw_plan = self.record_write_plan(
|
|
603
|
+
profile=profile,
|
|
604
|
+
operation="create",
|
|
605
|
+
app_key=app_key,
|
|
606
|
+
apply_id=None,
|
|
607
|
+
answers=normalized_answers,
|
|
608
|
+
fields={},
|
|
609
|
+
force_refresh_form=False,
|
|
610
|
+
)
|
|
611
|
+
return self._record_write_plan_response(
|
|
612
|
+
raw_plan,
|
|
613
|
+
operation="insert",
|
|
614
|
+
normalized_payload=normalized_payload,
|
|
615
|
+
output_profile=normalized_output_profile,
|
|
616
|
+
human_review=False,
|
|
617
|
+
)
|
|
618
|
+
raw_apply = self.record_create(
|
|
619
|
+
profile=profile,
|
|
620
|
+
app_key=app_key,
|
|
621
|
+
answers=normalized_answers,
|
|
622
|
+
fields={},
|
|
623
|
+
submit_type=submit_type_value,
|
|
363
624
|
verify_write=verify_write,
|
|
364
|
-
force_refresh_form=
|
|
625
|
+
force_refresh_form=False,
|
|
626
|
+
)
|
|
627
|
+
return self._record_write_apply_response(
|
|
628
|
+
raw_apply,
|
|
629
|
+
operation="insert",
|
|
630
|
+
normalized_payload=normalized_payload,
|
|
631
|
+
output_profile=normalized_output_profile,
|
|
632
|
+
human_review=False,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if normalized_operation == "update":
|
|
636
|
+
if record_id is None or record_id <= 0:
|
|
637
|
+
raise_tool_error(QingflowApiError.config_error("update requires record_id"))
|
|
638
|
+
if normalized_record_ids:
|
|
639
|
+
raise_tool_error(QingflowApiError.config_error("update does not support record_ids"))
|
|
640
|
+
if values:
|
|
641
|
+
raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
|
|
642
|
+
normalized_answers = self._normalize_record_write_clauses(set, location="set")
|
|
643
|
+
normalized_payload = {
|
|
644
|
+
"operation": "update",
|
|
645
|
+
"record_id": record_id,
|
|
646
|
+
"record_ids": [],
|
|
647
|
+
"answers": normalized_answers,
|
|
648
|
+
"submit_type": submit_type_value,
|
|
649
|
+
}
|
|
650
|
+
if normalized_mode == "plan":
|
|
651
|
+
raw_plan = self.record_write_plan(
|
|
652
|
+
profile=profile,
|
|
653
|
+
operation="update",
|
|
654
|
+
app_key=app_key,
|
|
655
|
+
apply_id=record_id,
|
|
656
|
+
answers=normalized_answers,
|
|
657
|
+
fields={},
|
|
658
|
+
force_refresh_form=False,
|
|
659
|
+
)
|
|
660
|
+
return self._record_write_plan_response(
|
|
661
|
+
raw_plan,
|
|
662
|
+
operation="update",
|
|
663
|
+
normalized_payload=normalized_payload,
|
|
664
|
+
output_profile=normalized_output_profile,
|
|
665
|
+
human_review=True,
|
|
666
|
+
)
|
|
667
|
+
raw_apply = self.record_update(
|
|
668
|
+
profile=profile,
|
|
669
|
+
app_key=app_key,
|
|
670
|
+
apply_id=record_id,
|
|
671
|
+
answers=normalized_answers,
|
|
672
|
+
fields={},
|
|
673
|
+
role=1,
|
|
674
|
+
verify_write=verify_write,
|
|
675
|
+
force_refresh_form=False,
|
|
676
|
+
)
|
|
677
|
+
return self._record_write_apply_response(
|
|
678
|
+
raw_apply,
|
|
679
|
+
operation="update",
|
|
680
|
+
normalized_payload=normalized_payload,
|
|
681
|
+
output_profile=normalized_output_profile,
|
|
682
|
+
human_review=True,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if values or set:
|
|
686
|
+
raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
|
|
687
|
+
delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
|
|
688
|
+
if not delete_ids:
|
|
689
|
+
raise_tool_error(QingflowApiError.config_error("delete requires record_id or record_ids"))
|
|
690
|
+
normalized_payload = {
|
|
691
|
+
"operation": "delete",
|
|
692
|
+
"record_id": record_id,
|
|
693
|
+
"record_ids": delete_ids,
|
|
694
|
+
"answers": [],
|
|
695
|
+
"submit_type": submit_type_value,
|
|
696
|
+
}
|
|
697
|
+
if normalized_mode == "plan":
|
|
698
|
+
return {
|
|
699
|
+
"profile": profile,
|
|
700
|
+
"ok": True,
|
|
701
|
+
"request_route": None,
|
|
702
|
+
"warnings": [],
|
|
703
|
+
"output_profile": normalized_output_profile,
|
|
704
|
+
"data": {
|
|
705
|
+
"action": {"operation": "delete", "mode": "plan"},
|
|
706
|
+
"resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": delete_ids},
|
|
707
|
+
"verification": None,
|
|
708
|
+
"normalized_payload": normalized_payload,
|
|
709
|
+
"blockers": [],
|
|
710
|
+
"human_review": self._record_write_human_review_payload("delete", enabled=True),
|
|
711
|
+
},
|
|
712
|
+
}
|
|
713
|
+
raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
|
|
714
|
+
return self._record_write_apply_response(
|
|
715
|
+
raw_apply,
|
|
716
|
+
operation="delete",
|
|
717
|
+
normalized_payload=normalized_payload,
|
|
718
|
+
output_profile=normalized_output_profile,
|
|
719
|
+
human_review=True,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def _schema_field_payload(self, field: FormField) -> JSONObject:
|
|
723
|
+
write_hints = self._schema_write_hints(field)
|
|
724
|
+
return {
|
|
725
|
+
"field_id": field.que_id,
|
|
726
|
+
"title": field.que_title,
|
|
727
|
+
"que_type": field.que_type,
|
|
728
|
+
"system": field.system,
|
|
729
|
+
"readonly": field.readonly,
|
|
730
|
+
"options": field.options,
|
|
731
|
+
"aliases": field.aliases,
|
|
732
|
+
"role_hints": self._schema_role_hints(field),
|
|
733
|
+
"readable": True,
|
|
734
|
+
"writable": write_hints["writable"],
|
|
735
|
+
"write_kind": write_hints["write_kind"],
|
|
736
|
+
"supported_read_ops": write_hints["supported_read_ops"],
|
|
737
|
+
"supported_write_ops": write_hints["supported_write_ops"],
|
|
738
|
+
"requires_lookup": write_hints["requires_lookup"],
|
|
739
|
+
"requires_upload": write_hints["requires_upload"],
|
|
740
|
+
"requires_existing_row_id": write_hints["requires_existing_row_id"],
|
|
741
|
+
"unsupported_reason": write_hints["unsupported_reason"],
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
def _schema_role_hints(self, field: FormField) -> JSONObject:
|
|
745
|
+
field_family = self._schema_field_family(field)
|
|
746
|
+
time_candidate = field.que_type in DATE_QUE_TYPES
|
|
747
|
+
identifier_like = self._schema_is_identifier_like(field, field_family=field_family)
|
|
748
|
+
metric_candidate = bool(field.que_type == 8 and not field.system and not field.readonly and not identifier_like)
|
|
749
|
+
dimension_candidate = bool(
|
|
750
|
+
field.que_type not in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES | VERIFY_UNSUPPORTED_WRITE_QUE_TYPES
|
|
751
|
+
and not field.system
|
|
752
|
+
)
|
|
753
|
+
return {
|
|
754
|
+
"dimension_candidate": dimension_candidate,
|
|
755
|
+
"metric_candidate": metric_candidate,
|
|
756
|
+
"time_candidate": time_candidate,
|
|
757
|
+
"field_family": field_family,
|
|
758
|
+
"supported_metric_ops": self._schema_supported_metric_ops(field, field_family=field_family),
|
|
759
|
+
"semantic_hints": self._schema_semantic_hint(field, field_family=field_family),
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
def _schema_write_hints(self, field: FormField) -> JSONObject:
|
|
763
|
+
write_format = _write_format_for_field(field)
|
|
764
|
+
kind = _normalize_optional_text(write_format.get("kind")) or "scalar_text"
|
|
765
|
+
support_level = _normalize_optional_text(write_format.get("support_level")) or "full"
|
|
766
|
+
write_kind = self._schema_write_kind(kind)
|
|
767
|
+
writable = bool(not field.system and not field.readonly and support_level != "unsupported")
|
|
768
|
+
supported_write_ops = ["insert", "update"] if writable else []
|
|
769
|
+
requires_lookup = write_kind in {"member", "department", "relation"}
|
|
770
|
+
requires_upload = write_kind == "attachment"
|
|
771
|
+
requires_existing_row_id = write_kind == "subtable"
|
|
772
|
+
unsupported_reason = _normalize_optional_text(write_format.get("reason"))
|
|
773
|
+
supported_read_ops = ["select"]
|
|
774
|
+
if field.que_type not in ATTACHMENT_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
775
|
+
supported_read_ops.append("filter")
|
|
776
|
+
if field.que_type not in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
777
|
+
supported_read_ops.append("sort")
|
|
778
|
+
return {
|
|
779
|
+
"writable": writable,
|
|
780
|
+
"write_kind": write_kind,
|
|
781
|
+
"supported_read_ops": supported_read_ops,
|
|
782
|
+
"supported_write_ops": supported_write_ops,
|
|
783
|
+
"requires_lookup": requires_lookup,
|
|
784
|
+
"requires_upload": requires_upload,
|
|
785
|
+
"requires_existing_row_id": requires_existing_row_id,
|
|
786
|
+
"unsupported_reason": unsupported_reason,
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
def _schema_write_kind(self, kind: str) -> str:
|
|
790
|
+
mapping = {
|
|
791
|
+
"single_select": "select",
|
|
792
|
+
"multi_select": "select",
|
|
793
|
+
"member_list": "member",
|
|
794
|
+
"department_list": "department",
|
|
795
|
+
"relation_record": "relation",
|
|
796
|
+
"attachment_list": "attachment",
|
|
797
|
+
"subtable_rows": "subtable",
|
|
798
|
+
"unsupported_direct_write": "unsupported",
|
|
799
|
+
"boolean_label": "scalar",
|
|
800
|
+
"date_string": "scalar",
|
|
801
|
+
"scalar_text": "scalar",
|
|
802
|
+
}
|
|
803
|
+
return mapping.get(kind, "scalar")
|
|
804
|
+
|
|
805
|
+
def _schema_field_family(self, field: FormField) -> str:
|
|
806
|
+
if self._schema_is_identifier_like(field):
|
|
807
|
+
return "text"
|
|
808
|
+
que_type = field.que_type
|
|
809
|
+
if que_type == 8:
|
|
810
|
+
return "number"
|
|
811
|
+
if que_type in DATE_QUE_TYPES:
|
|
812
|
+
return "date"
|
|
813
|
+
if que_type in SINGLE_SELECT_QUE_TYPES | MULTI_SELECT_QUE_TYPES:
|
|
814
|
+
return "category"
|
|
815
|
+
if que_type in MEMBER_QUE_TYPES:
|
|
816
|
+
return "member"
|
|
817
|
+
if que_type in DEPARTMENT_QUE_TYPES:
|
|
818
|
+
return "department"
|
|
819
|
+
if que_type in BOOLEAN_QUE_TYPES:
|
|
820
|
+
return "boolean"
|
|
821
|
+
if que_type in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
822
|
+
return "unknown"
|
|
823
|
+
if que_type is None:
|
|
824
|
+
return "unknown"
|
|
825
|
+
return "text"
|
|
826
|
+
|
|
827
|
+
def _schema_is_identifier_like(self, field: FormField, *, field_family: str | None = None) -> bool:
|
|
828
|
+
normalized_title = _normalize_field_lookup_key(field.que_title)
|
|
829
|
+
if field.que_id == 0:
|
|
830
|
+
return True
|
|
831
|
+
if any(
|
|
832
|
+
token in normalized_title for token in ("编号", "单号", "流水号", "编码", "序号", "uid", "id", "code")
|
|
833
|
+
):
|
|
834
|
+
return True
|
|
835
|
+
return False
|
|
836
|
+
|
|
837
|
+
def _schema_supported_metric_ops(self, field: FormField, *, field_family: str) -> list[str]:
|
|
838
|
+
if field.que_type in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES:
|
|
839
|
+
return []
|
|
840
|
+
if self._schema_is_identifier_like(field, field_family=field_family):
|
|
841
|
+
return ["distinct_count"]
|
|
842
|
+
if field_family == "number":
|
|
843
|
+
return ["sum", "avg", "min", "max", "distinct_count"]
|
|
844
|
+
if field_family in {"date", "category", "member", "department", "text", "boolean", "unknown"}:
|
|
845
|
+
return ["distinct_count"]
|
|
846
|
+
return []
|
|
847
|
+
|
|
848
|
+
def _schema_semantic_hint(self, field: FormField, *, field_family: str) -> str:
|
|
849
|
+
if self._schema_is_identifier_like(field, field_family=field_family):
|
|
850
|
+
return "unknown"
|
|
851
|
+
if field_family != "number":
|
|
852
|
+
return "unknown"
|
|
853
|
+
normalized_title = _normalize_field_lookup_key(field.que_title)
|
|
854
|
+
if any(token in normalized_title for token in ("比例", "比率", "占比", "转化率", "渗透率", "毛利率", "折扣率", "百分比")):
|
|
855
|
+
return "ratio_like"
|
|
856
|
+
if any(token in normalized_title for token in ("金额", "arr", "mrr", "收入", "成本", "价格", "单价", "费用", "预算", "报价", "应收", "实收", "总额", "价税")):
|
|
857
|
+
return "money_like"
|
|
858
|
+
if any(token in normalized_title for token in ("数量", "人数", "单量", "个数", "次数", "件数", "台数", "天数", "笔数", "数")):
|
|
859
|
+
return "quantity_like"
|
|
860
|
+
return "unknown"
|
|
861
|
+
|
|
862
|
+
def _resolve_field_by_id(self, field_id: int | None, index: FieldIndex, *, location: str) -> FormField:
|
|
863
|
+
if field_id is None:
|
|
864
|
+
raise RecordInputError(
|
|
865
|
+
message=f"{location} requires field_id",
|
|
866
|
+
error_code="MISSING_FIELD_ID",
|
|
867
|
+
fix_hint="Use record_schema_get and pass a valid field_id from the schema response.",
|
|
868
|
+
)
|
|
869
|
+
field = index.by_id.get(str(field_id))
|
|
870
|
+
if field is None:
|
|
871
|
+
raise RecordInputError(
|
|
872
|
+
message=f"{location} references unknown field_id '{field_id}'",
|
|
873
|
+
error_code="FIELD_NOT_FOUND",
|
|
874
|
+
fix_hint="Use record_schema_get to confirm the exact field_id before calling record_analyze.",
|
|
875
|
+
details={"location": location, "field_id": field_id},
|
|
876
|
+
)
|
|
877
|
+
return field
|
|
878
|
+
|
|
879
|
+
def _ensure_allowed_analyze_keys(
|
|
880
|
+
self,
|
|
881
|
+
item: JSONObject,
|
|
882
|
+
*,
|
|
883
|
+
location: str,
|
|
884
|
+
allowed_keys: set[str],
|
|
885
|
+
example: str,
|
|
886
|
+
) -> None:
|
|
887
|
+
unexpected_keys = sorted(str(key) for key in item.keys() if str(key) not in allowed_keys)
|
|
888
|
+
if unexpected_keys:
|
|
889
|
+
raise RecordInputError(
|
|
890
|
+
message=f"{location} contains unsupported keys: {unexpected_keys}",
|
|
891
|
+
error_code="UNSUPPORTED_ANALYZE_DSL_KEY",
|
|
892
|
+
fix_hint=f"Use {location} in the documented DSL shape only, for example {example}.",
|
|
893
|
+
details={
|
|
894
|
+
"location": location,
|
|
895
|
+
"unexpected_keys": unexpected_keys,
|
|
896
|
+
"allowed_keys": sorted(allowed_keys),
|
|
897
|
+
},
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
def _validate_analyze_filter_value(
|
|
901
|
+
self,
|
|
902
|
+
*,
|
|
903
|
+
field: FormField,
|
|
904
|
+
op: str,
|
|
905
|
+
value: JSONValue,
|
|
906
|
+
location: str,
|
|
907
|
+
) -> JSONValue:
|
|
908
|
+
if op in {"is_null", "not_null"}:
|
|
909
|
+
if value is not None:
|
|
910
|
+
raise RecordInputError(
|
|
911
|
+
message=f"{location} with op '{op}' must omit value",
|
|
912
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
913
|
+
fix_hint="Remove value when using is_null or not_null.",
|
|
914
|
+
details={"location": location, "op": op, "field": _field_ref_payload(field)},
|
|
915
|
+
)
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
if op in {"in", "not_in"}:
|
|
919
|
+
if not isinstance(value, list) or not value:
|
|
920
|
+
raise RecordInputError(
|
|
921
|
+
message=f"{location} with op '{op}' requires a non-empty array value",
|
|
922
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
923
|
+
fix_hint="Use an array for in/not_in, for example ['A', 'B'].",
|
|
924
|
+
details={"location": location, "op": op, "field": _field_ref_payload(field), "received_value": value},
|
|
925
|
+
)
|
|
926
|
+
if field.que_type in DATE_QUE_TYPES:
|
|
927
|
+
for idx, item in enumerate(value):
|
|
928
|
+
self._validate_strict_date_filter_value(item, location=f"{location}.value[{idx}]")
|
|
929
|
+
return value
|
|
930
|
+
|
|
931
|
+
if op == "between":
|
|
932
|
+
lower, upper = _coerce_filter_range(value)
|
|
933
|
+
if lower is None and upper is None:
|
|
934
|
+
raise RecordInputError(
|
|
935
|
+
message=f"{location} with op 'between' requires a two-bound range",
|
|
936
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
937
|
+
fix_hint="Use value like ['2026-03-01', '2026-03-31'] or [100, 200].",
|
|
938
|
+
details={"location": location, "op": op, "field": _field_ref_payload(field), "received_value": value},
|
|
939
|
+
)
|
|
940
|
+
if field.que_type == 8:
|
|
941
|
+
lower_amount = _coerce_amount(lower) if lower is not None else None
|
|
942
|
+
upper_amount = _coerce_amount(upper) if upper is not None else None
|
|
943
|
+
if lower is not None and lower_amount is None:
|
|
944
|
+
raise RecordInputError(
|
|
945
|
+
message=f"{location} lower bound is not a valid numeric value",
|
|
946
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
947
|
+
fix_hint="Use numeric bounds when filtering a numeric field.",
|
|
948
|
+
details={"location": location, "bound": "lower", "field": _field_ref_payload(field), "received_value": lower},
|
|
949
|
+
)
|
|
950
|
+
if upper is not None and upper_amount is None:
|
|
951
|
+
raise RecordInputError(
|
|
952
|
+
message=f"{location} upper bound is not a valid numeric value",
|
|
953
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
954
|
+
fix_hint="Use numeric bounds when filtering a numeric field.",
|
|
955
|
+
details={"location": location, "bound": "upper", "field": _field_ref_payload(field), "received_value": upper},
|
|
956
|
+
)
|
|
957
|
+
if lower_amount is not None and upper_amount is not None and lower_amount > upper_amount:
|
|
958
|
+
raise RecordInputError(
|
|
959
|
+
message=f"{location} lower bound cannot be greater than upper bound",
|
|
960
|
+
error_code="INVALID_ANALYZE_FILTER_RANGE",
|
|
961
|
+
fix_hint="Swap the between bounds so the lower value comes first.",
|
|
962
|
+
details={"location": location, "field": _field_ref_payload(field), "received_value": value},
|
|
963
|
+
)
|
|
964
|
+
return [lower, upper]
|
|
965
|
+
if field.que_type in DATE_QUE_TYPES:
|
|
966
|
+
lower_dt = self._validate_strict_date_filter_value(lower, location=f"{location}.value[0]") if lower is not None else None
|
|
967
|
+
upper_dt = self._validate_strict_date_filter_value(upper, location=f"{location}.value[1]") if upper is not None else None
|
|
968
|
+
if lower_dt is not None and upper_dt is not None and lower_dt > upper_dt:
|
|
969
|
+
raise RecordInputError(
|
|
970
|
+
message=f"{location} lower bound cannot be later than upper bound",
|
|
971
|
+
error_code="INVALID_ANALYZE_FILTER_RANGE",
|
|
972
|
+
fix_hint="Swap the date bounds so the earlier date comes first.",
|
|
973
|
+
details={"location": location, "field": _field_ref_payload(field), "received_value": value},
|
|
974
|
+
)
|
|
975
|
+
return [lower, upper]
|
|
976
|
+
raise RecordInputError(
|
|
977
|
+
message=f"{location} with op 'between' requires a numeric or date/time field",
|
|
978
|
+
error_code="INVALID_ANALYZE_FILTER_FIELD_TYPE",
|
|
979
|
+
fix_hint="Use between only on numeric or date/time fields.",
|
|
980
|
+
details={"location": location, "field": _field_ref_payload(field), "op": op},
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
if op in {"gt", "gte", "lt", "lte"}:
|
|
984
|
+
if value is None or isinstance(value, (list, dict)):
|
|
985
|
+
raise RecordInputError(
|
|
986
|
+
message=f"{location} with op '{op}' requires a single scalar value",
|
|
987
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
988
|
+
fix_hint="Use a single number or date string for comparison filters.",
|
|
989
|
+
details={"location": location, "op": op, "field": _field_ref_payload(field), "received_value": value},
|
|
990
|
+
)
|
|
991
|
+
if field.que_type == 8:
|
|
992
|
+
if _coerce_amount(value) is None:
|
|
993
|
+
raise RecordInputError(
|
|
994
|
+
message=f"{location} requires a numeric comparison value",
|
|
995
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
996
|
+
fix_hint="Use numeric values with gt/gte/lt/lte on numeric fields.",
|
|
997
|
+
details={"location": location, "field": _field_ref_payload(field), "received_value": value},
|
|
998
|
+
)
|
|
999
|
+
return value
|
|
1000
|
+
if field.que_type in DATE_QUE_TYPES:
|
|
1001
|
+
self._validate_strict_date_filter_value(value, location=f"{location}.value")
|
|
1002
|
+
return value
|
|
1003
|
+
raise RecordInputError(
|
|
1004
|
+
message=f"{location} with op '{op}' requires a numeric or date/time field",
|
|
1005
|
+
error_code="INVALID_ANALYZE_FILTER_FIELD_TYPE",
|
|
1006
|
+
fix_hint="Use gt/gte/lt/lte only on numeric or date/time fields.",
|
|
1007
|
+
details={"location": location, "field": _field_ref_payload(field), "op": op},
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
if value is None:
|
|
1011
|
+
raise RecordInputError(
|
|
1012
|
+
message=f"{location} with op '{op}' requires value",
|
|
1013
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
1014
|
+
fix_hint="Provide value for this filter, or use is_null / not_null.",
|
|
1015
|
+
details={"location": location, "op": op, "field": _field_ref_payload(field)},
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
if op == "contains" and isinstance(value, (list, dict)):
|
|
1019
|
+
raise RecordInputError(
|
|
1020
|
+
message=f"{location} with op 'contains' requires a single text value",
|
|
1021
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
1022
|
+
fix_hint="Use a single string for contains filters.",
|
|
1023
|
+
details={"location": location, "field": _field_ref_payload(field), "received_value": value},
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
if field.que_type in DATE_QUE_TYPES and op in {"eq", "neq"}:
|
|
1027
|
+
if isinstance(value, list):
|
|
1028
|
+
raise RecordInputError(
|
|
1029
|
+
message=f"{location} with op '{op}' requires a single date value",
|
|
1030
|
+
error_code="INVALID_ANALYZE_FILTER_VALUE",
|
|
1031
|
+
fix_hint="Use a single date string for eq/neq on date fields.",
|
|
1032
|
+
details={"location": location, "field": _field_ref_payload(field), "received_value": value},
|
|
1033
|
+
)
|
|
1034
|
+
self._validate_strict_date_filter_value(value, location=f"{location}.value")
|
|
1035
|
+
return value
|
|
1036
|
+
|
|
1037
|
+
def _validate_strict_date_filter_value(self, value: JSONValue, *, location: str) -> datetime:
|
|
1038
|
+
text = _normalize_optional_text(value)
|
|
1039
|
+
if text is None:
|
|
1040
|
+
raise RecordInputError(
|
|
1041
|
+
message=f"{location} requires a concrete date or datetime string",
|
|
1042
|
+
error_code="INVALID_DATE_FILTER_VALUE",
|
|
1043
|
+
fix_hint="Use a valid ISO-like date such as 2026-03-01 or 2026-03-01 00:00:00.",
|
|
1044
|
+
details={"location": location, "received_value": value},
|
|
1045
|
+
)
|
|
1046
|
+
parsed = _parse_datetime_like(text)
|
|
1047
|
+
if parsed is None:
|
|
1048
|
+
raise RecordInputError(
|
|
1049
|
+
message=f"{location} uses an invalid date value '{text}'",
|
|
1050
|
+
error_code="INVALID_DATE_FILTER_VALUE",
|
|
1051
|
+
fix_hint="Normalize relative time phrases into a real date range, and avoid impossible dates like 2026-02-29.",
|
|
1052
|
+
details={"location": location, "received_value": value},
|
|
1053
|
+
)
|
|
1054
|
+
return parsed
|
|
1055
|
+
|
|
1056
|
+
def _compile_analyze_dimensions(self, index: FieldIndex, dimensions: list[JSONObject]) -> list[JSONObject]:
|
|
1057
|
+
supported_buckets = {None, "day", "week", "month", "quarter", "year"}
|
|
1058
|
+
compiled: list[JSONObject] = []
|
|
1059
|
+
used_aliases: set[str] = set()
|
|
1060
|
+
for idx, item in enumerate(dimensions):
|
|
1061
|
+
if not isinstance(item, dict):
|
|
1062
|
+
raise RecordInputError(
|
|
1063
|
+
message=f"dimensions[{idx}] must be an object",
|
|
1064
|
+
error_code="INVALID_ANALYZE_DIMENSION",
|
|
1065
|
+
fix_hint="Pass dimensions like {'field_id': 2, 'alias': '状态'}",
|
|
1066
|
+
)
|
|
1067
|
+
self._ensure_allowed_analyze_keys(
|
|
1068
|
+
item,
|
|
1069
|
+
location=f"dimensions[{idx}]",
|
|
1070
|
+
allowed_keys={"field_id", "fieldId", "alias", "bucket"},
|
|
1071
|
+
example="{'field_id': 2, 'alias': '状态', 'bucket': 'month'}",
|
|
1072
|
+
)
|
|
1073
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
1074
|
+
field = self._resolve_field_by_id(field_id, index, location=f"dimensions[{idx}]")
|
|
1075
|
+
bucket = _normalize_optional_text(item.get("bucket"))
|
|
1076
|
+
if bucket not in supported_buckets:
|
|
1077
|
+
raise RecordInputError(
|
|
1078
|
+
message=f"dimensions[{idx}] uses unsupported bucket '{bucket}'",
|
|
1079
|
+
error_code="UNSUPPORTED_TIME_BUCKET",
|
|
1080
|
+
fix_hint="Use one of day/week/month/quarter/year, or omit bucket.",
|
|
1081
|
+
)
|
|
1082
|
+
if bucket is not None and field.que_type not in DATE_QUE_TYPES:
|
|
1083
|
+
raise RecordInputError(
|
|
1084
|
+
message=f"dimensions[{idx}] bucket requires a date/time field",
|
|
1085
|
+
error_code="INVALID_TIME_BUCKET_FIELD",
|
|
1086
|
+
fix_hint="Use bucket only on fields returned in suggested_time_fields by record_schema_get.",
|
|
1087
|
+
details={"field": _field_ref_payload(field), "bucket": bucket},
|
|
1088
|
+
)
|
|
1089
|
+
alias = _normalize_optional_text(item.get("alias")) or field.que_title
|
|
1090
|
+
if alias in used_aliases:
|
|
1091
|
+
raise RecordInputError(
|
|
1092
|
+
message=f"dimensions[{idx}] alias '{alias}' is duplicated",
|
|
1093
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
1094
|
+
fix_hint="Use unique aliases across dimensions and metrics.",
|
|
1095
|
+
)
|
|
1096
|
+
used_aliases.add(alias)
|
|
1097
|
+
compiled.append({"field": field, "alias": alias, "bucket": bucket})
|
|
1098
|
+
return compiled
|
|
1099
|
+
|
|
1100
|
+
def _compile_analyze_metrics(self, index: FieldIndex, metrics: list[JSONObject]) -> list[JSONObject]:
|
|
1101
|
+
requested_metrics = metrics or [{"op": "count", "alias": "记录数"}]
|
|
1102
|
+
supported_ops = {"count", "sum", "avg", "min", "max", "distinct_count"}
|
|
1103
|
+
compiled: list[JSONObject] = []
|
|
1104
|
+
used_aliases: set[str] = set()
|
|
1105
|
+
for idx, item in enumerate(requested_metrics):
|
|
1106
|
+
if not isinstance(item, dict):
|
|
1107
|
+
raise RecordInputError(
|
|
1108
|
+
message=f"metrics[{idx}] must be an object",
|
|
1109
|
+
error_code="INVALID_ANALYZE_METRIC",
|
|
1110
|
+
fix_hint="Pass metrics like {'op': 'count', 'alias': '记录数'}",
|
|
1111
|
+
)
|
|
1112
|
+
self._ensure_allowed_analyze_keys(
|
|
1113
|
+
item,
|
|
1114
|
+
location=f"metrics[{idx}]",
|
|
1115
|
+
allowed_keys={"op", "field_id", "fieldId", "alias"},
|
|
1116
|
+
example="{'op': 'sum', 'field_id': 7, 'alias': '总金额'}",
|
|
1117
|
+
)
|
|
1118
|
+
op = _normalize_optional_text(item.get("op"))
|
|
1119
|
+
if op not in supported_ops:
|
|
1120
|
+
raise RecordInputError(
|
|
1121
|
+
message=f"metrics[{idx}] uses unsupported op '{op}'",
|
|
1122
|
+
error_code="UNSUPPORTED_ANALYZE_METRIC",
|
|
1123
|
+
fix_hint="Use one of count/sum/avg/min/max/distinct_count.",
|
|
1124
|
+
)
|
|
1125
|
+
field: FormField | None = None
|
|
1126
|
+
if op != "count":
|
|
1127
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
1128
|
+
field = self._resolve_field_by_id(field_id, index, location=f"metrics[{idx}]")
|
|
1129
|
+
if op in {"sum", "avg", "min", "max"} and field.que_type != 8:
|
|
1130
|
+
raise RecordInputError(
|
|
1131
|
+
message=f"metrics[{idx}] with op '{op}' requires a numeric field",
|
|
1132
|
+
error_code="INVALID_METRIC_FIELD_TYPE",
|
|
1133
|
+
fix_hint="Use sum/avg/min/max only on numeric fields returned by record_schema_get.",
|
|
1134
|
+
details={"location": f"metrics[{idx}]", "field": _field_ref_payload(field), "op": op},
|
|
1135
|
+
)
|
|
1136
|
+
elif item.get("field_id", item.get("fieldId")) is not None:
|
|
1137
|
+
raise RecordInputError(
|
|
1138
|
+
message=f"metrics[{idx}] with op 'count' must not include field_id",
|
|
1139
|
+
error_code="INVALID_ANALYZE_METRIC",
|
|
1140
|
+
fix_hint="Remove field_id from count metrics.",
|
|
1141
|
+
details={"location": f"metrics[{idx}]", "op": op},
|
|
1142
|
+
)
|
|
1143
|
+
alias = _normalize_optional_text(item.get("alias"))
|
|
1144
|
+
if alias is None:
|
|
1145
|
+
if op == "count":
|
|
1146
|
+
alias = "count"
|
|
1147
|
+
elif field is not None:
|
|
1148
|
+
alias = f"{field.que_title}_{op}"
|
|
1149
|
+
else:
|
|
1150
|
+
alias = op
|
|
1151
|
+
if alias in used_aliases:
|
|
1152
|
+
raise RecordInputError(
|
|
1153
|
+
message=f"metrics[{idx}] alias '{alias}' is duplicated",
|
|
1154
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
1155
|
+
fix_hint="Use unique aliases across dimensions and metrics.",
|
|
1156
|
+
)
|
|
1157
|
+
used_aliases.add(alias)
|
|
1158
|
+
compiled.append({"op": op, "field": field, "alias": alias})
|
|
1159
|
+
return compiled
|
|
1160
|
+
|
|
1161
|
+
def _compile_analyze_filters(self, index: FieldIndex, filters: list[JSONObject]) -> list[JSONObject]:
|
|
1162
|
+
supported_ops = {"eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "between", "contains", "is_null", "not_null"}
|
|
1163
|
+
compiled: list[JSONObject] = []
|
|
1164
|
+
for idx, item in enumerate(filters):
|
|
1165
|
+
if not isinstance(item, dict):
|
|
1166
|
+
raise RecordInputError(
|
|
1167
|
+
message=f"filters[{idx}] must be an object",
|
|
1168
|
+
error_code="INVALID_ANALYZE_FILTER",
|
|
1169
|
+
fix_hint="Pass filters like {'field_id': 2, 'op': 'eq', 'value': '进行中'}.",
|
|
1170
|
+
)
|
|
1171
|
+
self._ensure_allowed_analyze_keys(
|
|
1172
|
+
item,
|
|
1173
|
+
location=f"filters[{idx}]",
|
|
1174
|
+
allowed_keys={"field_id", "fieldId", "op", "operator", "value", "values"},
|
|
1175
|
+
example="{'field_id': 2, 'op': 'eq', 'value': '进行中'}",
|
|
1176
|
+
)
|
|
1177
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
1178
|
+
op = _normalize_optional_text(item.get("op", item.get("operator"))) or "eq"
|
|
1179
|
+
if op not in supported_ops:
|
|
1180
|
+
raise RecordInputError(
|
|
1181
|
+
message=f"filters[{idx}] uses unsupported op '{op}'",
|
|
1182
|
+
error_code="UNSUPPORTED_ANALYZE_FILTER_OP",
|
|
1183
|
+
fix_hint="Use one of eq/neq/in/not_in/gt/gte/lt/lte/between/contains/is_null/not_null.",
|
|
1184
|
+
)
|
|
1185
|
+
field = self._resolve_field_by_id(field_id, index, location=f"filters[{idx}]")
|
|
1186
|
+
normalized_value = self._validate_analyze_filter_value(
|
|
1187
|
+
field=field,
|
|
1188
|
+
op=op,
|
|
1189
|
+
value=item.get("value", item.get("values")),
|
|
1190
|
+
location=f"filters[{idx}]",
|
|
1191
|
+
)
|
|
1192
|
+
compiled.append({"field": field, "field_id": field_id, "op": op, "value": normalized_value})
|
|
1193
|
+
return compiled
|
|
1194
|
+
|
|
1195
|
+
def _compile_analyze_sort(self, sort: list[JSONObject], dimensions: list[JSONObject], metrics: list[JSONObject]) -> list[JSONObject]:
|
|
1196
|
+
dimension_aliases = {str(item["alias"]) for item in dimensions}
|
|
1197
|
+
metric_aliases = {str(item["alias"]) for item in metrics}
|
|
1198
|
+
compiled: list[JSONObject] = []
|
|
1199
|
+
for idx, item in enumerate(sort):
|
|
1200
|
+
if not isinstance(item, dict):
|
|
1201
|
+
raise RecordInputError(
|
|
1202
|
+
message=f"sort[{idx}] must be an object",
|
|
1203
|
+
error_code="INVALID_ANALYZE_SORT",
|
|
1204
|
+
fix_hint="Pass sort like {'by': '记录数', 'order': 'desc'}.",
|
|
1205
|
+
)
|
|
1206
|
+
self._ensure_allowed_analyze_keys(
|
|
1207
|
+
item,
|
|
1208
|
+
location=f"sort[{idx}]",
|
|
1209
|
+
allowed_keys={"by", "order"},
|
|
1210
|
+
example="{'by': '记录数', 'order': 'desc'}",
|
|
365
1211
|
)
|
|
1212
|
+
by = _normalize_optional_text(item.get("by"))
|
|
1213
|
+
if by is None:
|
|
1214
|
+
raise RecordInputError(
|
|
1215
|
+
message=f"sort[{idx}] requires by",
|
|
1216
|
+
error_code="MISSING_SORT_KEY",
|
|
1217
|
+
fix_hint="Use a dimension alias or metric alias in sort.by.",
|
|
1218
|
+
)
|
|
1219
|
+
order = (_normalize_optional_text(item.get("order")) or "asc").lower()
|
|
1220
|
+
if order not in {"asc", "desc"}:
|
|
1221
|
+
raise RecordInputError(
|
|
1222
|
+
message=f"sort[{idx}] uses unsupported order '{order}'",
|
|
1223
|
+
error_code="INVALID_SORT_ORDER",
|
|
1224
|
+
fix_hint="Use asc or desc.",
|
|
1225
|
+
)
|
|
1226
|
+
if by in dimension_aliases:
|
|
1227
|
+
compiled.append({"by": by, "order": order, "kind": "dimension"})
|
|
1228
|
+
continue
|
|
1229
|
+
if by in metric_aliases:
|
|
1230
|
+
compiled.append({"by": by, "order": order, "kind": "metric"})
|
|
1231
|
+
continue
|
|
1232
|
+
raise RecordInputError(
|
|
1233
|
+
message=f"sort[{idx}] references unknown alias '{by}'",
|
|
1234
|
+
error_code="UNKNOWN_ANALYZE_SORT_KEY",
|
|
1235
|
+
fix_hint="Use a dimension alias or metric alias defined in the same record_analyze request.",
|
|
1236
|
+
)
|
|
1237
|
+
return compiled
|
|
366
1238
|
|
|
367
|
-
|
|
368
|
-
def record_delete(
|
|
369
|
-
profile: str = DEFAULT_PROFILE,
|
|
370
|
-
app_key: str = "",
|
|
371
|
-
apply_id: int = 0,
|
|
372
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
373
|
-
) -> JSONObject:
|
|
374
|
-
return self.record_delete(profile=profile, app_key=app_key, apply_id=apply_id, list_type=list_type)
|
|
375
|
-
|
|
376
|
-
def record_field_resolve(
|
|
1239
|
+
def _execute_record_analyze(
|
|
377
1240
|
self,
|
|
378
1241
|
*,
|
|
379
1242
|
profile: str,
|
|
1243
|
+
session_profile,
|
|
1244
|
+
context, # type: ignore[no-untyped-def]
|
|
380
1245
|
app_key: str,
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1246
|
+
index: FieldIndex,
|
|
1247
|
+
view_selection: ViewSelection | None,
|
|
1248
|
+
dimensions: list[JSONObject],
|
|
1249
|
+
metrics: list[JSONObject],
|
|
1250
|
+
filters: list[JSONObject],
|
|
1251
|
+
sort: list[JSONObject],
|
|
1252
|
+
limit: int,
|
|
1253
|
+
strict_full: bool,
|
|
1254
|
+
output_profile: str,
|
|
385
1255
|
) -> JSONObject:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1256
|
+
started_at = time.perf_counter()
|
|
1257
|
+
analysis_paging = _fixed_analysis_scan_policy()
|
|
1258
|
+
page_size = int(analysis_paging["page_size"])
|
|
1259
|
+
requested_pages = int(analysis_paging["requested_pages"])
|
|
1260
|
+
scan_max_pages = int(analysis_paging["scan_max_pages"])
|
|
1261
|
+
auto_expand_pages = bool(analysis_paging["auto_expand_pages"])
|
|
1262
|
+
query_id = _query_id()
|
|
1263
|
+
pages_to_scan = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
1264
|
+
current_page = 1
|
|
1265
|
+
scanned_pages = 0
|
|
1266
|
+
source_pages: list[int] = []
|
|
1267
|
+
result_amount: int | None = None
|
|
1268
|
+
has_more = False
|
|
1269
|
+
dept_member_cache: dict[int, set[int]] = {}
|
|
1270
|
+
local_filtering = bool(filters) or bool(view_selection is not None and view_selection.conditions)
|
|
1271
|
+
group_stats: dict[tuple[tuple[str, object], ...], JSONObject] = {}
|
|
1272
|
+
overall_metrics = self._initialize_metric_states(metrics)
|
|
1273
|
+
matched_rows = 0
|
|
1274
|
+
scan_control: JSONObject = {
|
|
1275
|
+
"requested_pages": max(requested_pages, 1),
|
|
1276
|
+
"scan_max_pages": max(scan_max_pages, 1),
|
|
1277
|
+
"auto_expand_pages": auto_expand_pages,
|
|
1278
|
+
"auto_expand_applied": False,
|
|
1279
|
+
"auto_expand_target_pages": pages_to_scan,
|
|
1280
|
+
"auto_expand_page_cap": DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP,
|
|
1281
|
+
}
|
|
393
1282
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
1283
|
+
while scanned_pages < pages_to_scan:
|
|
1284
|
+
page = self._search_page(
|
|
1285
|
+
context,
|
|
1286
|
+
app_key=app_key,
|
|
1287
|
+
page_num=current_page,
|
|
1288
|
+
page_size=page_size,
|
|
1289
|
+
query_key=None,
|
|
1290
|
+
match_rules=[],
|
|
1291
|
+
sorts=[],
|
|
1292
|
+
search_que_ids=None,
|
|
1293
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
1294
|
+
)
|
|
1295
|
+
scanned_pages += 1
|
|
1296
|
+
source_pages.append(current_page)
|
|
1297
|
+
items = page.get("list") if isinstance(page.get("list"), list) else []
|
|
1298
|
+
if result_amount is None:
|
|
1299
|
+
result_amount = _effective_total(page, page_size)
|
|
1300
|
+
pages_to_scan, scan_control = _compute_scan_limit(
|
|
1301
|
+
requested_pages=requested_pages,
|
|
1302
|
+
scan_max_pages=scan_max_pages,
|
|
1303
|
+
auto_expand_pages=auto_expand_pages,
|
|
1304
|
+
page=page,
|
|
1305
|
+
page_size=page_size,
|
|
1306
|
+
)
|
|
1307
|
+
has_more = _page_has_more(page, current_page, page_size, len(items))
|
|
1308
|
+
for item in items:
|
|
1309
|
+
if not isinstance(item, dict):
|
|
400
1310
|
continue
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1311
|
+
answers = item.get("answers")
|
|
1312
|
+
answer_list = answers if isinstance(answers, list) else []
|
|
1313
|
+
if not self._matches_view_selection(
|
|
1314
|
+
context,
|
|
1315
|
+
answer_list,
|
|
1316
|
+
view_selection=view_selection,
|
|
1317
|
+
dept_member_cache=dept_member_cache,
|
|
1318
|
+
):
|
|
1319
|
+
continue
|
|
1320
|
+
if not self._matches_analyze_filters(answer_list, filters):
|
|
1321
|
+
continue
|
|
1322
|
+
matched_rows += 1
|
|
1323
|
+
self._apply_metric_states(overall_metrics, metrics, answer_list)
|
|
1324
|
+
if not dimensions:
|
|
1325
|
+
continue
|
|
1326
|
+
group_payload = self._build_analyze_group_payload(answer_list, dimensions)
|
|
1327
|
+
group_key = self._analysis_group_key(group_payload)
|
|
1328
|
+
bucket = group_stats.get(group_key)
|
|
1329
|
+
if bucket is None:
|
|
1330
|
+
bucket = {
|
|
1331
|
+
"dimensions": group_payload,
|
|
1332
|
+
"metrics_state": self._initialize_metric_states(metrics),
|
|
1333
|
+
}
|
|
1334
|
+
group_stats[group_key] = bucket
|
|
1335
|
+
bucket_metrics = cast(dict[str, JSONObject], bucket["metrics_state"])
|
|
1336
|
+
self._apply_metric_states(bucket_metrics, metrics, answer_list)
|
|
1337
|
+
if not has_more:
|
|
1338
|
+
break
|
|
1339
|
+
current_page += 1
|
|
1340
|
+
|
|
1341
|
+
metric_totals = self._render_metric_values(overall_metrics, metrics)
|
|
1342
|
+
if dimensions:
|
|
1343
|
+
all_rows = [
|
|
1344
|
+
{
|
|
1345
|
+
"dimensions": cast(JSONObject, bucket["dimensions"]),
|
|
1346
|
+
"metrics": self._render_metric_values(cast(dict[str, JSONObject], bucket["metrics_state"]), metrics),
|
|
1347
|
+
}
|
|
1348
|
+
for bucket in group_stats.values()
|
|
1349
|
+
]
|
|
1350
|
+
all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
|
|
1351
|
+
rows_truncated = len(all_rows) > limit
|
|
1352
|
+
limited_rows = all_rows[:limit]
|
|
1353
|
+
rows = limited_rows
|
|
1354
|
+
rows_returned = len(limited_rows)
|
|
1355
|
+
group_count = len(all_rows)
|
|
1356
|
+
statement_scope = "returned_groups_only" if rows_truncated else "full_population"
|
|
1357
|
+
else:
|
|
1358
|
+
rows_truncated = False
|
|
1359
|
+
rows = [{"dimensions": {}, "metrics": metric_totals}]
|
|
1360
|
+
rows_returned = 1
|
|
1361
|
+
group_count = 1
|
|
1362
|
+
statement_scope = "full_population"
|
|
1363
|
+
raw_scan_complete = not has_more
|
|
1364
|
+
completeness_status = "complete" if raw_scan_complete else "incomplete"
|
|
1365
|
+
reason_code = "LOCAL_VIEW_FILTERING" if local_filtering and raw_scan_complete else ("SOURCE_EXHAUSTED" if raw_scan_complete else "SCAN_LIMIT_HIT")
|
|
1366
|
+
totals = {
|
|
1367
|
+
"scanned_count": matched_rows,
|
|
1368
|
+
"group_count": group_count,
|
|
1369
|
+
"metric_totals": metric_totals,
|
|
1370
|
+
}
|
|
1371
|
+
data: JSONObject = {
|
|
1372
|
+
"query": {
|
|
1373
|
+
"app_key": app_key,
|
|
1374
|
+
"dimensions": [
|
|
1375
|
+
{
|
|
1376
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
1377
|
+
"title": cast(FormField, item["field"]).que_title,
|
|
1378
|
+
"alias": item["alias"],
|
|
1379
|
+
"bucket": item["bucket"],
|
|
1380
|
+
}
|
|
1381
|
+
for item in dimensions
|
|
1382
|
+
],
|
|
1383
|
+
"metrics": [
|
|
1384
|
+
{
|
|
1385
|
+
"op": item["op"],
|
|
1386
|
+
"field_id": cast(FormField | None, item["field"]).que_id if item["field"] is not None else None,
|
|
1387
|
+
"title": cast(FormField | None, item["field"]).que_title if item["field"] is not None else None,
|
|
1388
|
+
"alias": item["alias"],
|
|
1389
|
+
}
|
|
1390
|
+
for item in metrics
|
|
1391
|
+
],
|
|
1392
|
+
"filters": [
|
|
1393
|
+
{
|
|
1394
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
1395
|
+
"title": cast(FormField, item["field"]).que_title,
|
|
1396
|
+
"op": item["op"],
|
|
1397
|
+
"value": item.get("value"),
|
|
1398
|
+
}
|
|
1399
|
+
for item in filters
|
|
1400
|
+
],
|
|
1401
|
+
"applied_sort": [{"by": item["by"], "order": item["order"]} for item in sort],
|
|
1402
|
+
"view": _view_selection_payload(view_selection),
|
|
1403
|
+
},
|
|
1404
|
+
"rows": rows,
|
|
1405
|
+
"totals": totals,
|
|
1406
|
+
"completeness": {
|
|
1407
|
+
"status": completeness_status,
|
|
1408
|
+
"reason_code": reason_code,
|
|
1409
|
+
"local_filtering_applied": local_filtering,
|
|
1410
|
+
"safe_for_final_conclusion": completeness_status == "complete",
|
|
1411
|
+
},
|
|
1412
|
+
"presentation": {
|
|
1413
|
+
"row_limit": limit,
|
|
1414
|
+
"rows_returned": rows_returned,
|
|
1415
|
+
"rows_truncated": rows_truncated,
|
|
1416
|
+
"statement_scope": statement_scope,
|
|
1417
|
+
},
|
|
1418
|
+
"warnings": self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated),
|
|
1419
|
+
}
|
|
1420
|
+
response: JSONObject = {
|
|
1421
|
+
"profile": profile,
|
|
1422
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1423
|
+
"ok": True,
|
|
1424
|
+
"status": "success" if raw_scan_complete else "partial",
|
|
1425
|
+
"query_id": query_id,
|
|
1426
|
+
"request_route": self._request_route_payload(context),
|
|
1427
|
+
"data": data,
|
|
1428
|
+
}
|
|
1429
|
+
if strict_full and not raw_scan_complete:
|
|
1430
|
+
response["ok"] = False
|
|
1431
|
+
response["status"] = "error"
|
|
1432
|
+
response["error"] = {
|
|
1433
|
+
"code": "INCOMPLETE_SCAN",
|
|
1434
|
+
"message": "record_analyze could not complete the scan within the fixed internal analysis budget.",
|
|
1435
|
+
"fix_hint": "Narrow the scope with view/filter constraints, or retry after reducing the dataset size.",
|
|
1436
|
+
}
|
|
1437
|
+
if output_profile == "verbose":
|
|
1438
|
+
response["data"]["debug"] = {
|
|
1439
|
+
"elapsed_ms": int((time.perf_counter() - started_at) * 1000),
|
|
1440
|
+
"backend_total_hint": scan_control.get("backend_total_count", result_amount),
|
|
1441
|
+
"backend_page_amount": scan_control.get("backend_page_amount"),
|
|
1442
|
+
"source_pages": source_pages,
|
|
1443
|
+
"raw_scan_complete": raw_scan_complete,
|
|
1444
|
+
"scan_control": scan_control,
|
|
412
1445
|
}
|
|
1446
|
+
return response
|
|
1447
|
+
|
|
1448
|
+
def _build_analyze_group_payload(self, answer_list: list[JSONValue], dimensions: list[JSONObject]) -> JSONObject:
|
|
1449
|
+
if not dimensions:
|
|
1450
|
+
return {}
|
|
1451
|
+
payload: JSONObject = {}
|
|
1452
|
+
for item in dimensions:
|
|
1453
|
+
field = cast(FormField, item["field"])
|
|
1454
|
+
alias = cast(str, item["alias"])
|
|
1455
|
+
bucket = cast(str | None, item["bucket"])
|
|
1456
|
+
value = _extract_field_value(answer_list, field)
|
|
1457
|
+
if bucket is not None:
|
|
1458
|
+
value = _to_time_bucket(value, bucket)
|
|
1459
|
+
payload[alias] = value
|
|
1460
|
+
return payload
|
|
413
1461
|
|
|
414
|
-
|
|
1462
|
+
def _initialize_metric_states(self, metrics: list[JSONObject]) -> dict[str, JSONObject]:
|
|
1463
|
+
states: dict[str, JSONObject] = {}
|
|
1464
|
+
for item in metrics:
|
|
1465
|
+
states[str(item["alias"])] = {
|
|
1466
|
+
"count": 0,
|
|
1467
|
+
"sum": 0.0,
|
|
1468
|
+
"min": None,
|
|
1469
|
+
"max": None,
|
|
1470
|
+
"seen": set(),
|
|
1471
|
+
}
|
|
1472
|
+
return states
|
|
415
1473
|
|
|
416
|
-
def
|
|
417
|
-
|
|
418
|
-
raise_tool_error(QingflowApiError.config_error(f"tool must be one of {sorted(SUPPORTED_QUERY_TOOLS)}"))
|
|
419
|
-
normalized = _normalize_plan_arguments(tool, arguments)
|
|
420
|
-
validation = self._validate_plan_arguments(tool, normalized)
|
|
1474
|
+
def _analysis_group_key(self, payload: JSONObject) -> tuple[tuple[str, object], ...]:
|
|
1475
|
+
return tuple((key, self._freeze_group_key_value(value)) for key, value in payload.items())
|
|
421
1476
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
field_mapping.append(self._resolve_plan_candidate(candidate, index))
|
|
429
|
-
view_selection = self._resolve_view_selection(
|
|
430
|
-
profile,
|
|
431
|
-
context,
|
|
432
|
-
cast(str, normalized["app_key"]),
|
|
433
|
-
view_key=_normalize_optional_text(normalized.get("view_key")),
|
|
434
|
-
view_name=_normalize_optional_text(normalized.get("view_name")),
|
|
435
|
-
)
|
|
436
|
-
if view_selection is not None:
|
|
437
|
-
view_resolution = {
|
|
438
|
-
"resolved": True,
|
|
439
|
-
"view_key": view_selection.view_key,
|
|
440
|
-
"view_name": view_selection.view_name,
|
|
441
|
-
"local_filtering": bool(view_selection.conditions),
|
|
442
|
-
"condition_group_count": len(view_selection.conditions),
|
|
443
|
-
}
|
|
444
|
-
estimate = _build_plan_estimate(tool, normalized)
|
|
445
|
-
readiness = _assess_plan_readiness(tool, normalized, validation, field_mapping, estimate)
|
|
446
|
-
return {
|
|
447
|
-
"profile": profile,
|
|
448
|
-
"ws_id": session_profile.selected_ws_id,
|
|
449
|
-
"ok": True,
|
|
450
|
-
"request_route": self._request_route_payload(context),
|
|
451
|
-
"data": {
|
|
452
|
-
"tool": tool,
|
|
453
|
-
"normalized_arguments": normalized,
|
|
454
|
-
"validation": validation,
|
|
455
|
-
"field_mapping": field_mapping,
|
|
456
|
-
"view_resolution": view_resolution,
|
|
457
|
-
"estimate": estimate,
|
|
458
|
-
"ready_for_final_conclusion": readiness["ready_for_final_conclusion"],
|
|
459
|
-
"final_conclusion_blockers": readiness["final_conclusion_blockers"],
|
|
460
|
-
"recommended_next_actions": readiness["recommended_next_actions"],
|
|
461
|
-
},
|
|
462
|
-
}
|
|
1477
|
+
def _freeze_group_key_value(self, value: JSONValue) -> object:
|
|
1478
|
+
if isinstance(value, dict):
|
|
1479
|
+
return tuple((key, self._freeze_group_key_value(item)) for key, item in sorted(value.items()))
|
|
1480
|
+
if isinstance(value, list):
|
|
1481
|
+
return tuple(self._freeze_group_key_value(item) for item in value)
|
|
1482
|
+
return value
|
|
463
1483
|
|
|
464
|
-
|
|
1484
|
+
def _apply_metric_states(self, states: dict[str, JSONObject], metrics: list[JSONObject], answer_list: list[JSONValue]) -> None:
|
|
1485
|
+
for item in metrics:
|
|
1486
|
+
alias = cast(str, item["alias"])
|
|
1487
|
+
op = cast(str, item["op"])
|
|
1488
|
+
field = cast(FormField | None, item["field"])
|
|
1489
|
+
state = states[alias]
|
|
1490
|
+
if op == "count":
|
|
1491
|
+
state["count"] = int(state["count"]) + 1
|
|
1492
|
+
continue
|
|
1493
|
+
value = _extract_field_value(answer_list, field)
|
|
1494
|
+
if op == "distinct_count":
|
|
1495
|
+
for entry in _flatten_distinct_values(value):
|
|
1496
|
+
cast(set[str], state["seen"]).add(entry)
|
|
1497
|
+
continue
|
|
1498
|
+
amount = _coerce_amount(value)
|
|
1499
|
+
if amount is None:
|
|
1500
|
+
continue
|
|
1501
|
+
state["count"] = int(state["count"]) + 1
|
|
1502
|
+
state["sum"] = float(state["sum"]) + amount
|
|
1503
|
+
state["min"] = amount if state["min"] is None else min(float(state["min"]), amount)
|
|
1504
|
+
state["max"] = amount if state["max"] is None else max(float(state["max"]), amount)
|
|
1505
|
+
|
|
1506
|
+
def _render_metric_values(self, states: dict[str, JSONObject], metrics: list[JSONObject]) -> JSONObject:
|
|
1507
|
+
rendered: JSONObject = {}
|
|
1508
|
+
for item in metrics:
|
|
1509
|
+
alias = cast(str, item["alias"])
|
|
1510
|
+
op = cast(str, item["op"])
|
|
1511
|
+
state = states[alias]
|
|
1512
|
+
count = int(state["count"] or 0)
|
|
1513
|
+
amount_sum = float(state["sum"] or 0.0)
|
|
1514
|
+
if op == "count":
|
|
1515
|
+
rendered[alias] = count
|
|
1516
|
+
elif op == "sum":
|
|
1517
|
+
rendered[alias] = amount_sum
|
|
1518
|
+
elif op == "avg":
|
|
1519
|
+
rendered[alias] = (amount_sum / count) if count else None
|
|
1520
|
+
elif op == "min":
|
|
1521
|
+
rendered[alias] = state["min"]
|
|
1522
|
+
elif op == "max":
|
|
1523
|
+
rendered[alias] = state["max"]
|
|
1524
|
+
elif op == "distinct_count":
|
|
1525
|
+
rendered[alias] = len(cast(set[str], state["seen"]))
|
|
1526
|
+
return rendered
|
|
1527
|
+
|
|
1528
|
+
def _matches_analyze_filters(self, answer_list: list[JSONValue], filters: list[JSONObject]) -> bool:
|
|
1529
|
+
for item in filters:
|
|
1530
|
+
field = cast(FormField, item["field"])
|
|
1531
|
+
if not _match_analyze_filter(_extract_field_value(answer_list, field), cast(str, item["op"]), item.get("value")):
|
|
1532
|
+
return False
|
|
1533
|
+
return True
|
|
1534
|
+
|
|
1535
|
+
def _sort_analyze_rows(
|
|
1536
|
+
self,
|
|
1537
|
+
rows: list[JSONObject],
|
|
1538
|
+
sort: list[JSONObject],
|
|
1539
|
+
dimensions: list[JSONObject],
|
|
1540
|
+
metrics: list[JSONObject],
|
|
1541
|
+
) -> list[JSONObject]:
|
|
1542
|
+
if not rows or not sort:
|
|
1543
|
+
if dimensions and any(item.get("bucket") for item in dimensions):
|
|
1544
|
+
return sorted(rows, key=lambda item: json.dumps(item.get("dimensions", {}), ensure_ascii=False, sort_keys=True))
|
|
1545
|
+
return rows
|
|
1546
|
+
sorted_rows = list(rows)
|
|
1547
|
+
for sort_item in reversed(sort):
|
|
1548
|
+
by = cast(str, sort_item["by"])
|
|
1549
|
+
reverse = cast(str, sort_item["order"]) == "desc"
|
|
1550
|
+
kind = cast(str, sort_item["kind"])
|
|
1551
|
+
sorted_rows.sort(
|
|
1552
|
+
key=lambda item: _sortable_value(
|
|
1553
|
+
cast(JSONObject, item["metrics" if kind == "metric" else "dimensions"]).get(by)
|
|
1554
|
+
),
|
|
1555
|
+
reverse=reverse,
|
|
1556
|
+
)
|
|
1557
|
+
return sorted_rows
|
|
1558
|
+
|
|
1559
|
+
def _build_analyze_warnings(self, *, local_filtering: bool, rows_truncated: bool) -> list[JSONObject]:
|
|
1560
|
+
warnings: list[JSONObject] = []
|
|
1561
|
+
if local_filtering:
|
|
1562
|
+
warnings.append(
|
|
1563
|
+
{
|
|
1564
|
+
"code": "LOCAL_FILTERING_APPLIED",
|
|
1565
|
+
"message": "Current analysis applies local filtering after scanning source pages.",
|
|
1566
|
+
}
|
|
1567
|
+
)
|
|
1568
|
+
if rows_truncated:
|
|
1569
|
+
warnings.append(
|
|
1570
|
+
{
|
|
1571
|
+
"code": "ROWS_TRUNCATED",
|
|
1572
|
+
"message": "Result rows were truncated by limit; totals remain based on the full analyzed result set.",
|
|
1573
|
+
}
|
|
1574
|
+
)
|
|
1575
|
+
return warnings
|
|
465
1576
|
|
|
466
1577
|
def record_write_plan(
|
|
467
1578
|
self,
|
|
@@ -558,7 +1669,7 @@ class RecordTools(ToolBase):
|
|
|
558
1669
|
blockers.append("payload writes readonly or system-managed fields")
|
|
559
1670
|
if question_relations:
|
|
560
1671
|
validation["warnings"].append("form contains questionRelations; linked visibility and runtime required rules may differ at submit time.")
|
|
561
|
-
actions = ["Use
|
|
1672
|
+
actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
|
|
562
1673
|
if support_matrix["restricted"]:
|
|
563
1674
|
actions.append("Review write_format.required_presteps for restricted fields before submit.")
|
|
564
1675
|
if invalid_fields:
|
|
@@ -606,6 +1717,7 @@ class RecordTools(ToolBase):
|
|
|
606
1717
|
page_size: int,
|
|
607
1718
|
requested_pages: int,
|
|
608
1719
|
scan_max_pages: int,
|
|
1720
|
+
auto_expand_pages: bool = False,
|
|
609
1721
|
query_key: str | None,
|
|
610
1722
|
filters: list[JSONObject],
|
|
611
1723
|
sorts: list[JSONObject],
|
|
@@ -614,277 +1726,61 @@ class RecordTools(ToolBase):
|
|
|
614
1726
|
select_columns: list[str | int],
|
|
615
1727
|
amount_column: str | int | None,
|
|
616
1728
|
time_range: JSONObject,
|
|
617
|
-
stat_policy: JSONObject,
|
|
618
|
-
strict_full: bool,
|
|
619
|
-
output_profile: str,
|
|
620
|
-
list_type: int,
|
|
621
|
-
view_key: str | None = None,
|
|
622
|
-
view_name: str | None = None,
|
|
623
|
-
) -> JSONObject:
|
|
624
|
-
resolved_mode = _resolve_query_mode(query_mode, apply_id=apply_id, amount_column=amount_column, time_range=time_range, stat_policy=stat_policy)
|
|
625
|
-
if resolved_mode == "
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
)
|
|
635
|
-
if resolved_mode == "summary":
|
|
636
|
-
return self._record_query_summary(
|
|
637
|
-
profile=profile,
|
|
638
|
-
app_key=app_key,
|
|
639
|
-
page_num=page_num,
|
|
640
|
-
page_size=page_size,
|
|
641
|
-
requested_pages=requested_pages,
|
|
642
|
-
scan_max_pages=scan_max_pages,
|
|
643
|
-
query_key=query_key,
|
|
644
|
-
filters=filters,
|
|
645
|
-
sorts=sorts,
|
|
646
|
-
max_rows=max_rows,
|
|
647
|
-
max_columns=max_columns,
|
|
648
|
-
select_columns=select_columns,
|
|
649
|
-
amount_column=amount_column,
|
|
650
|
-
time_range=time_range,
|
|
651
|
-
stat_policy=stat_policy,
|
|
652
|
-
strict_full=strict_full,
|
|
653
|
-
output_profile=output_profile,
|
|
654
|
-
list_type=list_type,
|
|
655
|
-
view_key=view_key,
|
|
656
|
-
view_name=view_name,
|
|
657
|
-
)
|
|
658
|
-
return self._record_query_list(
|
|
659
|
-
profile=profile,
|
|
660
|
-
app_key=app_key,
|
|
661
|
-
page_num=page_num,
|
|
662
|
-
page_size=page_size,
|
|
663
|
-
requested_pages=requested_pages,
|
|
664
|
-
scan_max_pages=scan_max_pages,
|
|
665
|
-
query_key=query_key,
|
|
666
|
-
filters=filters,
|
|
667
|
-
sorts=sorts,
|
|
668
|
-
max_rows=max_rows,
|
|
669
|
-
max_columns=max_columns,
|
|
670
|
-
select_columns=select_columns,
|
|
671
|
-
time_range=time_range,
|
|
672
|
-
output_profile=output_profile,
|
|
673
|
-
list_type=list_type,
|
|
674
|
-
view_key=view_key,
|
|
675
|
-
view_name=view_name,
|
|
676
|
-
)
|
|
677
|
-
|
|
678
|
-
def record_aggregate(
|
|
679
|
-
self,
|
|
680
|
-
*,
|
|
681
|
-
profile: str,
|
|
682
|
-
app_key: str,
|
|
683
|
-
group_by: list[str | int],
|
|
684
|
-
amount_column: str | int | None,
|
|
685
|
-
metrics: list[str],
|
|
686
|
-
page_num: int,
|
|
687
|
-
page_size: int,
|
|
688
|
-
requested_pages: int,
|
|
689
|
-
scan_max_pages: int,
|
|
690
|
-
query_key: str | None,
|
|
691
|
-
filters: list[JSONObject],
|
|
692
|
-
sorts: list[JSONObject],
|
|
693
|
-
time_range: JSONObject,
|
|
694
|
-
time_bucket: str | None,
|
|
695
|
-
max_groups: int,
|
|
696
|
-
strict_full: bool,
|
|
697
|
-
output_profile: str,
|
|
698
|
-
list_type: int,
|
|
699
|
-
view_key: str | None = None,
|
|
700
|
-
view_name: str | None = None,
|
|
701
|
-
) -> JSONObject:
|
|
702
|
-
if not app_key:
|
|
703
|
-
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
704
|
-
if max_groups <= 0:
|
|
705
|
-
raise_tool_error(QingflowApiError.config_error("max_groups must be positive"))
|
|
706
|
-
|
|
707
|
-
def runner(session_profile, context):
|
|
708
|
-
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
709
|
-
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
710
|
-
dept_member_cache: dict[int, set[int]] = {}
|
|
711
|
-
group_fields = [self._resolve_field_selector(item, index, location="group_by") for item in group_by]
|
|
712
|
-
amount_field = self._resolve_field_selector(amount_column, index, location="amount_column") if amount_column is not None else None
|
|
713
|
-
time_field = self._resolve_time_range_column(time_range, index)
|
|
714
|
-
match_rules = self._resolve_match_rules(context, filters, index)
|
|
715
|
-
sort_rules = self._resolve_sorts(sorts, index)
|
|
716
|
-
match_rules = self._append_time_range_filter(match_rules, time_range, time_field)
|
|
717
|
-
metric_names = _normalize_metrics(metrics, include_sum=amount_field is not None)
|
|
718
|
-
query_id = _query_id()
|
|
719
|
-
pages_to_scan = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
720
|
-
current_page = max(page_num, 1)
|
|
721
|
-
scanned_pages = 0
|
|
722
|
-
scanned_records = 0
|
|
723
|
-
source_pages: list[int] = []
|
|
724
|
-
result_amount: int | None = None
|
|
725
|
-
has_more = False
|
|
726
|
-
group_stats: dict[str, JSONObject] = {}
|
|
727
|
-
total_amount = 0.0
|
|
728
|
-
while scanned_pages < pages_to_scan:
|
|
729
|
-
page = self._search_page(
|
|
730
|
-
context,
|
|
731
|
-
app_key=app_key,
|
|
732
|
-
page_num=current_page,
|
|
733
|
-
page_size=page_size,
|
|
734
|
-
query_key=query_key,
|
|
735
|
-
match_rules=match_rules,
|
|
736
|
-
sorts=sort_rules,
|
|
737
|
-
search_que_ids=None,
|
|
738
|
-
list_type=list_type,
|
|
739
|
-
)
|
|
740
|
-
scanned_pages += 1
|
|
741
|
-
source_pages.append(current_page)
|
|
742
|
-
rows = page.get("list")
|
|
743
|
-
items = rows if isinstance(rows, list) else []
|
|
744
|
-
if result_amount is None:
|
|
745
|
-
result_amount = _effective_total(page, page_size)
|
|
746
|
-
has_more = _page_has_more(page, current_page, page_size, len(items))
|
|
747
|
-
for item in items:
|
|
748
|
-
if not isinstance(item, dict):
|
|
749
|
-
continue
|
|
750
|
-
answers = item.get("answers")
|
|
751
|
-
answer_list = answers if isinstance(answers, list) else []
|
|
752
|
-
if not self._matches_view_selection(
|
|
753
|
-
context,
|
|
754
|
-
answer_list,
|
|
755
|
-
view_selection=view_selection,
|
|
756
|
-
dept_member_cache=dept_member_cache,
|
|
757
|
-
):
|
|
758
|
-
continue
|
|
759
|
-
scanned_records += 1
|
|
760
|
-
group_payload = {
|
|
761
|
-
field.que_title: _extract_field_value(answer_list, field)
|
|
762
|
-
for field in group_fields
|
|
763
|
-
}
|
|
764
|
-
if time_bucket and time_field is not None:
|
|
765
|
-
group_payload["time_bucket"] = _to_time_bucket(_extract_field_value(answer_list, time_field), time_bucket)
|
|
766
|
-
group_key = json.dumps(group_payload, ensure_ascii=False, sort_keys=True)
|
|
767
|
-
bucket = group_stats.get(group_key)
|
|
768
|
-
if bucket is None:
|
|
769
|
-
bucket = {"group": group_payload, "count": 0, "amount_total": None, "metrics": {}}
|
|
770
|
-
group_stats[group_key] = bucket
|
|
771
|
-
bucket["count"] = int(bucket["count"]) + 1
|
|
772
|
-
amount_value = _coerce_amount(_extract_field_value(answer_list, amount_field)) if amount_field is not None else None
|
|
773
|
-
if amount_value is not None:
|
|
774
|
-
total_amount += amount_value
|
|
775
|
-
bucket["amount_total"] = float(bucket.get("amount_total") or 0.0) + amount_value
|
|
776
|
-
metrics_payload = bucket["metrics"] if isinstance(bucket.get("metrics"), dict) else {}
|
|
777
|
-
for metric in metric_names:
|
|
778
|
-
current_metric = metrics_payload.get(metric)
|
|
779
|
-
metric_state = current_metric if isinstance(current_metric, dict) else {"count": 0, "sum": 0.0, "min": None, "max": None}
|
|
780
|
-
metric_state["count"] = int(metric_state["count"]) + 1
|
|
781
|
-
if amount_value is not None:
|
|
782
|
-
metric_state["sum"] = float(metric_state["sum"]) + amount_value
|
|
783
|
-
metric_state["min"] = amount_value if metric_state["min"] is None else min(float(metric_state["min"]), amount_value)
|
|
784
|
-
metric_state["max"] = amount_value if metric_state["max"] is None else max(float(metric_state["max"]), amount_value)
|
|
785
|
-
metrics_payload[metric] = metric_state
|
|
786
|
-
bucket["metrics"] = metrics_payload
|
|
787
|
-
if len(group_stats) >= max_groups:
|
|
788
|
-
break
|
|
789
|
-
if len(group_stats) >= max_groups or not has_more:
|
|
790
|
-
break
|
|
791
|
-
current_page += 1
|
|
792
|
-
|
|
793
|
-
groups = []
|
|
794
|
-
for bucket in group_stats.values():
|
|
795
|
-
metrics_payload = bucket["metrics"] if isinstance(bucket.get("metrics"), dict) else {}
|
|
796
|
-
rendered_metrics: JSONObject = {}
|
|
797
|
-
for metric_name, metric_state in metrics_payload.items():
|
|
798
|
-
if not isinstance(metric_state, dict):
|
|
799
|
-
continue
|
|
800
|
-
count = int(metric_state.get("count", 0) or 0)
|
|
801
|
-
amount_sum = float(metric_state.get("sum", 0.0) or 0.0)
|
|
802
|
-
metric_result: JSONObject = {}
|
|
803
|
-
if metric_name == "count":
|
|
804
|
-
metric_result["value"] = count
|
|
805
|
-
else:
|
|
806
|
-
if metric_name == "sum":
|
|
807
|
-
metric_result["value"] = amount_sum
|
|
808
|
-
elif metric_name == "avg":
|
|
809
|
-
metric_result["value"] = (amount_sum / count) if count else None
|
|
810
|
-
elif metric_name == "min":
|
|
811
|
-
metric_result["value"] = metric_state.get("min")
|
|
812
|
-
elif metric_name == "max":
|
|
813
|
-
metric_result["value"] = metric_state.get("max")
|
|
814
|
-
rendered_metrics[metric_name] = metric_result
|
|
815
|
-
groups.append(
|
|
816
|
-
{
|
|
817
|
-
"group": bucket["group"],
|
|
818
|
-
"count": bucket["count"],
|
|
819
|
-
"count_ratio": (int(bucket["count"]) / scanned_records) if scanned_records else 0,
|
|
820
|
-
"amount_total": None if amount_field is None else _coerce_amount(bucket.get("amount_total")),
|
|
821
|
-
"amount_ratio": None,
|
|
822
|
-
"metrics": rendered_metrics,
|
|
823
|
-
}
|
|
824
|
-
)
|
|
825
|
-
groups.sort(key=lambda item: int(item["count"]), reverse=True)
|
|
826
|
-
if amount_field is not None and total_amount > 0:
|
|
827
|
-
for item in groups:
|
|
828
|
-
amount_total = _coerce_amount(item.get("amount_total"))
|
|
829
|
-
item["amount_ratio"] = (amount_total / total_amount) if amount_total is not None else None
|
|
830
|
-
effective_result_amount = scanned_records if view_selection is not None else (result_amount or scanned_records)
|
|
831
|
-
completeness = _build_completeness(
|
|
832
|
-
result_amount=effective_result_amount,
|
|
833
|
-
returned_items=len(groups),
|
|
834
|
-
fetched_pages=scanned_pages,
|
|
835
|
-
requested_pages=pages_to_scan,
|
|
836
|
-
has_more=has_more,
|
|
837
|
-
next_page_token=None,
|
|
838
|
-
is_complete=not has_more and len(groups) < max_groups,
|
|
839
|
-
omitted_items=max(0, effective_result_amount - len(groups)),
|
|
840
|
-
extra={
|
|
841
|
-
"raw_scan_complete": not has_more,
|
|
842
|
-
"scan_limit_hit": has_more,
|
|
843
|
-
"scanned_pages": scanned_pages,
|
|
844
|
-
"scan_limit": pages_to_scan,
|
|
845
|
-
"output_page_complete": len(groups) < max_groups,
|
|
846
|
-
"raw_next_page_token": None,
|
|
847
|
-
"output_next_page_token": None,
|
|
848
|
-
"stop_reason": "source_exhausted" if not has_more else "scan_limit",
|
|
849
|
-
},
|
|
850
|
-
)
|
|
851
|
-
evidence = {
|
|
852
|
-
"query_id": query_id,
|
|
853
|
-
"app_key": app_key,
|
|
854
|
-
"filters": _echo_filters(match_rules),
|
|
855
|
-
"selected_columns": [field.que_title for field in group_fields],
|
|
856
|
-
"time_range": time_range or None,
|
|
857
|
-
"source_pages": source_pages,
|
|
858
|
-
"view": _view_selection_payload(view_selection),
|
|
859
|
-
}
|
|
860
|
-
if strict_full and not bool(completeness.get("raw_scan_complete")):
|
|
861
|
-
self._raise_need_more_data(completeness, evidence, "Aggregate result is incomplete; increase requested_pages or scan_max_pages.")
|
|
862
|
-
response: JSONObject = {
|
|
863
|
-
"profile": profile,
|
|
864
|
-
"ws_id": session_profile.selected_ws_id,
|
|
865
|
-
"ok": True,
|
|
866
|
-
"request_route": self._request_route_payload(context),
|
|
867
|
-
"data": {
|
|
868
|
-
"app_key": app_key,
|
|
869
|
-
"view": _view_selection_payload(view_selection),
|
|
870
|
-
"summary": {
|
|
871
|
-
"total_count": scanned_records,
|
|
872
|
-
"total_amount": total_amount if amount_field is not None else None,
|
|
1729
|
+
stat_policy: JSONObject,
|
|
1730
|
+
strict_full: bool,
|
|
1731
|
+
output_profile: str,
|
|
1732
|
+
list_type: int,
|
|
1733
|
+
view_key: str | None = None,
|
|
1734
|
+
view_name: str | None = None,
|
|
1735
|
+
) -> JSONObject:
|
|
1736
|
+
resolved_mode = _resolve_query_mode(query_mode, apply_id=apply_id, amount_column=amount_column, time_range=time_range, stat_policy=stat_policy)
|
|
1737
|
+
if resolved_mode == "summary":
|
|
1738
|
+
raise_tool_error(
|
|
1739
|
+
QingflowApiError(
|
|
1740
|
+
category="config",
|
|
1741
|
+
message="query_mode='summary' is not supported",
|
|
1742
|
+
details={
|
|
1743
|
+
"error_code": "UNSUPPORTED_QUERY_MODE",
|
|
1744
|
+
"allowed_modes": ["auto", "list", "record"],
|
|
1745
|
+
"fix_hint": "Use record_schema_get followed by record_analyze for any statistical analysis.",
|
|
873
1746
|
},
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1747
|
+
)
|
|
1748
|
+
)
|
|
1749
|
+
if resolved_mode == "list":
|
|
1750
|
+
list_paging = _fixed_list_scan_policy()
|
|
1751
|
+
page_size = int(list_paging["page_size"])
|
|
1752
|
+
requested_pages = int(list_paging["requested_pages"])
|
|
1753
|
+
scan_max_pages = int(list_paging["scan_max_pages"])
|
|
1754
|
+
auto_expand_pages = bool(list_paging["auto_expand_pages"])
|
|
1755
|
+
if resolved_mode == "record":
|
|
1756
|
+
return self._record_query_record(
|
|
1757
|
+
profile=profile,
|
|
1758
|
+
app_key=app_key,
|
|
1759
|
+
apply_id=apply_id,
|
|
1760
|
+
select_columns=select_columns,
|
|
1761
|
+
max_columns=max_columns,
|
|
1762
|
+
output_profile=output_profile,
|
|
1763
|
+
list_type=list_type,
|
|
1764
|
+
)
|
|
1765
|
+
return self._record_query_list(
|
|
1766
|
+
profile=profile,
|
|
1767
|
+
app_key=app_key,
|
|
1768
|
+
page_num=page_num,
|
|
1769
|
+
page_size=page_size,
|
|
1770
|
+
requested_pages=requested_pages,
|
|
1771
|
+
scan_max_pages=scan_max_pages,
|
|
1772
|
+
query_key=query_key,
|
|
1773
|
+
filters=filters,
|
|
1774
|
+
sorts=sorts,
|
|
1775
|
+
max_rows=max_rows,
|
|
1776
|
+
max_columns=max_columns,
|
|
1777
|
+
select_columns=select_columns,
|
|
1778
|
+
time_range=time_range,
|
|
1779
|
+
output_profile=output_profile,
|
|
1780
|
+
list_type=list_type,
|
|
1781
|
+
view_key=view_key,
|
|
1782
|
+
view_name=view_name,
|
|
1783
|
+
)
|
|
888
1784
|
|
|
889
1785
|
def record_create(
|
|
890
1786
|
self,
|
|
@@ -1253,230 +2149,37 @@ class RecordTools(ToolBase):
|
|
|
1253
2149
|
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1254
2150
|
hard_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1255
2151
|
)
|
|
1256
|
-
selected_fields = self._resolve_select_columns(
|
|
1257
|
-
select_columns,
|
|
1258
|
-
index,
|
|
1259
|
-
max_columns=max_columns,
|
|
1260
|
-
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1261
|
-
)
|
|
1262
|
-
selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
|
|
1263
|
-
primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
|
|
1264
|
-
if view_selection is not None and not _view_selection_supported_by_search_ids(view_selection, primary_search_que_ids):
|
|
1265
|
-
primary_search_que_ids = None
|
|
1266
|
-
remaining_field_batches: list[list[FormField]] = []
|
|
1267
|
-
selected_fields_from_primary = selected_fields
|
|
1268
|
-
else:
|
|
1269
|
-
remaining_field_batches = selected_field_batches[1:]
|
|
1270
|
-
primary_search_que_ids = primary_search_que_ids or None
|
|
1271
|
-
primary_que_ids = set(primary_search_que_ids or [])
|
|
1272
|
-
selected_fields_from_primary = [field for field in selected_fields if field.que_id in primary_que_ids]
|
|
1273
|
-
time_field = self._resolve_time_range_column(time_range, index)
|
|
1274
|
-
match_rules = self._resolve_match_rules(context, filters, index)
|
|
1275
|
-
sort_rules = self._resolve_sorts(sorts, index)
|
|
1276
|
-
match_rules = self._append_time_range_filter(match_rules, time_range, time_field)
|
|
1277
|
-
scan_limit = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
1278
|
-
current_page = max(page_num, 1)
|
|
1279
|
-
scanned_pages = 0
|
|
1280
|
-
rows: list[JSONObject] = []
|
|
1281
|
-
matched_records = 0
|
|
1282
|
-
result_amount: int | None = None
|
|
1283
|
-
reported_total: int | None = None
|
|
1284
|
-
has_more = False
|
|
1285
|
-
source_pages: list[int] = []
|
|
1286
|
-
while scanned_pages < scan_limit and len(rows) < max_rows:
|
|
1287
|
-
page = self._search_page(
|
|
1288
|
-
context,
|
|
1289
|
-
app_key=app_key,
|
|
1290
|
-
page_num=current_page,
|
|
1291
|
-
page_size=page_size,
|
|
1292
|
-
query_key=query_key,
|
|
1293
|
-
match_rules=match_rules,
|
|
1294
|
-
sorts=sort_rules,
|
|
1295
|
-
search_que_ids=primary_search_que_ids,
|
|
1296
|
-
list_type=list_type,
|
|
1297
|
-
)
|
|
1298
|
-
scanned_pages += 1
|
|
1299
|
-
source_pages.append(current_page)
|
|
1300
|
-
page_rows = page.get("list")
|
|
1301
|
-
items = page_rows if isinstance(page_rows, list) else []
|
|
1302
|
-
if result_amount is None:
|
|
1303
|
-
reported_total = _coerce_count(page.get("total"))
|
|
1304
|
-
if reported_total is None:
|
|
1305
|
-
reported_total = _coerce_count(page.get("count"))
|
|
1306
|
-
result_amount = _effective_total(page, page_size)
|
|
1307
|
-
has_more = _page_has_more(page, current_page, page_size, len(items))
|
|
1308
|
-
page_output_rows: list[JSONObject] = []
|
|
1309
|
-
for item in items:
|
|
1310
|
-
if not isinstance(item, dict):
|
|
1311
|
-
continue
|
|
1312
|
-
answers = item.get("answers")
|
|
1313
|
-
answer_list = answers if isinstance(answers, list) else []
|
|
1314
|
-
if not self._matches_view_selection(
|
|
1315
|
-
context,
|
|
1316
|
-
answer_list,
|
|
1317
|
-
view_selection=view_selection,
|
|
1318
|
-
dept_member_cache=dept_member_cache,
|
|
1319
|
-
):
|
|
1320
|
-
continue
|
|
1321
|
-
matched_records += 1
|
|
1322
|
-
apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
|
|
1323
|
-
row = _build_flat_row(answer_list, selected_fields_from_primary, apply_id=apply_id)
|
|
1324
|
-
rows.append(row)
|
|
1325
|
-
page_output_rows.append(row)
|
|
1326
|
-
if len(rows) >= max_rows:
|
|
1327
|
-
break
|
|
1328
|
-
if page_output_rows and remaining_field_batches:
|
|
1329
|
-
page_row_map = {
|
|
1330
|
-
_coerce_count(row.get("apply_id")): row
|
|
1331
|
-
for row in page_output_rows
|
|
1332
|
-
if isinstance(row, dict) and _coerce_count(row.get("apply_id")) is not None
|
|
1333
|
-
}
|
|
1334
|
-
for batch in remaining_field_batches:
|
|
1335
|
-
extra_page = self._search_page(
|
|
1336
|
-
context,
|
|
1337
|
-
app_key=app_key,
|
|
1338
|
-
page_num=current_page,
|
|
1339
|
-
page_size=page_size,
|
|
1340
|
-
query_key=query_key,
|
|
1341
|
-
match_rules=match_rules,
|
|
1342
|
-
sorts=sort_rules,
|
|
1343
|
-
search_que_ids=[field.que_id for field in batch],
|
|
1344
|
-
list_type=list_type,
|
|
1345
|
-
)
|
|
1346
|
-
extra_rows = extra_page.get("list")
|
|
1347
|
-
extra_items = extra_rows if isinstance(extra_rows, list) else []
|
|
1348
|
-
for extra_item in extra_items:
|
|
1349
|
-
if not isinstance(extra_item, dict):
|
|
1350
|
-
continue
|
|
1351
|
-
apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
|
|
1352
|
-
if apply_id is None or apply_id not in page_row_map:
|
|
1353
|
-
continue
|
|
1354
|
-
extra_answers = extra_item.get("answers")
|
|
1355
|
-
extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
|
|
1356
|
-
partial_row = _build_flat_row(extra_answer_list, batch, apply_id=apply_id)
|
|
1357
|
-
partial_row.pop("apply_id", None)
|
|
1358
|
-
page_row_map[apply_id].update(partial_row)
|
|
1359
|
-
if not has_more:
|
|
1360
|
-
break
|
|
1361
|
-
current_page += 1
|
|
1362
|
-
effective_result_amount = matched_records if view_selection is not None else (result_amount or len(rows))
|
|
1363
|
-
completeness = _build_completeness(
|
|
1364
|
-
result_amount=effective_result_amount,
|
|
1365
|
-
returned_items=len(rows),
|
|
1366
|
-
fetched_pages=scanned_pages,
|
|
1367
|
-
requested_pages=scan_limit,
|
|
1368
|
-
has_more=has_more,
|
|
1369
|
-
next_page_token=None,
|
|
1370
|
-
is_complete=not has_more and len(rows) < max_rows,
|
|
1371
|
-
omitted_items=max(0, effective_result_amount - len(rows)),
|
|
1372
|
-
extra={},
|
|
1373
|
-
)
|
|
1374
|
-
evidence = {
|
|
1375
|
-
"query_id": _query_id(),
|
|
1376
|
-
"app_key": app_key,
|
|
1377
|
-
"filters": _echo_filters(match_rules),
|
|
1378
|
-
"selected_columns": [field.que_title for field in selected_fields],
|
|
1379
|
-
"time_range": time_range or None,
|
|
1380
|
-
"source_pages": source_pages,
|
|
1381
|
-
"view": _view_selection_payload(view_selection),
|
|
1382
|
-
}
|
|
1383
|
-
response: JSONObject = {
|
|
1384
|
-
"profile": profile,
|
|
1385
|
-
"ws_id": session_profile.selected_ws_id,
|
|
1386
|
-
"ok": True,
|
|
1387
|
-
"request_route": self._request_route_payload(context),
|
|
1388
|
-
"data": {
|
|
1389
|
-
"mode": "list",
|
|
1390
|
-
"source_tool": "record_search",
|
|
1391
|
-
"view": _view_selection_payload(view_selection),
|
|
1392
|
-
"list": {
|
|
1393
|
-
"rows": rows,
|
|
1394
|
-
"pagination": {
|
|
1395
|
-
"page_num": page_num,
|
|
1396
|
-
"page_size": page_size,
|
|
1397
|
-
"requested_pages": scan_limit,
|
|
1398
|
-
"result_amount": effective_result_amount,
|
|
1399
|
-
"returned_items": len(rows),
|
|
1400
|
-
},
|
|
1401
|
-
"applied_limits": {
|
|
1402
|
-
"row_cap": max_rows,
|
|
1403
|
-
"column_cap": resolved_column_cap,
|
|
1404
|
-
"selected_columns": [field.que_title for field in selected_fields],
|
|
1405
|
-
},
|
|
1406
|
-
},
|
|
1407
|
-
},
|
|
1408
|
-
"output_profile": output_profile,
|
|
1409
|
-
"next_page_token": None,
|
|
1410
|
-
}
|
|
1411
|
-
if output_profile == "verbose":
|
|
1412
|
-
response["completeness"] = completeness
|
|
1413
|
-
evidence["backend_reported_total"] = reported_total
|
|
1414
|
-
response["evidence"] = evidence
|
|
1415
|
-
response["resolved_mappings"] = {
|
|
1416
|
-
"select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in selected_fields],
|
|
1417
|
-
"filters": [_field_mapping_entry("filter", entry["field"], requested=entry["requested"]) for entry in self._resolve_filter_field_entries(filters, index)],
|
|
1418
|
-
"time_range": _field_mapping_entry("time", time_field, requested=time_field.que_title) if time_field is not None else None,
|
|
1419
|
-
}
|
|
1420
|
-
return response
|
|
1421
|
-
|
|
1422
|
-
return self._run_record_tool(profile, runner)
|
|
1423
|
-
|
|
1424
|
-
def _record_query_summary(
|
|
1425
|
-
self,
|
|
1426
|
-
*,
|
|
1427
|
-
profile: str,
|
|
1428
|
-
app_key: str,
|
|
1429
|
-
page_num: int,
|
|
1430
|
-
page_size: int,
|
|
1431
|
-
requested_pages: int,
|
|
1432
|
-
scan_max_pages: int,
|
|
1433
|
-
query_key: str | None,
|
|
1434
|
-
filters: list[JSONObject],
|
|
1435
|
-
sorts: list[JSONObject],
|
|
1436
|
-
max_rows: int,
|
|
1437
|
-
max_columns: int | None,
|
|
1438
|
-
select_columns: list[str | int],
|
|
1439
|
-
amount_column: str | int | None,
|
|
1440
|
-
time_range: JSONObject,
|
|
1441
|
-
stat_policy: JSONObject,
|
|
1442
|
-
strict_full: bool,
|
|
1443
|
-
output_profile: str,
|
|
1444
|
-
list_type: int,
|
|
1445
|
-
view_key: str | None = None,
|
|
1446
|
-
view_name: str | None = None,
|
|
1447
|
-
) -> JSONObject:
|
|
1448
|
-
if not app_key:
|
|
1449
|
-
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1450
|
-
|
|
1451
|
-
def runner(session_profile, context):
|
|
1452
|
-
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
1453
|
-
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
1454
|
-
dept_member_cache: dict[int, set[int]] = {}
|
|
1455
|
-
amount_field = self._resolve_field_selector(amount_column, index, location="amount_column") if amount_column is not None else None
|
|
1456
|
-
time_field = self._resolve_time_range_column(time_range, index)
|
|
1457
|
-
resolved_column_cap = _bounded_column_limit(
|
|
1458
|
-
max_columns,
|
|
1459
|
-
default_limit=MAX_SUMMARY_PREVIEW_COLUMN_LIMIT,
|
|
1460
|
-
hard_limit=MAX_SUMMARY_PREVIEW_COLUMN_LIMIT,
|
|
2152
|
+
selected_fields = self._resolve_select_columns(
|
|
2153
|
+
select_columns,
|
|
2154
|
+
index,
|
|
2155
|
+
max_columns=max_columns,
|
|
2156
|
+
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1461
2157
|
)
|
|
1462
|
-
|
|
2158
|
+
selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
|
|
2159
|
+
primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
|
|
2160
|
+
if view_selection is not None and not _view_selection_supported_by_search_ids(view_selection, primary_search_que_ids):
|
|
2161
|
+
primary_search_que_ids = None
|
|
2162
|
+
remaining_field_batches: list[list[FormField]] = []
|
|
2163
|
+
selected_fields_from_primary = selected_fields
|
|
2164
|
+
else:
|
|
2165
|
+
remaining_field_batches = selected_field_batches[1:]
|
|
2166
|
+
primary_search_que_ids = primary_search_que_ids or None
|
|
2167
|
+
primary_que_ids = set(primary_search_que_ids or [])
|
|
2168
|
+
selected_fields_from_primary = [field for field in selected_fields if field.que_id in primary_que_ids]
|
|
2169
|
+
time_field = self._resolve_time_range_column(time_range, index)
|
|
1463
2170
|
match_rules = self._resolve_match_rules(context, filters, index)
|
|
1464
2171
|
sort_rules = self._resolve_sorts(sorts, index)
|
|
1465
2172
|
match_rules = self._append_time_range_filter(match_rules, time_range, time_field)
|
|
1466
|
-
include_negative = bool(stat_policy.get("include_negative", True))
|
|
1467
|
-
include_null = bool(stat_policy.get("include_null", False))
|
|
1468
2173
|
scan_limit = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
1469
2174
|
current_page = max(page_num, 1)
|
|
1470
2175
|
scanned_pages = 0
|
|
1471
|
-
|
|
2176
|
+
rows: list[JSONObject] = []
|
|
2177
|
+
matched_records = 0
|
|
1472
2178
|
result_amount: int | None = None
|
|
2179
|
+
reported_total: int | None = None
|
|
1473
2180
|
has_more = False
|
|
1474
2181
|
source_pages: list[int] = []
|
|
1475
|
-
|
|
1476
|
-
total_amount = 0.0
|
|
1477
|
-
missing_count = 0
|
|
1478
|
-
by_day: dict[str, JSONObject] = {}
|
|
1479
|
-
while scanned_pages < scan_limit:
|
|
2182
|
+
while scanned_pages < scan_limit and len(rows) < max_rows:
|
|
1480
2183
|
page = self._search_page(
|
|
1481
2184
|
context,
|
|
1482
2185
|
app_key=app_key,
|
|
@@ -1485,7 +2188,7 @@ class RecordTools(ToolBase):
|
|
|
1485
2188
|
query_key=query_key,
|
|
1486
2189
|
match_rules=match_rules,
|
|
1487
2190
|
sorts=sort_rules,
|
|
1488
|
-
search_que_ids=
|
|
2191
|
+
search_que_ids=primary_search_que_ids,
|
|
1489
2192
|
list_type=list_type,
|
|
1490
2193
|
)
|
|
1491
2194
|
scanned_pages += 1
|
|
@@ -1493,8 +2196,12 @@ class RecordTools(ToolBase):
|
|
|
1493
2196
|
page_rows = page.get("list")
|
|
1494
2197
|
items = page_rows if isinstance(page_rows, list) else []
|
|
1495
2198
|
if result_amount is None:
|
|
2199
|
+
reported_total = _coerce_count(page.get("total"))
|
|
2200
|
+
if reported_total is None:
|
|
2201
|
+
reported_total = _coerce_count(page.get("count"))
|
|
1496
2202
|
result_amount = _effective_total(page, page_size)
|
|
1497
2203
|
has_more = _page_has_more(page, current_page, page_size, len(items))
|
|
2204
|
+
page_output_rows: list[JSONObject] = []
|
|
1498
2205
|
for item in items:
|
|
1499
2206
|
if not isinstance(item, dict):
|
|
1500
2207
|
continue
|
|
@@ -1507,83 +2214,102 @@ class RecordTools(ToolBase):
|
|
|
1507
2214
|
dept_member_cache=dept_member_cache,
|
|
1508
2215
|
):
|
|
1509
2216
|
continue
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
if
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
2217
|
+
matched_records += 1
|
|
2218
|
+
apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
|
|
2219
|
+
row = _build_flat_row(answer_list, selected_fields_from_primary, apply_id=apply_id)
|
|
2220
|
+
rows.append(row)
|
|
2221
|
+
page_output_rows.append(row)
|
|
2222
|
+
if len(rows) >= max_rows:
|
|
2223
|
+
break
|
|
2224
|
+
if page_output_rows and remaining_field_batches:
|
|
2225
|
+
page_row_map = {
|
|
2226
|
+
_coerce_count(row.get("apply_id")): row
|
|
2227
|
+
for row in page_output_rows
|
|
2228
|
+
if isinstance(row, dict) and _coerce_count(row.get("apply_id")) is not None
|
|
2229
|
+
}
|
|
2230
|
+
for batch in remaining_field_batches:
|
|
2231
|
+
extra_page = self._search_page(
|
|
2232
|
+
context,
|
|
2233
|
+
app_key=app_key,
|
|
2234
|
+
page_num=current_page,
|
|
2235
|
+
page_size=page_size,
|
|
2236
|
+
query_key=query_key,
|
|
2237
|
+
match_rules=match_rules,
|
|
2238
|
+
sorts=sort_rules,
|
|
2239
|
+
search_que_ids=[field.que_id for field in batch],
|
|
2240
|
+
list_type=list_type,
|
|
2241
|
+
)
|
|
2242
|
+
extra_rows = extra_page.get("list")
|
|
2243
|
+
extra_items = extra_rows if isinstance(extra_rows, list) else []
|
|
2244
|
+
for extra_item in extra_items:
|
|
2245
|
+
if not isinstance(extra_item, dict):
|
|
2246
|
+
continue
|
|
2247
|
+
apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
|
|
2248
|
+
if apply_id is None or apply_id not in page_row_map:
|
|
2249
|
+
continue
|
|
2250
|
+
extra_answers = extra_item.get("answers")
|
|
2251
|
+
extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
|
|
2252
|
+
partial_row = _build_flat_row(extra_answer_list, batch, apply_id=apply_id)
|
|
2253
|
+
partial_row.pop("apply_id", None)
|
|
2254
|
+
page_row_map[apply_id].update(partial_row)
|
|
1529
2255
|
if not has_more:
|
|
1530
2256
|
break
|
|
1531
2257
|
current_page += 1
|
|
1532
|
-
|
|
1533
|
-
effective_result_amount = scanned_records if view_selection is not None else max(result_amount or 0, scanned_records)
|
|
2258
|
+
effective_result_amount = matched_records if view_selection is not None else (result_amount or len(rows))
|
|
1534
2259
|
completeness = _build_completeness(
|
|
1535
2260
|
result_amount=effective_result_amount,
|
|
1536
|
-
returned_items=len(
|
|
2261
|
+
returned_items=len(rows),
|
|
1537
2262
|
fetched_pages=scanned_pages,
|
|
1538
2263
|
requested_pages=scan_limit,
|
|
1539
2264
|
has_more=has_more,
|
|
1540
2265
|
next_page_token=None,
|
|
1541
|
-
is_complete=
|
|
1542
|
-
omitted_items=max(0, effective_result_amount - len(
|
|
1543
|
-
extra={
|
|
1544
|
-
"raw_scan_complete": raw_scan_complete,
|
|
1545
|
-
"scan_limit_hit": has_more,
|
|
1546
|
-
"scanned_pages": scanned_pages,
|
|
1547
|
-
"scan_limit": scan_limit,
|
|
1548
|
-
"output_page_complete": len(preview_rows) < max_rows,
|
|
1549
|
-
"raw_next_page_token": None,
|
|
1550
|
-
"output_next_page_token": None,
|
|
1551
|
-
"stop_reason": "source_exhausted" if raw_scan_complete else "scan_limit",
|
|
1552
|
-
},
|
|
2266
|
+
is_complete=not has_more and len(rows) < max_rows,
|
|
2267
|
+
omitted_items=max(0, effective_result_amount - len(rows)),
|
|
2268
|
+
extra={},
|
|
1553
2269
|
)
|
|
1554
2270
|
evidence = {
|
|
1555
2271
|
"query_id": _query_id(),
|
|
1556
2272
|
"app_key": app_key,
|
|
1557
2273
|
"filters": _echo_filters(match_rules),
|
|
1558
|
-
"selected_columns": [field.que_title for field in
|
|
2274
|
+
"selected_columns": [field.que_title for field in selected_fields],
|
|
1559
2275
|
"time_range": time_range or None,
|
|
1560
2276
|
"source_pages": source_pages,
|
|
1561
2277
|
"view": _view_selection_payload(view_selection),
|
|
1562
2278
|
}
|
|
1563
|
-
if strict_full and not raw_scan_complete:
|
|
1564
|
-
self._raise_need_more_data(completeness, evidence, "Summary is incomplete; increase requested_pages or scan_max_pages.")
|
|
1565
2279
|
response: JSONObject = {
|
|
1566
2280
|
"profile": profile,
|
|
1567
2281
|
"ws_id": session_profile.selected_ws_id,
|
|
1568
2282
|
"ok": True,
|
|
1569
2283
|
"request_route": self._request_route_payload(context),
|
|
1570
2284
|
"data": {
|
|
1571
|
-
"mode": "
|
|
2285
|
+
"mode": "list",
|
|
1572
2286
|
"source_tool": "record_search",
|
|
1573
2287
|
"view": _view_selection_payload(view_selection),
|
|
1574
|
-
"
|
|
1575
|
-
"
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
2288
|
+
"list": {
|
|
2289
|
+
"rows": rows,
|
|
2290
|
+
"row_cap_hit": _list_row_cap_hit(returned_items=len(rows), row_cap=max_rows),
|
|
2291
|
+
"sample_only": _list_sample_only(
|
|
2292
|
+
returned_items=len(rows),
|
|
2293
|
+
row_cap=max_rows,
|
|
2294
|
+
result_amount=effective_result_amount,
|
|
2295
|
+
),
|
|
2296
|
+
"safe_for_final_conclusion": False,
|
|
2297
|
+
"analysis_warning": _list_sample_warning(
|
|
2298
|
+
returned_items=len(rows),
|
|
2299
|
+
row_cap=max_rows,
|
|
2300
|
+
result_amount=effective_result_amount,
|
|
2301
|
+
),
|
|
2302
|
+
"pagination": {
|
|
2303
|
+
"page_num": page_num,
|
|
2304
|
+
"page_size": page_size,
|
|
2305
|
+
"requested_pages": scan_limit,
|
|
2306
|
+
"result_amount": effective_result_amount,
|
|
2307
|
+
"returned_items": len(rows),
|
|
1580
2308
|
},
|
|
1581
|
-
"rows": preview_rows,
|
|
1582
|
-
"completeness": completeness,
|
|
1583
2309
|
"applied_limits": {
|
|
1584
2310
|
"row_cap": max_rows,
|
|
1585
2311
|
"column_cap": resolved_column_cap,
|
|
1586
|
-
"selected_columns": [field.que_title for field in
|
|
2312
|
+
"selected_columns": [field.que_title for field in selected_fields],
|
|
1587
2313
|
},
|
|
1588
2314
|
},
|
|
1589
2315
|
},
|
|
@@ -1592,10 +2318,11 @@ class RecordTools(ToolBase):
|
|
|
1592
2318
|
}
|
|
1593
2319
|
if output_profile == "verbose":
|
|
1594
2320
|
response["completeness"] = completeness
|
|
2321
|
+
evidence["backend_reported_total"] = reported_total
|
|
1595
2322
|
response["evidence"] = evidence
|
|
1596
2323
|
response["resolved_mappings"] = {
|
|
1597
|
-
"select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in
|
|
1598
|
-
"
|
|
2324
|
+
"select_columns": [_field_mapping_entry("row", field, requested=field.que_title) for field in selected_fields],
|
|
2325
|
+
"filters": [_field_mapping_entry("filter", entry["field"], requested=entry["requested"]) for entry in self._resolve_filter_field_entries(filters, index)],
|
|
1599
2326
|
"time_range": _field_mapping_entry("time", time_field, requested=time_field.que_title) if time_field is not None else None,
|
|
1600
2327
|
}
|
|
1601
2328
|
return response
|
|
@@ -1721,6 +2448,60 @@ class RecordTools(ToolBase):
|
|
|
1721
2448
|
return True
|
|
1722
2449
|
return False
|
|
1723
2450
|
|
|
2451
|
+
def _build_analysis_probe(
|
|
2452
|
+
self,
|
|
2453
|
+
profile: str,
|
|
2454
|
+
context, # type: ignore[no-untyped-def]
|
|
2455
|
+
*,
|
|
2456
|
+
app_key: str,
|
|
2457
|
+
arguments: JSONObject,
|
|
2458
|
+
view_selection: ViewSelection | None,
|
|
2459
|
+
) -> JSONObject:
|
|
2460
|
+
routed_mode = _resolve_query_mode(
|
|
2461
|
+
str(arguments.get("query_mode", "auto")),
|
|
2462
|
+
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
2463
|
+
amount_column=arguments.get("amount_column"),
|
|
2464
|
+
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
2465
|
+
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
2466
|
+
)
|
|
2467
|
+
fixed_policy = _fixed_analysis_scan_policy() if routed_mode == "summary" else _fixed_list_scan_policy()
|
|
2468
|
+
page_size = int(fixed_policy["page_size"])
|
|
2469
|
+
query_key = _normalize_optional_text(arguments.get("query_key"))
|
|
2470
|
+
filters = _as_object_list(arguments.get("filters"))
|
|
2471
|
+
sorts = _as_object_list(arguments.get("sorts"))
|
|
2472
|
+
app_form = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
2473
|
+
time_range = cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {})
|
|
2474
|
+
match_rules = self._resolve_match_rules(context, filters, app_form)
|
|
2475
|
+
sort_rules = self._resolve_sorts(sorts, app_form)
|
|
2476
|
+
time_field = self._resolve_time_range_column(time_range, app_form)
|
|
2477
|
+
match_rules = self._append_time_range_filter(match_rules, time_range, time_field)
|
|
2478
|
+
probe_page = self._search_page(
|
|
2479
|
+
context,
|
|
2480
|
+
app_key=app_key,
|
|
2481
|
+
page_num=max(_coerce_count(arguments.get("page_num")) or 1, 1),
|
|
2482
|
+
page_size=page_size,
|
|
2483
|
+
query_key=query_key,
|
|
2484
|
+
match_rules=match_rules,
|
|
2485
|
+
sorts=sort_rules,
|
|
2486
|
+
search_que_ids=None,
|
|
2487
|
+
list_type=_coerce_count(arguments.get("list_type")) or DEFAULT_RECORD_LIST_TYPE,
|
|
2488
|
+
)
|
|
2489
|
+
backend_total_count = _effective_total(probe_page, page_size)
|
|
2490
|
+
page_amount = _coerce_count(probe_page.get("pageAmount"))
|
|
2491
|
+
estimated_full_scan_pages = page_amount
|
|
2492
|
+
if estimated_full_scan_pages is None and backend_total_count > 0:
|
|
2493
|
+
estimated_full_scan_pages = (backend_total_count + page_size - 1) // page_size
|
|
2494
|
+
current_budget = min(int(fixed_policy["requested_pages"]), int(fixed_policy["scan_max_pages"]))
|
|
2495
|
+
return {
|
|
2496
|
+
"page_size": page_size,
|
|
2497
|
+
"backend_total_count": backend_total_count,
|
|
2498
|
+
"backend_page_amount": page_amount,
|
|
2499
|
+
"estimated_full_scan_pages": estimated_full_scan_pages,
|
|
2500
|
+
"current_scan_budget": current_budget,
|
|
2501
|
+
"would_exceed_current_budget": bool(estimated_full_scan_pages is not None and estimated_full_scan_pages > current_budget),
|
|
2502
|
+
"local_filtering": bool(view_selection.conditions) if view_selection is not None else False,
|
|
2503
|
+
}
|
|
2504
|
+
|
|
1724
2505
|
def _search_page(
|
|
1725
2506
|
self,
|
|
1726
2507
|
context, # type: ignore[no-untyped-def]
|
|
@@ -2099,6 +2880,178 @@ class RecordTools(ToolBase):
|
|
|
2099
2880
|
"qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
|
|
2100
2881
|
}
|
|
2101
2882
|
|
|
2883
|
+
def _normalize_public_output_profile(self, output_profile: str) -> str:
|
|
2884
|
+
normalized = (output_profile or "normal").strip().lower()
|
|
2885
|
+
if normalized not in {"normal", "verbose"}:
|
|
2886
|
+
raise_tool_error(QingflowApiError.config_error("output_profile must be normal or verbose"))
|
|
2887
|
+
return normalized
|
|
2888
|
+
|
|
2889
|
+
def _normalize_record_list_where(self, where: list[JSONObject]) -> list[JSONObject]:
|
|
2890
|
+
normalized: list[JSONObject] = []
|
|
2891
|
+
for idx, item in enumerate(where):
|
|
2892
|
+
if not isinstance(item, dict):
|
|
2893
|
+
raise_tool_error(QingflowApiError.config_error(f"where[{idx}] must be an object"))
|
|
2894
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2895
|
+
if field_id is None:
|
|
2896
|
+
raise_tool_error(QingflowApiError.config_error(f"where[{idx}] requires field_id"))
|
|
2897
|
+
payload: JSONObject = {"field_id": field_id}
|
|
2898
|
+
if "op" in item:
|
|
2899
|
+
payload["op"] = item["op"]
|
|
2900
|
+
if "operator" in item:
|
|
2901
|
+
payload["operator"] = item["operator"]
|
|
2902
|
+
if "value" in item:
|
|
2903
|
+
payload["value"] = item["value"]
|
|
2904
|
+
elif "values" in item:
|
|
2905
|
+
payload["values"] = item["values"]
|
|
2906
|
+
normalized.append(payload)
|
|
2907
|
+
return normalized
|
|
2908
|
+
|
|
2909
|
+
def _normalize_record_list_order_by(self, order_by: list[JSONObject]) -> list[JSONObject]:
|
|
2910
|
+
normalized: list[JSONObject] = []
|
|
2911
|
+
for idx, item in enumerate(order_by):
|
|
2912
|
+
if not isinstance(item, dict):
|
|
2913
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}] must be an object"))
|
|
2914
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2915
|
+
if field_id is None:
|
|
2916
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}] requires field_id"))
|
|
2917
|
+
direction = _normalize_optional_text(item.get("direction", item.get("order"))) or "asc"
|
|
2918
|
+
if direction not in {"asc", "desc"}:
|
|
2919
|
+
raise_tool_error(QingflowApiError.config_error(f"order_by[{idx}].direction must be asc or desc"))
|
|
2920
|
+
normalized.append({"field_id": field_id, "direction": direction})
|
|
2921
|
+
return normalized
|
|
2922
|
+
|
|
2923
|
+
def _normalize_record_write_submit_type(self, submit_type: str | int) -> int:
|
|
2924
|
+
if isinstance(submit_type, int):
|
|
2925
|
+
if submit_type in {0, 1}:
|
|
2926
|
+
return submit_type
|
|
2927
|
+
raise_tool_error(QingflowApiError.config_error("submit_type must be 0, 1, save, or submit"))
|
|
2928
|
+
normalized = (submit_type or "submit").strip().lower()
|
|
2929
|
+
if normalized == "submit":
|
|
2930
|
+
return 1
|
|
2931
|
+
if normalized in {"save", "draft"}:
|
|
2932
|
+
return 0
|
|
2933
|
+
raise_tool_error(QingflowApiError.config_error("submit_type must be save or submit"))
|
|
2934
|
+
|
|
2935
|
+
def _normalize_record_write_clauses(self, clauses: list[JSONObject], *, location: str) -> list[JSONObject]:
|
|
2936
|
+
normalized: list[JSONObject] = []
|
|
2937
|
+
for idx, item in enumerate(clauses):
|
|
2938
|
+
if not isinstance(item, dict):
|
|
2939
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] must be an object"))
|
|
2940
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
2941
|
+
if field_id is None:
|
|
2942
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] requires field_id"))
|
|
2943
|
+
payload: JSONObject = {"field_id": field_id}
|
|
2944
|
+
if "value" in item:
|
|
2945
|
+
payload["value"] = item["value"]
|
|
2946
|
+
elif "values" in item:
|
|
2947
|
+
payload["values"] = item["values"]
|
|
2948
|
+
else:
|
|
2949
|
+
raise_tool_error(QingflowApiError.config_error(f"{location}[{idx}] requires value"))
|
|
2950
|
+
normalized.append(payload)
|
|
2951
|
+
return normalized
|
|
2952
|
+
|
|
2953
|
+
def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
|
|
2954
|
+
if not enabled:
|
|
2955
|
+
return None
|
|
2956
|
+
return {
|
|
2957
|
+
"required": True,
|
|
2958
|
+
"operation": operation,
|
|
2959
|
+
"message": "Read the current record first and confirm the exact target before applying this high-risk write.",
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
def _record_write_plan_response(
|
|
2963
|
+
self,
|
|
2964
|
+
raw_plan: JSONObject,
|
|
2965
|
+
*,
|
|
2966
|
+
operation: str,
|
|
2967
|
+
normalized_payload: JSONObject,
|
|
2968
|
+
output_profile: str,
|
|
2969
|
+
human_review: bool,
|
|
2970
|
+
) -> JSONObject:
|
|
2971
|
+
plan_data = cast(JSONObject, raw_plan.get("data", {}))
|
|
2972
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
2973
|
+
warnings_payload = validation.get("warnings", [])
|
|
2974
|
+
warnings = [{"code": "PLAN_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
2975
|
+
response: JSONObject = {
|
|
2976
|
+
"profile": raw_plan.get("profile"),
|
|
2977
|
+
"ws_id": raw_plan.get("ws_id"),
|
|
2978
|
+
"ok": bool(raw_plan.get("ok", True)),
|
|
2979
|
+
"request_route": raw_plan.get("request_route"),
|
|
2980
|
+
"warnings": warnings,
|
|
2981
|
+
"output_profile": output_profile,
|
|
2982
|
+
"data": {
|
|
2983
|
+
"action": {"operation": operation, "mode": "plan"},
|
|
2984
|
+
"resource": {"type": "record", "app_key": plan_data.get("app_key"), "record_id": plan_data.get("apply_id")},
|
|
2985
|
+
"verification": None,
|
|
2986
|
+
"normalized_payload": normalized_payload,
|
|
2987
|
+
"blockers": plan_data.get("blockers", []),
|
|
2988
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
2989
|
+
},
|
|
2990
|
+
}
|
|
2991
|
+
if output_profile == "verbose":
|
|
2992
|
+
response["data"]["debug"] = {
|
|
2993
|
+
"legacy_plan": plan_data,
|
|
2994
|
+
}
|
|
2995
|
+
return response
|
|
2996
|
+
|
|
2997
|
+
def _record_write_apply_response(
|
|
2998
|
+
self,
|
|
2999
|
+
raw_apply: JSONObject,
|
|
3000
|
+
*,
|
|
3001
|
+
operation: str,
|
|
3002
|
+
normalized_payload: JSONObject,
|
|
3003
|
+
output_profile: str,
|
|
3004
|
+
human_review: bool,
|
|
3005
|
+
) -> JSONObject:
|
|
3006
|
+
response: JSONObject = {
|
|
3007
|
+
"profile": raw_apply.get("profile"),
|
|
3008
|
+
"ws_id": raw_apply.get("ws_id"),
|
|
3009
|
+
"ok": bool(raw_apply.get("ok", True)),
|
|
3010
|
+
"request_route": raw_apply.get("request_route"),
|
|
3011
|
+
"warnings": [],
|
|
3012
|
+
"output_profile": output_profile,
|
|
3013
|
+
"data": {
|
|
3014
|
+
"action": {"operation": operation, "mode": "apply"},
|
|
3015
|
+
"resource": raw_apply.get("resource"),
|
|
3016
|
+
"verification": raw_apply.get("verification"),
|
|
3017
|
+
"normalized_payload": normalized_payload,
|
|
3018
|
+
"blockers": [],
|
|
3019
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
3020
|
+
},
|
|
3021
|
+
}
|
|
3022
|
+
if output_profile == "verbose":
|
|
3023
|
+
response["data"]["debug"] = {
|
|
3024
|
+
"legacy_result": raw_apply.get("result"),
|
|
3025
|
+
"status": raw_apply.get("status"),
|
|
3026
|
+
"write_verified": raw_apply.get("write_verified"),
|
|
3027
|
+
}
|
|
3028
|
+
return response
|
|
3029
|
+
|
|
3030
|
+
def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
|
|
3031
|
+
if not app_key:
|
|
3032
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3033
|
+
normalized_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
|
|
3034
|
+
if not normalized_ids:
|
|
3035
|
+
raise_tool_error(QingflowApiError.config_error("record_ids must contain at least one positive id"))
|
|
3036
|
+
|
|
3037
|
+
def runner(session_profile, context):
|
|
3038
|
+
result = self.backend.request(
|
|
3039
|
+
"DELETE",
|
|
3040
|
+
context,
|
|
3041
|
+
f"/app/{app_key}/apply",
|
|
3042
|
+
json_body={"type": DEFAULT_RECORD_LIST_TYPE, "applyIds": normalized_ids},
|
|
3043
|
+
)
|
|
3044
|
+
return {
|
|
3045
|
+
"profile": profile,
|
|
3046
|
+
"ws_id": session_profile.selected_ws_id,
|
|
3047
|
+
"request_route": self._request_route_payload(context),
|
|
3048
|
+
"result": result,
|
|
3049
|
+
"resource": {"type": "record", "apply_ids": normalized_ids},
|
|
3050
|
+
"ok": True,
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
return self._run_record_tool(profile, runner)
|
|
3054
|
+
|
|
2102
3055
|
def _resolve_field_selector(self, selector: str | int | None, index: FieldIndex, *, location: str) -> FormField:
|
|
2103
3056
|
if selector is None:
|
|
2104
3057
|
raise RecordInputError(
|
|
@@ -2121,7 +3074,7 @@ class RecordTools(ToolBase):
|
|
|
2121
3074
|
raise RecordInputError(
|
|
2122
3075
|
message=f"{location} references unknown queId '{requested}'",
|
|
2123
3076
|
error_code="FIELD_NOT_FOUND",
|
|
2124
|
-
fix_hint="Use
|
|
3077
|
+
fix_hint="Use record_schema_get to confirm the exact field_id.",
|
|
2125
3078
|
details={"location": location, "requested": requested, "requested_key": requested_key},
|
|
2126
3079
|
)
|
|
2127
3080
|
matches = index.by_title.get(requested_key, [])
|
|
@@ -2131,7 +3084,7 @@ class RecordTools(ToolBase):
|
|
|
2131
3084
|
raise RecordInputError(
|
|
2132
3085
|
message=f"{location} field '{requested}' is ambiguous",
|
|
2133
3086
|
error_code="AMBIGUOUS_FIELD",
|
|
2134
|
-
fix_hint="Use numeric queId, or
|
|
3087
|
+
fix_hint="Use numeric queId, or inspect record_schema_get output to disambiguate the field.",
|
|
2135
3088
|
details={
|
|
2136
3089
|
"location": location,
|
|
2137
3090
|
"requested": requested,
|
|
@@ -2147,7 +3100,7 @@ class RecordTools(ToolBase):
|
|
|
2147
3100
|
raise RecordInputError(
|
|
2148
3101
|
message=f"{location} field '{requested}' is ambiguous",
|
|
2149
3102
|
error_code="AMBIGUOUS_FIELD",
|
|
2150
|
-
fix_hint="Use a more specific field title, or
|
|
3103
|
+
fix_hint="Use a more specific field title, or inspect record_schema_get aliases to disambiguate the field.",
|
|
2151
3104
|
details={
|
|
2152
3105
|
"location": location,
|
|
2153
3106
|
"requested": requested,
|
|
@@ -2159,7 +3112,7 @@ class RecordTools(ToolBase):
|
|
|
2159
3112
|
raise RecordInputError(
|
|
2160
3113
|
message=f"{location} cannot resolve field '{requested}'",
|
|
2161
3114
|
error_code="FIELD_NOT_FOUND",
|
|
2162
|
-
fix_hint="Use
|
|
3115
|
+
fix_hint="Use record_schema_get to confirm the exact field title or field_id.",
|
|
2163
3116
|
details={
|
|
2164
3117
|
"location": location,
|
|
2165
3118
|
"requested": requested,
|
|
@@ -2536,56 +3489,6 @@ class RecordTools(ToolBase):
|
|
|
2536
3489
|
"reason": error.message,
|
|
2537
3490
|
}
|
|
2538
3491
|
|
|
2539
|
-
def _resolve_plan_candidate(self, candidate: JSONObject, index: FieldIndex) -> JSONObject:
|
|
2540
|
-
requested = str(candidate.get("requested", "")).strip()
|
|
2541
|
-
role = str(candidate.get("role", "field"))
|
|
2542
|
-
try:
|
|
2543
|
-
field = self._resolve_field_selector(requested, index, location=role)
|
|
2544
|
-
return {
|
|
2545
|
-
"role": role,
|
|
2546
|
-
"requested": requested,
|
|
2547
|
-
"resolved": True,
|
|
2548
|
-
"que_id": field.que_id,
|
|
2549
|
-
"que_title": field.que_title,
|
|
2550
|
-
"que_type": field.que_type,
|
|
2551
|
-
"reason": None,
|
|
2552
|
-
}
|
|
2553
|
-
except RecordInputError as error:
|
|
2554
|
-
return {
|
|
2555
|
-
"role": role,
|
|
2556
|
-
"requested": requested,
|
|
2557
|
-
"resolved": False,
|
|
2558
|
-
"que_id": None,
|
|
2559
|
-
"que_title": None,
|
|
2560
|
-
"que_type": None,
|
|
2561
|
-
"reason": error.message,
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
def _validate_plan_arguments(self, tool: str, arguments: JSONObject) -> JSONObject:
|
|
2565
|
-
missing_required: list[str] = []
|
|
2566
|
-
warnings: list[str] = []
|
|
2567
|
-
if tool in {"record_query", "record_aggregate"} and not arguments.get("app_key"):
|
|
2568
|
-
missing_required.append("app_key")
|
|
2569
|
-
if tool == "record_get":
|
|
2570
|
-
if not arguments.get("app_key"):
|
|
2571
|
-
missing_required.append("app_key")
|
|
2572
|
-
if not arguments.get("apply_id"):
|
|
2573
|
-
missing_required.append("apply_id")
|
|
2574
|
-
if not arguments.get("select_columns"):
|
|
2575
|
-
missing_required.append("select_columns")
|
|
2576
|
-
query_mode = _resolve_query_mode(
|
|
2577
|
-
str(arguments.get("query_mode", "auto")),
|
|
2578
|
-
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
2579
|
-
amount_column=arguments.get("amount_column"),
|
|
2580
|
-
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
2581
|
-
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
2582
|
-
) if tool == "record_query" else None
|
|
2583
|
-
if tool == "record_query" and query_mode in {"list", "record"} and not arguments.get("select_columns"):
|
|
2584
|
-
missing_required.append("select_columns")
|
|
2585
|
-
if tool == "record_query" and query_mode == "summary" and not arguments.get("amount_column") and not arguments.get("time_range"):
|
|
2586
|
-
warnings.append("summary mode without amount_column or time_range only returns row counts")
|
|
2587
|
-
return {"valid": not missing_required, "missing_required": missing_required, "warnings": warnings}
|
|
2588
|
-
|
|
2589
3492
|
def _validate_app_and_record(self, app_key: str, apply_id: int | str) -> int:
|
|
2590
3493
|
if not app_key:
|
|
2591
3494
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
@@ -2910,143 +3813,44 @@ def _extract_question_options(question: JSONObject) -> list[str]:
|
|
|
2910
3813
|
return values
|
|
2911
3814
|
|
|
2912
3815
|
|
|
2913
|
-
def
|
|
2914
|
-
normalized = cast(JSONObject, _parse_json_like(arguments))
|
|
2915
|
-
alias_map = {
|
|
2916
|
-
"appKey": "app_key",
|
|
2917
|
-
"applyId": "apply_id",
|
|
2918
|
-
"queryMode": "query_mode",
|
|
2919
|
-
"pageNum": "page_num",
|
|
2920
|
-
"pageSize": "page_size",
|
|
2921
|
-
"requestedPages": "requested_pages",
|
|
2922
|
-
"scanMaxPages": "scan_max_pages",
|
|
2923
|
-
"queryKey": "query_key",
|
|
2924
|
-
"maxRows": "max_rows",
|
|
2925
|
-
"maxColumns": "max_columns",
|
|
2926
|
-
"selectColumns": "select_columns",
|
|
2927
|
-
"amountColumn": "amount_column",
|
|
2928
|
-
"timeRange": "time_range",
|
|
2929
|
-
"strictFull": "strict_full",
|
|
2930
|
-
"outputProfile": "output_profile",
|
|
2931
|
-
"listType": "list_type",
|
|
2932
|
-
"viewKey": "view_key",
|
|
2933
|
-
"viewName": "view_name",
|
|
2934
|
-
"groupBy": "group_by",
|
|
2935
|
-
"timeBucket": "time_bucket",
|
|
2936
|
-
"maxGroups": "max_groups",
|
|
2937
|
-
"forceRefreshForm": "force_refresh_form",
|
|
2938
|
-
}
|
|
2939
|
-
result = dict(normalized)
|
|
2940
|
-
for alias, canonical in alias_map.items():
|
|
2941
|
-
if alias in result and canonical not in result:
|
|
2942
|
-
result[canonical] = result[alias]
|
|
2943
|
-
if tool == "record_get" and "query_mode" not in result:
|
|
2944
|
-
result["query_mode"] = "record"
|
|
2945
|
-
return result
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
def _collect_plan_field_candidates(tool: str, arguments: JSONObject) -> list[JSONObject]:
|
|
2949
|
-
candidates: list[JSONObject] = []
|
|
2950
|
-
if tool in {"record_query", "record_get"}:
|
|
2951
|
-
for item in _as_selector_list(arguments.get("select_columns")):
|
|
2952
|
-
candidates.append({"role": "select_columns", "requested": str(item)})
|
|
2953
|
-
amount = arguments.get("amount_column")
|
|
2954
|
-
if amount is not None:
|
|
2955
|
-
candidates.append({"role": "amount_column", "requested": str(amount)})
|
|
2956
|
-
time_range = arguments.get("time_range")
|
|
2957
|
-
if isinstance(time_range, dict) and time_range.get("column") is not None:
|
|
2958
|
-
candidates.append({"role": "time_range.column", "requested": str(time_range["column"])})
|
|
2959
|
-
if tool == "record_aggregate":
|
|
2960
|
-
for item in _as_selector_list(arguments.get("group_by")):
|
|
2961
|
-
candidates.append({"role": "group_by", "requested": str(item)})
|
|
2962
|
-
amount = arguments.get("amount_column")
|
|
2963
|
-
if amount is not None:
|
|
2964
|
-
candidates.append({"role": "amount_column", "requested": str(amount)})
|
|
2965
|
-
time_range = arguments.get("time_range")
|
|
2966
|
-
if isinstance(time_range, dict) and time_range.get("column") is not None:
|
|
2967
|
-
candidates.append({"role": "time_range.column", "requested": str(time_range["column"])})
|
|
2968
|
-
for item in _as_object_list(arguments.get("filters")):
|
|
2969
|
-
selector = _extract_filter_selector(item)
|
|
2970
|
-
if selector is not None:
|
|
2971
|
-
candidates.append({"role": "filter", "requested": str(selector)})
|
|
2972
|
-
for item in _as_object_list(arguments.get("sorts", arguments.get("sort"))):
|
|
2973
|
-
selector = _extract_sort_selector(item)
|
|
2974
|
-
if selector is not None:
|
|
2975
|
-
candidates.append({"role": "sort", "requested": str(selector)})
|
|
2976
|
-
return candidates
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
def _build_plan_estimate(tool: str, arguments: JSONObject) -> JSONObject:
|
|
2980
|
-
page_size = _coerce_count(arguments.get("page_size")) or DEFAULT_QUERY_PAGE_SIZE
|
|
2981
|
-
requested_pages = _coerce_count(arguments.get("requested_pages")) or 1
|
|
2982
|
-
scan_max_pages = _coerce_count(arguments.get("scan_max_pages")) or requested_pages
|
|
2983
|
-
estimated_scan_pages = min(requested_pages, scan_max_pages)
|
|
2984
|
-
may_hit_limits = estimated_scan_pages > DEFAULT_SCAN_MAX_PAGES
|
|
2985
|
-
reasons = []
|
|
2986
|
-
if may_hit_limits:
|
|
2987
|
-
reasons.append("requested scan pages exceed the default analysis budget")
|
|
2988
|
-
if tool == "record_query":
|
|
2989
|
-
routed_mode = _resolve_query_mode(
|
|
2990
|
-
str(arguments.get("query_mode", "auto")),
|
|
2991
|
-
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
2992
|
-
amount_column=arguments.get("amount_column"),
|
|
2993
|
-
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
2994
|
-
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
2995
|
-
)
|
|
2996
|
-
if routed_mode == "list":
|
|
2997
|
-
reasons.append("list mode is not a safe final-analysis endpoint")
|
|
3816
|
+
def _fixed_list_scan_policy() -> JSONObject:
|
|
2998
3817
|
return {
|
|
2999
|
-
"page_size":
|
|
3000
|
-
"requested_pages":
|
|
3001
|
-
"scan_max_pages":
|
|
3002
|
-
"
|
|
3003
|
-
"
|
|
3004
|
-
"may_hit_limits": may_hit_limits,
|
|
3005
|
-
"reasons": reasons,
|
|
3006
|
-
"probe": None,
|
|
3818
|
+
"page_size": DEFAULT_LIST_PAGE_SIZE,
|
|
3819
|
+
"requested_pages": 1,
|
|
3820
|
+
"scan_max_pages": 1,
|
|
3821
|
+
"auto_expand_pages": False,
|
|
3822
|
+
"public_inputs_exposed": False,
|
|
3007
3823
|
}
|
|
3008
3824
|
|
|
3009
3825
|
|
|
3010
|
-
def
|
|
3011
|
-
tool: str,
|
|
3012
|
-
arguments: JSONObject,
|
|
3013
|
-
validation: JSONObject,
|
|
3014
|
-
field_mapping: list[JSONObject],
|
|
3015
|
-
estimate: JSONObject,
|
|
3016
|
-
) -> JSONObject:
|
|
3017
|
-
blockers: list[str] = []
|
|
3018
|
-
actions: list[str] = []
|
|
3019
|
-
if not bool(validation.get("valid")):
|
|
3020
|
-
blockers.append("arguments are not valid")
|
|
3021
|
-
actions.append("Fix missing_required before execution.")
|
|
3022
|
-
unresolved = [item for item in field_mapping if not bool(item.get("resolved"))]
|
|
3023
|
-
if unresolved:
|
|
3024
|
-
blockers.append("one or more fields are unresolved")
|
|
3025
|
-
actions.append("Use record_field_resolve to resolve field ids before execution.")
|
|
3026
|
-
if tool == "record_query":
|
|
3027
|
-
routed_mode = _resolve_query_mode(
|
|
3028
|
-
str(arguments.get("query_mode", "auto")),
|
|
3029
|
-
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
3030
|
-
amount_column=arguments.get("amount_column"),
|
|
3031
|
-
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
3032
|
-
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
3033
|
-
)
|
|
3034
|
-
if routed_mode == "list":
|
|
3035
|
-
blockers.append("list mode is not a safe final-analysis endpoint")
|
|
3036
|
-
actions.append("Use record_query(summary) or record_aggregate for final statistics.")
|
|
3037
|
-
if routed_mode == "record":
|
|
3038
|
-
blockers.append("record mode is a detail endpoint, not a final-analysis endpoint")
|
|
3039
|
-
if tool in {"record_query", "record_aggregate"} and not bool(arguments.get("strict_full")):
|
|
3040
|
-
blockers.append("strict_full should be true for final conclusions")
|
|
3041
|
-
actions.append("Set strict_full=true so incomplete scans block final conclusions.")
|
|
3042
|
-
actions.append("After execution, verify completeness before using the result as a final conclusion.")
|
|
3826
|
+
def _fixed_analysis_scan_policy() -> JSONObject:
|
|
3043
3827
|
return {
|
|
3044
|
-
"
|
|
3045
|
-
"
|
|
3046
|
-
"
|
|
3828
|
+
"page_size": DEFAULT_ANALYSIS_PAGE_SIZE,
|
|
3829
|
+
"requested_pages": DEFAULT_ANALYSIS_SCAN_MAX_PAGES,
|
|
3830
|
+
"scan_max_pages": DEFAULT_ANALYSIS_SCAN_MAX_PAGES,
|
|
3831
|
+
"auto_expand_pages": True,
|
|
3832
|
+
"public_inputs_exposed": False,
|
|
3047
3833
|
}
|
|
3048
3834
|
|
|
3049
3835
|
|
|
3836
|
+
def _list_row_cap_hit(*, returned_items: int, row_cap: int) -> bool:
|
|
3837
|
+
return row_cap > 0 and returned_items >= row_cap
|
|
3838
|
+
|
|
3839
|
+
|
|
3840
|
+
def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int | None) -> bool:
|
|
3841
|
+
if _list_row_cap_hit(returned_items=returned_items, row_cap=row_cap):
|
|
3842
|
+
return True
|
|
3843
|
+
if result_amount is None:
|
|
3844
|
+
return False
|
|
3845
|
+
return result_amount > returned_items
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
|
|
3849
|
+
if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
|
|
3850
|
+
return "当前仅返回样本,不适合最终统计结论。"
|
|
3851
|
+
return "record_list 适合浏览或导出明细;最终统计结论请改用 record_schema_get -> record_analyze。"
|
|
3852
|
+
|
|
3853
|
+
|
|
3050
3854
|
def _resolve_query_mode(
|
|
3051
3855
|
query_mode: str,
|
|
3052
3856
|
*,
|
|
@@ -3059,8 +3863,6 @@ def _resolve_query_mode(
|
|
|
3059
3863
|
return query_mode
|
|
3060
3864
|
if apply_id is not None and apply_id > 0:
|
|
3061
3865
|
return "record"
|
|
3062
|
-
if amount_column is not None or time_range or stat_policy:
|
|
3063
|
-
return "summary"
|
|
3064
3866
|
return "list"
|
|
3065
3867
|
|
|
3066
3868
|
|
|
@@ -3154,6 +3956,69 @@ def _view_selection_payload(view_selection: ViewSelection | None) -> JSONObject
|
|
|
3154
3956
|
}
|
|
3155
3957
|
|
|
3156
3958
|
|
|
3959
|
+
def _compute_scan_limit(
|
|
3960
|
+
*,
|
|
3961
|
+
requested_pages: int,
|
|
3962
|
+
scan_max_pages: int,
|
|
3963
|
+
auto_expand_pages: bool,
|
|
3964
|
+
page: JSONObject | None = None,
|
|
3965
|
+
page_size: int | None = None,
|
|
3966
|
+
) -> tuple[int, JSONObject]:
|
|
3967
|
+
base_requested = max(requested_pages, 1)
|
|
3968
|
+
base_scan_max = max(scan_max_pages, 1)
|
|
3969
|
+
initial_limit = min(base_requested, base_scan_max)
|
|
3970
|
+
meta: JSONObject = {
|
|
3971
|
+
"requested_pages": base_requested,
|
|
3972
|
+
"scan_max_pages": base_scan_max,
|
|
3973
|
+
"auto_expand_pages": auto_expand_pages,
|
|
3974
|
+
"auto_expand_applied": False,
|
|
3975
|
+
"auto_expand_target_pages": initial_limit,
|
|
3976
|
+
"auto_expand_page_cap": DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP,
|
|
3977
|
+
}
|
|
3978
|
+
if not auto_expand_pages or not isinstance(page, dict):
|
|
3979
|
+
return initial_limit, meta
|
|
3980
|
+
effective_page_size = max(page_size or 0, 1)
|
|
3981
|
+
backend_total = _effective_total(page, effective_page_size)
|
|
3982
|
+
page_amount = _coerce_count(page.get("pageAmount"))
|
|
3983
|
+
target_pages = page_amount
|
|
3984
|
+
if target_pages is None and backend_total > 0:
|
|
3985
|
+
target_pages = (backend_total + effective_page_size - 1) // effective_page_size
|
|
3986
|
+
if target_pages is None:
|
|
3987
|
+
return initial_limit, meta
|
|
3988
|
+
expanded_limit = min(max(initial_limit, target_pages), DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP)
|
|
3989
|
+
if expanded_limit > initial_limit:
|
|
3990
|
+
meta["auto_expand_applied"] = True
|
|
3991
|
+
meta["auto_expand_target_pages"] = target_pages
|
|
3992
|
+
meta["backend_total_count"] = backend_total
|
|
3993
|
+
meta["backend_page_amount"] = page_amount
|
|
3994
|
+
return expanded_limit, meta
|
|
3995
|
+
|
|
3996
|
+
|
|
3997
|
+
def _build_analysis_counts(
|
|
3998
|
+
*,
|
|
3999
|
+
backend_total_count: int | None,
|
|
4000
|
+
scanned_count: int,
|
|
4001
|
+
grouped_count: int,
|
|
4002
|
+
local_filtering: bool,
|
|
4003
|
+
) -> JSONObject:
|
|
4004
|
+
unscanned_count: int | None = None
|
|
4005
|
+
if backend_total_count is not None and not local_filtering:
|
|
4006
|
+
unscanned_count = max(backend_total_count - scanned_count, 0)
|
|
4007
|
+
return {
|
|
4008
|
+
"backend_total_count": backend_total_count,
|
|
4009
|
+
"scanned_count": scanned_count,
|
|
4010
|
+
"grouped_count": grouped_count,
|
|
4011
|
+
"unscanned_count": unscanned_count,
|
|
4012
|
+
"local_filtering": local_filtering,
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
|
|
4016
|
+
def _analysis_status_from_completeness(completeness: JSONObject) -> tuple[str, bool]:
|
|
4017
|
+
raw_scan_complete = bool(completeness.get("raw_scan_complete"))
|
|
4018
|
+
status = "success" if raw_scan_complete else "partial_success"
|
|
4019
|
+
return status, raw_scan_complete
|
|
4020
|
+
|
|
4021
|
+
|
|
3157
4022
|
def _build_completeness(
|
|
3158
4023
|
*,
|
|
3159
4024
|
result_amount: int,
|
|
@@ -3277,6 +4142,11 @@ def _to_time_bucket(value: JSONValue, bucket: str) -> str:
|
|
|
3277
4142
|
if bucket == "week":
|
|
3278
4143
|
iso_year, iso_week, _ = parsed.isocalendar()
|
|
3279
4144
|
return f"{iso_year}-W{iso_week:02d}"
|
|
4145
|
+
if bucket == "quarter":
|
|
4146
|
+
quarter = ((parsed.month - 1) // 3) + 1
|
|
4147
|
+
return f"{parsed.year}-Q{quarter}"
|
|
4148
|
+
if bucket == "year":
|
|
4149
|
+
return parsed.strftime("%Y")
|
|
3280
4150
|
return parsed.strftime("%Y-%m-%d")
|
|
3281
4151
|
|
|
3282
4152
|
|
|
@@ -3290,6 +4160,117 @@ def _parse_datetime_like(text: str) -> datetime | None:
|
|
|
3290
4160
|
return None
|
|
3291
4161
|
|
|
3292
4162
|
|
|
4163
|
+
def _flatten_distinct_values(value: JSONValue) -> list[str]:
|
|
4164
|
+
if value is None:
|
|
4165
|
+
return []
|
|
4166
|
+
if isinstance(value, list):
|
|
4167
|
+
items = value
|
|
4168
|
+
else:
|
|
4169
|
+
items = [value]
|
|
4170
|
+
flattened: list[str] = []
|
|
4171
|
+
for item in items:
|
|
4172
|
+
if item is None:
|
|
4173
|
+
continue
|
|
4174
|
+
if isinstance(item, (dict, list)):
|
|
4175
|
+
flattened.append(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
4176
|
+
else:
|
|
4177
|
+
flattened.append(_stringify_json(item))
|
|
4178
|
+
return flattened
|
|
4179
|
+
|
|
4180
|
+
|
|
4181
|
+
def _coerce_comparable(value: JSONValue) -> JSONValue:
|
|
4182
|
+
amount = _coerce_amount(value)
|
|
4183
|
+
if amount is not None:
|
|
4184
|
+
return amount
|
|
4185
|
+
text = _normalize_optional_text(value)
|
|
4186
|
+
if text is None:
|
|
4187
|
+
return None
|
|
4188
|
+
parsed = _parse_datetime_like(text)
|
|
4189
|
+
if parsed is not None:
|
|
4190
|
+
return parsed
|
|
4191
|
+
return text
|
|
4192
|
+
|
|
4193
|
+
|
|
4194
|
+
def _match_analyze_filter(field_value: JSONValue, op: str, expected: JSONValue) -> bool:
|
|
4195
|
+
if op == "is_null":
|
|
4196
|
+
return field_value is None or field_value == [] or field_value == ""
|
|
4197
|
+
if op == "not_null":
|
|
4198
|
+
return not _match_analyze_filter(field_value, "is_null", expected)
|
|
4199
|
+
|
|
4200
|
+
values = field_value if isinstance(field_value, list) else [field_value]
|
|
4201
|
+
normalized_values = [_coerce_comparable(item) for item in values if item is not None]
|
|
4202
|
+
|
|
4203
|
+
if op == "contains":
|
|
4204
|
+
needle = _normalize_optional_text(expected)
|
|
4205
|
+
if needle is None:
|
|
4206
|
+
return False
|
|
4207
|
+
return any(needle in _stringify_json(item) for item in values if item is not None)
|
|
4208
|
+
|
|
4209
|
+
if op in {"in", "not_in"}:
|
|
4210
|
+
expected_values = expected if isinstance(expected, list) else [expected]
|
|
4211
|
+
matched = any(
|
|
4212
|
+
_analyze_values_equal(actual=item, expected=expected_item)
|
|
4213
|
+
for item in values
|
|
4214
|
+
if item is not None
|
|
4215
|
+
for expected_item in expected_values
|
|
4216
|
+
if expected_item is not None
|
|
4217
|
+
)
|
|
4218
|
+
return matched if op == "in" else not matched
|
|
4219
|
+
|
|
4220
|
+
if op in {"eq", "neq"}:
|
|
4221
|
+
matched = any(_analyze_values_equal(actual=item, expected=expected) for item in values if item is not None)
|
|
4222
|
+
return matched if op == "eq" else not matched
|
|
4223
|
+
|
|
4224
|
+
if op == "between":
|
|
4225
|
+
lower, upper = _coerce_filter_range(expected)
|
|
4226
|
+
lower_value = _coerce_comparable(lower)
|
|
4227
|
+
upper_value = _coerce_comparable(upper)
|
|
4228
|
+
for item in normalized_values:
|
|
4229
|
+
if item is None:
|
|
4230
|
+
continue
|
|
4231
|
+
try:
|
|
4232
|
+
if lower_value is not None and item < lower_value:
|
|
4233
|
+
continue
|
|
4234
|
+
if upper_value is not None and item > upper_value:
|
|
4235
|
+
continue
|
|
4236
|
+
return True
|
|
4237
|
+
except TypeError:
|
|
4238
|
+
continue
|
|
4239
|
+
return False
|
|
4240
|
+
|
|
4241
|
+
target = _coerce_comparable(expected)
|
|
4242
|
+
for item in normalized_values:
|
|
4243
|
+
if item is None or target is None:
|
|
4244
|
+
continue
|
|
4245
|
+
try:
|
|
4246
|
+
if op == "gt" and item > target:
|
|
4247
|
+
return True
|
|
4248
|
+
if op == "gte" and item >= target:
|
|
4249
|
+
return True
|
|
4250
|
+
if op == "lt" and item < target:
|
|
4251
|
+
return True
|
|
4252
|
+
if op == "lte" and item <= target:
|
|
4253
|
+
return True
|
|
4254
|
+
except TypeError:
|
|
4255
|
+
continue
|
|
4256
|
+
return False
|
|
4257
|
+
|
|
4258
|
+
|
|
4259
|
+
def _analyze_values_equal(*, actual: JSONValue, expected: JSONValue) -> bool:
|
|
4260
|
+
actual_comparable = _coerce_comparable(actual)
|
|
4261
|
+
expected_comparable = _coerce_comparable(expected)
|
|
4262
|
+
if actual_comparable is not None and expected_comparable is not None and type(actual_comparable) is type(expected_comparable):
|
|
4263
|
+
return actual_comparable == expected_comparable
|
|
4264
|
+
return _stringify_json(actual) == _stringify_json(expected)
|
|
4265
|
+
|
|
4266
|
+
|
|
4267
|
+
def _sortable_value(value: JSONValue) -> tuple[int, JSONValue]:
|
|
4268
|
+
if value is None:
|
|
4269
|
+
return (1, "")
|
|
4270
|
+
comparable = _coerce_comparable(value)
|
|
4271
|
+
return (0, comparable if comparable is not None else "")
|
|
4272
|
+
|
|
4273
|
+
|
|
3293
4274
|
def _echo_filters(filters: list[JSONObject]) -> list[JSONObject]:
|
|
3294
4275
|
return [dict(item) for item in filters]
|
|
3295
4276
|
|
|
@@ -3850,7 +4831,7 @@ def _option_value(raw_value: JSONValue, field: FormField) -> JSONObject:
|
|
|
3850
4831
|
raise RecordInputError(
|
|
3851
4832
|
message=f"field '{field.que_title}' uses unknown option '{text}'",
|
|
3852
4833
|
error_code="OPTION_NOT_FOUND",
|
|
3853
|
-
fix_hint="Use
|
|
4834
|
+
fix_hint="Use record_schema_get or inspect the form to confirm allowed option values.",
|
|
3854
4835
|
details={"field": _field_ref_payload(field), "expected_format": _write_format_for_field(field), "received_value": raw_value},
|
|
3855
4836
|
)
|
|
3856
4837
|
return {"value": text}
|