- 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>
206 lines
7.7 KiB
Python
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"
|