Files
PlantAI-rag-new/sdk/app/services/test_normalize.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

206 lines
7.7 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"] == {}
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"