Files
XingHuo/app/tool/core_tools.py
T
GaoYiYuan_0626 930ea9db56 feat: 阶段C 风控对话线(C1 四只读 Tool + C2 risk 分支意图 + C3 A-6 验收与边界)
- C1: app/service/risk/chat_tools.py 新增 alert_query/customer_context/suitability_check/aml_lookup 四只读 Tool,挂 RISK_TOOL_REGISTRY(与 core_tools.ToolSpec 同构)
- C1: tool_service.run_tool 改查 core+risk 统一注册表并恒传 risk_repo;_normalize_params 读 spec 白名单;core_tools 三函数加 risk_repo=None;summarize 补四工具分支
- C2: match_intent 按 agent 分组扩 risk 关键词;agent_service.tool_node 守卫放宽(requires_customer=False 的 Tool 允许无绑定客户运行,支撑 A-6 全局待审)
- C3: A-6 验收(risk_officer 问待审预警→alert_query 全量→回复含待审数)+ 诱导处置边界(无处置 Tool、系统提示禁自动处置、诱导只触发只读且预警状态不变)
- 测试 tests/test_risk_chat_tools.py(21 例);全量 pytest 342 绿(原 321 + 21)
2026-09-07 10:26:54 +08:00

152 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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