@josephyan/qingflow-app-user-mcp 0.2.0-beta.19 → 0.2.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.19
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.20
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.19 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.20 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.19",
3
+ "version": "0.2.0-beta.20",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b19"
7
+ version = "0.2.0b20"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,8 +9,8 @@ For final statistics, grouped distributions, or insight-style analysis, use [$qi
9
9
  - If `record_analyze.status!=success`, treat the result as exploratory unless the user explicitly asked for a partial sample
10
10
  - `record_query(list)` is for browsing and sample inspection. If it reports `row_cap_hit`, `sample_only`, or capped `returned_items`, do not present it as full data
11
11
  - When coverage matters, surface:
12
- - `backend_total_count`
13
12
  - `scanned_count`
13
+ - `presentation.statement_scope`
14
14
  - Use narrower views, filters, or smaller analysis questions instead of inventing manual scan settings by hand
15
15
  - If the browser and MCP disagree, compare `request_route.base_url` and `request_route.qf_version` first
16
16
  - Do not mix a full aggregate total with sample-only list detail in one sentence like “基于全部数据分析”; split the answer into `全量结论` and `样本观察`
@@ -26,7 +26,7 @@ Use [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/
26
26
  2. Generate one or more field_id-based DSLs
27
27
  3. Run `record_analyze(strict_full=true)` for summary/distribution/trend/cross analysis
28
28
  4. Run `record_query(query_mode="list")` only if you still need sample rows or examples
29
- 5. Report `backend_total_count`, `scanned_count`, and whether the result is safe for a final conclusion
29
+ 5. Report `scanned_count`, `presentation.statement_scope`, and whether the result is safe for a final conclusion
30
30
  6. If `status=error` or `safe_for_final_conclusion=false`, stop at “partial result” instead of presenting a final business conclusion
31
31
  7. If list rows are sample-only, separate the answer into:
32
32
  - `全量可信结论`
@@ -32,10 +32,10 @@ Use this skill when the user asks for:
32
32
  - sort by the count alias
33
33
  5. Run `record_analyze`
34
34
  6. Report:
35
- - `backend_total_count`
36
35
  - `scanned_count`
37
36
  - `safe_for_final_conclusion`
38
37
  - `presentation.statement_scope`
38
+ - `completeness.local_filtering_applied` when it affects how the result should be framed
39
39
  7. If grouped rows are truncated, describe the answer as `主要分组` or `已返回分组中`, not `各部门` or `全部`
40
40
 
41
41
  ## penetration / conversion / share-of-total pattern
@@ -78,7 +78,6 @@ If the user asked for multiple conclusions but only some are complete:
78
78
 
79
79
  ### 全量可信结论
80
80
 
81
- - `backend_total_count=1134`
82
81
  - `scanned_count=1134`
83
82
  - `safe_for_final_conclusion=true`
84
83
  - 这里写最终业务结论
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b19"
5
+ __version__ = "0.2.0b20"
@@ -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
@@ -459,7 +460,8 @@ class RecordTools(ToolBase):
459
460
  def _schema_role_hints(self, field: FormField) -> JSONObject:
460
461
  field_family = self._schema_field_family(field)
461
462
  time_candidate = field.que_type in DATE_QUE_TYPES
462
- metric_candidate = bool(field.que_type == 8 and not field.system and not field.readonly)
463
+ identifier_like = self._schema_is_identifier_like(field, field_family=field_family)
464
+ metric_candidate = bool(field.que_type == 8 and not field.system and not field.readonly and not identifier_like)
463
465
  dimension_candidate = bool(
464
466
  field.que_type not in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES | VERIFY_UNSUPPORTED_WRITE_QUE_TYPES
465
467
  and not field.system
@@ -474,6 +476,8 @@ class RecordTools(ToolBase):
474
476
  }
475
477
 
476
478
  def _schema_field_family(self, field: FormField) -> str:
479
+ if self._schema_is_identifier_like(field):
480
+ return "text"
477
481
  que_type = field.que_type
478
482
  if que_type == 8:
479
483
  return "number"
@@ -493,9 +497,21 @@ class RecordTools(ToolBase):
493
497
  return "unknown"
494
498
  return "text"
495
499
 
500
+ def _schema_is_identifier_like(self, field: FormField, *, field_family: str | None = None) -> bool:
501
+ normalized_title = _normalize_field_lookup_key(field.que_title)
502
+ if field.que_id == 0:
503
+ return True
504
+ if any(
505
+ token in normalized_title for token in ("编号", "单号", "流水号", "编码", "序号", "uid", "id", "code")
506
+ ):
507
+ return True
508
+ return False
509
+
496
510
  def _schema_supported_metric_ops(self, field: FormField, *, field_family: str) -> list[str]:
497
511
  if field.que_type in ATTACHMENT_QUE_TYPES | RELATION_QUE_TYPES | SUBTABLE_QUE_TYPES:
498
512
  return []
513
+ if self._schema_is_identifier_like(field, field_family=field_family):
514
+ return ["distinct_count"]
499
515
  if field_family == "number":
500
516
  return ["sum", "avg", "min", "max", "distinct_count"]
501
517
  if field_family in {"date", "category", "member", "department", "text", "boolean", "unknown"}:
@@ -503,6 +519,8 @@ class RecordTools(ToolBase):
503
519
  return []
504
520
 
505
521
  def _schema_semantic_hint(self, field: FormField, *, field_family: str) -> str:
522
+ if self._schema_is_identifier_like(field, field_family=field_family):
523
+ return "unknown"
506
524
  if field_family != "number":
507
525
  return "unknown"
508
526
  normalized_title = _normalize_field_lookup_key(field.que_title)
@@ -908,6 +926,7 @@ class RecordTools(ToolBase):
908
926
  strict_full: bool,
909
927
  output_profile: str,
910
928
  ) -> JSONObject:
929
+ started_at = time.perf_counter()
911
930
  analysis_paging = _fixed_analysis_scan_policy()
912
931
  page_size = int(analysis_paging["page_size"])
913
932
  requested_pages = int(analysis_paging["requested_pages"])
@@ -922,7 +941,7 @@ class RecordTools(ToolBase):
922
941
  has_more = False
923
942
  dept_member_cache: dict[int, set[int]] = {}
924
943
  local_filtering = bool(filters) or bool(view_selection is not None and view_selection.conditions)
925
- group_stats: dict[str, JSONObject] = {}
944
+ group_stats: dict[tuple[tuple[str, object], ...], JSONObject] = {}
926
945
  overall_metrics = self._initialize_metric_states(metrics)
927
946
  matched_rows = 0
928
947
  scan_control: JSONObject = {
@@ -974,8 +993,11 @@ class RecordTools(ToolBase):
974
993
  if not self._matches_analyze_filters(answer_list, filters):
975
994
  continue
976
995
  matched_rows += 1
996
+ self._apply_metric_states(overall_metrics, metrics, answer_list)
997
+ if not dimensions:
998
+ continue
977
999
  group_payload = self._build_analyze_group_payload(answer_list, dimensions)
978
- group_key = json.dumps(group_payload, ensure_ascii=False, sort_keys=True)
1000
+ group_key = self._analysis_group_key(group_payload)
979
1001
  bucket = group_stats.get(group_key)
980
1002
  if bucket is None:
981
1003
  bucket = {
@@ -985,30 +1007,39 @@ class RecordTools(ToolBase):
985
1007
  group_stats[group_key] = bucket
986
1008
  bucket_metrics = cast(dict[str, JSONObject], bucket["metrics_state"])
987
1009
  self._apply_metric_states(bucket_metrics, metrics, answer_list)
988
- self._apply_metric_states(overall_metrics, metrics, answer_list)
989
1010
  if not has_more:
990
1011
  break
991
1012
  current_page += 1
992
1013
 
993
- all_rows = [
994
- {
995
- "dimensions": cast(JSONObject, bucket["dimensions"]),
996
- "metrics": self._render_metric_values(cast(dict[str, JSONObject], bucket["metrics_state"]), metrics),
997
- }
998
- for bucket in group_stats.values()
999
- ]
1000
- all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
1001
- rows_truncated = len(all_rows) > limit
1002
- limited_rows = all_rows[:limit]
1014
+ metric_totals = self._render_metric_values(overall_metrics, metrics)
1015
+ if dimensions:
1016
+ all_rows = [
1017
+ {
1018
+ "dimensions": cast(JSONObject, bucket["dimensions"]),
1019
+ "metrics": self._render_metric_values(cast(dict[str, JSONObject], bucket["metrics_state"]), metrics),
1020
+ }
1021
+ for bucket in group_stats.values()
1022
+ ]
1023
+ all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
1024
+ rows_truncated = len(all_rows) > limit
1025
+ limited_rows = all_rows[:limit]
1026
+ rows = limited_rows
1027
+ rows_returned = len(limited_rows)
1028
+ group_count = len(all_rows)
1029
+ statement_scope = "returned_groups_only" if rows_truncated else "full_population"
1030
+ else:
1031
+ rows_truncated = False
1032
+ rows = [{"dimensions": {}, "metrics": metric_totals}]
1033
+ rows_returned = 1
1034
+ group_count = 1
1035
+ statement_scope = "full_population"
1003
1036
  raw_scan_complete = not has_more
1004
1037
  completeness_status = "complete" if raw_scan_complete else "incomplete"
1005
1038
  reason_code = "LOCAL_VIEW_FILTERING" if local_filtering and raw_scan_complete else ("SOURCE_EXHAUSTED" if raw_scan_complete else "SCAN_LIMIT_HIT")
1006
- totals_backend_count = None if local_filtering else result_amount
1007
1039
  totals = {
1008
- "backend_total_count": totals_backend_count,
1009
1040
  "scanned_count": matched_rows,
1010
- "group_count": len(all_rows) if dimensions else 1,
1011
- "metric_totals": self._render_metric_values(overall_metrics, metrics),
1041
+ "group_count": group_count,
1042
+ "metric_totals": metric_totals,
1012
1043
  }
1013
1044
  data: JSONObject = {
1014
1045
  "query": {
@@ -1043,7 +1074,7 @@ class RecordTools(ToolBase):
1043
1074
  "applied_sort": [{"by": item["by"], "order": item["order"]} for item in sort],
1044
1075
  "view": _view_selection_payload(view_selection),
1045
1076
  },
1046
- "rows": limited_rows if dimensions else [{"dimensions": {}, "metrics": totals["metric_totals"]}],
1077
+ "rows": rows,
1047
1078
  "totals": totals,
1048
1079
  "completeness": {
1049
1080
  "status": completeness_status,
@@ -1053,9 +1084,9 @@ class RecordTools(ToolBase):
1053
1084
  },
1054
1085
  "presentation": {
1055
1086
  "row_limit": limit,
1056
- "rows_returned": 1 if not dimensions else len(limited_rows),
1057
- "rows_truncated": rows_truncated if dimensions else False,
1058
- "statement_scope": "returned_groups_only" if dimensions and rows_truncated else "full_population",
1087
+ "rows_returned": rows_returned,
1088
+ "rows_truncated": rows_truncated,
1089
+ "statement_scope": statement_scope,
1059
1090
  },
1060
1091
  "warnings": self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated),
1061
1092
  }
@@ -1078,6 +1109,9 @@ class RecordTools(ToolBase):
1078
1109
  }
1079
1110
  if output_profile == "verbose":
1080
1111
  response["data"]["debug"] = {
1112
+ "elapsed_ms": int((time.perf_counter() - started_at) * 1000),
1113
+ "backend_total_hint": scan_control.get("backend_total_count", result_amount),
1114
+ "backend_page_amount": scan_control.get("backend_page_amount"),
1081
1115
  "source_pages": source_pages,
1082
1116
  "raw_scan_complete": raw_scan_complete,
1083
1117
  "scan_control": scan_control,
@@ -1110,6 +1144,16 @@ class RecordTools(ToolBase):
1110
1144
  }
1111
1145
  return states
1112
1146
 
1147
+ def _analysis_group_key(self, payload: JSONObject) -> tuple[tuple[str, object], ...]:
1148
+ return tuple((key, self._freeze_group_key_value(value)) for key, value in payload.items())
1149
+
1150
+ def _freeze_group_key_value(self, value: JSONValue) -> object:
1151
+ if isinstance(value, dict):
1152
+ return tuple((key, self._freeze_group_key_value(item)) for key, item in sorted(value.items()))
1153
+ if isinstance(value, list):
1154
+ return tuple(self._freeze_group_key_value(item) for item in value)
1155
+ return value
1156
+
1113
1157
  def _apply_metric_states(self, states: dict[str, JSONObject], metrics: list[JSONObject], answer_list: list[JSONValue]) -> None:
1114
1158
  for item in metrics:
1115
1159
  alias = cast(str, item["alias"])