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 仍默认关闭。
This commit is contained in:
@@ -697,3 +697,75 @@ def test_risk_level_change_rule_still_catches_the_real_requests() -> None:
|
||||
]
|
||||
for message in caught:
|
||||
assert rules.is_risk_level_change_request(message) is True, message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `W34` 阶段 0:术语归一化(`normalize_query`)
|
||||
#
|
||||
# 守四条约束:
|
||||
# 1. **只对齐书写差异** —— 典型业务问句必须原样返回(归一化不得成为"改写器");
|
||||
# 2. **幂等** —— 跑两次与跑一次结果相同(否则重试 / 幂等键会漂移);
|
||||
# 3. **不做语义替换** —— 同义词表里不得出现会改语义指向的条目;
|
||||
# 4. **等级代号大写** —— 库里全大写(`R1`—`R5`/`C1`—`C5`),客户常打小写。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_query_keeps_plain_business_questions_untouched() -> None:
|
||||
"""普通业务问句**一个字符都不能动** —— 归一化不是改写器。"""
|
||||
untouched = [
|
||||
"南方季季盈90天起投金额是多少",
|
||||
"基金赎回几天到账",
|
||||
"季季盈90天的管理费怎么算",
|
||||
"我想了解定投",
|
||||
]
|
||||
for message in untouched:
|
||||
assert rules.normalize_query(message) == message, message
|
||||
|
||||
|
||||
def test_normalize_query_uppercases_grade_codes() -> None:
|
||||
"""`r1`/`c1` 这类**等级代号**统一成大写,与库内写法对齐。"""
|
||||
assert rules.normalize_query("r1到r5分别代表什么") == "R1到R5分别代表什么"
|
||||
assert rules.normalize_query("c1能买什么") == "C1能买什么"
|
||||
|
||||
|
||||
def test_normalize_query_does_not_touch_real_latin_words() -> None:
|
||||
"""负向断言:`abc1` 里的 `c` 前面还有字母,不是等级代号,不得被大写。"""
|
||||
assert rules.normalize_query("abc1") == "abc1"
|
||||
|
||||
|
||||
def test_normalize_query_applies_width_and_whitespace_normalization() -> None:
|
||||
"""全角字母/数字走 `NFKC`,连续空白折叠成单空格。"""
|
||||
assert rules.normalize_query("R1到R5") == "R1到R5"
|
||||
assert rules.normalize_query("起投金额 是多少") == "起投金额 是多少"
|
||||
|
||||
|
||||
def test_normalize_query_applies_synonym_table() -> None:
|
||||
"""同义表按**长度降序**应用:长条目不被短条目切碎。"""
|
||||
assert rules.normalize_query("7日年化是多少") == "七日年化是多少"
|
||||
assert rules.normalize_query("风险承受能力评测怎么做") == "风险承受能力测评怎么做"
|
||||
|
||||
|
||||
def test_normalize_query_is_idempotent() -> None:
|
||||
"""幂等:`f(f(x)) == f(x)`。不幂等会让重试产生不同查询串。"""
|
||||
samples = [
|
||||
"r1到r5分别代表什么",
|
||||
"7日年化是多少",
|
||||
"R1 到 R5",
|
||||
"南方季季盈90天起投金额是多少",
|
||||
"",
|
||||
]
|
||||
for message in samples:
|
||||
once = rules.normalize_query(message)
|
||||
assert rules.normalize_query(once) == once, message
|
||||
|
||||
|
||||
def test_synonym_table_only_contains_writing_variants() -> None:
|
||||
"""同义表纪律:条目形如 `(源, 目标)`、源非空、源与目标不同、且源唯一。"""
|
||||
sources = [source for source, _ in rules.QUERY_SYNONYMS]
|
||||
assert len(sources) == len(set(sources)), "同义表存在重复源串"
|
||||
for source, target in rules.QUERY_SYNONYMS:
|
||||
assert source and target
|
||||
assert source != target, source
|
||||
# 语义类同义(会改变指向)不得进表:这里只做形式校验——
|
||||
# 目标串必须包含源串的全部信息(书写差异是"改写",不是"改写语义")。
|
||||
assert isinstance(source, str) and isinstance(target, str)
|
||||
|
||||
Reference in New Issue
Block a user