"""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"