设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。
## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
+ NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)
## 改动(重点)
- customer_service.py:
· _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
· drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
(旧黑名单会误删权重:A-06 业绩基准公式整行消失)
· allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
(该 422 是**子集**校验而非数量上限,根因即此项漏配)
· 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
+ is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)
## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活
## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)
## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
196 lines
8.4 KiB
Python
196 lines
8.4 KiB
Python
"""`E6` 行情走势出口的数据层:把 `fin_nav_history` 的净值序列折成区间涨跌与区间高低。
|
||
|
||
## 为什么单独成工具,而不是复用 `query_fund_quote`
|
||
|
||
`query_fund_quote`(投顾线在用)走**外部行情源**(东财),返回的是"当前一档" —— 最新净值
|
||
与各期回报。答辩/演示现场一旦外网不通,整条出口降级;而"走势"这种问题,客户真正要看的是
|
||
**区间口径**(近 N 个净值日涨了多少、区间高低在哪)。
|
||
|
||
本工具只读**库内净值序列**(`fin_nav_history`,由 `tools/sync_nav_history.py` 从净值接口
|
||
同步):离线可复现、可逐行核对,且时间口径由 `nav_date` 直接给出 —— 不依赖任何外部服务。
|
||
|
||
## 数据边界(不变量 `INV-7`:数据边界必须自陈)
|
||
|
||
- `fin_product` 里**只有场内 `ETF` / `LOF`**(`status='上市'`)。产品手册里的示例产品
|
||
(如 `南方稳健增利债券A`)**不在其中、没有净值序列** ⇒ 本工具返回 `found=False`,
|
||
由 Agent 侧如实告知"暂无公开净值序列",**不得**拿手册里的虚构数字充作行情。
|
||
- 区间按**净值日个数**(5 / 20 / 60 / 120)而非自然日:净值按交易日发布,自然日口径会把
|
||
周末与停牌日算进去,客户无法核对。文档与答复必须写同一个口径。
|
||
- 表里没数据时返回**空结果而不是报错**(与 `PublicProductService.nav_history` 同一取向)。
|
||
|
||
## 数值口径
|
||
|
||
`change_pct` = (末净值 − 初净值) / 初净值 × 100,保留 2 位小数(`Decimal`,不用浮点)。
|
||
起点取 `points[-n]`(含端点共 n 个净值点 ⇒ 跨越 n−1 个净值日间隔),答复里写"近 n 个净值日"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
from datetime import date
|
||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.fund_contracts import FundTrendQuery
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.fund import FundNavHistory, FundProduct
|
||
|
||
#: 只查在售产品(`fin_product.status`)。**故意不 import**
|
||
#: `public_product_service.LISTED_STATUS`:那条链是
|
||
#: `public_product_service → admin_service → agent.bootstrap`,而 bootstrap 又要 import
|
||
#: 本模块,会形成**循环导入**(实测 `partially initialized module`)。
|
||
#: 常量取值与 `public_product_service` 逐字相同,两处都不得单独改动;
|
||
#: `tests/unit/service/test_fund_trend_service.py` 有断言钉住两者相等。
|
||
LISTED_STATUS = "上市"
|
||
|
||
#: 净值来源标识:答复与审计都写它,避免"数字从哪来"说不清(`INV-6`)。
|
||
NAV_SOURCE = "fin_nav_history"
|
||
#: 区间口径(净值日个数)
|
||
TREND_INTERVALS: tuple[int, ...] = (5, 20, 60, 120)
|
||
#: 名称模糊匹配的最短长度:太短会误配(如「南方」一次命中多只)
|
||
MIN_NAME_MATCH_CHARS = 4
|
||
|
||
|
||
def _to_decimal(value: object) -> Decimal | None:
|
||
try:
|
||
return Decimal(str(value))
|
||
except (InvalidOperation, TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _pct(start: Decimal, end: Decimal) -> Decimal:
|
||
if start == 0:
|
||
return Decimal("0.00")
|
||
return ((end - start) / start * 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
|
||
|
||
def summarize_trend(
|
||
points: Sequence[dict[str, Any]], *, intervals: tuple[int, ...] = TREND_INTERVALS
|
||
) -> dict[str, Any]:
|
||
"""把净值点折成「最新一档 + 各区间涨跌 + 区间高低」。**纯函数**:不碰库、不调模型。
|
||
|
||
入参 `points` 每项形如 `{"nav_date": "2026-09-11", "nav": "1.2345"}`(顺序不限)。
|
||
出参稳定契约(调用方与测试都依赖):
|
||
|
||
```
|
||
{found, latest_nav, latest_nav_date, from_date, to_date, series_points,
|
||
intervals: [{name, trading_days, start_date, start_nav, end_date, end_nav, change_pct}],
|
||
high: {nav, nav_date}, low: {nav, nav_date}, source}
|
||
```
|
||
"""
|
||
parsed: list[tuple[date, Decimal]] = []
|
||
for item in points or ():
|
||
if not isinstance(item, dict):
|
||
continue
|
||
raw_date, nav = item.get("nav_date"), _to_decimal(item.get("nav"))
|
||
if nav is None or raw_date is None:
|
||
continue
|
||
try:
|
||
day = date.fromisoformat(str(raw_date)[:10])
|
||
except ValueError:
|
||
continue
|
||
parsed.append((day, nav))
|
||
if not parsed:
|
||
return {"found": False, "reason": "no_nav_series", "source": NAV_SOURCE}
|
||
parsed.sort(key=lambda pair: pair[0])
|
||
|
||
latest_date, latest_nav = parsed[-1]
|
||
first_date = parsed[0][0]
|
||
high_date, high_nav = max(parsed, key=lambda pair: pair[1])
|
||
low_date, low_nav = min(parsed, key=lambda pair: pair[1])
|
||
rows: list[dict[str, Any]] = []
|
||
for window in intervals:
|
||
if window < 2 or len(parsed) < window:
|
||
continue
|
||
start_date, start_nav = parsed[-window]
|
||
rows.append(
|
||
{
|
||
"name": f"近 {window} 个净值日",
|
||
"trading_days": window - 1,
|
||
"start_date": start_date.isoformat(),
|
||
"start_nav": str(start_nav),
|
||
"end_date": latest_date.isoformat(),
|
||
"end_nav": str(latest_nav),
|
||
"change_pct": str(_pct(start_nav, latest_nav)),
|
||
}
|
||
)
|
||
return {
|
||
"found": True,
|
||
"latest_nav": str(latest_nav),
|
||
"latest_nav_date": latest_date.isoformat(),
|
||
"from_date": first_date.isoformat(),
|
||
"to_date": latest_date.isoformat(),
|
||
"series_points": len(parsed),
|
||
"intervals": rows,
|
||
"high": {"nav": str(high_nav), "nav_date": high_date.isoformat()},
|
||
"low": {"nav": str(low_nav), "nav_date": low_date.isoformat()},
|
||
"source": NAV_SOURCE,
|
||
}
|
||
|
||
|
||
async def _resolve_product(session: Any, arguments: FundTrendQuery) -> tuple[Any, str]:
|
||
"""按代码或名称定位产品。返回 `(product, reason)`;`reason` 非空表示查不到的原因。"""
|
||
base = select(FundProduct).where(FundProduct.status == LISTED_STATUS)
|
||
if arguments.fund_code:
|
||
product = (
|
||
await session.execute(base.where(FundProduct.product_code == arguments.fund_code))
|
||
).scalars().first()
|
||
return (product, "") if product is not None else (None, "product_not_found")
|
||
name = (arguments.fund_name or "").strip()
|
||
exact = (await session.execute(base.where(FundProduct.product_name == name))).scalars().first()
|
||
if exact is not None:
|
||
return exact, ""
|
||
if len(name) < MIN_NAME_MATCH_CHARS:
|
||
return None, "product_not_found"
|
||
# 模糊匹配**必须唯一**:命中多只时宁可要客户给代码,也不能替他挑一只(`INV-2` 取向)
|
||
matches = (
|
||
await session.execute(base.where(FundProduct.product_name.contains(name)).limit(3))
|
||
).scalars().all()
|
||
if len(matches) == 1:
|
||
return matches[0], ""
|
||
if len(matches) > 1:
|
||
# 再收一次:把「名称片段」当子串比较,仍多解就交回澄清
|
||
tight = [item for item in matches if name in item.product_name]
|
||
if len(tight) == 1:
|
||
return tight[0], ""
|
||
return None, "ambiguous"
|
||
return None, "product_not_found"
|
||
|
||
|
||
async def query_fund_trend_tool(
|
||
arguments: FundTrendQuery, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
"""`E6` 行情出口的工具处理器:只读、无写权限、不调模型。"""
|
||
del context # 权限、审计与来源由 ToolExecutor 统一处理
|
||
async with SessionFactory() as session:
|
||
product, reason = await _resolve_product(session, arguments)
|
||
if product is None:
|
||
return {"found": False, "reason": reason, "source": NAV_SOURCE}
|
||
rows = (
|
||
(
|
||
await session.execute(
|
||
select(FundNavHistory)
|
||
.where(FundNavHistory.product_id == product.id)
|
||
.order_by(FundNavHistory.nav_date.desc())
|
||
.limit(max(5, min(arguments.days, 365)))
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
points = [
|
||
{"nav_date": row.nav_date.isoformat(), "nav": str(row.nav)} for row in reversed(rows)
|
||
]
|
||
summary = summarize_trend(points)
|
||
return {
|
||
**summary,
|
||
"fund_code": str(product.product_code),
|
||
"fund_name": str(product.product_name),
|
||
"product_category": str(product.product_category or ""),
|
||
"risk_level": str(product.risk_level or ""),
|
||
"exchange_code": str(product.exchange_code or ""),
|
||
}
|