@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
- package/skills/qingflow-app-user/references/record-patterns.md +1 -1
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +1 -1
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +0 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/tools/record_tools.py +66 -22
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.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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 `
|
|
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
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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":
|
|
1011
|
-
"metric_totals":
|
|
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":
|
|
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":
|
|
1057
|
-
"rows_truncated": rows_truncated
|
|
1058
|
-
"statement_scope":
|
|
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"])
|