Files
group_fqcd_jr/tests/unit/service/test_decision_scope_w35.py
T
张胜宇 2a55269e20 feat(W34-W36): 客服双通道口径分离收口 + 会签 20/21/22 落地 + 演示启动器修复
W34 · 会签 20/21/22 三项落地(先立单、经授权、后动手)
- 会签 20(白名单外):runtime_config_service 新增 load_collection_routes() /
  collection_routes() / _first_collection_name(),首次消费既有 JSON 列 collection_routes;
  消费方 customer_service 走「配置优先、缺失回落代码常量」。零 DDL;该列当前全为 None
  ⇒ 实际走回落路径,行为与改动前一致。
- 会签 21(类 3 + 融合层):retrieval_fusion 新增 fuse_rrf() + RRF_K(排名融合,只吃名次
  不吃分数 ⇒ 异质分数不可能污染判定分,best_vector_score 仍只取向量路原始 cosine);
  knowledge_search_service::search() 新增 literal_parallel: bool = False(默认值使行为
  逐字等同现状)。工具层透传未做 —— 那需改 KnowledgeSearchInput 契约(extra="forbid"),
  超出本单范围。
- 会签 22(发布配置 + bootstrap):customer_service 新增 INTENT_MARKET_QUOTE 常量 +
  TREND_WHITELIST_INTENT_CANDIDATES(按优先级回落)+ _trend_whitelist_intent()
  (运行时自检 + 自动回落,强于「仅报错」)。发布配置 customer_service:market_quote
  (release 260,allowed_tools=['query_fund_trend'])已写入并回读校验(9 → 10 行)。
  刻意未加入 supported_intents:授权维度与判定维度解耦,不动判定分布。
- 阶段 0:customer_service_rules 新增 normalize_query() + QUERY_SYNONYMS + 等级代号大写
  (纯函数;同义表只收纯书写差异,语义类同义留待金标 A/B 后逐条加;调用方默认不启用)。

W35 · 判定口径与融合口径分离(修 A-01 / C-04 / I-02 / E-04 四条)
- _dual_route_output 返回值新增 vector_order(向量路原始 doc_id 顺序、去重);
- 新增 _vector_decision_hits() 据此还原「判定序列」(带向量分的 basic 补位块回补首位;
  无 vector_order / 空 / id 全对不上 ⇒ 返回 None 回落原分支);
- _answer_from_knowledge 的 score / gap 与原文直返的 best 改从向量路原始序列取。
  语义边界(刻意):_evidence_pack / _exit_clarify / _answer_from_evidence 仍吃融合序列
  —— 融合的收益只留在「给哪些块、什么顺序」,符合三层分数分离约束。

W36 · 选块口径归一(收口最后一条 E-01)
- 新增 _pack_order():order 命中的块排前,其余按原相对顺序追加在后;
- _evidence_pack 新增 order= 参数,三处遍历 hits → ordered,top 由 hits[0] → ordered[0];
- _answer_from_knowledge 传入 order=[judge 的 doc_id 序列];judge is None ⇒ None
  (开关关闭时逐字零改动)。order 只当排序键、不当过滤器 ⇒ 证据包成员集合不变。

演示环境与文档
- start.ps1 / demo.ps1 默认端口 8000 → 8099(与 README / docs/06,07,09,14,15,32 /
  tools/smoke_check.py / login_console.py 的全仓口径对齐;字节级定长替换,保住
  UTF-8 BOM + CRLF,字节数不变);
- portal/README.md 更正 fin_nav_history 过期口径(「0 行」→ 实测 2494 行 / 20 个产品 /
  nav_date 覆盖 2026-03-18—2026-09-13)。

测试(新增 3 个文件、补强 2 个)
- 新增 tests/unit/service/test_decision_scope_w35.py(10 条)、
  tests/unit/service/test_evidence_pack_order_w36.py(13 条)、
  tests/unit/service/test_customer_service_trend_chart_inv8.py(INV-8 字面级判定,
  纳入 pytest 门禁,此前只在 jsdom 脚本里覆盖);
- 补强 tests/unit/core/test_customer_service_rules.py(normalize_query 7 条)与
  tests/integration/test_customer_service_trend_chart_persistence.py(图内每个数字
  都必须在答复正文出现过,判定口径与 INV-8 单测一致)。

验证
- 全量 pytest:2642 passed / 3 skipped / 0 failed(基线 2629 + 新增 13);
- 55 条金标真实链路 A/B 四组:off / norm / dual / on 均 55/55 = 100%
  (改前 dual 92.7%、on 90.9%);M-4 事实正确率恒 100%、M-7—M-10 全 0;
- 红线四条守住:融合/精排层仍不持 Milvus 客户端(INV-1)、阈值一字未动、零 DDL;
- 三个实验开关 CS_DUAL_ROUTE / CS_RERANK / CS_QUERY_NORM 仍默认关闭。
2026-09-22 18:05:59 +08:00

274 lines
12 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.
"""`W35` 判定口径与融合口径分离:出口判定与原文直返取「向量路原始序列」。
## 这个文件在防什么
`test_dual_route_wiring_w33.py` 已钉住"写回的 `score` 是向量分、不是融合分"。
但那只保证**分数本身**没被换掉,**没保证"取哪两条来算 `gap`"**。
实测 `A-01`(「南方基金的全称和简称是什么?」)的失败恰恰出在这里:
单路 top1/top2 = `0.8428 / 0.7651`(`gap 0.0777 ≥ MIN_GAP` → `E3`);
融合按 `fused_score`("共识优先")把 `COMP-001`(在向量路里排第 5、分 `0.7992`)
提权到第 2 名 ⇒ 判定读到 `0.8428 − 0.7992 = 0.0436 < MIN_GAP` ⇒ `E3` 被顶成 `E4`。
**分数没被污染,"名次"被改了** —— 而 `MIN_GAP` 是在单路口径下标定的。
## 三条不变量
1. 判定用「向量路原始序列」:被融合提权的块**不得**挤进 top1/top2。
2. 无向量分的块(`payloads()` 写 `score = 0.0`)**不得**参与 `gap`(否则 `gap` 虚高,
把"其实有并列候选"误判成高置信直返)。
3. 开关关闭 / 无 `vector_order` ⇒ 回落既有闭环,**与改动前逐字一致**。
"""
from __future__ import annotations
from typing import Any
import pytest
from app.core.contracts import AgentRequest, RequestContext
from app.service.agent.implementations import customer_service as cs
AGENT = cs.CustomerServiceAgent
CUSTOMER = RequestContext(user_id="9001", trace_id="t", roles=("customer",))
def build_agent() -> cs.CustomerServiceAgent:
return AGENT(AGENT.definition)
def build_request(message: str = "基金申购费率是多少") -> AgentRequest:
return AgentRequest(
agent_type="customer_service",
message=message,
session_id="s-w35-scope",
idempotency_key="w35-decision-scope-key",
)
def _hit(doc_id: str, score: float, content: str = "") -> dict[str, Any]:
return {
"doc_id": doc_id,
"score": score,
"title": f"标题-{doc_id}",
"content": content or f"正文-{doc_id}",
"family_id": "F1",
}
class _ToolStub:
"""照 `W33` 夹具:记录每次 `call_tool`,并按 `collection` 返回不同候选。"""
def __init__(self, by_collection: dict[str, Any]) -> None:
self.calls: list[dict[str, Any]] = []
self._by_collection = by_collection
async def __call__(self, name: str, arguments: dict, *, intent: str, context: Any):
self.calls.append({"name": name, "arguments": dict(arguments), "intent": intent})
return self._by_collection.get(str(arguments.get("collection") or ""), {"hits": []})
# ---- ① 回落:拿不到向量路序列时行为不变 ----
def test_no_vector_order_falls_back() -> None:
"""没有 `vector_order`(开关关闭 / 单路)⇒ 返回 `None`,调用方走既有闭环。"""
merged = [_hit("D1", 0.90), _hit("D2", 0.80)]
assert AGENT._vector_decision_hits({"hits": merged}, merged) is None
def test_empty_vector_order_falls_back() -> None:
"""向量路失败 ⇒ 空序列 ⇒ 同样回落(增益信息拿不到就当没有,不影响原能力)。"""
merged = [_hit("D1", 0.90)]
assert AGENT._vector_decision_hits({"hits": merged, "vector_order": []}, merged) is None
def test_all_ordered_ids_missing_falls_back() -> None:
"""`vector_order` 里的 id 在融合结果里一个都找不到 ⇒ 回落,而不是返回空序列。"""
merged = [_hit("D1", 0.90)]
out = {"hits": merged, "vector_order": ["X1", "X2"]}
assert AGENT._vector_decision_hits(out, merged) is None
# ---- ② 核心:被融合提权的块不得挤进 top1/top2 ----
def test_promoted_block_does_not_enter_decision_sequence() -> None:
"""🔴 `A-01` 场景复刻:融合把向量路第 5 名提权到第 2 名,判定仍读向量路前两名。
这是 `M-1` 从 90.9% 回到 100% 的机制本身 —— 谁把 `judge` 换回 `hits`,谁红。
"""
merged = [
_hit("FAQ-0001", 0.8428), # 融合第 1、向量路第 1
_hit("COMP-001", 0.7992), # 融合第 2(被提权上来的)、向量路第 5
_hit("COMP-001-02", 0.7651), # 融合第 3、向量路第 2
]
out = {"hits": merged, "vector_order": ["FAQ-0001", "COMP-001-02", "COMP-001"]}
agent = build_agent()
judge = AGENT._vector_decision_hits(out, merged)
assert judge is not None
assert [hit["doc_id"] for hit in judge] == [
"FAQ-0001",
"COMP-001-02",
"COMP-001",
], "判定序列必须还原向量路原序"
score = AGENT._score(judge[0]["score"])
gap = score - agent._second_score(judge)
assert score == pytest.approx(0.8428, abs=1e-6)
assert gap == pytest.approx(0.8428 - 0.7651, abs=1e-6)
assert gap >= cs.MIN_GAP, "判定必须仍看到向量路口径的 gap,否则 E3 会被顶成 E4"
# 反证:拿融合序列算,gap 掉到 MIN_GAP 以下 —— 这就是原来的失败路径。
fused_gap = AGENT._score(merged[0]["score"]) - agent._second_score(merged)
assert fused_gap == pytest.approx(0.8428 - 0.7992, abs=1e-6)
assert fused_gap < cs.MIN_GAP, "本用例的构造必须真的能复现退化,否则断言是空转"
def test_zero_scored_literal_hit_never_enters_gap() -> None:
"""🔴 纯字面命中(`payloads()` 写 `score = 0.0`)不得参与 `gap`。
它若排到第 2 位,`gap` 会被拉到 `0.80` —— "其实有并列候选"被误判成"领先很多",
高置信直返会把一个仅靠字面重合命中的块当成 top1 的并列证据。
"""
merged = [
_hit("D1", 0.80),
_hit("LITERAL-1", 0.0), # 只在字面路命中 ⇒ payloads 写 0.0,融合排第 2
_hit("D2", 0.70), # 向量路第 2
]
out = {"hits": merged, "vector_order": ["D1", "D2"]}
agent = build_agent()
judge = AGENT._vector_decision_hits(out, merged)
assert judge is not None
assert [hit["doc_id"] for hit in judge] == ["D1", "D2"]
gap = AGENT._score(judge[0]["score"]) - agent._second_score(judge)
assert gap == pytest.approx(0.10, abs=1e-6)
assert agent._second_score(merged) == 0.0, "本用例要求构造出 0 分块占据第 2 位"
# ---- ③ `basic` 补位:唯一一处刻意不分离 ----
def test_basic_promoted_hit_stays_on_top() -> None:
"""`basic` 补位块(不在向量路、但**严格更高分**)仍留判定序列首位。
`_supplement_basic_explain` 的采用判据就是"严格更高" ⇒ 分离不得把它抹掉,
否则概念型问句会比开关关闭时更差。
"""
merged = [
{"doc_id": "BASIC-9", "score": 0.87, "title": "常识块",
"content": "正文", "collection": cs.BASIC_COLLECTION},
_hit("FAQ-1", 0.82),
_hit("FAQ-2", 0.70),
]
out = {"hits": merged, "vector_order": ["FAQ-1", "FAQ-2"]}
judge = AGENT._vector_decision_hits(out, merged)
assert judge is not None
assert [hit["doc_id"] for hit in judge] == ["BASIC-9", "FAQ-1", "FAQ-2"]
def test_lower_scored_non_vector_hit_is_not_promoted() -> None:
"""分数**不高于**向量路 top1 的非向量块不得回补(否则分离失效)。"""
merged = [_hit("D1", 0.80), _hit("NARROW-ONLY", 0.0), _hit("D2", 0.70)]
out = {"hits": merged, "vector_order": ["D1", "D2"]}
judge = AGENT._vector_decision_hits(out, merged)
assert judge is not None
assert [hit["doc_id"] for hit in judge] == ["D1", "D2"]
# ---- ④ `vector_order` 的生成:原顺序 + 去重 ----
async def test_vector_order_keeps_route_original_order_and_dedupes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔴 `vector_order` 必须是向量路**返回的原始顺序**(且去重)。
⚠️ 不能对融合结果"按分数重排":① 融合结果的名字已被 `fused_score` 改过;
② 同一 `doc_id` 在该路可能返回多条(`fuse` 对它们取 `max`)。
本用例特意让分数序与返回序**不一致**(`C` 分高于 `B` 但排在 `B` 后),
并让 `B` 重复出现 —— 两种写错方式都会在这里红。
"""
monkeypatch.setattr(cs, "DUAL_ROUTE_ENABLED", True)
stub = _ToolStub({
"": {"hits": [_hit("A", 0.90), _hit("B", 0.70), _hit("C", 0.80), _hit("B", 0.70)]},
cs.PRODUCT_COLLECTION: {"hits": [_hit("C", 0.99)]},
})
agent = build_agent()
agent.call_tool = stub # type: ignore[method-assign]
out = await agent._dual_route_output(
build_request(), CUSTOMER, cs.INTENT_PRODUCT, cs.TOOL_NAME
)
assert out is not None
assert out["vector_order"] == ["A", "B", "C"], "必须是向量路原顺序且去重"
async def test_vector_route_failure_yields_empty_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""向量路失败 ⇒ `vector_order` 为空、判定回落 —— 但**另一路的命中照常返回**。"""
monkeypatch.setattr(cs, "DUAL_ROUTE_ENABLED", True)
stub = _ToolStub({
"": {"degraded": True, "reason": "search_failed"},
cs.PRODUCT_COLLECTION: {"hits": [_hit("D2", 0.90)]},
})
agent = build_agent()
agent.call_tool = stub # type: ignore[method-assign]
out = await agent._dual_route_output(
build_request(), CUSTOMER, cs.INTENT_PRODUCT, cs.TOOL_NAME
)
assert out is not None
assert out["vector_order"] == []
assert [hit["doc_id"] for hit in out["hits"]] == ["D2"], "另一路的结果不得被丢"
assert AGENT._vector_decision_hits(out, out["hits"]) is None
# ---- ⑤ 端到端:判定与「原文直返的内容」同源 ----
async def test_direct_reply_follows_vector_scope(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔴 端到端:融合把 `COMP-001` 顶到第 1 名时,直返的仍是**向量路 top1**。
构造:向量路 `FAQ-0001 0.84` / `COMP-001-02 0.7651`(`COMP-001 0.7992` 在向量路下方);
收窄路给 `COMP-001` 0.95 ⇒ 融合分 `0.6×0.7992 + 0.4×0.95 = 0.85952`,**压过 `FAQ-0001`
的 `0.6×0.84 = 0.504`** ⇒ 融合第 1 名成了 `COMP-001`。
若不分离:判定读 `COMP-001(0.7992) − FAQ-0001(0.84) = −0.0408` ⇒ 既不满足高置信、
也不满足 gap,直接掉进澄清/部分答 —— 而单路口径下这条本该是 `E3` 直返。
"""
monkeypatch.setattr(cs, "DUAL_ROUTE_ENABLED", True)
# 只放开"主体相关性闸门"这一维度(它另有专门用例),不放宽被测的判定路径。
monkeypatch.setattr(
AGENT, "_subject_covered_by", classmethod(lambda cls, hits, terms: True)
)
monkeypatch.setattr(
AGENT, "_is_general_knowledge_question", classmethod(lambda cls, message: False)
)
stub = _ToolStub({
"": {"hits": [ # 向量路:原序(FAQ-0001 第一、COMP-001-02 第二、COMP-001 在其后)
_hit("FAQ-0001", 0.8428, "南方基金管理股份有限公司,简称南方基金。"),
_hit("COMP-001-02", 0.7651, "公司全称与简称的说明。"),
_hit("COMP-001", 0.7992, "这是被融合提权上来的那一块。"),
]},
cs.PRODUCT_COLLECTION: {"hits": [_hit("COMP-001", 0.95, "这是被融合提权上来的那一块。")]},
})
agent = build_agent()
agent.call_tool = stub # type: ignore[method-assign]
result = await agent._answer_from_knowledge(
build_request("南方基金的全称是什么"), CUSTOMER, cs.INTENT_PRODUCT
)
assert result.exit_code == cs.EXIT_KNOWLEDGE_DIRECT, (
f"应走知识直返,实际 {result.exit_code}(判定被融合名次改写了)"
)
assert "简称南方基金" in result.text, "直返内容必须来自向量路 top1"
assert "被融合提权" not in result.text, "直返内容不得是融合提权上来的块"