Files
group_fqcd_jr/tests/unit/service/test_knowledge_granularity.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

109 lines
4.6 KiB
Python
Raw 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([]) == []