zhoujie ea69788d0c feat(literature-search-verify): 新增 OpenAlex 检索源、限流重试与单元测试
新增 scripts/http_utils.py 统一封装 HTTP 请求的限流重试(429/5xx 指数退避,
优先遵守服务端 Retry-After),search_arxiv/crossref/semantic_scholar.py 和
verify_citation.py 都已接入,不再各自裸调 urllib。

新增 scripts/search_openalex.py 作为第四个检索源:免费、无需 API key,覆盖面
比单独的 Crossref 更广,还能拿到开放获取PDF直链;已接入 literature_search.py
的主检索流程。verify_citation.py 的跨源标题核查同步改为同时查 Semantic
Scholar 和 OpenAlex 两个独立源、任一命中相似度达标即通过,不再单点依赖 S2——
这是针对"S2 被限流导致整批候选退化成 unverified"这个实际发生过的问题的直接
修复,已用真实网络请求验证:复测中 S2 确实当场返回了 429,靠 OpenAlex 兜底
最终判定仍然是 verified。

顺带修了 archive_references.py 的 slugify(),之前中文主题名会被正则全部
过滤掉、退化成通用的 "references",导致不同中文主题的归档目录互相冲突。

scripts/tests/ 下补了 37 个 unittest(全部 mock 网络请求,不发真实请求),
覆盖 verify_citation 的三档判定和双源核查合并逻辑、archive_references 的
bib 解析边界情况(嵌套花括号、中文主题名)、literature_search 的候选去重
合并与 BibTeX 生成、http_utils 的重试逻辑。用标准库 unittest 而不是 pytest,
和这些脚本本身不引入第三方依赖的原则保持一致。

CLAUDE.md 的下一步计划里,这一项已标记为完成。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:53:27 -10:00

100 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for archive_references.py's slugify() and the minimal bib parser,
covering the edge cases that are easy to get wrong silently: CJK topic names
and nested-brace LaTeX field values (e.g. \\ensuremath{\\epsilon}).
Run: python -m unittest discover -s .claude/skills/literature-search-verify/scripts
"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
import archive_references as ar
class TestSlugify(unittest.TestCase):
def test_ascii_topic(self):
self.assertEqual(ar.slugify("UAV aeromagnetic compensation"), "uav_aeromagnetic_compensation")
def test_chinese_topic_is_preserved_not_collapsed_to_generic_name(self):
slug = ar.slugify("无人机磁补偿")
self.assertNotEqual(slug, "references")
self.assertIn("补偿", slug)
def test_mixed_chinese_and_english(self):
slug = ar.slugify("无人机磁补偿 Tolles-Lawson")
self.assertTrue(slug.endswith("tolles_lawson"))
def test_blank_topic_falls_back_to_references(self):
self.assertEqual(ar.slugify(" "), "references")
def test_truncated_to_60_chars(self):
self.assertEqual(len(ar.slugify("a" * 100)), 60)
class TestParseBibEntries(unittest.TestCase):
def _write_bib(self, content):
fd, path = tempfile.mkstemp(suffix=".bib")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(os.remove, path)
return path
def test_nested_braces_in_field_value_do_not_truncate_it(self):
bib = (
"@article{wu2017,\n"
" title = {Aeromagnetic gradient compensation using \\ensuremath{\\epsilon}-SVR},\n"
" journal = {Journal of Applied Remote Sensing},\n"
" year = {2017},\n"
" doi = {10.1117/1.jrs.11.025012},\n"
"}"
)
entries = ar.parse_bib_entries(self._write_bib(bib))
self.assertEqual(len(entries), 1)
self.assertIn("\\ensuremath{\\epsilon}-SVR", entries[0]["title"])
self.assertEqual(entries[0]["year"], "2017")
def test_multiple_entries_and_optional_fields(self):
bib = (
"@article{a2020,\n"
" title = {First paper},\n"
" year = {2020},\n"
"}\n\n"
"@article{b2021,\n"
" title = {Second paper},\n"
" year = {2021},\n"
" note = {some note},\n"
"}"
)
entries = ar.parse_bib_entries(self._write_bib(bib))
self.assertEqual([e["key"] for e in entries], ["a2020", "b2021"])
self.assertNotIn("note", entries[0])
self.assertEqual(entries[1]["note"], "some note")
class TestYearSortKey(unittest.TestCase):
def test_entries_without_a_parseable_year_sort_last(self):
entries = [{"key": "b", "year": ""}, {"key": "a", "year": "1999"}]
entries.sort(key=ar.year_sort_key)
self.assertEqual([e["key"] for e in entries], ["a", "b"])
class TestBuildReadme(unittest.TestCase):
def test_omits_optional_sections_when_not_given(self):
readme = ar.build_readme("Topic", [], 0, [], None)
self.assertNotIn("Flagged during search", readme)
self.assertNotIn("Search coverage notes", readme)
def test_includes_suspect_and_notes_when_given(self):
readme = ar.build_readme("Topic", [], 0, ["Bad title|dubious venue"], "coverage notes here")
self.assertIn("Bad title", readme)
self.assertIn("dubious venue", readme)
self.assertIn("coverage notes here", readme)
if __name__ == "__main__":
unittest.main()