2026-09-10 22:29:23 +08:00
|
|
|
|
"""知识块粒度选择的单元测试。
|
|
|
|
|
|
|
|
|
|
|
|
背景是实测的三次翻车,每条判据都对应其中一次:
|
|
|
|
|
|
|
|
|
|
|
|
1. 客户问「起投多少」和「风险高吗」时命中同一块(整个产品小节),拿到**完全相同**的
|
|
|
|
|
|
整节内容,看起来像客服没听懂问题——所以把表格行拆成了行级子块。
|
|
|
|
|
|
2. 拆细之后「介绍一下」又被某一行抢答(返回"产品期限 90天封闭期")——所以要能换回整节。
|
|
|
|
|
|
3. 两次判据写错:用"含连字符"认子块时,整节块自己的编号 PROD-901 被误判成子块;
|
|
|
|
|
|
用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块、把正确答案挤出了 top1。
|
2026-09-15 09:31:57 +08:00
|
|
|
|
|
|
|
|
|
|
第 4 次翻车(2026-09-15)与"同节兄弟子块互相打平"有关:`doc_id` 去重挡不住
|
|
|
|
|
|
`POL-AST-009-07` 与 `POL-AST-009-12` 这种**同父不同子**,实测它们把「风险评估问卷怎么评分」
|
|
|
|
|
|
的 top1/次优差压到 0.002 → 客服判并列转人工。
|
2026-09-10 22:29:23 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
2026-09-15 09:31:57 +08:00
|
|
|
|
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)
|
2026-09-10 22:29:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_section_chosen_for_overview_question() -> None:
|
|
|
|
|
|
"""客户问整节时,用父块替掉抢答的那一行。"""
|
|
|
|
|
|
child = {"doc_id": "PROD-007-05", "title": "手册 · 2.1 南方季季盈90天 · 产品期限"}
|
|
|
|
|
|
parent = {"doc_id": "PROD-007", "content": "整节内容"}
|
|
|
|
|
|
|
|
|
|
|
|
assert CustomerServiceAgent._prefer_section(
|
|
|
|
|
|
"南方季季盈90天介绍一下", child, [child, parent]
|
|
|
|
|
|
) is parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_row_kept_when_question_names_the_field() -> None:
|
|
|
|
|
|
"""客户问的正是那一行时不能换成整节,否则"聚焦"就白做了。"""
|
|
|
|
|
|
child = {"doc_id": "PROD-007-04", "title": "手册 · 2.1 南方季季盈90天 · 起投金额"}
|
|
|
|
|
|
parent = {"doc_id": "PROD-007", "content": "整节内容"}
|
|
|
|
|
|
|
|
|
|
|
|
assert CustomerServiceAgent._prefer_section(
|
|
|
|
|
|
"季季盈90天起投多少", child, [child, parent]
|
|
|
|
|
|
) is child
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_parent_falls_back_to_row() -> None:
|
|
|
|
|
|
"""父块没带回来时仍用子块:宁可答得窄,也不要拿不相干的块搪塞。"""
|
|
|
|
|
|
child = {"doc_id": "PROD-007-05", "title": "手册 · 2.1 南方季季盈90天 · 产品期限"}
|
|
|
|
|
|
|
|
|
|
|
|
assert CustomerServiceAgent._prefer_section("介绍一下", child, [child]) is child
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plain_block_is_not_treated_as_section() -> None:
|
|
|
|
|
|
"""FAQ 这类独立块没有子块挂在下面,不该被当成整节块。"""
|
|
|
|
|
|
plain = {"doc_id": "FAQ-0016", "title": "基金赎回到账需要多长时间?"}
|
|
|
|
|
|
|
|
|
|
|
|
assert CustomerServiceAgent._prefer_section("基金赎回几天到账", plain, [plain]) is plain
|
2026-09-15 09:31:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 同节兄弟子块归并(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([]) == []
|