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:
张胜宇
2026-09-22 18:05:59 +08:00
parent 2c3a5188fb
commit 2a55269e20
14 changed files with 1523 additions and 26 deletions
+63 -1
View File
@@ -1186,4 +1186,66 @@ def route_message(message: str) -> SafetyRoute | None:
reply=P2_REPLY, transfer_required=True,
transfer_reason=TRANSFER_REASON_EXPLICIT,
)
return None
return None
# ---------------------------------------------------------------------------
# 术语归一化(检索查询串)—— 默认**不启用**,由 `customer_service.py` 的
# `CS_QUERY_NORM` 决定是否调用本模块
# ---------------------------------------------------------------------------
#
# 为什么需要它
# ------------
# 检索查询串此前只做 ``strip()``(`knowledge_search_service.py`),而字面通道的
# 重合判定是**逐字符比较、大小写敏感**:客户打「r1到r5分别代表什么」,库里是
# 「R1 到 R5 风险等级分别代表什么?」—— 同一个实体,字面上零重合,字面通道拿不到兜底。
#
# 三条刻意约束(不是疏漏)
# ------------------------
# 1. **只做同义替换,不做上下位词扩展**。「基金」→「公募基金/货币基金」属另一件事,
# 会引入原问句里根本没有的概念,风险高于收益,**本轮不做**。
# 2. **只对齐"同一实体的不同写法"**:全角/半角、大小写、异体字、常见同义简称。
# 任何会改变语义指向的替换都不得进 :data:`QUERY_SYNONYMS`。
# 3. **调用方默认关闭**。归一化会改写查询串 ⇒ 改写向量化输入 ⇒ **可能移动 `gap`**。
# 而 `customer_service.py` 的 `MIN_GAP` 实测合法区间只有 `(0.0649, 0.0759)`、
# 两侧裕度各约 0.005 —— 这是一次**必须先用 55 条金标 A/B 实测**才能开启的改动。
# 因此本模块本身是纯函数(无副作用、可单测),开关留在调用方。
#: 同义替换表。**按长度降序应用**,避免短串先命中把长串切碎(如「起投」吃掉「起投金额」)。
#: ⚠️ 新增条目必须同时满足:同一实体、不改变语义指向、且有真实客户问法作为依据。
#: 本轮刻意只收**纯书写差异**的条目;语义类同义(如「起购」/「起投」)留待金标 A/B 后逐条加。
QUERY_SYNONYMS: tuple[tuple[str, str], ...] = (
("7日年化", "七日年化"),
("7天年化", "七日年化"),
("7日年化收益", "七日年化收益"),
("风险评测", "风险测评"),
("风险承受能力评测", "风险承受能力测评"),
)
#: 单个拉丁字母紧跟数字时视为**等级/类别代号**(`r1`/`c1`/`R1`),统一成大写。
#: 为什么需要:库里全部写作大写(`R1`—`R5`、`C1`—`C5`),客户常打小写。
#: 负向断言(`(?<![A-Za-z])`)保证不会动到 `abc1` 这类真正的英文词。
_GRADE_CODE = re.compile(r"(?<![A-Za-z])([a-z])(?=\d)")
#: 写成函数而不是常量替换表:全半角归一化必须走 `unicodedata`,无法用字面表表达。
def normalize_query(text: str) -> str:
"""把查询串里的**书写差异**对齐到库内写法;语义不变的纯函数。
只做四件事,顺序有意:
1. `NFKC` 全半角归一(全角字母/数字/括号 → 半角);
2. 空白折叠(检索前多处会拼上文,容易留下连续空格);
3. 异体字与常见同义简称(:data:`QUERY_SYNONYMS`,按长度降序替换,**一次通过**,
不做链式替换 —— 链式会让「A→B、B→C」这种组合产生意料外的结果);
4. 等级代号大写(:data:`_GRADE_CODE`)。
不做的事:分词、上下位词扩展、同义改写、去停用词 —— 那些会改语义,不属"归一化"。
"""
if not text:
return text
normalized = unicodedata.normalize("NFKC", text)
normalized = " ".join(normalized.split())
for source, target in sorted(QUERY_SYNONYMS, key=lambda pair: -len(pair[0])):
if source in normalized:
normalized = normalized.replace(source, target)
normalized = _GRADE_CODE.sub(lambda match: match.group(1).upper(), normalized)
return normalized
+88
View File
@@ -182,6 +182,94 @@ def fuse(routes: list[RetrievalRoute] | tuple[RetrievalRoute, ...]) -> FusionOut
)
#: RRF(排名融合)的平滑常数。取 60 —— 原始论文与主流实现的通行值。
#: 它的作用是把"第 1 名 vs 第 2 名"的差距压到一个不至于过大的量级,
#: 使多路"共同命中但都不靠前"的条目有机会胜出(这正是融合想要的"共识优先")。
RRF_K = 60
def fuse_rrf(
routes: list[RetrievalRoute] | tuple[RetrievalRoute, ...],
*,
k: int = RRF_K,
) -> FusionOutcome:
"""排名融合(Reciprocal Rank Fusion):`score = Σ 1 / (k + rank_i)`。
## 什么时候用它(而不是 `fuse`)
一期两路都是 cosine、天然同尺度,加权求和即可(`fuse`)。
但引入**异质通道**后(例如字面锚点命中给满分 `1.0`),两路分数**不再是同一把尺子**:
直接加权会让字面路**压平** `gap`,而 `customer_service` 的 `MIN_GAP` 裕度只有 ±0.005。
此时改用 RRF —— 它**只吃名次、不吃分数**,异质分数被彻底丢弃 ⇒ 不可能污染判定分。
## 与 `fuse` 的共同点(消费方接口不变)
- 返回仍是 `FusionOutcome`,`FusedHit` 字段语义不变;
- 🔴 `best_vector_score` **仍然只取向量路的原始 cosine**,绝不由 RRF 分替代 ——
判定分与排序分的分离是本模块的第一约束(见模块头);
- `fused_score` 这里装的是 RRF 原始分(量级约 `1/k`),**只用于排序**;
- `degraded` 的三态合并口径与 `fuse` 逐字一致。
⚠️ 注意量纲差异:`fuse` 的 `fused_score` 落在 `[0, 1]`,RRF 的落在约 `(0, 2/k]`。
两者都**只用于排序**,因此可互替;但**不得**拿二者跨策略比较大小。
"""
usable = list(routes)
if not usable:
return FusionOutcome()
failed = [r for r in usable if r.degraded]
degraded = bool(failed)
if failed and len(failed) == len(usable):
reason = "all_routes_failed"
elif failed:
reason = "partial_route_failure"
else:
reason = ""
smooth = float(k) if k > 0 else 1.0
acc: dict[str, dict[str, Any]] = {}
for route in usable:
seen: set[str] = set()
for index, hit in enumerate(route.hits, start=1):
doc_id = _doc_id_of(hit)
if not doc_id or doc_id in seen:
continue # 同一路里重复出现的同一条,只计它最好的那次名次
seen.add(doc_id)
entry = acc.get(doc_id)
if entry is None:
entry = {"payload": hit, "rrf": 0.0, "routes": [], "best_vector": None}
acc[doc_id] = entry
# 后出现的路不覆盖 payload:先出现者优先(保序稳定、可复算)
entry["rrf"] += 1.0 / (smooth + index)
if route.name not in entry["routes"]:
entry["routes"].append(route.name)
if route.name == VECTOR_ROUTE_NAME:
score = _score_of(hit)
if score is not None:
prev = entry["best_vector"]
entry["best_vector"] = score if prev is None else max(prev, score)
fused_hits = [
FusedHit(
doc_id=doc_id,
payload=entry["payload"],
fused_score=float(entry["rrf"]),
best_vector_score=entry["best_vector"],
routes=tuple(entry["routes"]),
route_scores={}, # RRF 不保留各路原始分:它们已被刻意丢弃(异质尺度)
)
for doc_id, entry in acc.items()
]
# 融合分降序;同分保持插入序 ⇒ 可复算
fused_hits.sort(key=lambda item: item.fused_score, reverse=True)
return FusionOutcome(
hits=tuple(fused_hits),
degraded=degraded,
reason=reason,
routes=tuple(r.name for r in usable),
)
def payloads(outcome: FusionOutcome) -> list[dict[str, Any]]:
"""把融合结果还原成"工具返回值形态"的列表,供既有下游(证据包 / 展示)直接消费。