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 @@ - - + + + + + + - + + + + + + - { + "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" + ] } -}]]> +} + - + - + + + - - - - - @@ -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}"