"""Core 模拟库只读 Tool(T-04 · FLOW §2「CoreReadOnlyRepository:持仓/流水/L0」)。 Tool 定义层(纯查询,无业务流程):customer_id 由调用方(tool_service) 注入,**不接受 LLM 生成**——归属防线之一(A-01 语义,T-03 之前的止损)。 注册表 TOOL_REGISTRY 是对话 Tool 的唯一白名单(tool_service 校验)。 requires_customer:Tool 是否必须绑定会话客户(True → 无归属主体即 blocked,不触发查询)。风控分支 Tool(C1 chat_tools)注册时对台账类 统计用 requires_customer=False,复用同一 runner。 返回值约定:JSON 安全 dict(Decimal→float 两位、datetime/date→isoformat), 落库 tool_output 与 LLM 上下文共用同一结构,不做第二套序列化。 """ from __future__ import annotations import datetime as _dt from decimal import Decimal from typing import Any, Callable from app.repository.core_ro import CoreReadOnlyRepository DETAIL_LIMIT = 20 # 明细条数上限(上下文与落库同限,防超长) DEFAULT_TRADE_DAYS = 30 # 持仓拉取上限(SQL 层 LIMIT,防大客户全量拉回;命中上限时 truncated=True) HOLDING_FETCH_LIMIT = 500 # 整数参数取值边界(tool_service 钳制;防 LLM 传超大值拉爆查询) INT_PARAM_BOUNDS: dict[str, tuple[int, int]] = {"days": (1, 365)} # 各 Tool 允许的可变入参(白名单;customer_id/core_ro 由 runner 注入,禁止入参覆盖) TOOL_PARAM_WHITELIST: dict[str, tuple[str, ...]] = { "query_customer_profile": (), "query_holdings": (), "query_recent_trades": ("days",), } def _now_naive() -> _dt.datetime: """时间窗基准(naive 本地时间)。 口径:Core 库 core_trade.traded_at 为 DATETIME(3),种子与模拟网关均写入 naive 本地时间,故此处同用本地时间比较。部署环境时区须与 DB 会话时区一致 (B8 localtime 挂账项,与本项目其它时间窗统一收敛;届时可只改此函数)。 """ return _dt.datetime.now() def _jsonable(value: Any) -> Any: """MySQL 行 → JSON 安全结构(递归;Decimal 两位小数、时间 isoformat)。""" if isinstance(value, Decimal): return float(round(value, 2)) if isinstance(value, (_dt.datetime, _dt.date)): return value.isoformat() if isinstance(value, dict): return {k: _jsonable(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_jsonable(v) for v in value] return value def query_customer_profile( customer_id: str, core_ro: CoreReadOnlyRepository | None = None, risk_repo=None ) -> dict[str, Any]: """客户档案与风险测评(L0):core_customer + core_customer_risk。""" repo = core_ro or CoreReadOnlyRepository() row = repo.get_customer_l0(customer_id) if row is None: return {"found": False, "customer_id": customer_id} return {"found": True, **_jsonable(row)} def query_holdings( customer_id: str, core_ro: CoreReadOnlyRepository | None = None, risk_repo=None ) -> dict[str, Any]: """持仓明细(按市值降序)+ 合计(sum_market_value)。 SQL 层 LIMIT HOLDING_FETCH_LIMIT(T-04 评审 P2):命中上限时 truncated= True——此时 total_count/sum_market_value 为"已拉取部分"的统计,摘要会 显式提示截断,避免静默给出偏小口径。 """ repo = core_ro or CoreReadOnlyRepository() # 多取 1 条用于判定是否真被截断(恰好 500 笔不误报) rows = repo.list_holdings(customer_id, limit=HOLDING_FETCH_LIMIT + 1) truncated = len(rows) > HOLDING_FETCH_LIMIT if truncated: rows = rows[:HOLDING_FETCH_LIMIT] total = sum((r.get("market_value") or 0) for r in rows) return { "total_count": len(rows), "sum_market_value": _jsonable(total), "truncated": truncated, "items": [_jsonable(r) for r in rows[:DETAIL_LIMIT]], } def query_recent_trades( customer_id: str, days: int = DEFAULT_TRADE_DAYS, core_ro: CoreReadOnlyRepository | None = None, risk_repo=None, ) -> dict[str, Any]: """近 N 天 confirmed 申赎流水(时间升序截断至 DETAIL_LIMIT)。""" repo = core_ro or CoreReadOnlyRepository() end = _now_naive() start = end - _dt.timedelta(days=days) rows = repo.list_trades_range(customer_id, start, end) total = sum((r.get("amount") or 0) for r in rows) return { "days": days, "total_count": len(rows), "sum_amount": _jsonable(total), "items": [_jsonable(r) for r in rows[:DETAIL_LIMIT]], } class ToolSpec(dict): """注册表条目:func + 描述 + 是否必须绑定会话客户。""" TOOL_REGISTRY: dict[str, ToolSpec] = { "query_customer_profile": ToolSpec( func=query_customer_profile, description="查询客户档案与风险测评等级(L0)", requires_customer=True, param_whitelist=TOOL_PARAM_WHITELIST["query_customer_profile"], int_bounds={}, ), "query_holdings": ToolSpec( func=query_holdings, description="查询客户持仓明细与合计市值", requires_customer=True, param_whitelist=TOOL_PARAM_WHITELIST["query_holdings"], int_bounds={}, ), "query_recent_trades": ToolSpec( func=query_recent_trades, description="查询客户近期申赎交易流水", requires_customer=True, param_whitelist=TOOL_PARAM_WHITELIST["query_recent_trades"], int_bounds={k: v for k, v in INT_PARAM_BOUNDS.items() if k in TOOL_PARAM_WHITELIST["query_recent_trades"]}, ), } def get_tool(name: str) -> ToolSpec | None: """白名单查找(未知 Tool 一律 None,由 runner 拒绝)。""" return TOOL_REGISTRY.get(name) def tool_func(name: str) -> Callable[..., dict[str, Any]] | None: spec = TOOL_REGISTRY.get(name) return spec["func"] if spec else None