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
+118
View File
@@ -261,6 +261,114 @@
---
## 组 7 · 意图层与检索层的三项扩张(**`W34` 新增 · ☑ 2026-09-22 受理并落地**)
> **性质**:本组提案在前;用户在 2026-09-22 的对话中**逐项授权会签**后当场落地
> (与组 5 同类流程:先立单、经授权、后动手)。
> **共同背景**:这三项都源自同一个结构性事实 —— **意图码既是语义标签,又是工具权限 key**
> (`agent_tools/customer_service:<intent>`)。它们分别从"配置化"、"通道化"、"命名"三个方向
> 去松动这个耦合,因此**必须一起评估**:只做其一,另外两项的收益会被抵消。
### 会签 20 · `app/service/runtime_config_service.py`(白名单外)
**一、改什么**
新增 `load_collection_routes(agent_type) -> dict[str, str]`:只读 `agent_intent_config` 的
`status='active'` 行(与既有 `load_active_intent_configs()` **同一生效判定**),把
`collection_routes`(**JSON 列,已存在于表中**)投影成 `{intent_code: collection}`。
消费方 `customer_service.py`(**类 2**)用它在构造 `NARROW_COLLECTION_BY_INTENT` 时**优先取配置、
缺失回落代码常量**。
**二、为什么必须会签**
`app/service/runtime_config_service.py` **不在 `D2.1` §1.1 / §1.3 名单内,也不在 §1.5 零改动清单内**
⇒ 按 `docs/48` 开篇口径,它属**白名单之外**。
`customer_service.py:948–949` 的代码注释**自认**这一点:「等权重与路由要进
`agent_intent_config.collection_routes` 时(二期),再走发布配置(那需要会签 —— 读该表的落点在
白名单之外)」。
**三、最小化边界**
**零 DDL**(`collection_routes` 是 `app/model/configuration.py:94` 的**既有 JSON 列**,本轮只是
**首次消费**它 —— 该列目前**全仓零消费**,属"有列无消费");
不改表结构 / 不改函数签名 / 不改返回形状;新增函数与既有 `load_active_intent_configs()` 并列,
**不改动**后者。
**四、依据**:`D2.1` 决策 19(配置化);`W33` 方案 §二期;`docs/48` 类 3 口径。
**五、影响面**
`customer_service.py::NARROW_COLLECTION_BY_INTENT` 的取值来源(**常量 → 配置优先**);
`tests/unit/service/test_customer_service_agent.py`;配置面 `admin_service` 的
`agent-intent-configs` 写入口(**不改**,仅新增读路径)。
**六、降级方案(不受理时)**
保留环境变量 + 代码常量(现状)。代价:路由调整**每次都要发版**,且环境变量在生产
**不可热改** —— 评测可以 A/B,线上不能灰度。
### 会签 21 · `app/service/knowledge_search_service.py`(**类 3**)+ `app/core/retrieval_fusion.py`
**一、改什么**
① 检索层把**字面通道**从"条件触发的兜底"改为**可并行召回的独立通道**(暴露独立入参,
不改既有默认行为);
② `app/core/retrieval_fusion.py` 增加 **`rrf` 融合策略**(排名融合),与既有"加权求和"
并存、**由调用方选择**;消费方接口**不变**(该模块 `:29–31` 已明写二期加 `rrf` 策略即可)。
**二、为什么必须会签**
`app/service/knowledge_search_service.py` 是**类 3**(`docs/48:40`,已受理项仅 `D-01`/`D-02`),
**新改动须新会签**。`retrieval_fusion.py` 本身属客服模块(`W33` 新增),但 **② 只能在 ① 落地后才有意义**,
故合成一张单。
**三、最小化边界**
不改 `search()` 签名(新入参带默认值,默认行为**逐字等同**现状);不改 `KnowledgeHit` /
`KnowledgeSearchOutcome` 形状;不改表结构 / **零 DDL**;不动 `tiers` 必填与
`visibility_expression` 下推;**融合层仍不得持有 Milvus 客户端**(`INV-1`,见
`retrieval_fusion.py:41–42`)。
**四、依据**
`retrieval_fusion.py:26–31`("等二期引入**异质通道**(字面锚点给满分 `1.0`)时,才需要切换成
排名融合");`knowledge_search_service.py` 的 `VECTOR_CONFIDENT_SCORE = 0.75` 闸门。
**五、影响面**
⚠️ **这是本组风险最高的一项**:字面通道命中给**满分 `1.0`** 会把 `gap` **压平** ⇒ 触发
"领先不足"判据 ⇒ 一律转人工。因此 ① 与 ② **必须同批上线**,且上线前必须用 55 条金标
验证 `M-1` 不下降(当前 `MIN_GAP` 裕度仅 ±0.005)。
**六、降级方案(不受理时)**
维持现状:字面通道继续只做"条件触发兜底"。代价:新词条 / 新别名仍只能靠补 FAQ 命中,
字面通道无法参与并行召回。
### 会签 22 · 发布配置 `agent_tools/customer_service:*` + `app/service/agent/bootstrap.py`
**一、改什么**
① 新增一个**独立意图码**(暂名 `market_quote`)承接行情类问法,**并新增对应工具白名单 key**;
② `customer_service.py` 把 `TREND_WHITELIST_INTENT` 由 `INTENT_FAQ` 改为该新码
(**类 2**,同时改 `INTENT_*` 常量与 `AgentDefinition.supported_intents` 声明);
③ `agent_intent_config` 补该意图码的**分类描述**(走既有发布流程,非代码)。
**二、为什么必须会签**
`app/service/agent/bootstrap.py` 是 `D2.1` §1.3 **组 2** 已会签文件,**新改动须新会签**;
发布配置 `agent_tools/*` 的写入口不在客服模块内。
**三、最小化边界**
只**新增**意图码与权限 key,**不删除、不改名**任何既有码;不动 `IntentClassifier` 算法
(`app/service/intent_classifier.py` **仍不触碰**);不改 `exit_codes.py` 的 `E6`。
**四、依据**
`customer_service.py:201–205` 的铁证 —— 调用画像工具**必须复用已发布的 `faq`**,因为
「**发布配置里只有 `agent_tools/customer_service:faq` 一个 key**;换新意图码会让交集为空 ⇒
`AGENT_PERMISSION_DENIED`」。这正是 **`faq` 吸水坍缩**的机制根源:判定被权限**倒逼**,
判错也不报错。**"重叠意图合并"在本项目必然撞墙,唯一出路是拆开判定维度与授权维度。**
**五、影响面**
发布配置(`config_release` + `config_item`,`namespace='agent_tools'`);`bootstrap.py:458`
的 `AgentDefinition.allowed_tools` **交集**逻辑;`E6` 行情出口的权限来源;金标 55 条。
**六、降级方案(不受理时)**
保留 `TREND_WHITELIST_INTENT = INTENT_FAQ` 这一**被迫借用**,并**加一条启动自检**:
断言该常量确实落在已发布的工具 key 集合内 ⇒ 任何一次"按语义整理意图码"的动作
会在**启动时**报错,而不是在客户问行情时**静默失效**。
(⚠️ 现状:按语义把 `query_fund_trend` 挪到 `product_inquiry`,`E6` **立刻失效并冒泡成错误**
—— 不是兜底话术。这条隐性耦合目前**没有任何守卫**。)
---
## 会签结论
| 组 | 项数 | 结论 |
@@ -271,6 +379,7 @@
| 组 4(入参边界对齐) | 5 文件 / 1 张单 | ☑ **受理**(2026-09-20 补签) |
| 组 5(NL2SQL 只读边界) | 1 文件 / 1 张单 | ☑ **受理**(2026-09-22 补签) |
| 组 6(`GROUP BY` 拼装缺陷) | 1 文件 / 1 张单 | ☑ **受理**(2026-09-22 补签) |
| **组 7**(意图层与检索层三项扩张) | 3 项 / 3 张单(其中 1 项触碰类 3) | ☑ **受理**(2026-09-22,授权后当场落地;见下方补签说明) |
**会签人签名 / 日期**:项目 owner(本人会签,`甲-3` 口径:一次性授权 + 逐项留痕) **2026-09-22**(组 1—4 为 2026-09-20)
@@ -284,4 +393,13 @@
> 并新增**误杀边界**用例(查询语境的「变更 / 变化 / 导出 / 更新」不得被拦);
> 全量 `pytest` 与 55 条金标**逐项零差异**(见 `_W29-NL2SQL接线与对话内图表实施报告-2026-09-22.md` §2)。
> ✅ **组 7 补签说明(2026-09-22)**:用户在本日对话中明确「授予会签(放行)权限」,三项当场落地。
>
> **落地证据(按单)**:
> - **会签 20**:`app/service/runtime_config_service.py` 新增 `collection_routes()` / `load_collection_routes()` / `_first_collection_name()`;消费方 `customer_service.py::_narrow_collection_for()`(**配置优先、缺失回落代码常量**)。**零 DDL**;`collection_routes` 列当前全为 `None` ⇒ 实际走**回落**路径,行为与改动前一致。
> - **会签 21**:`app/core/retrieval_fusion.py` 新增 `fuse_rrf()` + `RRF_K`(与 `fuse` 并存、由调用方选择;只吃名次不吃分数 ⇒ 异质分数不可能污染判定分);`app/service/knowledge_search_service.py::search()` 新增 `literal_parallel: bool = False` 入参(默认值使行为**逐字等同**现状)。⚠️ **工具层透传未做** —— 那需要改 `KnowledgeSearchInput` 契约,**超出本单范围**,另行立单。
> - **会签 22**:`customer_service.py` 新增 `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`**:授权维度与判定维度**解耦**,不改分类候选空间 ⇒ 不动判定分布、不威胁 `MIN_GAP` 窄带。
>
> **回退方法**:代码按函数级回退;发布配置 `DELETE FROM platform_config_item WHERE release_id=260 AND namespace='agent_tools' AND config_key='customer_service:market_quote'`(或恢复 `_AI工作区\归档\_backup_release260_agent_tools.json` 的 9 行快照)。
> **口径**:本文件是**追溯留痕**(`甲-3` 已一次性授权,未逐项等待签字)。未受理项须按各组「六、降级方案」执行,并在 `D2.1` 中标注为**降级**。