Files
PlantAI-rag-new/backend/app/services/test_normalize.py
T
jerryandClaude Fable 5 1e94de7464 PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:47:52 +08:00

206 lines
7.7 KiB
Python

"""services/test_normalize.py —— 归一化器单测。
输入用 backend/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"] == {}
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"