@josephyan/qingflow-app-user-mcp 0.2.0-beta.16 → 0.2.0-beta.18
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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +7 -6
- package/skills/qingflow-app-user/references/data-gotchas.md +5 -6
- package/skills/qingflow-app-user/references/record-patterns.md +11 -12
- package/skills/qingflow-record-analysis/SKILL.md +171 -24
- package/skills/qingflow-record-analysis/agents/openai.yaml +2 -2
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +29 -15
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +36 -20
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +7 -7
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +3 -2
- package/src/qingflow_mcp/tools/record_tools.py +863 -171
|
@@ -16,10 +16,11 @@ from .base import ToolBase
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
19
|
-
|
|
19
|
+
DEFAULT_LIST_PAGE_SIZE = 200
|
|
20
|
+
DEFAULT_ANALYSIS_PAGE_SIZE = 1000
|
|
20
21
|
DEFAULT_SCAN_MAX_PAGES = 10
|
|
21
|
-
DEFAULT_ANALYSIS_SCAN_MAX_PAGES =
|
|
22
|
-
DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP =
|
|
22
|
+
DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
|
|
23
|
+
DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP = 100
|
|
23
24
|
DEFAULT_ROW_LIMIT = 200
|
|
24
25
|
DEFAULT_OUTPUT_PROFILE = "compact"
|
|
25
26
|
MAX_LIST_COLUMN_LIMIT = 20
|
|
@@ -139,36 +140,55 @@ class RecordTools(ToolBase):
|
|
|
139
140
|
|
|
140
141
|
def register(self, mcp: FastMCP) -> None:
|
|
141
142
|
@mcp.tool()
|
|
142
|
-
def
|
|
143
|
+
def record_schema_get(
|
|
143
144
|
profile: str = DEFAULT_PROFILE,
|
|
144
145
|
app_key: str = "",
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
fuzzy: bool = True,
|
|
146
|
+
view_key: str | None = None,
|
|
147
|
+
view_name: str | None = None,
|
|
148
|
+
output_profile: str = "normal",
|
|
149
149
|
) -> JSONObject:
|
|
150
|
-
return self.
|
|
150
|
+
return self.record_schema_get(
|
|
151
151
|
profile=profile,
|
|
152
152
|
app_key=app_key,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
fuzzy=fuzzy,
|
|
153
|
+
view_key=view_key,
|
|
154
|
+
view_name=view_name,
|
|
155
|
+
output_profile=output_profile,
|
|
157
156
|
)
|
|
158
157
|
|
|
159
158
|
@mcp.tool(
|
|
160
159
|
description=(
|
|
161
|
-
"
|
|
162
|
-
"
|
|
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."
|
|
163
164
|
)
|
|
164
165
|
)
|
|
165
|
-
def
|
|
166
|
+
def record_analyze(
|
|
166
167
|
profile: str = DEFAULT_PROFILE,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
app_key: str = "",
|
|
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",
|
|
170
178
|
) -> JSONObject:
|
|
171
|
-
return self.
|
|
179
|
+
return self.record_analyze(
|
|
180
|
+
profile=profile,
|
|
181
|
+
app_key=app_key,
|
|
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,
|
|
191
|
+
)
|
|
172
192
|
|
|
173
193
|
@mcp.tool(
|
|
174
194
|
description=(
|
|
@@ -197,10 +217,9 @@ class RecordTools(ToolBase):
|
|
|
197
217
|
|
|
198
218
|
@mcp.tool(
|
|
199
219
|
description=(
|
|
200
|
-
"Unified read entry for record list / record detail
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"List mode is for browsing, export, and sample inspection; do not use it alone for final statistical conclusions."
|
|
220
|
+
"Unified read entry for record list / record detail. "
|
|
221
|
+
"Use query_mode=auto to route: apply_id -> record, otherwise -> list. "
|
|
222
|
+
"For statistical analysis use record_schema_get and record_analyze instead of query_mode=summary."
|
|
204
223
|
)
|
|
205
224
|
)
|
|
206
225
|
def record_query(
|
|
@@ -209,98 +228,45 @@ class RecordTools(ToolBase):
|
|
|
209
228
|
app_key: str = "",
|
|
210
229
|
apply_id: int | None = None,
|
|
211
230
|
page_num: int = 1,
|
|
212
|
-
page_size: int = DEFAULT_QUERY_PAGE_SIZE,
|
|
213
|
-
requested_pages: int = 1,
|
|
214
|
-
scan_max_pages: int = DEFAULT_SCAN_MAX_PAGES,
|
|
215
|
-
auto_expand_pages: bool = False,
|
|
216
231
|
query_key: str | None = None,
|
|
217
232
|
filters: list[JSONObject] | None = None,
|
|
218
233
|
sorts: list[JSONObject] | None = None,
|
|
219
234
|
max_rows: int = DEFAULT_ROW_LIMIT,
|
|
220
235
|
max_columns: int | None = None,
|
|
221
236
|
select_columns: list[str | int] | None = None,
|
|
222
|
-
amount_column: str | int | None = None,
|
|
223
|
-
time_range: JSONObject | None = None,
|
|
224
|
-
stat_policy: JSONObject | None = None,
|
|
225
|
-
strict_full: bool = False,
|
|
226
237
|
output_profile: str = DEFAULT_OUTPUT_PROFILE,
|
|
227
238
|
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
228
239
|
view_key: str | None = None,
|
|
229
240
|
view_name: str | None = None,
|
|
230
241
|
) -> JSONObject:
|
|
242
|
+
resolved_mode = _resolve_query_mode(
|
|
243
|
+
query_mode,
|
|
244
|
+
apply_id=apply_id,
|
|
245
|
+
amount_column=None,
|
|
246
|
+
time_range={},
|
|
247
|
+
stat_policy={},
|
|
248
|
+
)
|
|
249
|
+
paging = _fixed_analysis_scan_policy() if resolved_mode == "summary" else _fixed_list_scan_policy()
|
|
231
250
|
return self.record_query(
|
|
232
251
|
profile=profile,
|
|
233
252
|
query_mode=query_mode,
|
|
234
253
|
app_key=app_key,
|
|
235
254
|
apply_id=apply_id,
|
|
236
255
|
page_num=page_num,
|
|
237
|
-
page_size=page_size,
|
|
238
|
-
requested_pages=requested_pages,
|
|
239
|
-
scan_max_pages=scan_max_pages,
|
|
240
|
-
auto_expand_pages=auto_expand_pages,
|
|
256
|
+
page_size=int(paging["page_size"]),
|
|
257
|
+
requested_pages=int(paging["requested_pages"]),
|
|
258
|
+
scan_max_pages=int(paging["scan_max_pages"]),
|
|
259
|
+
auto_expand_pages=bool(paging["auto_expand_pages"]),
|
|
241
260
|
query_key=query_key,
|
|
242
261
|
filters=filters or [],
|
|
243
262
|
sorts=sorts or [],
|
|
244
263
|
max_rows=max_rows,
|
|
245
264
|
max_columns=max_columns,
|
|
246
265
|
select_columns=select_columns or [],
|
|
247
|
-
amount_column=
|
|
248
|
-
time_range=
|
|
249
|
-
stat_policy=
|
|
250
|
-
strict_full=
|
|
251
|
-
output_profile=output_profile,
|
|
252
|
-
list_type=list_type,
|
|
253
|
-
view_key=view_key,
|
|
254
|
-
view_name=view_name,
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
@mcp.tool(
|
|
258
|
-
description=(
|
|
259
|
-
"Grouped record analysis endpoint. Aggregates record counts and numeric metrics by selected dimensions. "
|
|
260
|
-
"Use strict_full=true when the result will be used as a final conclusion."
|
|
261
|
-
)
|
|
262
|
-
)
|
|
263
|
-
def record_aggregate(
|
|
264
|
-
profile: str = DEFAULT_PROFILE,
|
|
265
|
-
app_key: str = "",
|
|
266
|
-
group_by: list[str | int] | None = None,
|
|
267
|
-
amount_column: str | int | None = None,
|
|
268
|
-
metrics: list[str] | None = None,
|
|
269
|
-
page_num: int = 1,
|
|
270
|
-
page_size: int = DEFAULT_ANALYSIS_PAGE_SIZE,
|
|
271
|
-
requested_pages: int = 1,
|
|
272
|
-
scan_max_pages: int = DEFAULT_ANALYSIS_SCAN_MAX_PAGES,
|
|
273
|
-
auto_expand_pages: bool = False,
|
|
274
|
-
query_key: str | None = None,
|
|
275
|
-
filters: list[JSONObject] | None = None,
|
|
276
|
-
sorts: list[JSONObject] | None = None,
|
|
277
|
-
time_range: JSONObject | None = None,
|
|
278
|
-
time_bucket: str | None = None,
|
|
279
|
-
max_groups: int = 200,
|
|
280
|
-
strict_full: bool = False,
|
|
281
|
-
output_profile: str = DEFAULT_OUTPUT_PROFILE,
|
|
282
|
-
list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
283
|
-
view_key: str | None = None,
|
|
284
|
-
view_name: str | None = None,
|
|
285
|
-
) -> JSONObject:
|
|
286
|
-
return self.record_aggregate(
|
|
287
|
-
profile=profile,
|
|
288
|
-
app_key=app_key,
|
|
289
|
-
group_by=group_by or [],
|
|
290
|
-
amount_column=amount_column,
|
|
291
|
-
metrics=metrics or [],
|
|
292
|
-
page_num=page_num,
|
|
293
|
-
page_size=page_size,
|
|
294
|
-
requested_pages=requested_pages,
|
|
295
|
-
scan_max_pages=scan_max_pages,
|
|
296
|
-
auto_expand_pages=auto_expand_pages,
|
|
297
|
-
query_key=query_key,
|
|
298
|
-
filters=filters or [],
|
|
299
|
-
sorts=sorts or [],
|
|
300
|
-
time_range=time_range or {},
|
|
301
|
-
time_bucket=time_bucket,
|
|
302
|
-
max_groups=max_groups,
|
|
303
|
-
strict_full=strict_full,
|
|
266
|
+
amount_column=None,
|
|
267
|
+
time_range={},
|
|
268
|
+
stat_policy={},
|
|
269
|
+
strict_full=False,
|
|
304
270
|
output_profile=output_profile,
|
|
305
271
|
list_type=list_type,
|
|
306
272
|
view_key=view_key,
|
|
@@ -381,6 +347,601 @@ class RecordTools(ToolBase):
|
|
|
381
347
|
) -> JSONObject:
|
|
382
348
|
return self.record_delete(profile=profile, app_key=app_key, apply_id=apply_id, list_type=list_type)
|
|
383
349
|
|
|
350
|
+
def record_schema_get(
|
|
351
|
+
self,
|
|
352
|
+
*,
|
|
353
|
+
profile: str,
|
|
354
|
+
app_key: str,
|
|
355
|
+
view_key: str | None,
|
|
356
|
+
view_name: str | None,
|
|
357
|
+
output_profile: str,
|
|
358
|
+
) -> JSONObject:
|
|
359
|
+
if not app_key:
|
|
360
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
361
|
+
|
|
362
|
+
def runner(session_profile, context):
|
|
363
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
364
|
+
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
365
|
+
fields = [self._schema_field_payload(field) for field in index.by_id.values()]
|
|
366
|
+
suggested_dimensions = [
|
|
367
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
368
|
+
for item in fields
|
|
369
|
+
if bool(cast(JSONObject, item["role_hints"]).get("dimension_candidate"))
|
|
370
|
+
]
|
|
371
|
+
suggested_metrics = [
|
|
372
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
373
|
+
for item in fields
|
|
374
|
+
if bool(cast(JSONObject, item["role_hints"]).get("metric_candidate"))
|
|
375
|
+
]
|
|
376
|
+
suggested_time_fields = [
|
|
377
|
+
{"field_id": item["field_id"], "title": item["title"]}
|
|
378
|
+
for item in fields
|
|
379
|
+
if bool(cast(JSONObject, item["role_hints"]).get("time_candidate"))
|
|
380
|
+
]
|
|
381
|
+
response: JSONObject = {
|
|
382
|
+
"profile": profile,
|
|
383
|
+
"ws_id": session_profile.selected_ws_id,
|
|
384
|
+
"ok": True,
|
|
385
|
+
"status": "success",
|
|
386
|
+
"request_route": self._request_route_payload(context),
|
|
387
|
+
"data": {
|
|
388
|
+
"app_key": app_key,
|
|
389
|
+
"view_resolution": _view_selection_payload(view_selection),
|
|
390
|
+
"fields": fields,
|
|
391
|
+
"suggested_dimensions": suggested_dimensions,
|
|
392
|
+
"suggested_metrics": suggested_metrics,
|
|
393
|
+
"suggested_time_fields": suggested_time_fields,
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
if output_profile == "verbose":
|
|
397
|
+
response["data"]["field_count"] = len(fields)
|
|
398
|
+
return response
|
|
399
|
+
|
|
400
|
+
return self._run_record_tool(profile, runner)
|
|
401
|
+
|
|
402
|
+
def record_analyze(
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
profile: str,
|
|
406
|
+
app_key: str,
|
|
407
|
+
dimensions: list[JSONObject],
|
|
408
|
+
metrics: list[JSONObject],
|
|
409
|
+
filters: list[JSONObject],
|
|
410
|
+
sort: list[JSONObject],
|
|
411
|
+
limit: int,
|
|
412
|
+
strict_full: bool,
|
|
413
|
+
view_key: str | None,
|
|
414
|
+
view_name: str | None,
|
|
415
|
+
output_profile: str,
|
|
416
|
+
) -> JSONObject:
|
|
417
|
+
if not app_key:
|
|
418
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
419
|
+
if limit <= 0:
|
|
420
|
+
raise_tool_error(QingflowApiError.config_error("limit must be positive"))
|
|
421
|
+
|
|
422
|
+
def runner(session_profile, context):
|
|
423
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
424
|
+
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
425
|
+
compiled_dimensions = self._compile_analyze_dimensions(index, dimensions)
|
|
426
|
+
compiled_metrics = self._compile_analyze_metrics(index, metrics)
|
|
427
|
+
duplicate_aliases = {str(item["alias"]) for item in compiled_dimensions} & {str(item["alias"]) for item in compiled_metrics}
|
|
428
|
+
if duplicate_aliases:
|
|
429
|
+
raise RecordInputError(
|
|
430
|
+
message=f"dimensions and metrics cannot share aliases: {sorted(duplicate_aliases)}",
|
|
431
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
432
|
+
fix_hint="Use distinct aliases across dimensions and metrics.",
|
|
433
|
+
)
|
|
434
|
+
compiled_filters = self._compile_analyze_filters(index, filters)
|
|
435
|
+
compiled_sort = self._compile_analyze_sort(sort, compiled_dimensions, compiled_metrics)
|
|
436
|
+
return self._execute_record_analyze(
|
|
437
|
+
profile=profile,
|
|
438
|
+
session_profile=session_profile,
|
|
439
|
+
context=context,
|
|
440
|
+
app_key=app_key,
|
|
441
|
+
index=index,
|
|
442
|
+
view_selection=view_selection,
|
|
443
|
+
dimensions=compiled_dimensions,
|
|
444
|
+
metrics=compiled_metrics,
|
|
445
|
+
filters=compiled_filters,
|
|
446
|
+
sort=compiled_sort,
|
|
447
|
+
limit=limit,
|
|
448
|
+
strict_full=strict_full,
|
|
449
|
+
output_profile=output_profile,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return self._run_record_tool(profile, runner)
|
|
453
|
+
|
|
454
|
+
def _schema_field_payload(self, field: FormField) -> JSONObject:
|
|
455
|
+
return {
|
|
456
|
+
"field_id": field.que_id,
|
|
457
|
+
"title": field.que_title,
|
|
458
|
+
"que_type": field.que_type,
|
|
459
|
+
"system": field.system,
|
|
460
|
+
"readonly": field.readonly,
|
|
461
|
+
"options": field.options,
|
|
462
|
+
"aliases": field.aliases,
|
|
463
|
+
"role_hints": self._schema_role_hints(field),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
def _schema_role_hints(self, field: FormField) -> JSONObject:
|
|
467
|
+
time_candidate = field.que_type in DATE_QUE_TYPES
|
|
468
|
+
metric_candidate = bool(field.que_type == 8 and not field.system and not field.readonly)
|
|
469
|
+
dimension_candidate = bool(
|
|
470
|
+
field.que_type not in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES | VERIFY_UNSUPPORTED_WRITE_QUE_TYPES
|
|
471
|
+
and not field.system
|
|
472
|
+
)
|
|
473
|
+
return {
|
|
474
|
+
"dimension_candidate": dimension_candidate,
|
|
475
|
+
"metric_candidate": metric_candidate,
|
|
476
|
+
"time_candidate": time_candidate,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
def _resolve_field_by_id(self, field_id: int | None, index: FieldIndex, *, location: str) -> FormField:
|
|
480
|
+
if field_id is None:
|
|
481
|
+
raise RecordInputError(
|
|
482
|
+
message=f"{location} requires field_id",
|
|
483
|
+
error_code="MISSING_FIELD_ID",
|
|
484
|
+
fix_hint="Use record_schema_get and pass a valid field_id from the schema response.",
|
|
485
|
+
)
|
|
486
|
+
field = index.by_id.get(str(field_id))
|
|
487
|
+
if field is None:
|
|
488
|
+
raise RecordInputError(
|
|
489
|
+
message=f"{location} references unknown field_id '{field_id}'",
|
|
490
|
+
error_code="FIELD_NOT_FOUND",
|
|
491
|
+
fix_hint="Use record_schema_get to confirm the exact field_id before calling record_analyze.",
|
|
492
|
+
details={"location": location, "field_id": field_id},
|
|
493
|
+
)
|
|
494
|
+
return field
|
|
495
|
+
|
|
496
|
+
def _compile_analyze_dimensions(self, index: FieldIndex, dimensions: list[JSONObject]) -> list[JSONObject]:
|
|
497
|
+
supported_buckets = {None, "day", "week", "month", "quarter", "year"}
|
|
498
|
+
compiled: list[JSONObject] = []
|
|
499
|
+
used_aliases: set[str] = set()
|
|
500
|
+
for idx, item in enumerate(dimensions):
|
|
501
|
+
if not isinstance(item, dict):
|
|
502
|
+
raise RecordInputError(
|
|
503
|
+
message=f"dimensions[{idx}] must be an object",
|
|
504
|
+
error_code="INVALID_ANALYZE_DIMENSION",
|
|
505
|
+
fix_hint="Pass dimensions like {'field_id': 2, 'alias': '状态'}",
|
|
506
|
+
)
|
|
507
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
508
|
+
field = self._resolve_field_by_id(field_id, index, location=f"dimensions[{idx}]")
|
|
509
|
+
bucket = _normalize_optional_text(item.get("bucket"))
|
|
510
|
+
if bucket not in supported_buckets:
|
|
511
|
+
raise RecordInputError(
|
|
512
|
+
message=f"dimensions[{idx}] uses unsupported bucket '{bucket}'",
|
|
513
|
+
error_code="UNSUPPORTED_TIME_BUCKET",
|
|
514
|
+
fix_hint="Use one of day/week/month/quarter/year, or omit bucket.",
|
|
515
|
+
)
|
|
516
|
+
if bucket is not None and field.que_type not in DATE_QUE_TYPES:
|
|
517
|
+
raise RecordInputError(
|
|
518
|
+
message=f"dimensions[{idx}] bucket requires a date/time field",
|
|
519
|
+
error_code="INVALID_TIME_BUCKET_FIELD",
|
|
520
|
+
fix_hint="Use bucket only on fields returned in suggested_time_fields by record_schema_get.",
|
|
521
|
+
details={"field": _field_ref_payload(field), "bucket": bucket},
|
|
522
|
+
)
|
|
523
|
+
alias = _normalize_optional_text(item.get("alias")) or field.que_title
|
|
524
|
+
if alias in used_aliases:
|
|
525
|
+
raise RecordInputError(
|
|
526
|
+
message=f"dimensions[{idx}] alias '{alias}' is duplicated",
|
|
527
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
528
|
+
fix_hint="Use unique aliases across dimensions and metrics.",
|
|
529
|
+
)
|
|
530
|
+
used_aliases.add(alias)
|
|
531
|
+
compiled.append({"field": field, "alias": alias, "bucket": bucket})
|
|
532
|
+
return compiled
|
|
533
|
+
|
|
534
|
+
def _compile_analyze_metrics(self, index: FieldIndex, metrics: list[JSONObject]) -> list[JSONObject]:
|
|
535
|
+
requested_metrics = metrics or [{"op": "count", "alias": "记录数"}]
|
|
536
|
+
supported_ops = {"count", "sum", "avg", "min", "max", "distinct_count"}
|
|
537
|
+
compiled: list[JSONObject] = []
|
|
538
|
+
used_aliases: set[str] = set()
|
|
539
|
+
for idx, item in enumerate(requested_metrics):
|
|
540
|
+
if not isinstance(item, dict):
|
|
541
|
+
raise RecordInputError(
|
|
542
|
+
message=f"metrics[{idx}] must be an object",
|
|
543
|
+
error_code="INVALID_ANALYZE_METRIC",
|
|
544
|
+
fix_hint="Pass metrics like {'op': 'count', 'alias': '记录数'}",
|
|
545
|
+
)
|
|
546
|
+
op = _normalize_optional_text(item.get("op"))
|
|
547
|
+
if op not in supported_ops:
|
|
548
|
+
raise RecordInputError(
|
|
549
|
+
message=f"metrics[{idx}] uses unsupported op '{op}'",
|
|
550
|
+
error_code="UNSUPPORTED_ANALYZE_METRIC",
|
|
551
|
+
fix_hint="Use one of count/sum/avg/min/max/distinct_count.",
|
|
552
|
+
)
|
|
553
|
+
field: FormField | None = None
|
|
554
|
+
if op != "count":
|
|
555
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
556
|
+
field = self._resolve_field_by_id(field_id, index, location=f"metrics[{idx}]")
|
|
557
|
+
alias = _normalize_optional_text(item.get("alias"))
|
|
558
|
+
if alias is None:
|
|
559
|
+
if op == "count":
|
|
560
|
+
alias = "count"
|
|
561
|
+
elif field is not None:
|
|
562
|
+
alias = f"{field.que_title}_{op}"
|
|
563
|
+
else:
|
|
564
|
+
alias = op
|
|
565
|
+
if alias in used_aliases:
|
|
566
|
+
raise RecordInputError(
|
|
567
|
+
message=f"metrics[{idx}] alias '{alias}' is duplicated",
|
|
568
|
+
error_code="DUPLICATE_ANALYZE_ALIAS",
|
|
569
|
+
fix_hint="Use unique aliases across dimensions and metrics.",
|
|
570
|
+
)
|
|
571
|
+
used_aliases.add(alias)
|
|
572
|
+
compiled.append({"op": op, "field": field, "alias": alias})
|
|
573
|
+
return compiled
|
|
574
|
+
|
|
575
|
+
def _compile_analyze_filters(self, index: FieldIndex, filters: list[JSONObject]) -> list[JSONObject]:
|
|
576
|
+
supported_ops = {"eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "between", "contains", "is_null", "not_null"}
|
|
577
|
+
compiled: list[JSONObject] = []
|
|
578
|
+
for idx, item in enumerate(filters):
|
|
579
|
+
if not isinstance(item, dict):
|
|
580
|
+
raise RecordInputError(
|
|
581
|
+
message=f"filters[{idx}] must be an object",
|
|
582
|
+
error_code="INVALID_ANALYZE_FILTER",
|
|
583
|
+
fix_hint="Pass filters like {'field_id': 2, 'op': 'eq', 'value': '进行中'}.",
|
|
584
|
+
)
|
|
585
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
586
|
+
op = _normalize_optional_text(item.get("op", item.get("operator"))) or "eq"
|
|
587
|
+
if op not in supported_ops:
|
|
588
|
+
raise RecordInputError(
|
|
589
|
+
message=f"filters[{idx}] uses unsupported op '{op}'",
|
|
590
|
+
error_code="UNSUPPORTED_ANALYZE_FILTER_OP",
|
|
591
|
+
fix_hint="Use one of eq/neq/in/not_in/gt/gte/lt/lte/between/contains/is_null/not_null.",
|
|
592
|
+
)
|
|
593
|
+
field = self._resolve_field_by_id(field_id, index, location=f"filters[{idx}]")
|
|
594
|
+
compiled.append({"field": field, "field_id": field_id, "op": op, "value": item.get("value", item.get("values"))})
|
|
595
|
+
return compiled
|
|
596
|
+
|
|
597
|
+
def _compile_analyze_sort(self, sort: list[JSONObject], dimensions: list[JSONObject], metrics: list[JSONObject]) -> list[JSONObject]:
|
|
598
|
+
dimension_aliases = {str(item["alias"]) for item in dimensions}
|
|
599
|
+
metric_aliases = {str(item["alias"]) for item in metrics}
|
|
600
|
+
compiled: list[JSONObject] = []
|
|
601
|
+
for idx, item in enumerate(sort):
|
|
602
|
+
if not isinstance(item, dict):
|
|
603
|
+
raise RecordInputError(
|
|
604
|
+
message=f"sort[{idx}] must be an object",
|
|
605
|
+
error_code="INVALID_ANALYZE_SORT",
|
|
606
|
+
fix_hint="Pass sort like {'by': '记录数', 'order': 'desc'}.",
|
|
607
|
+
)
|
|
608
|
+
by = _normalize_optional_text(item.get("by"))
|
|
609
|
+
if by is None:
|
|
610
|
+
raise RecordInputError(
|
|
611
|
+
message=f"sort[{idx}] requires by",
|
|
612
|
+
error_code="MISSING_SORT_KEY",
|
|
613
|
+
fix_hint="Use a dimension alias or metric alias in sort.by.",
|
|
614
|
+
)
|
|
615
|
+
order = (_normalize_optional_text(item.get("order")) or "asc").lower()
|
|
616
|
+
if order not in {"asc", "desc"}:
|
|
617
|
+
raise RecordInputError(
|
|
618
|
+
message=f"sort[{idx}] uses unsupported order '{order}'",
|
|
619
|
+
error_code="INVALID_SORT_ORDER",
|
|
620
|
+
fix_hint="Use asc or desc.",
|
|
621
|
+
)
|
|
622
|
+
if by in dimension_aliases:
|
|
623
|
+
compiled.append({"by": by, "order": order, "kind": "dimension"})
|
|
624
|
+
continue
|
|
625
|
+
if by in metric_aliases:
|
|
626
|
+
compiled.append({"by": by, "order": order, "kind": "metric"})
|
|
627
|
+
continue
|
|
628
|
+
raise RecordInputError(
|
|
629
|
+
message=f"sort[{idx}] references unknown alias '{by}'",
|
|
630
|
+
error_code="UNKNOWN_ANALYZE_SORT_KEY",
|
|
631
|
+
fix_hint="Use a dimension alias or metric alias defined in the same record_analyze request.",
|
|
632
|
+
)
|
|
633
|
+
return compiled
|
|
634
|
+
|
|
635
|
+
def _execute_record_analyze(
|
|
636
|
+
self,
|
|
637
|
+
*,
|
|
638
|
+
profile: str,
|
|
639
|
+
session_profile,
|
|
640
|
+
context, # type: ignore[no-untyped-def]
|
|
641
|
+
app_key: str,
|
|
642
|
+
index: FieldIndex,
|
|
643
|
+
view_selection: ViewSelection | None,
|
|
644
|
+
dimensions: list[JSONObject],
|
|
645
|
+
metrics: list[JSONObject],
|
|
646
|
+
filters: list[JSONObject],
|
|
647
|
+
sort: list[JSONObject],
|
|
648
|
+
limit: int,
|
|
649
|
+
strict_full: bool,
|
|
650
|
+
output_profile: str,
|
|
651
|
+
) -> JSONObject:
|
|
652
|
+
analysis_paging = _fixed_analysis_scan_policy()
|
|
653
|
+
page_size = int(analysis_paging["page_size"])
|
|
654
|
+
requested_pages = int(analysis_paging["requested_pages"])
|
|
655
|
+
scan_max_pages = int(analysis_paging["scan_max_pages"])
|
|
656
|
+
auto_expand_pages = bool(analysis_paging["auto_expand_pages"])
|
|
657
|
+
query_id = _query_id()
|
|
658
|
+
pages_to_scan = min(max(requested_pages, 1), max(scan_max_pages, 1))
|
|
659
|
+
current_page = 1
|
|
660
|
+
scanned_pages = 0
|
|
661
|
+
source_pages: list[int] = []
|
|
662
|
+
result_amount: int | None = None
|
|
663
|
+
has_more = False
|
|
664
|
+
dept_member_cache: dict[int, set[int]] = {}
|
|
665
|
+
local_filtering = bool(filters) or bool(view_selection is not None and view_selection.conditions)
|
|
666
|
+
group_stats: dict[str, JSONObject] = {}
|
|
667
|
+
overall_metrics = self._initialize_metric_states(metrics)
|
|
668
|
+
matched_rows = 0
|
|
669
|
+
scan_control: JSONObject = {
|
|
670
|
+
"requested_pages": max(requested_pages, 1),
|
|
671
|
+
"scan_max_pages": max(scan_max_pages, 1),
|
|
672
|
+
"auto_expand_pages": auto_expand_pages,
|
|
673
|
+
"auto_expand_applied": False,
|
|
674
|
+
"auto_expand_target_pages": pages_to_scan,
|
|
675
|
+
"auto_expand_page_cap": DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP,
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
while scanned_pages < pages_to_scan:
|
|
679
|
+
page = self._search_page(
|
|
680
|
+
context,
|
|
681
|
+
app_key=app_key,
|
|
682
|
+
page_num=current_page,
|
|
683
|
+
page_size=page_size,
|
|
684
|
+
query_key=None,
|
|
685
|
+
match_rules=[],
|
|
686
|
+
sorts=[],
|
|
687
|
+
search_que_ids=None,
|
|
688
|
+
list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
689
|
+
)
|
|
690
|
+
scanned_pages += 1
|
|
691
|
+
source_pages.append(current_page)
|
|
692
|
+
items = page.get("list") if isinstance(page.get("list"), list) else []
|
|
693
|
+
if result_amount is None:
|
|
694
|
+
result_amount = _effective_total(page, page_size)
|
|
695
|
+
pages_to_scan, scan_control = _compute_scan_limit(
|
|
696
|
+
requested_pages=requested_pages,
|
|
697
|
+
scan_max_pages=scan_max_pages,
|
|
698
|
+
auto_expand_pages=auto_expand_pages,
|
|
699
|
+
page=page,
|
|
700
|
+
page_size=page_size,
|
|
701
|
+
)
|
|
702
|
+
has_more = _page_has_more(page, current_page, page_size, len(items))
|
|
703
|
+
for item in items:
|
|
704
|
+
if not isinstance(item, dict):
|
|
705
|
+
continue
|
|
706
|
+
answers = item.get("answers")
|
|
707
|
+
answer_list = answers if isinstance(answers, list) else []
|
|
708
|
+
if not self._matches_view_selection(
|
|
709
|
+
context,
|
|
710
|
+
answer_list,
|
|
711
|
+
view_selection=view_selection,
|
|
712
|
+
dept_member_cache=dept_member_cache,
|
|
713
|
+
):
|
|
714
|
+
continue
|
|
715
|
+
if not self._matches_analyze_filters(answer_list, filters):
|
|
716
|
+
continue
|
|
717
|
+
matched_rows += 1
|
|
718
|
+
group_payload = self._build_analyze_group_payload(answer_list, dimensions)
|
|
719
|
+
group_key = json.dumps(group_payload, ensure_ascii=False, sort_keys=True)
|
|
720
|
+
bucket = group_stats.get(group_key)
|
|
721
|
+
if bucket is None:
|
|
722
|
+
bucket = {
|
|
723
|
+
"dimensions": group_payload,
|
|
724
|
+
"metrics_state": self._initialize_metric_states(metrics),
|
|
725
|
+
}
|
|
726
|
+
group_stats[group_key] = bucket
|
|
727
|
+
bucket_metrics = cast(dict[str, JSONObject], bucket["metrics_state"])
|
|
728
|
+
self._apply_metric_states(bucket_metrics, metrics, answer_list)
|
|
729
|
+
self._apply_metric_states(overall_metrics, metrics, answer_list)
|
|
730
|
+
if not has_more:
|
|
731
|
+
break
|
|
732
|
+
current_page += 1
|
|
733
|
+
|
|
734
|
+
all_rows = [
|
|
735
|
+
{
|
|
736
|
+
"dimensions": cast(JSONObject, bucket["dimensions"]),
|
|
737
|
+
"metrics": self._render_metric_values(cast(dict[str, JSONObject], bucket["metrics_state"]), metrics),
|
|
738
|
+
}
|
|
739
|
+
for bucket in group_stats.values()
|
|
740
|
+
]
|
|
741
|
+
all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
|
|
742
|
+
rows_truncated = len(all_rows) > limit
|
|
743
|
+
limited_rows = all_rows[:limit]
|
|
744
|
+
raw_scan_complete = not has_more
|
|
745
|
+
completeness_status = "complete" if raw_scan_complete else "incomplete"
|
|
746
|
+
reason_code = "LOCAL_VIEW_FILTERING" if local_filtering and raw_scan_complete else ("SOURCE_EXHAUSTED" if raw_scan_complete else "SCAN_LIMIT_HIT")
|
|
747
|
+
totals_backend_count = None if local_filtering else result_amount
|
|
748
|
+
totals = {
|
|
749
|
+
"backend_total_count": totals_backend_count,
|
|
750
|
+
"scanned_count": matched_rows,
|
|
751
|
+
"group_count": len(all_rows) if dimensions else 1,
|
|
752
|
+
"metric_totals": self._render_metric_values(overall_metrics, metrics),
|
|
753
|
+
}
|
|
754
|
+
data: JSONObject = {
|
|
755
|
+
"query": {
|
|
756
|
+
"app_key": app_key,
|
|
757
|
+
"dimensions": [
|
|
758
|
+
{
|
|
759
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
760
|
+
"title": cast(FormField, item["field"]).que_title,
|
|
761
|
+
"alias": item["alias"],
|
|
762
|
+
"bucket": item["bucket"],
|
|
763
|
+
}
|
|
764
|
+
for item in dimensions
|
|
765
|
+
],
|
|
766
|
+
"metrics": [
|
|
767
|
+
{
|
|
768
|
+
"op": item["op"],
|
|
769
|
+
"field_id": cast(FormField | None, item["field"]).que_id if item["field"] is not None else None,
|
|
770
|
+
"title": cast(FormField | None, item["field"]).que_title if item["field"] is not None else None,
|
|
771
|
+
"alias": item["alias"],
|
|
772
|
+
}
|
|
773
|
+
for item in metrics
|
|
774
|
+
],
|
|
775
|
+
"filters": [
|
|
776
|
+
{
|
|
777
|
+
"field_id": cast(FormField, item["field"]).que_id,
|
|
778
|
+
"title": cast(FormField, item["field"]).que_title,
|
|
779
|
+
"op": item["op"],
|
|
780
|
+
"value": item.get("value"),
|
|
781
|
+
}
|
|
782
|
+
for item in filters
|
|
783
|
+
],
|
|
784
|
+
"sort": [{"by": item["by"], "order": item["order"]} for item in sort],
|
|
785
|
+
"view": _view_selection_payload(view_selection),
|
|
786
|
+
},
|
|
787
|
+
"rows": limited_rows if dimensions else [{"dimensions": {}, "metrics": totals["metric_totals"]}],
|
|
788
|
+
"totals": totals,
|
|
789
|
+
"completeness": {
|
|
790
|
+
"status": completeness_status,
|
|
791
|
+
"reason_code": reason_code,
|
|
792
|
+
"safe_for_final_conclusion": completeness_status == "complete",
|
|
793
|
+
},
|
|
794
|
+
"presentation": {
|
|
795
|
+
"row_limit": limit,
|
|
796
|
+
"rows_returned": 1 if not dimensions else len(limited_rows),
|
|
797
|
+
"rows_truncated": rows_truncated if dimensions else False,
|
|
798
|
+
},
|
|
799
|
+
"warnings": self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated),
|
|
800
|
+
}
|
|
801
|
+
response: JSONObject = {
|
|
802
|
+
"profile": profile,
|
|
803
|
+
"ws_id": session_profile.selected_ws_id,
|
|
804
|
+
"ok": True,
|
|
805
|
+
"status": "success" if raw_scan_complete else "partial",
|
|
806
|
+
"query_id": query_id,
|
|
807
|
+
"request_route": self._request_route_payload(context),
|
|
808
|
+
"data": data,
|
|
809
|
+
}
|
|
810
|
+
if strict_full and not raw_scan_complete:
|
|
811
|
+
response["ok"] = False
|
|
812
|
+
response["status"] = "error"
|
|
813
|
+
response["error"] = {
|
|
814
|
+
"code": "INCOMPLETE_SCAN",
|
|
815
|
+
"message": "record_analyze could not complete the scan within the fixed internal analysis budget.",
|
|
816
|
+
"fix_hint": "Narrow the scope with view/filter constraints, or retry after reducing the dataset size.",
|
|
817
|
+
}
|
|
818
|
+
if output_profile == "verbose":
|
|
819
|
+
response["data"]["debug"] = {
|
|
820
|
+
"source_pages": source_pages,
|
|
821
|
+
"raw_scan_complete": raw_scan_complete,
|
|
822
|
+
"scan_control": scan_control,
|
|
823
|
+
}
|
|
824
|
+
return response
|
|
825
|
+
|
|
826
|
+
def _build_analyze_group_payload(self, answer_list: list[JSONValue], dimensions: list[JSONObject]) -> JSONObject:
|
|
827
|
+
if not dimensions:
|
|
828
|
+
return {}
|
|
829
|
+
payload: JSONObject = {}
|
|
830
|
+
for item in dimensions:
|
|
831
|
+
field = cast(FormField, item["field"])
|
|
832
|
+
alias = cast(str, item["alias"])
|
|
833
|
+
bucket = cast(str | None, item["bucket"])
|
|
834
|
+
value = _extract_field_value(answer_list, field)
|
|
835
|
+
if bucket is not None:
|
|
836
|
+
value = _to_time_bucket(value, bucket)
|
|
837
|
+
payload[alias] = value
|
|
838
|
+
return payload
|
|
839
|
+
|
|
840
|
+
def _initialize_metric_states(self, metrics: list[JSONObject]) -> dict[str, JSONObject]:
|
|
841
|
+
states: dict[str, JSONObject] = {}
|
|
842
|
+
for item in metrics:
|
|
843
|
+
states[str(item["alias"])] = {
|
|
844
|
+
"count": 0,
|
|
845
|
+
"sum": 0.0,
|
|
846
|
+
"min": None,
|
|
847
|
+
"max": None,
|
|
848
|
+
"seen": set(),
|
|
849
|
+
}
|
|
850
|
+
return states
|
|
851
|
+
|
|
852
|
+
def _apply_metric_states(self, states: dict[str, JSONObject], metrics: list[JSONObject], answer_list: list[JSONValue]) -> None:
|
|
853
|
+
for item in metrics:
|
|
854
|
+
alias = cast(str, item["alias"])
|
|
855
|
+
op = cast(str, item["op"])
|
|
856
|
+
field = cast(FormField | None, item["field"])
|
|
857
|
+
state = states[alias]
|
|
858
|
+
if op == "count":
|
|
859
|
+
state["count"] = int(state["count"]) + 1
|
|
860
|
+
continue
|
|
861
|
+
value = _extract_field_value(answer_list, field)
|
|
862
|
+
if op == "distinct_count":
|
|
863
|
+
for entry in _flatten_distinct_values(value):
|
|
864
|
+
cast(set[str], state["seen"]).add(entry)
|
|
865
|
+
continue
|
|
866
|
+
amount = _coerce_amount(value)
|
|
867
|
+
if amount is None:
|
|
868
|
+
continue
|
|
869
|
+
state["count"] = int(state["count"]) + 1
|
|
870
|
+
state["sum"] = float(state["sum"]) + amount
|
|
871
|
+
state["min"] = amount if state["min"] is None else min(float(state["min"]), amount)
|
|
872
|
+
state["max"] = amount if state["max"] is None else max(float(state["max"]), amount)
|
|
873
|
+
|
|
874
|
+
def _render_metric_values(self, states: dict[str, JSONObject], metrics: list[JSONObject]) -> JSONObject:
|
|
875
|
+
rendered: JSONObject = {}
|
|
876
|
+
for item in metrics:
|
|
877
|
+
alias = cast(str, item["alias"])
|
|
878
|
+
op = cast(str, item["op"])
|
|
879
|
+
state = states[alias]
|
|
880
|
+
count = int(state["count"] or 0)
|
|
881
|
+
amount_sum = float(state["sum"] or 0.0)
|
|
882
|
+
if op == "count":
|
|
883
|
+
rendered[alias] = count
|
|
884
|
+
elif op == "sum":
|
|
885
|
+
rendered[alias] = amount_sum
|
|
886
|
+
elif op == "avg":
|
|
887
|
+
rendered[alias] = (amount_sum / count) if count else None
|
|
888
|
+
elif op == "min":
|
|
889
|
+
rendered[alias] = state["min"]
|
|
890
|
+
elif op == "max":
|
|
891
|
+
rendered[alias] = state["max"]
|
|
892
|
+
elif op == "distinct_count":
|
|
893
|
+
rendered[alias] = len(cast(set[str], state["seen"]))
|
|
894
|
+
return rendered
|
|
895
|
+
|
|
896
|
+
def _matches_analyze_filters(self, answer_list: list[JSONValue], filters: list[JSONObject]) -> bool:
|
|
897
|
+
for item in filters:
|
|
898
|
+
field = cast(FormField, item["field"])
|
|
899
|
+
if not _match_analyze_filter(_extract_field_value(answer_list, field), cast(str, item["op"]), item.get("value")):
|
|
900
|
+
return False
|
|
901
|
+
return True
|
|
902
|
+
|
|
903
|
+
def _sort_analyze_rows(
|
|
904
|
+
self,
|
|
905
|
+
rows: list[JSONObject],
|
|
906
|
+
sort: list[JSONObject],
|
|
907
|
+
dimensions: list[JSONObject],
|
|
908
|
+
metrics: list[JSONObject],
|
|
909
|
+
) -> list[JSONObject]:
|
|
910
|
+
if not rows or not sort:
|
|
911
|
+
if dimensions and any(item.get("bucket") for item in dimensions):
|
|
912
|
+
return sorted(rows, key=lambda item: json.dumps(item.get("dimensions", {}), ensure_ascii=False, sort_keys=True))
|
|
913
|
+
return rows
|
|
914
|
+
sorted_rows = list(rows)
|
|
915
|
+
for sort_item in reversed(sort):
|
|
916
|
+
by = cast(str, sort_item["by"])
|
|
917
|
+
reverse = cast(str, sort_item["order"]) == "desc"
|
|
918
|
+
kind = cast(str, sort_item["kind"])
|
|
919
|
+
sorted_rows.sort(
|
|
920
|
+
key=lambda item: _sortable_value(
|
|
921
|
+
cast(JSONObject, item["metrics" if kind == "metric" else "dimensions"]).get(by)
|
|
922
|
+
),
|
|
923
|
+
reverse=reverse,
|
|
924
|
+
)
|
|
925
|
+
return sorted_rows
|
|
926
|
+
|
|
927
|
+
def _build_analyze_warnings(self, *, local_filtering: bool, rows_truncated: bool) -> list[JSONObject]:
|
|
928
|
+
warnings: list[JSONObject] = []
|
|
929
|
+
if local_filtering:
|
|
930
|
+
warnings.append(
|
|
931
|
+
{
|
|
932
|
+
"code": "LOCAL_FILTERING_APPLIED",
|
|
933
|
+
"message": "Current analysis applies local filtering after scanning source pages.",
|
|
934
|
+
}
|
|
935
|
+
)
|
|
936
|
+
if rows_truncated:
|
|
937
|
+
warnings.append(
|
|
938
|
+
{
|
|
939
|
+
"code": "ROWS_TRUNCATED",
|
|
940
|
+
"message": "Result rows were truncated by limit; totals remain based on the full analyzed result set.",
|
|
941
|
+
}
|
|
942
|
+
)
|
|
943
|
+
return warnings
|
|
944
|
+
|
|
384
945
|
def record_field_resolve(
|
|
385
946
|
self,
|
|
386
947
|
*,
|
|
@@ -597,7 +1158,7 @@ class RecordTools(ToolBase):
|
|
|
597
1158
|
blockers.append("payload writes readonly or system-managed fields")
|
|
598
1159
|
if question_relations:
|
|
599
1160
|
validation["warnings"].append("form contains questionRelations; linked visibility and runtime required rules may differ at submit time.")
|
|
600
|
-
actions = ["Use
|
|
1161
|
+
actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
|
|
601
1162
|
if support_matrix["restricted"]:
|
|
602
1163
|
actions.append("Review write_format.required_presteps for restricted fields before submit.")
|
|
603
1164
|
if invalid_fields:
|
|
@@ -662,6 +1223,23 @@ class RecordTools(ToolBase):
|
|
|
662
1223
|
view_name: str | None = None,
|
|
663
1224
|
) -> JSONObject:
|
|
664
1225
|
resolved_mode = _resolve_query_mode(query_mode, apply_id=apply_id, amount_column=amount_column, time_range=time_range, stat_policy=stat_policy)
|
|
1226
|
+
if resolved_mode == "summary":
|
|
1227
|
+
raise_tool_error(
|
|
1228
|
+
QingflowApiError(
|
|
1229
|
+
category="config",
|
|
1230
|
+
message="record_query(query_mode='summary') is no longer supported",
|
|
1231
|
+
details={
|
|
1232
|
+
"error_code": "ANALYSIS_MOVED",
|
|
1233
|
+
"fix_hint": "Use record_schema_get to fetch field metadata, then call record_analyze with field_id-based DSL.",
|
|
1234
|
+
},
|
|
1235
|
+
)
|
|
1236
|
+
)
|
|
1237
|
+
if resolved_mode == "list":
|
|
1238
|
+
list_paging = _fixed_list_scan_policy()
|
|
1239
|
+
page_size = int(list_paging["page_size"])
|
|
1240
|
+
requested_pages = int(list_paging["requested_pages"])
|
|
1241
|
+
scan_max_pages = int(list_paging["scan_max_pages"])
|
|
1242
|
+
auto_expand_pages = bool(list_paging["auto_expand_pages"])
|
|
665
1243
|
if resolved_mode == "record":
|
|
666
1244
|
return self._record_query_record(
|
|
667
1245
|
profile=profile,
|
|
@@ -672,30 +1250,6 @@ class RecordTools(ToolBase):
|
|
|
672
1250
|
output_profile=output_profile,
|
|
673
1251
|
list_type=list_type,
|
|
674
1252
|
)
|
|
675
|
-
if resolved_mode == "summary":
|
|
676
|
-
return self._record_query_summary(
|
|
677
|
-
profile=profile,
|
|
678
|
-
app_key=app_key,
|
|
679
|
-
page_num=page_num,
|
|
680
|
-
page_size=page_size,
|
|
681
|
-
requested_pages=requested_pages,
|
|
682
|
-
scan_max_pages=scan_max_pages,
|
|
683
|
-
auto_expand_pages=auto_expand_pages,
|
|
684
|
-
query_key=query_key,
|
|
685
|
-
filters=filters,
|
|
686
|
-
sorts=sorts,
|
|
687
|
-
max_rows=max_rows,
|
|
688
|
-
max_columns=max_columns,
|
|
689
|
-
select_columns=select_columns,
|
|
690
|
-
amount_column=amount_column,
|
|
691
|
-
time_range=time_range,
|
|
692
|
-
stat_policy=stat_policy,
|
|
693
|
-
strict_full=strict_full,
|
|
694
|
-
output_profile=output_profile,
|
|
695
|
-
list_type=list_type,
|
|
696
|
-
view_key=view_key,
|
|
697
|
-
view_name=view_name,
|
|
698
|
-
)
|
|
699
1253
|
return self._record_query_list(
|
|
700
1254
|
profile=profile,
|
|
701
1255
|
app_key=app_key,
|
|
@@ -745,6 +1299,11 @@ class RecordTools(ToolBase):
|
|
|
745
1299
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
746
1300
|
if max_groups <= 0:
|
|
747
1301
|
raise_tool_error(QingflowApiError.config_error("max_groups must be positive"))
|
|
1302
|
+
analysis_paging = _fixed_analysis_scan_policy()
|
|
1303
|
+
page_size = int(analysis_paging["page_size"])
|
|
1304
|
+
requested_pages = int(analysis_paging["requested_pages"])
|
|
1305
|
+
scan_max_pages = int(analysis_paging["scan_max_pages"])
|
|
1306
|
+
auto_expand_pages = bool(analysis_paging["auto_expand_pages"])
|
|
748
1307
|
|
|
749
1308
|
def runner(session_profile, context):
|
|
750
1309
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
@@ -933,10 +1492,6 @@ class RecordTools(ToolBase):
|
|
|
933
1492
|
"app_key": app_key,
|
|
934
1493
|
"group_by": [field.que_title for field in group_fields],
|
|
935
1494
|
"page_num": max(page_num, 1),
|
|
936
|
-
"page_size": page_size,
|
|
937
|
-
"requested_pages": max(requested_pages, 1),
|
|
938
|
-
"scan_max_pages": max(scan_max_pages, 1),
|
|
939
|
-
"auto_expand_pages": auto_expand_pages,
|
|
940
1495
|
"query_key": query_key,
|
|
941
1496
|
"filters": filters,
|
|
942
1497
|
"sorts": sorts,
|
|
@@ -1721,10 +2276,6 @@ class RecordTools(ToolBase):
|
|
|
1721
2276
|
"query_mode": "summary",
|
|
1722
2277
|
"app_key": app_key,
|
|
1723
2278
|
"page_num": max(page_num, 1),
|
|
1724
|
-
"page_size": page_size,
|
|
1725
|
-
"requested_pages": max(requested_pages, 1),
|
|
1726
|
-
"scan_max_pages": max(scan_max_pages, 1),
|
|
1727
|
-
"auto_expand_pages": auto_expand_pages,
|
|
1728
2279
|
"query_key": query_key,
|
|
1729
2280
|
"filters": filters,
|
|
1730
2281
|
"sorts": sorts,
|
|
@@ -1934,7 +2485,15 @@ class RecordTools(ToolBase):
|
|
|
1934
2485
|
arguments: JSONObject,
|
|
1935
2486
|
view_selection: ViewSelection | None,
|
|
1936
2487
|
) -> JSONObject:
|
|
1937
|
-
|
|
2488
|
+
routed_mode = _resolve_query_mode(
|
|
2489
|
+
str(arguments.get("query_mode", "auto")),
|
|
2490
|
+
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
2491
|
+
amount_column=arguments.get("amount_column"),
|
|
2492
|
+
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
2493
|
+
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
2494
|
+
)
|
|
2495
|
+
fixed_policy = _fixed_analysis_scan_policy() if routed_mode == "summary" else _fixed_list_scan_policy()
|
|
2496
|
+
page_size = int(fixed_policy["page_size"])
|
|
1938
2497
|
query_key = _normalize_optional_text(arguments.get("query_key"))
|
|
1939
2498
|
filters = _as_object_list(arguments.get("filters"))
|
|
1940
2499
|
sorts = _as_object_list(arguments.get("sorts"))
|
|
@@ -1960,10 +2519,7 @@ class RecordTools(ToolBase):
|
|
|
1960
2519
|
estimated_full_scan_pages = page_amount
|
|
1961
2520
|
if estimated_full_scan_pages is None and backend_total_count > 0:
|
|
1962
2521
|
estimated_full_scan_pages = (backend_total_count + page_size - 1) // page_size
|
|
1963
|
-
current_budget = min(
|
|
1964
|
-
max(_coerce_count(arguments.get("requested_pages")) or 1, 1),
|
|
1965
|
-
max(_coerce_count(arguments.get("scan_max_pages")) or 1, 1),
|
|
1966
|
-
)
|
|
2522
|
+
current_budget = min(int(fixed_policy["requested_pages"]), int(fixed_policy["scan_max_pages"]))
|
|
1967
2523
|
return {
|
|
1968
2524
|
"page_size": page_size,
|
|
1969
2525
|
"backend_total_count": backend_total_count,
|
|
@@ -2374,7 +2930,7 @@ class RecordTools(ToolBase):
|
|
|
2374
2930
|
raise RecordInputError(
|
|
2375
2931
|
message=f"{location} references unknown queId '{requested}'",
|
|
2376
2932
|
error_code="FIELD_NOT_FOUND",
|
|
2377
|
-
fix_hint="Use
|
|
2933
|
+
fix_hint="Use record_schema_get to confirm the exact field_id.",
|
|
2378
2934
|
details={"location": location, "requested": requested, "requested_key": requested_key},
|
|
2379
2935
|
)
|
|
2380
2936
|
matches = index.by_title.get(requested_key, [])
|
|
@@ -2384,7 +2940,7 @@ class RecordTools(ToolBase):
|
|
|
2384
2940
|
raise RecordInputError(
|
|
2385
2941
|
message=f"{location} field '{requested}' is ambiguous",
|
|
2386
2942
|
error_code="AMBIGUOUS_FIELD",
|
|
2387
|
-
fix_hint="Use numeric queId, or
|
|
2943
|
+
fix_hint="Use numeric queId, or inspect record_schema_get output to disambiguate the field.",
|
|
2388
2944
|
details={
|
|
2389
2945
|
"location": location,
|
|
2390
2946
|
"requested": requested,
|
|
@@ -2400,7 +2956,7 @@ class RecordTools(ToolBase):
|
|
|
2400
2956
|
raise RecordInputError(
|
|
2401
2957
|
message=f"{location} field '{requested}' is ambiguous",
|
|
2402
2958
|
error_code="AMBIGUOUS_FIELD",
|
|
2403
|
-
fix_hint="Use a more specific field title, or
|
|
2959
|
+
fix_hint="Use a more specific field title, or inspect record_schema_get aliases to disambiguate the field.",
|
|
2404
2960
|
details={
|
|
2405
2961
|
"location": location,
|
|
2406
2962
|
"requested": requested,
|
|
@@ -2412,7 +2968,7 @@ class RecordTools(ToolBase):
|
|
|
2412
2968
|
raise RecordInputError(
|
|
2413
2969
|
message=f"{location} cannot resolve field '{requested}'",
|
|
2414
2970
|
error_code="FIELD_NOT_FOUND",
|
|
2415
|
-
fix_hint="Use
|
|
2971
|
+
fix_hint="Use record_schema_get to confirm the exact field title or field_id.",
|
|
2416
2972
|
details={
|
|
2417
2973
|
"location": location,
|
|
2418
2974
|
"requested": requested,
|
|
@@ -3194,11 +3750,34 @@ def _normalize_plan_arguments(tool: str, arguments: JSONObject) -> JSONObject:
|
|
|
3194
3750
|
for alias, canonical in alias_map.items():
|
|
3195
3751
|
if alias in result and canonical not in result:
|
|
3196
3752
|
result[canonical] = result[alias]
|
|
3753
|
+
if tool in {"record_query", "record_aggregate"}:
|
|
3754
|
+
for key in ("page_size", "requested_pages", "scan_max_pages", "auto_expand_pages"):
|
|
3755
|
+
result.pop(key, None)
|
|
3197
3756
|
if tool == "record_get" and "query_mode" not in result:
|
|
3198
3757
|
result["query_mode"] = "record"
|
|
3199
3758
|
return result
|
|
3200
3759
|
|
|
3201
3760
|
|
|
3761
|
+
def _fixed_list_scan_policy() -> JSONObject:
|
|
3762
|
+
return {
|
|
3763
|
+
"page_size": DEFAULT_LIST_PAGE_SIZE,
|
|
3764
|
+
"requested_pages": 1,
|
|
3765
|
+
"scan_max_pages": 1,
|
|
3766
|
+
"auto_expand_pages": False,
|
|
3767
|
+
"public_inputs_exposed": False,
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
|
|
3771
|
+
def _fixed_analysis_scan_policy() -> JSONObject:
|
|
3772
|
+
return {
|
|
3773
|
+
"page_size": DEFAULT_ANALYSIS_PAGE_SIZE,
|
|
3774
|
+
"requested_pages": DEFAULT_ANALYSIS_SCAN_MAX_PAGES,
|
|
3775
|
+
"scan_max_pages": DEFAULT_ANALYSIS_SCAN_MAX_PAGES,
|
|
3776
|
+
"auto_expand_pages": True,
|
|
3777
|
+
"public_inputs_exposed": False,
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
|
|
3202
3781
|
def _collect_plan_field_candidates(tool: str, arguments: JSONObject) -> list[JSONObject]:
|
|
3203
3782
|
candidates: list[JSONObject] = []
|
|
3204
3783
|
if tool in {"record_query", "record_get"}:
|
|
@@ -3231,15 +3810,10 @@ def _collect_plan_field_candidates(tool: str, arguments: JSONObject) -> list[JSO
|
|
|
3231
3810
|
|
|
3232
3811
|
|
|
3233
3812
|
def _build_plan_estimate(tool: str, arguments: JSONObject, *, probe: JSONObject | None = None) -> JSONObject:
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
may_hit_limits = estimated_scan_pages > DEFAULT_SCAN_MAX_PAGES
|
|
3239
|
-
reasons = []
|
|
3240
|
-
if may_hit_limits:
|
|
3241
|
-
reasons.append("requested scan pages exceed the default analysis budget")
|
|
3242
|
-
if tool == "record_query":
|
|
3813
|
+
if tool == "record_aggregate":
|
|
3814
|
+
fixed_policy = _fixed_analysis_scan_policy()
|
|
3815
|
+
routed_mode = "summary"
|
|
3816
|
+
elif tool == "record_query":
|
|
3243
3817
|
routed_mode = _resolve_query_mode(
|
|
3244
3818
|
str(arguments.get("query_mode", "auto")),
|
|
3245
3819
|
apply_id=_coerce_count(arguments.get("apply_id")),
|
|
@@ -3247,8 +3821,18 @@ def _build_plan_estimate(tool: str, arguments: JSONObject, *, probe: JSONObject
|
|
|
3247
3821
|
time_range=cast(JSONObject, arguments.get("time_range") if isinstance(arguments.get("time_range"), dict) else {}),
|
|
3248
3822
|
stat_policy=cast(JSONObject, arguments.get("stat_policy") if isinstance(arguments.get("stat_policy"), dict) else {}),
|
|
3249
3823
|
)
|
|
3250
|
-
if routed_mode == "
|
|
3251
|
-
|
|
3824
|
+
fixed_policy = _fixed_analysis_scan_policy() if routed_mode == "summary" else _fixed_list_scan_policy()
|
|
3825
|
+
else:
|
|
3826
|
+
routed_mode = "summary"
|
|
3827
|
+
fixed_policy = _fixed_list_scan_policy()
|
|
3828
|
+
page_size = int(fixed_policy["page_size"])
|
|
3829
|
+
requested_pages = int(fixed_policy["requested_pages"])
|
|
3830
|
+
scan_max_pages = int(fixed_policy["scan_max_pages"])
|
|
3831
|
+
estimated_scan_pages = min(requested_pages, scan_max_pages)
|
|
3832
|
+
may_hit_limits = False
|
|
3833
|
+
reasons = []
|
|
3834
|
+
if tool == "record_query" and routed_mode == "list":
|
|
3835
|
+
reasons.append("list mode is not a safe final-analysis endpoint")
|
|
3252
3836
|
recommended_arguments = _recommended_analysis_arguments(
|
|
3253
3837
|
tool=tool,
|
|
3254
3838
|
arguments=arguments,
|
|
@@ -3259,7 +3843,7 @@ def _build_plan_estimate(tool: str, arguments: JSONObject, *, probe: JSONObject
|
|
|
3259
3843
|
estimated_scan_pages = max(estimated_scan_pages, min(int(probe["estimated_full_scan_pages"]), _coerce_count(recommended_arguments.get("scan_max_pages")) or estimated_scan_pages))
|
|
3260
3844
|
if bool(probe.get("would_exceed_current_budget")):
|
|
3261
3845
|
may_hit_limits = True
|
|
3262
|
-
reasons.append("matching rows appear to exceed the
|
|
3846
|
+
reasons.append("matching rows appear to exceed the fixed internal analysis scan budget")
|
|
3263
3847
|
return {
|
|
3264
3848
|
"page_size": page_size,
|
|
3265
3849
|
"requested_pages": requested_pages,
|
|
@@ -3270,6 +3854,7 @@ def _build_plan_estimate(tool: str, arguments: JSONObject, *, probe: JSONObject
|
|
|
3270
3854
|
"reasons": reasons,
|
|
3271
3855
|
"probe": probe,
|
|
3272
3856
|
"recommended_arguments": recommended_arguments,
|
|
3857
|
+
"fixed_scan_policy": fixed_policy,
|
|
3273
3858
|
}
|
|
3274
3859
|
|
|
3275
3860
|
|
|
@@ -3280,37 +3865,37 @@ def _recommended_analysis_arguments(
|
|
|
3280
3865
|
probe: JSONObject | None,
|
|
3281
3866
|
routed_mode: str,
|
|
3282
3867
|
) -> JSONObject:
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
if tool == "record_aggregate" or routed_mode == "summary":
|
|
3287
|
-
recommended_page_size = max(base_page_size, DEFAULT_ANALYSIS_PAGE_SIZE)
|
|
3288
|
-
recommended_scan_max_pages = max(base_scan_max_pages, DEFAULT_ANALYSIS_SCAN_MAX_PAGES)
|
|
3289
|
-
else:
|
|
3290
|
-
recommended_page_size = base_page_size
|
|
3291
|
-
recommended_scan_max_pages = base_scan_max_pages
|
|
3868
|
+
fixed_policy = _fixed_analysis_scan_policy() if tool == "record_aggregate" or routed_mode == "summary" else _fixed_list_scan_policy()
|
|
3869
|
+
recommended_page_size = int(fixed_policy["page_size"])
|
|
3870
|
+
recommended_scan_max_pages = int(fixed_policy["scan_max_pages"])
|
|
3292
3871
|
backend_total_count = _coerce_count(probe.get("backend_total_count") if isinstance(probe, dict) else None)
|
|
3293
3872
|
if backend_total_count is not None and backend_total_count > 0:
|
|
3294
3873
|
estimated_full_scan_pages = (backend_total_count + recommended_page_size - 1) // recommended_page_size
|
|
3295
|
-
recommended_requested_pages = max(
|
|
3296
|
-
recommended_scan_max_pages = max(recommended_scan_max_pages, estimated_full_scan_pages)
|
|
3874
|
+
recommended_requested_pages = min(max(estimated_full_scan_pages, 1), recommended_scan_max_pages)
|
|
3297
3875
|
else:
|
|
3298
|
-
recommended_requested_pages =
|
|
3876
|
+
recommended_requested_pages = int(fixed_policy["requested_pages"])
|
|
3299
3877
|
estimated_full_scan_pages = None
|
|
3300
3878
|
return {
|
|
3301
|
-
"page_size": recommended_page_size,
|
|
3302
|
-
"requested_pages": recommended_requested_pages,
|
|
3303
|
-
"scan_max_pages": recommended_scan_max_pages,
|
|
3304
3879
|
"strict_full": True,
|
|
3305
|
-
"auto_expand_pages": True,
|
|
3306
3880
|
"estimated_full_scan_pages": estimated_full_scan_pages,
|
|
3881
|
+
"fixed_scan_policy": {
|
|
3882
|
+
"page_size": recommended_page_size,
|
|
3883
|
+
"requested_pages": recommended_requested_pages,
|
|
3884
|
+
"scan_max_pages": recommended_scan_max_pages,
|
|
3885
|
+
"auto_expand_pages": bool(fixed_policy["auto_expand_pages"]),
|
|
3886
|
+
"public_inputs_exposed": False,
|
|
3887
|
+
},
|
|
3307
3888
|
}
|
|
3308
3889
|
|
|
3309
3890
|
|
|
3310
3891
|
def _callable_analysis_arguments(arguments: JSONObject) -> JSONObject:
|
|
3311
3892
|
if not isinstance(arguments, dict):
|
|
3312
3893
|
return {}
|
|
3313
|
-
return {
|
|
3894
|
+
return {
|
|
3895
|
+
key: value
|
|
3896
|
+
for key, value in arguments.items()
|
|
3897
|
+
if key not in {"estimated_full_scan_pages", "fixed_scan_policy"}
|
|
3898
|
+
}
|
|
3314
3899
|
|
|
3315
3900
|
|
|
3316
3901
|
def _build_recommended_analysis_chain(tool: str, arguments: JSONObject, estimate: JSONObject) -> list[JSONObject]:
|
|
@@ -3332,13 +3917,10 @@ def _build_recommended_analysis_chain(tool: str, arguments: JSONObject, estimate
|
|
|
3332
3917
|
recommended = _callable_analysis_arguments(
|
|
3333
3918
|
cast(JSONObject, estimate.get("recommended_arguments") if isinstance(estimate.get("recommended_arguments"), dict) else {})
|
|
3334
3919
|
)
|
|
3920
|
+
fixed_scan_policy = cast(JSONObject, estimate.get("fixed_scan_policy") if isinstance(estimate.get("fixed_scan_policy"), dict) else {})
|
|
3335
3921
|
common_args: JSONObject = {
|
|
3336
3922
|
"app_key": app_key,
|
|
3337
3923
|
"page_num": max(_coerce_count(arguments.get("page_num")) or 1, 1),
|
|
3338
|
-
"page_size": _coerce_count(arguments.get("page_size")) or DEFAULT_QUERY_PAGE_SIZE,
|
|
3339
|
-
"requested_pages": _coerce_count(arguments.get("requested_pages")) or 1,
|
|
3340
|
-
"scan_max_pages": _coerce_count(arguments.get("scan_max_pages")) or 1,
|
|
3341
|
-
"auto_expand_pages": bool(arguments.get("auto_expand_pages")),
|
|
3342
3924
|
"query_key": arguments.get("query_key"),
|
|
3343
3925
|
"filters": _as_object_list(arguments.get("filters")),
|
|
3344
3926
|
"sorts": _as_object_list(arguments.get("sorts")),
|
|
@@ -3370,6 +3952,8 @@ def _build_recommended_analysis_chain(tool: str, arguments: JSONObject, estimate
|
|
|
3370
3952
|
"tool_name": "record_query",
|
|
3371
3953
|
"purpose": "Confirm the full scope before presenting conclusions.",
|
|
3372
3954
|
"safe_for_final_conclusion": True,
|
|
3955
|
+
"note": "Summary uses fixed internal scan settings; do not pass manual paging controls.",
|
|
3956
|
+
"scan_policy": fixed_scan_policy,
|
|
3373
3957
|
"arguments": summary_args,
|
|
3374
3958
|
}
|
|
3375
3959
|
]
|
|
@@ -3393,6 +3977,8 @@ def _build_recommended_analysis_chain(tool: str, arguments: JSONObject, estimate
|
|
|
3393
3977
|
"tool_name": "record_aggregate",
|
|
3394
3978
|
"purpose": "Use grouped aggregation for distribution, ratio, ranking, or top-N conclusions.",
|
|
3395
3979
|
"safe_for_final_conclusion": True,
|
|
3980
|
+
"note": "Aggregate uses fixed internal scan settings; do not pass manual paging controls.",
|
|
3981
|
+
"scan_policy": fixed_scan_policy,
|
|
3396
3982
|
"arguments": aggregate_arguments,
|
|
3397
3983
|
}
|
|
3398
3984
|
)
|
|
@@ -3422,6 +4008,7 @@ def _build_recommended_analysis_chain(tool: str, arguments: JSONObject, estimate
|
|
|
3422
4008
|
"purpose": "Browse representative sample rows only after the full-scan analysis steps.",
|
|
3423
4009
|
"safe_for_final_conclusion": False,
|
|
3424
4010
|
"note": "List mode is sample-only evidence when capped; do not use it as the final statistical basis.",
|
|
4011
|
+
"scan_policy": _fixed_list_scan_policy(),
|
|
3425
4012
|
"arguments": list_arguments,
|
|
3426
4013
|
}
|
|
3427
4014
|
)
|
|
@@ -3488,8 +4075,8 @@ def _assess_plan_readiness(
|
|
|
3488
4075
|
actions.append("Set strict_full=true so incomplete scans block final conclusions.")
|
|
3489
4076
|
probe = estimate.get("probe") if isinstance(estimate.get("probe"), dict) else None
|
|
3490
4077
|
if isinstance(probe, dict) and bool(probe.get("would_exceed_current_budget")):
|
|
3491
|
-
blockers.append("
|
|
3492
|
-
actions.append("
|
|
4078
|
+
blockers.append("the estimated matching dataset exceeds the fixed internal analysis scan budget")
|
|
4079
|
+
actions.append("Reuse suggested_next_call; do not try to hand-tune paging controls for analysis.")
|
|
3493
4080
|
actions.append("After execution, verify completeness before using the result as a final conclusion.")
|
|
3494
4081
|
return {
|
|
3495
4082
|
"ready_for_final_conclusion": not blockers,
|
|
@@ -3791,6 +4378,11 @@ def _to_time_bucket(value: JSONValue, bucket: str) -> str:
|
|
|
3791
4378
|
if bucket == "week":
|
|
3792
4379
|
iso_year, iso_week, _ = parsed.isocalendar()
|
|
3793
4380
|
return f"{iso_year}-W{iso_week:02d}"
|
|
4381
|
+
if bucket == "quarter":
|
|
4382
|
+
quarter = ((parsed.month - 1) // 3) + 1
|
|
4383
|
+
return f"{parsed.year}-Q{quarter}"
|
|
4384
|
+
if bucket == "year":
|
|
4385
|
+
return parsed.strftime("%Y")
|
|
3794
4386
|
return parsed.strftime("%Y-%m-%d")
|
|
3795
4387
|
|
|
3796
4388
|
|
|
@@ -3804,6 +4396,106 @@ def _parse_datetime_like(text: str) -> datetime | None:
|
|
|
3804
4396
|
return None
|
|
3805
4397
|
|
|
3806
4398
|
|
|
4399
|
+
def _flatten_distinct_values(value: JSONValue) -> list[str]:
|
|
4400
|
+
if value is None:
|
|
4401
|
+
return []
|
|
4402
|
+
if isinstance(value, list):
|
|
4403
|
+
items = value
|
|
4404
|
+
else:
|
|
4405
|
+
items = [value]
|
|
4406
|
+
flattened: list[str] = []
|
|
4407
|
+
for item in items:
|
|
4408
|
+
if item is None:
|
|
4409
|
+
continue
|
|
4410
|
+
if isinstance(item, (dict, list)):
|
|
4411
|
+
flattened.append(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
4412
|
+
else:
|
|
4413
|
+
flattened.append(_stringify_json(item))
|
|
4414
|
+
return flattened
|
|
4415
|
+
|
|
4416
|
+
|
|
4417
|
+
def _coerce_comparable(value: JSONValue) -> JSONValue:
|
|
4418
|
+
amount = _coerce_amount(value)
|
|
4419
|
+
if amount is not None:
|
|
4420
|
+
return amount
|
|
4421
|
+
text = _normalize_optional_text(value)
|
|
4422
|
+
if text is None:
|
|
4423
|
+
return None
|
|
4424
|
+
parsed = _parse_datetime_like(text)
|
|
4425
|
+
if parsed is not None:
|
|
4426
|
+
return parsed
|
|
4427
|
+
return text
|
|
4428
|
+
|
|
4429
|
+
|
|
4430
|
+
def _match_analyze_filter(field_value: JSONValue, op: str, expected: JSONValue) -> bool:
|
|
4431
|
+
if op == "is_null":
|
|
4432
|
+
return field_value is None or field_value == [] or field_value == ""
|
|
4433
|
+
if op == "not_null":
|
|
4434
|
+
return not _match_analyze_filter(field_value, "is_null", expected)
|
|
4435
|
+
|
|
4436
|
+
values = field_value if isinstance(field_value, list) else [field_value]
|
|
4437
|
+
normalized_values = [_coerce_comparable(item) for item in values if item is not None]
|
|
4438
|
+
|
|
4439
|
+
if op == "contains":
|
|
4440
|
+
needle = _normalize_optional_text(expected)
|
|
4441
|
+
if needle is None:
|
|
4442
|
+
return False
|
|
4443
|
+
return any(needle in _stringify_json(item) for item in values if item is not None)
|
|
4444
|
+
|
|
4445
|
+
if op in {"in", "not_in"}:
|
|
4446
|
+
expected_values = expected if isinstance(expected, list) else [expected]
|
|
4447
|
+
expected_set = {_stringify_json(item) for item in expected_values if item is not None}
|
|
4448
|
+
actual_set = {_stringify_json(item) for item in values if item is not None}
|
|
4449
|
+
matched = bool(actual_set & expected_set)
|
|
4450
|
+
return matched if op == "in" else not matched
|
|
4451
|
+
|
|
4452
|
+
if op in {"eq", "neq"}:
|
|
4453
|
+
expected_text = _stringify_json(expected)
|
|
4454
|
+
matched = any(_stringify_json(item) == expected_text for item in values if item is not None)
|
|
4455
|
+
return matched if op == "eq" else not matched
|
|
4456
|
+
|
|
4457
|
+
if op == "between":
|
|
4458
|
+
lower, upper = _coerce_filter_range(expected)
|
|
4459
|
+
lower_value = _coerce_comparable(lower)
|
|
4460
|
+
upper_value = _coerce_comparable(upper)
|
|
4461
|
+
for item in normalized_values:
|
|
4462
|
+
if item is None:
|
|
4463
|
+
continue
|
|
4464
|
+
try:
|
|
4465
|
+
if lower_value is not None and item < lower_value:
|
|
4466
|
+
continue
|
|
4467
|
+
if upper_value is not None and item > upper_value:
|
|
4468
|
+
continue
|
|
4469
|
+
return True
|
|
4470
|
+
except TypeError:
|
|
4471
|
+
continue
|
|
4472
|
+
return False
|
|
4473
|
+
|
|
4474
|
+
target = _coerce_comparable(expected)
|
|
4475
|
+
for item in normalized_values:
|
|
4476
|
+
if item is None or target is None:
|
|
4477
|
+
continue
|
|
4478
|
+
try:
|
|
4479
|
+
if op == "gt" and item > target:
|
|
4480
|
+
return True
|
|
4481
|
+
if op == "gte" and item >= target:
|
|
4482
|
+
return True
|
|
4483
|
+
if op == "lt" and item < target:
|
|
4484
|
+
return True
|
|
4485
|
+
if op == "lte" and item <= target:
|
|
4486
|
+
return True
|
|
4487
|
+
except TypeError:
|
|
4488
|
+
continue
|
|
4489
|
+
return False
|
|
4490
|
+
|
|
4491
|
+
|
|
4492
|
+
def _sortable_value(value: JSONValue) -> tuple[int, JSONValue]:
|
|
4493
|
+
if value is None:
|
|
4494
|
+
return (1, "")
|
|
4495
|
+
comparable = _coerce_comparable(value)
|
|
4496
|
+
return (0, comparable if comparable is not None else "")
|
|
4497
|
+
|
|
4498
|
+
|
|
3807
4499
|
def _echo_filters(filters: list[JSONObject]) -> list[JSONObject]:
|
|
3808
4500
|
return [dict(item) for item in filters]
|
|
3809
4501
|
|
|
@@ -4364,7 +5056,7 @@ def _option_value(raw_value: JSONValue, field: FormField) -> JSONObject:
|
|
|
4364
5056
|
raise RecordInputError(
|
|
4365
5057
|
message=f"field '{field.que_title}' uses unknown option '{text}'",
|
|
4366
5058
|
error_code="OPTION_NOT_FOUND",
|
|
4367
|
-
fix_hint="Use
|
|
5059
|
+
fix_hint="Use record_schema_get or inspect the form to confirm allowed option values.",
|
|
4368
5060
|
details={"field": _field_ref_payload(field), "expected_format": _write_format_for_field(field), "received_value": raw_value},
|
|
4369
5061
|
)
|
|
4370
5062
|
return {"value": text}
|