新增 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>
100 lines
3.6 KiB
Python
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()
|