@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21

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