设计与依据:新增 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)仍沿用
240 lines
9.1 KiB
Python
240 lines
9.1 KiB
Python
from datetime import date
|
||
from decimal import Decimal
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.fund_contracts import FundTrendQuery
|
||
from app.model.fund import FundNavHistory
|
||
from app.service import fund_trend_service
|
||
from app.service.fund_trend_service import (
|
||
LISTED_STATUS,
|
||
NAV_SOURCE,
|
||
query_fund_trend_tool,
|
||
summarize_trend,
|
||
)
|
||
|
||
|
||
def _series(days: int, *, start: str = "1.0000", step: str = "0.0100") -> list[dict[str, str]]:
|
||
points: list[dict[str, str]] = []
|
||
nav = Decimal(start)
|
||
day = date(2026, 1, 2)
|
||
for _ in range(days):
|
||
points.append({"nav_date": day.isoformat(), "nav": str(nav)})
|
||
nav += Decimal(step)
|
||
day = date.fromordinal(day.toordinal() + 1)
|
||
return points
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 一、纯函数:`summarize_trend`
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def test_summarize_trend_reports_no_series_for_empty_input() -> None:
|
||
"""**没有净值序列是一种结论,不是一次失败**(`INV-7` 靠它说"无可奉告")。"""
|
||
assert summarize_trend([]) == {
|
||
"found": False, "reason": "no_nav_series", "source": NAV_SOURCE,
|
||
}
|
||
|
||
|
||
def test_summarize_trend_skips_unparsable_points() -> None:
|
||
"""脏行(日期解析不了 / 值是 `--`)**跳过**,不能让一条脏数据毁掉整条序列。"""
|
||
result = summarize_trend([
|
||
{"nav_date": "not-a-date", "nav": "1.0"},
|
||
{"nav_date": "2026-03-02", "nav": "--"},
|
||
{"nav_date": "2026-03-02", "nav": "1.5000"},
|
||
])
|
||
assert result["found"] is True
|
||
assert result["series_points"] == 1
|
||
assert result["latest_nav"] == "1.5000"
|
||
|
||
|
||
def test_summarize_trend_orders_points_regardless_of_input_order() -> None:
|
||
"""入参顺序不限:**最新一档取日期最大的那条**,不是数组的最后一个。"""
|
||
result = summarize_trend([
|
||
{"nav_date": "2026-03-05", "nav": "1.2000"},
|
||
{"nav_date": "2026-03-01", "nav": "1.0000"},
|
||
{"nav_date": "2026-03-03", "nav": "1.1000"},
|
||
])
|
||
assert result["latest_nav_date"] == "2026-03-05"
|
||
assert result["from_date"] == "2026-03-01"
|
||
|
||
|
||
def test_summarize_trend_computes_intervals_high_and_low() -> None:
|
||
result = summarize_trend(_series(6))
|
||
assert result["found"] is True
|
||
assert result["source"] == NAV_SOURCE
|
||
names = [row["name"] for row in result["intervals"]]
|
||
# 只有 6 个点:够到 5 日窗口,够不到 20/60/120 —— 不够的窗口**不编**。
|
||
assert names == ["近 5 个净值日"]
|
||
row = result["intervals"][0]
|
||
assert row["trading_days"] == 4
|
||
assert row["start_nav"] == "1.0100"
|
||
assert row["end_nav"] == "1.0500"
|
||
assert row["change_pct"] == "3.96"
|
||
assert result["high"] == {"nav": "1.0500", "nav_date": "2026-01-07"}
|
||
assert result["low"] == {"nav": "1.0000", "nav_date": "2026-01-02"}
|
||
|
||
|
||
def test_summarize_trend_change_pct_is_signed_for_down_moves() -> None:
|
||
result = summarize_trend(_series(6, step="-0.0100"))
|
||
# 5 日窗口:起点 0.9900 → 终点 0.9500 ⇒ (0.95-0.99)/0.99 = -4.04%
|
||
assert result["intervals"][0]["change_pct"] == "-4.04"
|
||
|
||
|
||
def test_listed_status_matches_public_product_service() -> None:
|
||
"""`LISTED_STATUS` 与 `public_product_service` **必须逐字相同**。
|
||
|
||
本模块**刻意不 import** 那个常量(会形成
|
||
`public_product_service → admin_service → agent.bootstrap → 本模块` 的循环导入,
|
||
实测 `partially initialized module`)。于是"两处同值"这件事只能靠测试钉住 ——
|
||
否则哪天有人改了其中一处,行情出口会静默查不到任何产品(返回"无公开净值序列"),
|
||
而那看起来像数据问题、不像代码问题。
|
||
"""
|
||
from app.service.public_product_service import LISTED_STATUS as public_listed_status
|
||
|
||
assert LISTED_STATUS == public_listed_status
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 二、工具处理器:`query_fund_trend_tool`(用假 session 走真实 `_resolve_product`)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
class _FakeScalars:
|
||
def __init__(self, items: list[Any]) -> None:
|
||
self._items = items
|
||
|
||
def all(self) -> list[Any]:
|
||
return list(self._items)
|
||
|
||
def first(self) -> Any:
|
||
return self._items[0] if self._items else None
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, items: list[Any]) -> None:
|
||
self._items = items
|
||
|
||
def scalars(self) -> _FakeScalars:
|
||
return _FakeScalars(self._items)
|
||
|
||
|
||
class _FakeSession:
|
||
"""按**实体类型**分发假结果,并记录产品查询次数。
|
||
|
||
`_resolve_product` 的产品查询最多两次(先精确、后 `contains` 模糊),
|
||
用计数器模拟这两步,而不是去复刻 SQL 语义。
|
||
"""
|
||
|
||
def __init__(self, *, products: list[list[Any]], navs: list[Any]) -> None:
|
||
self._products = list(products)
|
||
self._navs = list(navs)
|
||
self.product_queries = 0
|
||
|
||
async def execute(self, statement: Any) -> _FakeResult:
|
||
entity = statement.column_descriptions[0]["entity"]
|
||
if entity is FundNavHistory:
|
||
return _FakeResult(self._navs)
|
||
self.product_queries += 1
|
||
index = min(self.product_queries, len(self._products) + 1) - 1
|
||
if index >= len(self._products):
|
||
return _FakeResult([])
|
||
return _FakeResult(self._products[index])
|
||
|
||
|
||
def _patch_session(monkeypatch: pytest.MonkeyPatch, session: _FakeSession) -> None:
|
||
class _Factory:
|
||
def __call__(self) -> "_Factory": # pragma: no cover - 仅用于类型直觉
|
||
return self
|
||
|
||
async def __aenter__(self) -> _FakeSession:
|
||
return session
|
||
|
||
async def __aexit__(self, *args: object) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(fund_trend_service, "SessionFactory", _Factory())
|
||
|
||
|
||
def _product(**overrides: Any) -> SimpleNamespace:
|
||
base = dict(
|
||
id=1, product_code="159382", product_name="创业板人工智能ETF南方",
|
||
product_category="ETF", risk_level="R4", exchange_code="SZSE",
|
||
status=LISTED_STATUS,
|
||
)
|
||
base.update(overrides)
|
||
return SimpleNamespace(**base)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_query_fund_trend_tool_returns_series_and_keeps_risk_level_as_text(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""`risk_level` 必须**原样透传字符串**(`'R4'`)。
|
||
|
||
回归钉子:`fin_product.risk_level` 列存的是 `R1`—`R5` 这种**字符串**,不是整数。
|
||
本工具最初写成 `int(product.risk_level)`,冒烟直接抛
|
||
`ValueError: invalid literal for int() with base 10: 'R4'` —— 且只在**真的有产品**
|
||
的路径上才炸(查不到产品的路径不碰这个字段),属于"最像成功的那种失败"。
|
||
口径与 `public_product_service._view()` 一致:原样透传,不在这一层做数值转换。
|
||
"""
|
||
session = _FakeSession(
|
||
products=[[_product()]],
|
||
navs=[
|
||
SimpleNamespace(nav_date=date(2026, 3, 2), nav=Decimal("1.0000")),
|
||
SimpleNamespace(nav_date=date(2026, 3, 3), nav=Decimal("1.5000")),
|
||
],
|
||
)
|
||
_patch_session(monkeypatch, session)
|
||
result = await query_fund_trend_tool(
|
||
FundTrendQuery(fund_code="159382"), RequestContext(user_id="1", trace_id="t")
|
||
)
|
||
assert result["found"] is True
|
||
assert result["fund_code"] == "159382"
|
||
assert result["fund_name"] == "创业板人工智能ETF南方"
|
||
assert result["risk_level"] == "R4"
|
||
assert result["source"] == NAV_SOURCE
|
||
assert result["latest_nav"] == "1.5000"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_query_fund_trend_tool_reports_missing_product(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""查不到产品时**如实返回 `found=False`**,不抛异常、不猜一只相近的。"""
|
||
session = _FakeSession(products=[[]], navs=[])
|
||
_patch_session(monkeypatch, session)
|
||
result = await query_fund_trend_tool(
|
||
FundTrendQuery(fund_name="南方稳健增利债券A"),
|
||
RequestContext(user_id="1", trace_id="t"),
|
||
)
|
||
assert result == {"found": False, "reason": "product_not_found", "source": NAV_SOURCE}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_query_fund_trend_tool_reports_ambiguous_name(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""名称命中多只时返回 `ambiguous` —— **替客户挑一只**才是这里最坏的行为(`INV-2`)。"""
|
||
session = _FakeSession(
|
||
products=[
|
||
[],
|
||
[_product(id=1, product_code="159700"), _product(id=2, product_code="159701")],
|
||
],
|
||
navs=[],
|
||
)
|
||
_patch_session(monkeypatch, session)
|
||
result = await query_fund_trend_tool(
|
||
FundTrendQuery(fund_name="科创债ETF南方"), RequestContext(user_id="1", trace_id="t")
|
||
)
|
||
assert result["found"] is False
|
||
assert result["reason"] == "ambiguous"
|
||
|
||
|
||
def test_trend_query_requires_at_least_one_entity() -> None:
|
||
"""契约层就挡住"两个实体都不给"的调用(否则工具会去扫全表)。"""
|
||
with pytest.raises(ValueError):
|
||
FundTrendQuery()
|