feat:更新智能体工具

This commit is contained in:
2026-07-13 22:06:05 +08:00
parent 900d5bd774
commit 5213897bbd
14 changed files with 672 additions and 157 deletions

78
.idea/workspace.xml generated
View File

@@ -5,11 +5,20 @@
</component>
<component name="ChangeListManager">
<list default="true" id="fca82301-8db7-46f2-bd02-1f76c1dc566b" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/prompt/mobile.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/sweet-hut-agent.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/sweet-hut-agent.iml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/cache.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/config/domain.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/domain/base.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/domain/bill.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/prompt/base.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/tools/base.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/modes/agent.py" beforeDir="false" afterPath="$PROJECT_DIR$/modes/agent.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/agent/bill.py" beforeDir="false" afterPath="$PROJECT_DIR$/agent/bill.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/agent/model.py" beforeDir="false" afterPath="$PROJECT_DIR$/agent/model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/prompt/bill.py" beforeDir="false" afterPath="$PROJECT_DIR$/prompt/bill.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/requirements.txt" beforeDir="false" afterPath="$PROJECT_DIR$/requirements.txt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/services.py" beforeDir="false" afterPath="$PROJECT_DIR$/services.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tools/bill.py" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -36,35 +45,42 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"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": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
&quot;FastAPI.sweet-hut-agent.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;last_opened_file_path&quot;: &quot;D:/Projects/GithubProjects/sweet-hut-agent&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;mysql&quot;,
&quot;postgresql&quot;
]
}
}]]></component>
}</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="D:\Cxx\PythonProjects\sweet-hut-agent\prompt" />
<recent name="D:\Cxx\PythonProjects\sweet-hut-agent" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="D:\Projects\GithubProjects\sweet-hut-agent\domain" />
<recent name="D:\Projects\GithubProjects\sweet-hut-agent\prompt" />
</key>
</component>
<component name="RunManager" selected="FastAPI.sweet-hut-agent">
<component name="RunManager" selected="Docker.Dockerfile">
<configuration name="sweet-hut-agent" type="Python.FastAPI">
<option name="file" value="D:\Projects\GithubProjects\sweet-hut-agent\main.py" />
<module name="sweet-hut-agent" />
@@ -92,14 +108,11 @@
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile" />
<deployment type="dockerfile">
<settings />
</deployment>
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Docker.Dockerfile" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
@@ -123,7 +136,12 @@
<workItem from="1783001713796" duration="2000" />
<workItem from="1783001739320" duration="123000" />
<workItem from="1783001898877" duration="308000" />
<workItem from="1783154148904" duration="5949000" />
<workItem from="1783154148904" duration="6214000" />
<workItem from="1783348720903" duration="989000" />
<workItem from="1783431529383" duration="837000" />
<workItem from="1783603121259" duration="31000" />
<workItem from="1783857924445" duration="1566000" />
<workItem from="1783910237353" duration="18018000" />
</task>
<servers />
</component>
@@ -131,6 +149,6 @@
<option name="version" value="3" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/sweet_hut_agent$sweet_hut_agent.coverage" NAME="sweet-hut-agent Coverage Results" MODIFIED="1783163395097" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
<SUITE FILE_PATH="coverage/sweet_hut_agent$sweet_hut_agent.coverage" NAME="sweet-hut-agent Coverage Results" MODIFIED="1783951004770" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
</component>
</project>

View File

@@ -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}"
)

View File

@@ -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",

2
cache.py Normal file
View File

@@ -0,0 +1,2 @@
_GLOBAL_RAW_CACHE = {} # {domain: list[dict]}
_GLOBAL_FILTERED_CACHE = {} # {domain: list[dict]}

72
config/domain.py Normal file
View File

@@ -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]

17
domain/base.py Normal file
View File

@@ -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",
))

40
domain/bill.py Normal file
View File

@@ -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")]

12
main.py
View File

@@ -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,

108
prompt/base.py Normal file
View File

@@ -0,0 +1,108 @@
base_prompt = """
你是一个数据分析助手,能够通过通用工具对任意业务域的数据进行查询、筛选、检索与统计分析。
# 一、可用工具(严格按签名调用)
## 1. query_data
- 作用:按时间范围查询指定业务域的原始数据
- 参数:
- domain业务域名称
- start_dateYYYY-MM-DD
- end_dateYYYY-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 条:直接展示完整明细
- 2150 条:先告知数量,询问是否查看明细
- > 50 条:不展示明细,建议缩小范围
- 明细中不得使用表格
- 统计分析时不得同时展示明细
- 所有维度名称使用中文(见业务映射表)
# 七、执行流程
1. 解析用户意图与时间范围
2. 调用 query_data
3. 如有筛选或检索需求,调用 filter_data
4. 判断用户意图:
- 若用户要求“统计 / 分析 / 汇总 / 占比 / 趋势 / 排行”
→ 调用 analyze_data
- 若用户仅要求“列出 / 查看 / 有哪些 / 明细”
→ 不调用 analyze_data直接展示 filter_data 返回的明细
5. 输出最终回答,立即结束
"""

View File

@@ -1,36 +1,8 @@
bill_prompt = """
你是一个个人账单查询助手,负责帮助用户查询账单记录
# 业务域:账单记录
domainbill
# 可调用的工具
- 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支出
## 三、默认统计维度
账单日期
账本名称
账单类型
账单类别
支付账户
## 四、明细展示字段顺序
账单日期
账本名称
账单类型
账单类别
账单地点
支付账户
金额
账单内容
备注
"""

View File

@@ -9,3 +9,4 @@ SQLAlchemy~=2.0.48
langchain-core~=1.2.17
langchain~=1.2.10
starlette~=0.52.1
pandas

View File

@@ -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))

338
tools/base.py Normal file
View File

@@ -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 条:直接展示
- 2150 条:询问用户是否查看
- > 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)

View File

@@ -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}"