Files
group_xinghuo_jinrong/app/tool/core_tools.py
T
GaoYiYuan_0626 a6ac39edd6 基金转换 T-11:工具与 SQL 汇总去重(FR-C15 / R-d)
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
2026-09-10 18:40:27 +08:00

168 lines
7.0 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
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