fix(r4-27): normalize_source title 空值回填(实体名/元数据标题字段,正文片段绝不参与)

消息级 sources items 的 title 为空导致前端文献列表渲染出正文片段。
回填优先级:顶层标题类字段(publication_title/article_title/journal_title/name)
→ metadata 标题类字段(paper_title/source_entity/name/source)
→ 顶层 source(断言主体实体名);全部缺失保持 null(前端有 fallback)。
paper_id/score/snippet 等既有字段不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:18:43 +08:00
co-authored by Claude Fable 5
parent 7840527f82
commit 00eb0bc1d0
2 changed files with 73 additions and 0 deletions
+27
View File
@@ -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")
+46
View File
@@ -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",