消息级 sources items 的 title 为空导致前端文献列表渲染出正文片段。 回填优先级:顶层标题类字段(publication_title/article_title/journal_title/name) → metadata 标题类字段(paper_title/source_entity/name/source) → 顶层 source(断言主体实体名);全部缺失保持 null(前端有 fallback)。 paper_id/score/snippet 等既有字段不受影响。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.4 KiB
Python
252 lines
9.4 KiB
Python
"""services/test_normalize.py —— 归一化器单测。
|
||
|
||
输入用 sdk/mock/fixtures/upstream_real_shape_sample.json(真实上游形态样本,
|
||
DISCOVERY.md §4.1),断言 API.md §7/§8/§9 的归一化输出关键字段。
|
||
fixture 是单一事实源(ARCHITECTURE.md §6),本测试同时守护 mock/BFF 契约一致。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from app.services.normalize import (
|
||
extract_chains,
|
||
normalize_graph,
|
||
normalize_hop,
|
||
normalize_qa_response,
|
||
normalize_source,
|
||
pick,
|
||
)
|
||
|
||
FIXTURE = (
|
||
Path(__file__).resolve().parents[2]
|
||
/ "mock" / "fixtures" / "upstream_real_shape_sample.json"
|
||
)
|
||
|
||
|
||
def load_raw() -> dict:
|
||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# pick
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_pick_first_existing_key():
|
||
obj = {"b": 2, "c": 3}
|
||
assert pick(obj, "a", "b", "c") == 2
|
||
assert pick(obj, "a") is None
|
||
assert pick(obj, "a", default="x") == "x"
|
||
assert pick(obj, "n", "b", default="y") == 2
|
||
|
||
|
||
def test_pick_skips_none_values():
|
||
assert pick({"a": None, "b": 1}, "a", "b") == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §8 SourceItem
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_normalize_source_all_keys_present():
|
||
src = normalize_source(load_raw()["sources"][0])
|
||
expected_keys = {
|
||
"id", "title", "paper_id", "chunk_id", "pmid", "pmcid", "doi", "url",
|
||
"snippet", "score", "locator", "section", "support_count", "type", "metadata",
|
||
}
|
||
assert expected_keys <= set(src.keys()) # 所有字段键必须存在(缺失为 null)
|
||
assert src["id"] == "S1" # evidence_id → id
|
||
assert src["title"] == "Genome-wide analysis of the SPL gene family in pecan"
|
||
assert src["paper_id"] == "PMC5488377_12864_2017_Article_3882"
|
||
assert src["pmid"] == "28655295"
|
||
assert src["doi"] == "10.1186/s12864-017-3882-4"
|
||
assert src["score"] == 0.982 # final_score → score
|
||
assert src["snippet"].startswith("## Phylogenetic")
|
||
assert src["url"] == "https://doi.org/10.1186/s12864-017-3882-4"
|
||
# 上游 item 的 source 是断言主体实体名(非论文标题),落 metadata.source_entity
|
||
assert src["metadata"]["source_entity"] == "SPL gene family"
|
||
assert src["metadata"]["assertion_id"] == "cass_d12f62f9abffbd903b97"
|
||
assert src["metadata"]["vector_score"] == 0.714
|
||
assert src["metadata"]["rerank_score"] == 0.989
|
||
assert src["metadata"]["output_layer"] == "main_kg"
|
||
|
||
|
||
def test_normalize_source_title_preferred_over_source():
|
||
src = normalize_source({"title": "Real Title", "source": "Entity Name"})
|
||
assert src["title"] == "Real Title"
|
||
assert src["metadata"]["source_entity"] == "Entity Name"
|
||
|
||
|
||
def test_normalize_source_only_doi_derives_url():
|
||
src = normalize_source({"doi": "10.1000/xyz"})
|
||
assert src["url"] == "https://doi.org/10.1000/xyz"
|
||
assert src["title"] is None
|
||
assert src["metadata"] == {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §8 title 回填(缺陷 #27,2026-09-11)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_normalize_source_title_fill_from_source_entity():
|
||
"""title 为空 -> 回填 source(断言主体实体名),正文片段绝不参与。"""
|
||
src = normalize_source({
|
||
"source": "OsSPL14",
|
||
"title": "",
|
||
"evidence": "## 正文片段开头(不是标题)",
|
||
"paper_id": "P1",
|
||
})
|
||
assert src["title"] == "OsSPL14"
|
||
# title 即 source 回填:两者相等,source_entity 不落 metadata(不重复)
|
||
assert src["metadata"].get("source_entity") is None
|
||
assert src["paper_id"] == "P1"
|
||
|
||
|
||
def test_normalize_source_title_fill_from_metadata():
|
||
"""title/paper_title 均空 -> metadata 标题类字段回填。"""
|
||
src = normalize_source({
|
||
"source": "entity",
|
||
"metadata": {"paper_title": "水稻抗病基因研究进展", "source_entity": "entity"},
|
||
})
|
||
assert src["title"] == "水稻抗病基因研究进展"
|
||
|
||
|
||
def test_normalize_source_title_not_overridden():
|
||
"""title 非空 -> 不回填(既有语义不变)。"""
|
||
src = normalize_source({
|
||
"title": "Real Title",
|
||
"source": "Entity",
|
||
"paper_title": "Other",
|
||
"metadata": {"paper_title": "Meta"},
|
||
})
|
||
assert src["title"] == "Real Title"
|
||
assert src["metadata"]["source_entity"] == "Entity"
|
||
|
||
|
||
def test_normalize_source_title_all_empty_stays_none():
|
||
"""全空 -> 保持 null(前端有 fallback)。"""
|
||
src = normalize_source({"content": "snippet only", "paper_id": "P1"})
|
||
assert src["title"] is None
|
||
assert src["snippet"] == "snippet only"
|
||
|
||
|
||
def test_normalize_source_alias_chains():
|
||
src = normalize_source({
|
||
"content": "snippet text",
|
||
"doc_id": "doc-1",
|
||
"confidence": 0.5,
|
||
"assertion_id": "cass_x",
|
||
})
|
||
assert src["snippet"] == "snippet text"
|
||
assert src["paper_id"] == "doc-1"
|
||
assert src["score"] == 0.5
|
||
assert src["metadata"]["assertion_id"] == "cass_x"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §9 图谱
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_normalize_hop_aliases():
|
||
hop = normalize_hop({
|
||
"from_name": "A", "from_id": "cent_a", "source_type": "gene",
|
||
"to_name": "B", "to_id": "cent_b", "target_type": "species",
|
||
"relation": "characterized_in", "paper_id": "P1", "pmid": "1",
|
||
"pmcid": None, "doi": "10.1/x",
|
||
})
|
||
assert hop["source"] == "A"
|
||
assert hop["target"] == "B"
|
||
assert hop["source_id"] == "cent_a"
|
||
assert hop["target_id"] == "cent_b"
|
||
assert hop["source_type"] == "gene"
|
||
assert hop["target_type"] == "species"
|
||
assert hop["relation"] == "characterized_in"
|
||
assert hop["paper_id"] == "P1"
|
||
|
||
|
||
def test_extract_chains_both_shapes():
|
||
raw = load_raw()
|
||
chains = extract_chains(raw) # 优先 native_graph_chains(列表形态)
|
||
assert len(chains) == 1
|
||
assert chains[0]["chain_id"] == "G1"
|
||
assert len(chains[0]["hops"]) == 1
|
||
|
||
# dict 形态(graph_knowledge_chains)→ 取 .chains
|
||
chains_dict = extract_chains({"graph_knowledge_chains": raw["graph_knowledge_chains"]})
|
||
assert len(chains_dict) == 1
|
||
assert chains_dict[0]["chain_id"] == "G1"
|
||
assert len(chains_dict[0]["hops"]) == 2
|
||
|
||
|
||
def test_normalize_graph_nodes_edges_chains():
|
||
g = normalize_graph(load_raw())
|
||
assert isinstance(g, dict) and set(g.keys()) == {"nodes", "edges", "chains"}
|
||
assert len(g["chains"]) == 1
|
||
assert g["chains"][0]["chain_id"] == "G1"
|
||
assert len(g["chains"][0]["hops"]) == 1 # native_graph_chains 里只有 1 跳
|
||
|
||
# 节点:按 id 去重,label 取名字、type 取 source_type/target_type
|
||
ids = [n["id"] for n in g["nodes"]]
|
||
assert "cent_spl" in ids and "cent_oryza" in ids
|
||
assert len(ids) == len(set(ids))
|
||
by_id = {n["id"]: n for n in g["nodes"]}
|
||
assert by_id["cent_spl"]["label"] == "SPL gene"
|
||
assert by_id["cent_spl"]["type"] == "gene_family"
|
||
assert by_id["cent_oryza"]["type"] == "species"
|
||
|
||
# 边:带 chain_id / hop / paper_id
|
||
assert len(g["edges"]) == 1
|
||
edge = g["edges"][0]
|
||
assert edge["source"] == "cent_spl"
|
||
assert edge["target"] == "cent_oryza"
|
||
assert edge["relation"] == "characterized_in"
|
||
assert edge["label"] == "characterized_in"
|
||
assert edge["chain_id"] == "G1"
|
||
assert edge["hop"] == 0
|
||
assert edge["paper_id"] == "PMC5488377_12864_2017_Article_3882"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §7 归一化 QA 响应
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_normalize_qa_response():
|
||
out = normalize_qa_response(load_raw())
|
||
assert out["query_id"] == "20260825_000715_49f801a9"
|
||
assert out["version"] == "1.1.73"
|
||
assert out["answer"] == "" # search 模式空
|
||
assert out["success"] is True
|
||
assert out["source_count"] == 1
|
||
assert isinstance(out["sources"], list) and len(out["sources"]) == 1
|
||
assert out["sources"][0]["id"] == "S1"
|
||
assert out["used_sources"] == []
|
||
# timing:真实键 timings → timing
|
||
assert out["timing"]["total"] == 91.9
|
||
# citation_validation → citation_map
|
||
assert out["citation_map"]["valid"] is True
|
||
# query_plan → retrieval_plan
|
||
assert "agentic_plan" in out["retrieval_plan"]
|
||
# graph 与 chains 同源
|
||
assert len(out["graph"]["chains"]) == 1
|
||
assert out["chains"][0]["chain_id"] == "G1"
|
||
# raw 保存原始完整响应
|
||
assert out["raw"]["question"] == "What is the SPL gene family?"
|
||
|
||
|
||
def test_normalize_qa_response_alt_aliases():
|
||
out = normalize_qa_response({
|
||
"response": "answer text",
|
||
"citations": [{"evidence_id": "E1", "paper_title": "T", "final_score": 0.9}],
|
||
"used_sources": [],
|
||
"agentic_plan": {"anchor": "x"},
|
||
"timing": {"total_s": 1.0},
|
||
"citation_map": {"valid": False},
|
||
"notes": "n",
|
||
})
|
||
assert out["answer"] == "answer text"
|
||
assert out["sources"][0]["title"] == "T"
|
||
assert out["retrieval_plan"] == {"anchor": "x"}
|
||
assert out["timing"] == {"total_s": 1.0}
|
||
assert out["citation_map"] == {"valid": False}
|
||
assert out["retrieval_notes"] == "n"
|