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>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="fca82301-8db7-46f2-bd02-1f76c1dc566b" name="Changes" comment=""> <list default="true" id="fca82301-8db7-46f2-bd02-1f76c1dc566b" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/prompt/mobile.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/cache.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$/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$/.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$/services.py" beforeDir="false" afterPath="$PROJECT_DIR$/services.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tools/bill.py" beforeDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -36,35 +45,42 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Docker.Dockerfile.executor": "Run", &quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
"FastAPI.sweet-hut-agent.executor": "Run", &quot;FastAPI.sweet-hut-agent.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"git-widget-placeholder": "master", &quot;git-widget-placeholder&quot;: &quot;master&quot;,
"last_opened_file_path": "D:/Projects/GithubProjects/sweet-hut-agent", &quot;last_opened_file_path&quot;: &quot;D:/Projects/GithubProjects/sweet-hut-agent&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
"vue.rearranger.settings.migration": "true" &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"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="D:\Cxx\PythonProjects\sweet-hut-agent\prompt" /> <recent name="D:\Cxx\PythonProjects\sweet-hut-agent\prompt" />
<recent name="D:\Cxx\PythonProjects\sweet-hut-agent" /> <recent name="D:\Cxx\PythonProjects\sweet-hut-agent" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="D:\Projects\GithubProjects\sweet-hut-agent\domain" />
<recent name="D:\Projects\GithubProjects\sweet-hut-agent\prompt" /> <recent name="D:\Projects\GithubProjects\sweet-hut-agent\prompt" />
</key> </key>
</component> </component>
<component name="RunManager" selected="FastAPI.sweet-hut-agent"> <component name="RunManager" selected="Docker.Dockerfile">
<configuration name="sweet-hut-agent" type="Python.FastAPI"> <configuration name="sweet-hut-agent" type="Python.FastAPI">
<option name="file" value="D:\Projects\GithubProjects\sweet-hut-agent\main.py" /> <option name="file" value="D:\Projects\GithubProjects\sweet-hut-agent\main.py" />
<module name="sweet-hut-agent" /> <module name="sweet-hut-agent" />
@@ -92,14 +108,11 @@
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true"> <configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile" /> <deployment type="dockerfile">
<settings />
</deployment>
<method v="2" /> <method v="2" />
</configuration> </configuration>
<recent_temporary>
<list>
<item itemvalue="Docker.Dockerfile" />
</list>
</recent_temporary>
</component> </component>
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
@@ -123,7 +136,12 @@
<workItem from="1783001713796" duration="2000" /> <workItem from="1783001713796" duration="2000" />
<workItem from="1783001739320" duration="123000" /> <workItem from="1783001739320" duration="123000" />
<workItem from="1783001898877" duration="308000" /> <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> </task>
<servers /> <servers />
</component> </component>
@@ -131,6 +149,6 @@
<option name="version" value="3" /> <option name="version" value="3" />
</component> </component>
<component name="com.intellij.coverage.CoverageDataManagerImpl"> <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> </component>
</project> </project>

View File

@@ -1,11 +1,12 @@
from langchain.agents import create_agent from langchain.agents import create_agent
from agent.model import model from agent.model import model
from prompt.base import base_prompt
from prompt.bill import bill_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( bill_agent = create_agent(
model=model, model=model,
tools=[bill_search], tools=[query_data, filter_data, inspect_data, analyze_data],
system_prompt=bill_prompt system_prompt=f"{base_prompt}\n\n{bill_prompt}"
) )

View File

@@ -1,19 +1,5 @@
from langchain.chat_models import init_chat_model 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 = init_chat_model(
model="deepseek-v4-flash", model="deepseek-v4-flash",
model_provider="openai", 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 fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from domain.base import init_domains
from modes.agent import QueryRequest from modes.agent import QueryRequest
from services import query_bill_agent 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( app.add_middleware(
CORSMiddleware, 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 = """ 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 - id: 账单ID
- bookName: 账本名称 - bookName: 账本名称
- type: 账单类型income / expense - type: 账单类型income / expense
@@ -42,8 +14,27 @@ bill_prompt = """
- remark: 备注 - remark: 备注
- date: 账单日期yyyy-MM-dd - 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-core~=1.2.17
langchain~=1.2.10 langchain~=1.2.10
starlette~=0.52.1 starlette~=0.52.1
pandas

View File

@@ -12,12 +12,10 @@ async def query_bill_agent(query: QueryRequest):
print(query) print(query)
today = datetime.today().strftime("%Y-%m-%d") today = datetime.today().strftime("%Y-%m-%d")
date_content = ( messages: list[BaseMessage] = [
f"[当前系统日期: {today}]\n\n" SystemMessage(content=f"当前系统日期{today}"),
f"{query.message}" HumanMessage(content=query.message),
) ]
messages: list[BaseMessage] = [HumanMessage(content=date_content)]
if query.platform == "mobile": if query.platform == "mobile":
messages.insert(0, SystemMessage(content=mobile_prompt)) 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}"