Files
group_fqcd_jr/tests/unit/service/test_knowledge_granularity.py
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 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 生成的本地产物
2026-09-20 14:33:30 +08:00

109 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识块粒度选择的单元测试。
本文件当前只覆盖**检索层**(`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([]) == []