From 5213897bbd2d7b92aa2db02e50013b3639f09b02 Mon Sep 17 00:00:00 2001
From: Cxx0822 <1556464090@qq.com>
Date: Mon, 13 Jul 2026 22:06:05 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E4=BD=93=E5=B7=A5=E5=85=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.idea/workspace.xml | 78 ++++++----
agent/bill.py | 7 +-
agent/model.py | 14 --
cache.py | 2 +
config/domain.py | 72 ++++++++++
domain/base.py | 17 +++
domain/bill.py | 40 ++++++
main.py | 12 +-
prompt/base.py | 108 ++++++++++++++
prompt/bill.py | 61 ++++----
requirements.txt | 3 +-
services.py | 10 +-
tools/base.py | 338 ++++++++++++++++++++++++++++++++++++++++++++
tools/bill.py | 67 ---------
14 files changed, 672 insertions(+), 157 deletions(-)
create mode 100644 cache.py
create mode 100644 config/domain.py
create mode 100644 domain/base.py
create mode 100644 domain/bill.py
create mode 100644 prompt/base.py
create mode 100644 tools/base.py
delete mode 100644 tools/bill.py
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 530ca28..a6d6c51 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,11 +5,20 @@
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
@@ -36,35 +45,42 @@
- {
+ "keyToString": {
+ "Docker.Dockerfile.executor": "Run",
+ "FastAPI.sweet-hut-agent.executor": "Run",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
+ "git-widget-placeholder": "master",
+ "last_opened_file_path": "D:/Projects/GithubProjects/sweet-hut-agent",
+ "node.js.detected.package.eslint": "true",
+ "node.js.detected.package.tslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
+ "settings.editor.selected.configurable": "preferences.lookFeel",
+ "vue.rearranger.settings.migration": "true"
+ },
+ "keyToStringList": {
+ "DatabaseDriversLRU": [
+ "mysql",
+ "postgresql"
+ ]
}
-}]]>
+}
+
-
+
@@ -92,14 +108,11 @@
-
+
+
+
-
-
-
-
-
@@ -123,7 +136,12 @@
-
+
+
+
+
+
+
@@ -131,6 +149,6 @@
-
+
\ No newline at end of file
diff --git a/agent/bill.py b/agent/bill.py
index ce3c548..e64d8ea 100644
--- a/agent/bill.py
+++ b/agent/bill.py
@@ -1,11 +1,12 @@
from langchain.agents import create_agent
from agent.model import model
+from prompt.base import base_prompt
from prompt.bill import bill_prompt
-from tools.bill import bill_search
+from tools.base import query_data, filter_data, analyze_data, inspect_data
bill_agent = create_agent(
model=model,
- tools=[bill_search],
- system_prompt=bill_prompt
+ tools=[query_data, filter_data, inspect_data, analyze_data],
+ system_prompt=f"{base_prompt}\n\n{bill_prompt}"
)
diff --git a/agent/model.py b/agent/model.py
index 7cdd87d..6bbe925 100644
--- a/agent/model.py
+++ b/agent/model.py
@@ -1,19 +1,5 @@
from langchain.chat_models import init_chat_model
-# model = init_chat_model(
-# model="glm-5.1",
-# model_provider="openai",
-# base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
-# api_key="sk-52bcd98e9c1d45908437c4e8706eefff"
-# )
-
-# model = init_chat_model(
-# model="GLM-5-Turbo",
-# model_provider="openai",
-# base_url="https://open.bigmodel.cn/api/coding/paas/v4",
-# api_key="ea3f96fd542f4bab805a719cf3063cd6.HCRhCV7nqwkDsVKm",
-# )
-
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="openai",
diff --git a/cache.py b/cache.py
new file mode 100644
index 0000000..9d373e5
--- /dev/null
+++ b/cache.py
@@ -0,0 +1,2 @@
+_GLOBAL_RAW_CACHE = {} # {domain: list[dict]}
+_GLOBAL_FILTERED_CACHE = {} # {domain: list[dict]}
\ No newline at end of file
diff --git a/config/domain.py b/config/domain.py
new file mode 100644
index 0000000..a5f9551
--- /dev/null
+++ b/config/domain.py
@@ -0,0 +1,72 @@
+from dataclasses import dataclass
+from typing import Callable, Dict, List, Optional
+
+
+@dataclass
+class MetricConfig:
+ """
+ 指标字段配置(完全去业务语义)
+ """
+ field: str # 原始字段名,如 amount
+ name: str # 展示名称,如 实收金额 / 时长 / 评分
+ agg: str = "sum" # 聚合方式:sum / mean / max / min / count
+ format: str = "auto" # 展示格式:auto / money / number:2 / int / duration
+
+
+class DomainConfig:
+ def __init__(
+ self,
+ name: str,
+ api_url: str,
+ entity_name: str,
+ field_name_map: Dict[str, str],
+ detail_fields: List[str] | None = None,
+ search_fields: List[str] | None = None,
+ metrics: Optional[List[MetricConfig]] = None,
+ token_getter: Callable[[], str] | None = None,
+ auth_header: str = "Authorization",
+ date_field: str = "date",
+ primary_key: str = "id",
+ ):
+ self.name = name
+ self.api_url = api_url
+ self.entity_name = entity_name
+ self.field_name_map = field_name_map
+ self.detail_fields = detail_fields or []
+ self.search_fields = search_fields or []
+ self.metrics: List[MetricConfig] = metrics or []
+
+ self.token_getter = token_getter
+ self.auth_header = auth_header
+ self.date_field = date_field
+ self.primary_key = primary_key
+
+ self._metric_map: Dict[str, MetricConfig] = {
+ m.field: m for m in self.metrics
+ }
+
+ def get_metric(self, field: str) -> Optional[MetricConfig]:
+ return self._metric_map.get(field)
+
+ def metric_fields(self) -> List[str]:
+ return [m.field for m in self.metrics]
+
+ def has_metric(self, field: str) -> bool:
+ return field in self._metric_map
+
+ def get_chinese_name(self, field: str) -> str:
+ return self.field_name_map.get(field, field)
+
+
+_DOMAINS: Dict[str, DomainConfig] = {}
+
+
+def register_domain(config: DomainConfig):
+ print("注册业务域:", config.name)
+ _DOMAINS[config.name] = config
+
+
+def get_domain(name: str) -> DomainConfig:
+ if name not in _DOMAINS:
+ raise ValueError(f"未注册的业务域:{name}")
+ return _DOMAINS[name]
diff --git a/domain/base.py b/domain/base.py
new file mode 100644
index 0000000..2c48a59
--- /dev/null
+++ b/domain/base.py
@@ -0,0 +1,17 @@
+from config.domain import register_domain, DomainConfig
+from domain.bill import *
+
+
+def init_domains():
+ register_domain(DomainConfig(
+ name="bill",
+ api_url=f"{BILL_BASE_URL}bill/summary",
+ entity_name="账单",
+ field_name_map=BILL_FIELD_NAME_MAP,
+ detail_fields=BILL_DETAIL_FIELDS,
+ search_fields=BILL_SEARCH_FIELDS,
+ metrics=BILL_METRICS,
+ token_getter=get_bill_token,
+ auth_header="satoken",
+ date_field="date",
+ ))
diff --git a/domain/bill.py b/domain/bill.py
new file mode 100644
index 0000000..b73632e
--- /dev/null
+++ b/domain/bill.py
@@ -0,0 +1,40 @@
+import requests
+
+from config.domain import MetricConfig
+
+BILL_BASE_URL = "https://cxx0822.s.3q.hair/home-api/"
+USERNAME = "Cxx0822"
+PASSWORD = "19940822Cxx"
+
+_BILL_RAW_CACHE = None
+_BILL_FILTERED_CACHE = None
+
+
+def get_bill_token():
+ resp = requests.post(
+ f"{BILL_BASE_URL}session",
+ params={"name": USERNAME, "password": PASSWORD},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return resp.json()["saToken"]["tokenValue"]
+
+
+BILL_FIELD_NAME_MAP = {
+ "date": "账单日期",
+ "bookName": "账本名称",
+ "type": "账单类型",
+ "category": "账单类别",
+ "location": "账单地点",
+ "payAccount": "支付账户",
+ "amount": "金额",
+ "content": "账单内容",
+ "remark": "备注",
+}
+
+BILL_DETAIL_FIELDS = ["date", "bookName", "type", "customer", "category",
+ "location", "payAccount", "amount", "content", "remark"]
+
+BILL_SEARCH_FIELDS = ["location", "content", "remark"]
+
+BILL_METRICS = [MetricConfig("amount", "金额", agg="sum", format="money")]
diff --git a/main.py b/main.py
index 28e5f16..1aa79e6 100644
--- a/main.py
+++ b/main.py
@@ -1,11 +1,21 @@
+from contextlib import asynccontextmanager
+
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse
+from domain.base import init_domains
from modes.agent import QueryRequest
from services import query_bill_agent
-app = FastAPI(title="Integrate Agent API")
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ init_domains()
+ yield
+
+
+app = FastAPI(title="Integrate Agent API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
diff --git a/prompt/base.py b/prompt/base.py
new file mode 100644
index 0000000..d038fdc
--- /dev/null
+++ b/prompt/base.py
@@ -0,0 +1,108 @@
+base_prompt = """
+你是一个数据分析助手,能够通过通用工具对任意业务域的数据进行查询、筛选、检索与统计分析。
+
+# 一、可用工具(严格按签名调用)
+## 1. query_data
+- 作用:按时间范围查询指定业务域的原始数据
+- 参数:
+ - domain:业务域名称
+ - start_date:YYYY-MM-DD
+ - end_date:YYYY-MM-DD
+- 示例:
+ query_data(domain="repair", start_date="2026-01-01", end_date="2026-01-31")
+
+## 2. filter_data(筛选 + 检索)
+- 作用:缩小数据集范围(不展示明细)
+- 参数:
+ - domain:业务域名称
+ - filters:结构化筛选条件(AND)
+ - search:关键词检索(OR)
+
+- filters 结构:
+ {
+ "field": "字段英文名",
+ "op": "eq | ne | contains | not_contains",
+ "value": "值"
+ }
+
+- 示例:
+ filter_data(
+ domain="repair",
+ filters=[{"field": "deviceModel", "op": "eq", "value": "销售机"}],
+ search=["钢丝绳"]
+ )
+
+## 3. inspect_data(查看明细)
+- 作用:对筛选后的数据进行排序、抽样并展示明细
+- 参数:
+ - domain
+ - sort_by:排序字段(如 amount)
+ - ascending:是否升序(默认 False,即降序)
+ - top_n:返回条数(如 1 / 10)
+- 示例:
+ inspect_data(domain="bill", sort_by="amount", ascending=False, top_n=10)
+
+## 4. analyze_data(统计分析)
+- 作用:对筛选后的数据进行多维度统计
+- 参数:
+ - domain:业务域名称
+ - dimensions:英文维度字段列表(统计视角)
+
+- 示例:
+ analyze_data(domain="repair", dimensions=["province", "deviceName", "faultType"])
+
+# 二、工具调用硬性规则
+
+- 每轮对话,每个工具最多调用 1 次
+- 禁止循环调用工具
+- 禁止在 analyze_data 之后再次调用任何工具
+- 工具调用顺序原则:
+ 1. query_data(必须第一步)
+ 2. filter_data(按需)
+ 3. inspect_data(查看明细,按需)
+ 4. analyze_data(统计分析,按需)
+- 查看明细 ≠ 统计分析,二者只选其一
+
+# 三、统计口径规则(全局生效)
+
+- 默认统计指标为「记录条数」
+- 默认不进行任何去重(包括设备、客户、订单等)
+- 如需按实体维度统计(如设备数、客户数),必须在 dimensions 中明确指定该字段
+- 统计结果中,指标统一称为「数量」
+
+# 四、日期处理规则
+
+- 用户未指定日期时,默认使用最近 90 天
+- 用户说“全部 / 所有数据”:
+ - 起始日期:2026-01-01
+ - 结束日期:当天
+- 回答开头必须声明时间范围
+
+# 五、空值处理规则
+
+- null / 空字符串 / 字段缺失 统一显示为:「未填写」
+- 统计时,「未填写」必须作为独立一项展示
+
+# 六、输出规则
+
+- 禁止输出原始 JSON
+- 明细展示:
+ - ≤ 20 条:直接展示完整明细
+ - 21–50 条:先告知数量,询问是否查看明细
+ - > 50 条:不展示明细,建议缩小范围
+- 明细中不得使用表格
+- 统计分析时不得同时展示明细
+- 所有维度名称使用中文(见业务映射表)
+
+# 七、执行流程
+
+1. 解析用户意图与时间范围
+2. 调用 query_data
+3. 如有筛选或检索需求,调用 filter_data
+4. 判断用户意图:
+ - 若用户要求“统计 / 分析 / 汇总 / 占比 / 趋势 / 排行”
+ → 调用 analyze_data
+ - 若用户仅要求“列出 / 查看 / 有哪些 / 明细”
+ → 不调用 analyze_data,直接展示 filter_data 返回的明细
+5. 输出最终回答,立即结束
+"""
\ No newline at end of file
diff --git a/prompt/bill.py b/prompt/bill.py
index 150d81b..2ff2d16 100644
--- a/prompt/bill.py
+++ b/prompt/bill.py
@@ -1,36 +1,8 @@
bill_prompt = """
-你是一个个人账单查询助手,负责帮助用户查询账单记录。
+# 业务域:账单记录
+domain:bill
-# 可调用的工具
-- bill_search(start_date, end_date)
- - 用于查询指定日期范围内的所有账单
- - 日期格式:YYYY-MM-DD
- - 该接口不区分收入和支出,会一并返回
-
-# 日期推断规则(非常重要)
-当用户未明确给出日期时,你必须先按以下规则推断 start_date 和 end_date,再调用工具:
-
-- 近期 / 最近:今天之前的 30 天
-- 本月:当前自然月(如 2026-08-01 ~ 2026-08-31)
-- 上个月:上一个自然月
-- 本季度:当前季度(Q1: 01-01~03-31,依此类推)
-- 上半年:01-01 ~ 06-30
-- 下半年:07-01 ~ 12-31
-- 今年 / 本年:当前自然年
-- 去年:上一年自然年
-- 无时间描述:默认最近 90 天
-
-# 示例
-- 用户:帮我看看最近的账单
- → 推断为最近 30 天 → 调用 bill_search
-
-- 用户:查一下这个月的支出
- → 推断为本月日期范围 → 调用 bill_search,后续再用文本过滤支出
-
-- 用户:上半年收入怎么样
- → 推断为 01-01 ~ 06-30 → 调用 bill_search,再自行判断 type = income
-
-# 返回字段说明(JSON)
+## 一、字段映射表(英文 → 中文)
- id: 账单ID
- bookName: 账本名称
- type: 账单类型(income / expense)
@@ -42,8 +14,27 @@ bill_prompt = """
- remark: 备注
- date: 账单日期(yyyy-MM-dd)
-# 行为准则
-- 用户未指定日期时,必须先按上述规则推断日期范围
-- 查询前应在回复中说明使用的查询条件,例如:“为您查询本月(2026-08-01 ~ 2026-08-31)的支出账单:”
-- 查询完成后,对账单进行解读(收入 / 支出 / 分类 / 汇总)
+## 二、枚举字段类型
+
+### 账单类型
+income:收入
+expense:支出
+
+## 三、默认统计维度
+账单日期
+账本名称
+账单类型
+账单类别
+支付账户
+
+## 四、明细展示字段顺序
+账单日期
+账本名称
+账单类型
+账单类别
+账单地点
+支付账户
+金额
+账单内容
+备注
"""
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 664cf23..6da9981 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,4 +8,5 @@ uvicorn~=0.23.0
SQLAlchemy~=2.0.48
langchain-core~=1.2.17
langchain~=1.2.10
-starlette~=0.52.1
\ No newline at end of file
+starlette~=0.52.1
+pandas
\ No newline at end of file
diff --git a/services.py b/services.py
index 2a72af4..6dbb4d5 100644
--- a/services.py
+++ b/services.py
@@ -12,12 +12,10 @@ async def query_bill_agent(query: QueryRequest):
print(query)
today = datetime.today().strftime("%Y-%m-%d")
- date_content = (
- f"[当前系统日期: {today}]\n\n"
- f"{query.message}"
- )
-
- messages: list[BaseMessage] = [HumanMessage(content=date_content)]
+ messages: list[BaseMessage] = [
+ SystemMessage(content=f"当前系统日期:{today}"),
+ HumanMessage(content=query.message),
+ ]
if query.platform == "mobile":
messages.insert(0, SystemMessage(content=mobile_prompt))
diff --git a/tools/base.py b/tools/base.py
new file mode 100644
index 0000000..ec58f20
--- /dev/null
+++ b/tools/base.py
@@ -0,0 +1,338 @@
+from langchain_core.tools import tool
+import requests
+import pandas as pd
+from cache import _GLOBAL_RAW_CACHE, _GLOBAL_FILTERED_CACHE
+from config.domain import get_domain
+
+MAX_DETAIL_COUNT = 20
+MAX_CONFIRM_COUNT = 50
+
+
+@tool
+def query_data(domain: str, start_date: str, end_date: str) -> str:
+ """
+ 按时间范围查询任意业务域的原始数据。
+
+ 参数:
+ - domain: 业务域名称(如 repair)
+ - start_date: YYYY-MM-DD
+ - end_date: YYYY-MM-DD
+ """
+ print(f"[TOOL] query_data | domain={domain} | {start_date} ~ {end_date}")
+
+ cfg = get_domain(domain)
+ token = cfg.token_getter()
+
+ print(f"[TOOL] query_data | api_url={cfg.api_url}")
+
+ resp = requests.get(
+ cfg.api_url,
+ params={"startDate": start_date, "endDate": end_date},
+ headers={cfg.auth_header: token},
+ timeout=15,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ _GLOBAL_RAW_CACHE[domain] = data
+ _GLOBAL_FILTERED_CACHE[domain] = data
+
+ print(f"[TOOL] query_data | fetched={len(data)} rows")
+ return f"已获取 {len(data)} 条 {cfg.entity_name} 数据({start_date} ~ {end_date})"
+
+
+@tool
+def filter_data(domain: str, filters: list[dict] | None = None, search: list[str] | None = None) -> str:
+ """
+ 对数据进行筛选与检索。
+
+ 参数:
+ - domain: 业务域名称
+ - filters: 结构化筛选条件(AND)
+ - search: 关键词检索(OR)
+
+ 示例:
+ {
+ "domain": "repair",
+ "filters": [{"field": "deviceModel", "op": "eq", "value": "销售机"}],
+ "search": ["钢丝绳"]
+ }
+ """
+ print(f"[TOOL] filter_data | domain={domain}")
+
+ if filters:
+ print(f"[TOOL] filter_data | filters={filters}")
+ if search:
+ print(f"[TOOL] filter_data | search={search}")
+
+ if domain not in _GLOBAL_RAW_CACHE:
+ return f"⚠️ 请先调用 query_data 查询 {domain} 数据"
+
+ cfg = get_domain(domain)
+ df = pd.DataFrame(_GLOBAL_RAW_CACHE[domain])
+ original_len = len(df)
+
+ # ---------- 筛选(AND) ----------
+ if filters:
+ for f in filters:
+ field, op, value = f["field"], f["op"], f["value"]
+ if field not in df.columns:
+ continue
+
+ col = df[field]
+ # 处理多值情况
+ if col.apply(lambda x: isinstance(x, (list, tuple, set))).any():
+ if op == "contains":
+ col = col.apply(lambda x: value in x if isinstance(x, (list, tuple, set)) else False)
+ elif op == "not_contains":
+ col = col.apply(lambda x: value not in x if isinstance(x, (list, tuple, set)) else True)
+ df = df[col]
+ continue
+
+ # 单值转为字符串比较
+ col = col.astype(str)
+ if op == "eq":
+ df = df[col == value]
+ elif op == "ne":
+ df = df[col != value]
+ elif op == "contains":
+ df = df[col.str.contains(value, na=False)]
+ elif op == "not_contains":
+ df = df[~col.str.contains(value, na=False)]
+
+ # ---------- 检索(OR) ----------
+ if search:
+ mask = pd.Series(False, index=df.index)
+ for kw in search:
+ for field in cfg.search_fields:
+ if field not in df.columns:
+ continue
+ # 如果包含就表示命中
+ mask |= df[field].astype(str).str.contains(kw, na=False)
+ df = df[mask]
+
+ filtered_len = len(df)
+ _GLOBAL_FILTERED_CACHE[domain] = df.to_dict("records")
+
+ print(f"[TOOL] filter_data | original={original_len} filtered={filtered_len}")
+
+ base = f"筛选完成。原始:{original_len} 条,筛选后:{filtered_len} 条。"
+
+ if filtered_len == 0:
+ return base + "未命中任何记录。"
+
+ if filtered_len <= MAX_CONFIRM_COUNT:
+ return base + "如需查看明细,请调用 inspect_data。"
+ else:
+ return base + "数据量较大,建议进一步缩小筛选范围后再查看明细。"
+
+
+@tool
+def inspect_data(domain: str, sort_by: str | None = None, ascending: bool = False, top_n: int | None = None) -> str:
+ """
+ 对筛选后的数据进行排序、抽样并按安全策略展示明细。
+
+ 展示策略:
+ - ≤ 20 条:直接展示
+ - 21–50 条:询问用户是否查看
+ - > 50 条:拒绝展示,建议缩小范围
+
+ 参数:
+ - domain: 业务域名称
+ - sort_by: 排序字段
+ - ascending: 是否升序(默认 False,即降序)
+ - top_n: 返回条数(可选,不传则使用展示策略)
+ """
+ print(f"[TOOL] inspect_data | domain={domain}")
+ print(f"[TOOL] inspect_data | sort_by={sort_by}, ascending={ascending}, top_n={top_n}")
+
+ if domain not in _GLOBAL_FILTERED_CACHE:
+ print(f"[TOOL] inspect_data | ERROR: filtered cache missing for {domain}")
+ return f"⚠️ 请先调用 filter_data 或 query_data 确定数据集"
+
+ cfg = get_domain(domain)
+ df = pd.DataFrame(_GLOBAL_FILTERED_CACHE[domain])
+ total_after_filter = len(df)
+ print(f"[TOOL] inspect_data | filtered_rows={total_after_filter}")
+
+ # ---------- 排序 ----------
+ if sort_by:
+ if sort_by not in df.columns:
+ print(f"[TOOL] inspect_data | ERROR: sort_by field={sort_by} not exist")
+ return f"⚠️ 字段 {sort_by} 不存在,无法排序"
+
+ try:
+ df[sort_by] = pd.to_numeric(df[sort_by], errors="raise")
+ except Exception:
+ pass
+
+ df = df.sort_values(sort_by, ascending=ascending)
+ print(f"[TOOL] inspect_data | sorted by {sort_by} ascending={ascending}")
+
+ # ---------- 抽样(TopN 优先于展示策略) ----------
+ if top_n:
+ df = df.head(top_n)
+ print(f"[TOOL] inspect_data | sampled top_n={top_n}")
+ else:
+ # 未指定 top_n 时,仍可能受展示策略限制
+ pass
+
+ final_len = len(df)
+ print(f"[TOOL] inspect_data | final_rows={final_len}, show_fields={cfg.detail_fields}")
+
+ if df.empty:
+ return "⚠️ 当前数据集为空,无法展示明细。"
+
+ # ---------- 展示策略(核心) ----------
+ # 情况 1:少量数据,直接展示
+ if final_len <= MAX_DETAIL_COUNT:
+ lines = [f"💡 共展示 {final_len} 条记录"]
+ for _, row in df.iterrows():
+ lines.append("---")
+ for fld in cfg.detail_fields:
+ if fld not in row:
+ continue
+ val = row[fld]
+ if pd.isna(val) or val == "":
+ val = "未填写"
+ lines.append(f"- {cfg.get_chinese_name(fld)}:{val}")
+ print(f"[TOOL] inspect_data | rendered directly")
+ return "\n".join(lines)
+
+ # 情况 2:中等数据量,询问用户
+ if final_len <= MAX_CONFIRM_COUNT:
+ print(f"[TOOL] inspect_data | confirm required")
+ return (
+ f"筛选后共 {final_len} 条记录,是否查看明细?\n"
+ f"(提示:可使用 top_n 参数只查看前 N 条,如最大的一笔)"
+ )
+
+ # 情况 3:数据量过大,拒绝展示
+ print(f"[TOOL] inspect_data | too large, rejected")
+ return (
+ f"数据量过大({final_len} 条),暂不展示明细。\n"
+ f"建议:缩小筛选范围;\n"
+ )
+
+
+@tool
+def analyze_data(domain: str, dimensions: list[str], metrics: list[str] | None = None) -> str:
+ """
+ 对数据进行多维度统计分析。
+
+ 参数:
+ - domain: 业务域名称
+ - dimensions: 英文维度字段列表(如 ["category", "merchant"])
+ - metrics: 指标字段列表(如 ["amount"])
+ 若不传,仅统计条数
+
+ 示例:
+ {
+ "domain": "bill",
+ "dimensions": ["category"],
+ "metrics": ["amount"]
+ }
+ """
+ print(f"[TOOL] analyze_data | domain={domain} | dimensions={dimensions} | metrics={metrics}")
+
+ if domain not in _GLOBAL_FILTERED_CACHE:
+ return f"⚠️ 请先调用 query_data 查询 {domain} 数据"
+
+ cfg = get_domain(domain)
+ df = pd.DataFrame(_GLOBAL_FILTERED_CACHE[domain])
+
+ # ---------- 基础信息 ----------
+ lines = [
+ "统计完成。",
+ f"数据总量:{len(df)} 条",
+ f"统计维度:{', '.join(cfg.get_chinese_name(d) for d in dimensions)}",
+ ]
+
+ metrics = metrics or []
+ metric_meta = {m.field: m for m in cfg.metrics if m.field in metrics}
+
+ if metric_meta:
+ metric_names = ", ".join(m.name for m in metric_meta.values())
+ lines.append(f"统计指标:条数、{metric_names}\n")
+ else:
+ lines.append("")
+
+ # ---------- 按维度统计 ----------
+ for field in dimensions:
+ if field not in df.columns:
+ continue
+
+ col = df[field]
+
+ # 多值字段拆行
+ if col.apply(lambda x: isinstance(x, (list, tuple, set))).any():
+ print(f"[WARN] analyze_data | field={field} is multi-value, metrics may be duplicated")
+ df_tmp = df.explode(field)
+ else:
+ df_tmp = df
+
+ # 构建聚合规则
+ agg_dict = {
+ "数量": (cfg.primary_key, "count")
+ }
+
+ for m in metrics:
+ meta = cfg.get_metric(m)
+ if not meta or meta.field not in df_tmp.columns:
+ continue
+ agg_dict[meta.name] = (meta.field, meta.agg)
+
+ if not agg_dict:
+ continue
+
+ agg = (
+ df_tmp.groupby(field, dropna=False)
+ .agg(**agg_dict)
+ .sort_values("数量", ascending=False)
+ .reset_index()
+ )
+
+ lines.append(f"【{cfg.get_chinese_name(field)}】")
+
+ for _, row in agg.iterrows():
+ parts = [f"{row[field]}:{int(row['数量'])} 条"]
+
+ for m in metrics:
+ meta = cfg.get_metric(m)
+ if not meta:
+ continue
+
+ col_name = meta.name
+ if col_name not in row:
+ continue
+
+ val = row[col_name]
+ parts.append(
+ f"{meta.name} {format_value(val, meta.format)}"
+ )
+
+ lines.append(",".join(parts))
+
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def format_value(val, fmt: str) -> str:
+ if pd.isna(val):
+ return "—"
+
+ if fmt == "money":
+ return f"¥{val:,.2f}"
+ if fmt == "duration":
+ return f"{val:.1f} 分钟"
+ if fmt == "percent":
+ return f"{val * 100:.1f}%"
+ if fmt.startswith("number:"):
+ prec = int(fmt.split(":")[1])
+ return f"{val:.{prec}f}"
+ if fmt == "int":
+ return f"{int(val)}"
+
+ # auto / fallback
+ return str(val)
diff --git a/tools/bill.py b/tools/bill.py
deleted file mode 100644
index dfa5c07..0000000
--- a/tools/bill.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from langchain_core.tools import tool
-import requests
-
-BASE_URL = "https://cxx0822.s.3q.hair/home-api/"
-USERNAME = "Cxx0822"
-PASSWORD = "19940822Cxx"
-
-
-def get_token() -> str:
- """
- 调用登录接口,返回 saToken.tokenValue
- """
- params = {
- "name": USERNAME,
- "password": PASSWORD
- }
-
- try:
- resp = requests.post(f"{BASE_URL}session", params=params, timeout=10)
- resp.raise_for_status()
- data = resp.json()
-
- # 按你给的返回结构取值
- token_value = data["saToken"]["tokenValue"]
- return token_value
-
- except requests.exceptions.RequestException as e:
- raise RuntimeError(f"登录失败,无法获取 token: {e}")
- except KeyError as e:
- raise RuntimeError(f"登录响应结构异常,未找到 tokenValue: {e}")
-
-
-@tool
-def bill_search(start_date: str, end_date: str) -> str:
- """
- 查询账单列表。
-
- 参数说明:
- - start_date (必填):
- - 开始日期,格式:YYYY-MM-DD
- - 示例:2026-06-01
- - end_date (必填):
- - 结束日期,格式:YYYY-MM-DD
- - 示例:2026-07-01
- """
-
- print(f"start_date: {start_date}, end_date: {end_date}")
-
- params = {
- "bookName": "",
- "type": "",
- "startDate": start_date,
- "endDate": end_date
- }
-
- token = get_token()
-
- headers = {
- "satoken": token
- }
-
- try:
- resp = requests.get(f"{BASE_URL}bill/summary", params=params, headers=headers, timeout=10)
- resp.raise_for_status()
- return resp.text
- except Exception as e:
- return f"查询账单失败: {e}"