diff --git a/sdk/app/services/normalize.py b/sdk/app/services/normalize.py index fc3d40d..0fd94b6 100644 --- a/sdk/app/services/normalize.py +++ b/sdk/app/services/normalize.py @@ -20,6 +20,16 @@ def pick(obj: dict, *names: str, default=None): # --------------------------------------------------------------------------- # §8 Source 归一化(缺失字段键必须存在,值为 null) # --------------------------------------------------------------------------- +# title 回填优先级(缺陷 #27,2026-09-11):title/paper_title 缺失时, +# 依次从以下字段取可读文本(论文标题优先于实体名),全部缺失才保持 null。 +# 排除 content/evidence/text/snippet 正文字段——正文片段不是标题。 +_TITLE_FALLBACK_TOPLEVEL = ( + "publication_title", "article_title", "journal_title", "name", +) +_TITLE_FALLBACK_METADATA = ( + "paper_title", "publication_title", "article_title", "journal_title", + "source_entity", "name", "source", +) def normalize_source(item: dict[str, Any] | None) -> dict[str, Any]: """单个 source item → API.md §8 形态(所有键存在,缺失为 null)。 @@ -41,6 +51,23 @@ def normalize_source(item: dict[str, Any] | None) -> dict[str, Any]: source = pick(item, "source") # 断言主体实体名(上游语义) title = pick(item, "title", "paper_title") + # 缺陷 #27:title 为空时按固定优先级回填—— + # 顶层标题类字段 → metadata 标题类字段 → 顶层 source(断言主体实体名)。 + # 正文字段绝不参与(正文片段不是标题)。 + if not title: + for _key in _TITLE_FALLBACK_TOPLEVEL: + _candidate = pick(item, _key) + if _candidate: + title = _candidate + break + if not title and isinstance(pick(item, "metadata"), dict): + for _key in _TITLE_FALLBACK_METADATA: + _candidate = pick(item["metadata"], _key) + if _candidate: + title = _candidate + break + if not title: + title = source doi = pick(item, "doi") url = pick(item, "url", "doi_url") diff --git a/sdk/app/services/test_normalize.py b/sdk/app/services/test_normalize.py index 8723245..f51a207 100644 --- a/sdk/app/services/test_normalize.py +++ b/sdk/app/services/test_normalize.py @@ -84,6 +84,52 @@ def test_normalize_source_only_doi_derives_url(): 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",