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 仍默认关闭。
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""`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, "直返内容不得是融合提权上来的块"
|