feat:更新智能体工具
This commit is contained in:
78
.idea/workspace.xml
generated
78
.idea/workspace.xml
generated
@@ -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">{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}]]></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>
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
2
cache.py
Normal file
@@ -0,0 +1,2 @@
|
||||
_GLOBAL_RAW_CACHE = {} # {domain: list[dict]}
|
||||
_GLOBAL_FILTERED_CACHE = {} # {domain: list[dict]}
|
||||
72
config/domain.py
Normal file
72
config/domain.py
Normal 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
17
domain/base.py
Normal 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
40
domain/bill.py
Normal 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
12
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,
|
||||
|
||||
108
prompt/base.py
Normal file
108
prompt/base.py
Normal file
@@ -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. 输出最终回答,立即结束
|
||||
"""
|
||||
@@ -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:支出
|
||||
|
||||
## 三、默认统计维度
|
||||
账单日期
|
||||
账本名称
|
||||
账单类型
|
||||
账单类别
|
||||
支付账户
|
||||
|
||||
## 四、明细展示字段顺序
|
||||
账单日期
|
||||
账本名称
|
||||
账单类型
|
||||
账单类别
|
||||
账单地点
|
||||
支付账户
|
||||
金额
|
||||
账单内容
|
||||
备注
|
||||
"""
|
||||
@@ -9,3 +9,4 @@ SQLAlchemy~=2.0.48
|
||||
langchain-core~=1.2.17
|
||||
langchain~=1.2.10
|
||||
starlette~=0.52.1
|
||||
pandas
|
||||
10
services.py
10
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))
|
||||
|
||||
338
tools/base.py
Normal file
338
tools/base.py
Normal 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 条:直接展示
|
||||
- 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)
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user