一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
109 lines
4.6 KiB
Python
109 lines
4.6 KiB
Python
"""知识块粒度选择的单元测试。
|
||
|
||
本文件当前只覆盖**检索层**(`KnowledgeSearchService`)的粒度归并。原先另有 4 条用例覆盖
|
||
`CustomerServiceAgent._prefer_section`(「整节块」与「行级子块」之间的取舍),已随客服
|
||
Agent 模块一并移除——重建客服 Agent 时须把该判据连同用例一起带回来。
|
||
|
||
检索层用例的背景是实测的三次翻车,每条判据都对应其中一次:
|
||
|
||
1. 客户问「起投多少」和「风险高吗」时命中同一块(整个产品小节),拿到**完全相同**的
|
||
整节内容,看起来像客服没听懂问题——所以把表格行拆成了行级子块。
|
||
2. 拆细之后「介绍一下」又被某一行抢答(返回"产品期限 90天封闭期")——所以要能换回整节。
|
||
3. 两次判据写错:用"含连字符"认子块时,整节块自己的编号 PROD-901 被误判成子块;
|
||
用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块、把正确答案挤出了 top1。
|
||
|
||
第 4 次翻车(2026-09-15)与"同节兄弟子块互相打平"有关:`doc_id` 去重挡不住
|
||
`POL-AST-009-07` 与 `POL-AST-009-12` 这种**同父不同子**,实测它们把「风险评估问卷怎么评分」
|
||
的 top1/次优差压到 0.002 → 客服判并列转人工。
|
||
"""
|
||
|
||
from app.service.knowledge_search_service import KnowledgeHit, KnowledgeSearchService
|
||
|
||
|
||
def hit(doc_id: str, score: float, content: str = "正文") -> KnowledgeHit:
|
||
return KnowledgeHit(doc_id=doc_id, title=doc_id, content=content, score=score)
|
||
|
||
|
||
def test_parent_of_recognises_row_blocks() -> None:
|
||
assert KnowledgeSearchService._parent_of("PROD-007-04") == "PROD-007"
|
||
|
||
|
||
def test_parent_of_rejects_section_blocks() -> None:
|
||
"""整节块的编号本身就含连字符(PROD-901),不能被当成子块。"""
|
||
assert KnowledgeSearchService._parent_of("PROD-901") is None
|
||
assert KnowledgeSearchService._parent_of("FAQ-0016") is None
|
||
assert KnowledgeSearchService._parent_of("HNW-003") is None
|
||
|
||
|
||
# --- 同节兄弟子块归并(2026-09-15) -------------------------------------------
|
||
|
||
|
||
def test_sibling_subblocks_of_one_section_collapse_to_the_highest_scoring_one() -> None:
|
||
"""同一节的多个子块是"同一答案的不同细节",不是并列候选:只留最高分那条。"""
|
||
hits = [
|
||
hit("POL-AST-009-12", 0.7359),
|
||
hit("POL-AST-009-07", 0.7346),
|
||
hit("POL-AST-009-19", 0.7340),
|
||
hit("POL-AST-009-51", 0.7340),
|
||
]
|
||
|
||
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
|
||
|
||
assert [item.doc_id for item in merged] == ["POL-AST-009-12"]
|
||
|
||
|
||
def test_merge_keeps_one_block_per_section() -> None:
|
||
"""**不同**父块各自的最高分子块都要留下:它们是真正不同的候选。"""
|
||
hits = [
|
||
hit("PROD-007-04", 0.86),
|
||
hit("PROD-007-05", 0.85),
|
||
hit("HNW-005-02", 0.80),
|
||
hit("HNW-005-01", 0.79),
|
||
]
|
||
|
||
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
|
||
|
||
assert [item.doc_id for item in merged] == ["PROD-007-04", "HNW-005-02"]
|
||
|
||
|
||
def test_merge_leaves_plain_blocks_alone() -> None:
|
||
"""FAQ / 政策 / 公司信息这类块本身就是细粒度答案:它们之间打平是真的多个候选,
|
||
**不能**合并(否则"存在并列"这个信号会被抹掉,客服会硬答一个巧合高分)。"""
|
||
hits = [
|
||
hit("FAQ-0016", 0.85),
|
||
hit("FAQ-0015", 0.84),
|
||
hit("POL-SPM-010", 0.83),
|
||
hit("POL-AST-009", 0.82),
|
||
]
|
||
|
||
assert KnowledgeSearchService._merge_sibling_subblocks(hits) == hits
|
||
|
||
|
||
def test_merge_preserves_score_order_and_the_parent_block() -> None:
|
||
"""归并不改顺序;父块(整节)不受影响,仍会按保底名额回到候选里。"""
|
||
hits = [
|
||
hit("PROD-007-04", 0.86),
|
||
hit("PROD-007", 0.77), # 父块:整节,`_parent_of` 为 None
|
||
hit("PROD-007-05", 0.75),
|
||
hit("FAQ-0015", 0.60),
|
||
]
|
||
|
||
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
|
||
|
||
assert [item.doc_id for item in merged] == ["PROD-007-04", "PROD-007", "FAQ-0015"]
|
||
assert [item.score for item in merged] == [0.86, 0.77, 0.60]
|
||
|
||
|
||
def test_merge_is_idempotent() -> None:
|
||
"""重复调用不得继续删东西(幂等,便于以后在别处复用)。"""
|
||
hits = [hit("PROD-007-04", 0.86), hit("PROD-007-05", 0.75), hit("FAQ-0015", 0.60)]
|
||
|
||
once = KnowledgeSearchService._merge_sibling_subblocks(hits)
|
||
twice = KnowledgeSearchService._merge_sibling_subblocks(once)
|
||
|
||
assert twice == once
|
||
|
||
|
||
def test_merge_of_empty_list_is_empty() -> None:
|
||
assert KnowledgeSearchService._merge_sibling_subblocks([]) == []
|