convert 在 core_trade 落两条流水(转出 redeem + 转入 subscribe,共享 convert_group_id), 所有金额聚合读取方必须只计一次,否则同笔转换金额翻倍。 落地(改码 3 处 + 新增 1 脚本 + 测试 3 文件) - rules.py:_amount_view → 公开 amount_view(提升而非复制别名,全仓唯一金额聚合口径); docstring 补「跨模块共用」说明 - core_tools.py:query_recent_trades 的 sum_amount 改走 amount_view(FR-C15); items / total_count 保持全量不变——两条流水是真实的两笔权益变动,读到两条是对的 - core_ro.py:list_holdings 加 h.qty > 0(convert 转出全部份额留下的 qty=0 归零行不是持仓); sum_trades_on_date 加 convert 去重条件(R-d),组内只计转出端 - 新增 tests/test_core_tools.py(3 条:汇总不翻倍 / 跨口径一致性 / 持仓不返回 qty=0 行) - tests/test_core_ro_sum.py 追加 1 条 convert 去重用例(既有断言零改动) - 新增 scripts/dev/verify_convert_tools.py:真库验证脚本(MySQL 8.0.46) 执行期裁定 2 条(已留痕) - _amount_view 提升为公开而非复制:一个函数两个名字会漂移(自检第 13 问) - SQL 条件从 IS NULL 扩为 IS NULL OR = '':Python 的 if not gid 把空串当无组, 而 SQL 里 '' IS NULL 恒假。真库实证:只写 IS NULL → 合计数 400000(正确 450000,漏算 50000)。 不加则 RISK-002 漏算,且 sqlite 单测若只造 NULL 数据永远发现不了 - 等价性边界:组内无 redeem 时 SQL 丢整组、amount_view 保留首条;由 R-b 保证不可达 验证 - pytest -q → 718 passed / 3 skipped(基线 714 加 4,零回归) - 突变验证 3 组均精准命中:去掉去重条件(2 红)/ 去掉 qty>0(1 红)/ 汇总不走 amount_view(2 红) - 真库 verify_convert_tools.py 14/14,隔离数据零残留 - 复跑受影响真库脚本零回归:T-8 31/31、T-7 35/35、T-10 20/20
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
"""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
|
||
from app.service.risk.rules import amount_view
|
||
|
||
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 为"已拉取部分"的统计,摘要会
|
||
显式提示截断,避免静默给出偏小口径。
|
||
|
||
⚠️ **归零行已在 SQL 层过滤**(T-11 · `core_ro.list_holdings` 的 `h.qty > 0`):
|
||
convert 转出全部份额时 `core_share_lot` 批次归零、`core_holding.qty` 记 0,
|
||
但**行仍保留**(份额台账留痕,D 决策:转出归零保留 `qty=0` 行,不物理删除);
|
||
持仓查询须过滤,否则会把已清仓产品当持仓返回。过滤**下沉到 SQL** 而非本层,
|
||
是为让 `total_count` / `sum_market_value` / `truncated` 三者口径一致。
|
||
"""
|
||
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)。
|
||
|
||
⚠️ **汇总与明细口径刻意不同**(FR-C15 · PRD §6.4 · T-11):
|
||
`items` / `total_count` 保持**全量** —— 一次基金转换落两条流水是真实的两笔权益变动,
|
||
读到两条是对的;但 `sum_amount` **必须走 `amount_view`**,
|
||
否则同一笔转换会被计两次、金额翻倍。
|
||
|
||
`amount_view` 是全仓唯一的金额聚合口径(D7 / 自检第 13 问),**不得在本模块另写一份**;
|
||
与 `core_ro.sum_trades_on_date` 的 SQL 条件同语义(由跨口径一致性断言守护)。
|
||
"""
|
||
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 amount_view(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
|