@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.
@@ -16,10 +16,11 @@ from .base import ToolBase
16
16
 
17
17
 
18
18
  DEFAULT_QUERY_PAGE_SIZE = 50
19
- DEFAULT_ANALYSIS_PAGE_SIZE = 100
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 = 20
22
- DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP = 50
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 record_field_resolve(
143
+ def record_schema_get(
143
144
  profile: str = DEFAULT_PROFILE,
144
145
  app_key: str = "",
145
- query: str | int | None = None,
146
- queries: list[str | int] | None = None,
147
- top_k: int = 3,
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.record_field_resolve(
150
+ return self.record_schema_get(
151
151
  profile=profile,
152
152
  app_key=app_key,
153
- query=query,
154
- queries=queries,
155
- top_k=top_k,
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
- "Preflight complex read requests before actual execution. Resolves field selectors, validates required arguments, "
162
- "and estimates scan scope. Use this first for grouped analysis, final statistics, or whenever scan completeness matters."
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 record_query_plan(
166
+ def record_analyze(
166
167
  profile: str = DEFAULT_PROFILE,
167
- tool: str = "record_query",
168
- arguments: JSONObject | None = None,
169
- resolve_fields: bool = True,
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.record_query_plan(profile=profile, tool=tool, arguments=arguments or {}, resolve_fields=resolve_fields)
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 / summary analysis. "
201
- "List mode returns flattened wide-table rows only. Use query_mode=auto to route: "
202
- "apply_id -> record, amount_column/time_range/stat_policy -> summary, otherwise -> list. "
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=amount_column,
248
- time_range=time_range or {},
249
- stat_policy=stat_policy or {},
250
- strict_full=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 record_field_resolve when field titles are ambiguous."]
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
- page_size = max(_coerce_count(arguments.get("page_size")) or DEFAULT_QUERY_PAGE_SIZE, 1)
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 record_field_resolve to confirm the exact field id.",
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 resolve the field first with record_field_resolve.",
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 run record_field_resolve to inspect the alias candidates.",
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 record_field_resolve to confirm the exact field title.",
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
- page_size = _coerce_count(arguments.get("page_size")) or DEFAULT_QUERY_PAGE_SIZE
3235
- requested_pages = _coerce_count(arguments.get("requested_pages")) or 1
3236
- scan_max_pages = _coerce_count(arguments.get("scan_max_pages")) or requested_pages
3237
- estimated_scan_pages = min(requested_pages, scan_max_pages)
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 == "list":
3251
- reasons.append("list mode is not a safe final-analysis endpoint")
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 current scan budget")
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
- base_page_size = _coerce_count(arguments.get("page_size")) or DEFAULT_QUERY_PAGE_SIZE
3284
- base_requested_pages = _coerce_count(arguments.get("requested_pages")) or 1
3285
- base_scan_max_pages = _coerce_count(arguments.get("scan_max_pages")) or base_requested_pages
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(base_requested_pages, estimated_full_scan_pages)
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 = base_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 {key: value for key, value in arguments.items() if key not in {"estimated_full_scan_pages"}}
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("current scan budget is smaller than the estimated matching dataset")
3492
- actions.append("Use estimate.recommended_arguments or enable auto_expand_pages before trusting aggregate or summary results.")
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 record_field_resolve or inspect the form to confirm allowed option values.",
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}