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>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for http_utils.fetch's retry/backoff behavior. All network calls
|
||||
are mocked -- these never touch the real network.
|
||||
|
||||
Run: python -m unittest discover -s .claude/skills/literature-search-verify/scripts
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from email.message import Message
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
import http_utils
|
||||
|
||||
|
||||
def _http_error(code, retry_after=None):
|
||||
hdrs = Message()
|
||||
if retry_after is not None:
|
||||
hdrs["Retry-After"] = str(retry_after)
|
||||
return urllib.error.HTTPError("http://example.com", code, "err", hdrs, None)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
|
||||
class TestFetch(unittest.TestCase):
|
||||
@mock.patch("urllib.request.urlopen")
|
||||
@mock.patch("http_utils.time.sleep", return_value=None)
|
||||
def test_succeeds_first_try_without_sleeping(self, mock_sleep, mock_urlopen):
|
||||
mock_urlopen.return_value = FakeResponse(b"ok")
|
||||
result = http_utils.fetch("http://example.com")
|
||||
self.assertEqual(result, b"ok")
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@mock.patch("urllib.request.urlopen")
|
||||
@mock.patch("http_utils.time.sleep", return_value=None)
|
||||
def test_retries_on_429_then_succeeds(self, mock_sleep, mock_urlopen):
|
||||
mock_urlopen.side_effect = [_http_error(429), FakeResponse(b"ok")]
|
||||
result = http_utils.fetch("http://example.com", max_retries=3)
|
||||
self.assertEqual(result, b"ok")
|
||||
self.assertEqual(mock_urlopen.call_count, 2)
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@mock.patch("urllib.request.urlopen")
|
||||
@mock.patch("http_utils.time.sleep", return_value=None)
|
||||
def test_retries_on_5xx(self, mock_sleep, mock_urlopen):
|
||||
mock_urlopen.side_effect = [_http_error(503), FakeResponse(b"ok")]
|
||||
result = http_utils.fetch("http://example.com", max_retries=3)
|
||||
self.assertEqual(result, b"ok")
|
||||
|
||||
@mock.patch("urllib.request.urlopen")
|
||||
@mock.patch("http_utils.time.sleep", return_value=None)
|
||||
def test_raises_after_exhausting_retries(self, mock_sleep, mock_urlopen):
|
||||
mock_urlopen.side_effect = [_http_error(429)] * 10
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
http_utils.fetch("http://example.com", max_retries=2)
|
||||
self.assertEqual(mock_urlopen.call_count, 3) # initial attempt + 2 retries
|
||||
|
||||
@mock.patch("urllib.request.urlopen")
|
||||
@mock.patch("http_utils.time.sleep", return_value=None)
|
||||
def test_non_retryable_status_raises_immediately(self, mock_sleep, mock_urlopen):
|
||||
mock_urlopen.side_effect = _http_error(404)
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
http_utils.fetch("http://example.com")
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
class TestWaitSeconds(unittest.TestCase):
|
||||
def test_honors_retry_after_header(self):
|
||||
err = _http_error(429, retry_after=5)
|
||||
self.assertEqual(http_utils._wait_seconds(err, attempt=0), 5.0)
|
||||
|
||||
def test_falls_back_to_exponential_backoff_without_header(self):
|
||||
err = _http_error(429)
|
||||
self.assertAlmostEqual(http_utils._wait_seconds(err, attempt=2, base=1.0), 4.0)
|
||||
|
||||
def test_caps_wait_time(self):
|
||||
err = _http_error(429, retry_after=999)
|
||||
self.assertEqual(http_utils._wait_seconds(err, attempt=0, cap=20.0), 20.0)
|
||||
|
||||
def test_ignores_unparseable_retry_after(self):
|
||||
err = _http_error(429, retry_after="not-a-number")
|
||||
self.assertAlmostEqual(http_utils._wait_seconds(err, attempt=0, base=1.0), 1.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for literature_search.py's candidate merging and BibTeX generation.
|
||||
|
||||
Run: python -m unittest discover -s .claude/skills/literature-search-verify/scripts
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
import literature_search as ls
|
||||
|
||||
|
||||
class TestMergeCandidates(unittest.TestCase):
|
||||
def test_dedupes_same_paper_across_sources_and_fills_missing_fields(self):
|
||||
items = [
|
||||
{"source": "arxiv", "title": "Deep Learning for Aeromagnetic Compensation",
|
||||
"arxiv_id": "2401.00001", "doi": None, "year": "2024"},
|
||||
{"source": "crossref", "title": "Deep Learning for Aeromagnetic Compensation",
|
||||
"arxiv_id": None, "doi": "10.1/x", "year": 2024, "venue": "Some Journal"},
|
||||
]
|
||||
merged = ls.merge_candidates(items)
|
||||
self.assertEqual(len(merged), 1)
|
||||
self.assertEqual(merged[0]["arxiv_id"], "2401.00001")
|
||||
self.assertEqual(merged[0]["doi"], "10.1/x")
|
||||
self.assertEqual(sorted(merged[0]["sources"]), ["arxiv", "crossref"])
|
||||
|
||||
def test_distinct_titles_are_not_merged(self):
|
||||
items = [
|
||||
{"source": "arxiv", "title": "Paper About Cats"},
|
||||
{"source": "crossref", "title": "Paper About Dogs"},
|
||||
]
|
||||
self.assertEqual(len(ls.merge_candidates(items)), 2)
|
||||
|
||||
|
||||
class TestBibtexKey(unittest.TestCase):
|
||||
def test_collision_gets_letter_suffix(self):
|
||||
used = set()
|
||||
key1 = ls.make_bibtex_key({"authors": ["Jane Smith"], "year": "2020"}, used)
|
||||
key2 = ls.make_bibtex_key({"authors": ["John Smith"], "year": "2020"}, used)
|
||||
self.assertEqual(key1, "smith2020")
|
||||
self.assertEqual(key2, "smith2020a")
|
||||
|
||||
def test_no_authors_falls_back_to_unknown(self):
|
||||
key = ls.make_bibtex_key({"authors": [], "year": "2020"}, set())
|
||||
self.assertEqual(key, "unknown2020")
|
||||
|
||||
|
||||
class TestToBibtex(unittest.TestCase):
|
||||
def test_arxiv_only_candidate_uses_misc_with_eprint(self):
|
||||
cand = {"authors": ["A B"], "title": "T", "year": "2024",
|
||||
"arxiv_id": "2401.00001", "venue": None, "doi": None}
|
||||
bib = ls.to_bibtex(cand, "b2024")
|
||||
self.assertTrue(bib.startswith("@misc{b2024,"))
|
||||
self.assertIn("eprint = {2401.00001}", bib)
|
||||
|
||||
def test_candidate_with_venue_uses_article_with_doi(self):
|
||||
cand = {"authors": ["A B"], "title": "T", "year": "2024",
|
||||
"arxiv_id": None, "venue": "Some Journal", "doi": "10.1/x"}
|
||||
bib = ls.to_bibtex(cand, "b2024")
|
||||
self.assertTrue(bib.startswith("@article{b2024,"))
|
||||
self.assertIn("doi = {10.1/x}", bib)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for verify_citation's verdict aggregation logic -- this is the
|
||||
anti-hallucination guarantee, so its three-way verified/suspect/unverified
|
||||
decision needs to stay correct independent of any real network call. All
|
||||
individual check_* functions are mocked; only the aggregation logic itself
|
||||
(verify() and _combine_title_checks()) is under test.
|
||||
|
||||
Run: python -m unittest discover -s .claude/skills/literature-search-verify/scripts
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
import verify_citation as vc
|
||||
|
||||
|
||||
class TestCombineTitleChecks(unittest.TestCase):
|
||||
"""Two independent title sources (S2 + OpenAlex) shouldn't let one
|
||||
source's incomplete coverage outvote the other's confirmation."""
|
||||
|
||||
def test_both_pass_picks_higher_similarity(self):
|
||||
s2 = {"status": "pass", "similarity": 0.95, "matched_title": "A"}
|
||||
oa = {"status": "pass", "similarity": 0.99, "matched_title": "B"}
|
||||
combined = vc._combine_title_checks(s2, oa)
|
||||
self.assertEqual(combined["status"], "pass")
|
||||
self.assertEqual(combined["matched_title"], "B")
|
||||
|
||||
def test_one_pass_one_fail_still_passes(self):
|
||||
s2 = {"status": "fail", "reason": "no matching title found on Semantic Scholar"}
|
||||
oa = {"status": "pass", "similarity": 0.95, "matched_title": "B"}
|
||||
self.assertEqual(vc._combine_title_checks(s2, oa)["status"], "pass")
|
||||
|
||||
def test_one_pass_one_skipped_still_passes(self):
|
||||
s2 = {"status": "skipped", "reason": "timeout"}
|
||||
oa = {"status": "pass", "similarity": 0.95, "matched_title": "B"}
|
||||
self.assertEqual(vc._combine_title_checks(s2, oa)["status"], "pass")
|
||||
|
||||
def test_both_fail_is_fail(self):
|
||||
s2 = {"status": "fail", "reason": "x"}
|
||||
oa = {"status": "fail", "reason": "y"}
|
||||
self.assertEqual(vc._combine_title_checks(s2, oa)["status"], "fail")
|
||||
|
||||
def test_fail_plus_skipped_is_still_fail(self):
|
||||
s2 = {"status": "fail", "reason": "x"}
|
||||
oa = {"status": "skipped", "reason": "y"}
|
||||
self.assertEqual(vc._combine_title_checks(s2, oa)["status"], "fail")
|
||||
|
||||
def test_both_skipped_is_skipped_not_fail(self):
|
||||
s2 = {"status": "skipped", "reason": "x"}
|
||||
oa = {"status": "skipped", "reason": "y"}
|
||||
self.assertEqual(vc._combine_title_checks(s2, oa)["status"], "skipped")
|
||||
|
||||
|
||||
class TestVerifyVerdict(unittest.TestCase):
|
||||
def test_all_checks_pass_is_verified(self):
|
||||
with mock.patch.object(vc, "check_arxiv_id", return_value={"status": "pass"}), \
|
||||
mock.patch.object(vc, "check_doi", return_value={"status": "pass"}), \
|
||||
mock.patch.object(vc, "check_title_cross_source_s2", return_value={"status": "pass", "similarity": 1.0}), \
|
||||
mock.patch.object(vc, "check_title_cross_source_openalex", return_value={"status": "pass", "similarity": 1.0}):
|
||||
result = vc.verify(title="T", arxiv_id="1234.5678", doi="10.1/x")
|
||||
self.assertEqual(result["verdict"], "verified")
|
||||
|
||||
def test_one_failing_check_is_suspect_even_if_another_passes(self):
|
||||
with mock.patch.object(vc, "check_arxiv_id", return_value={"status": "fail", "reason": "arXiv ID not found"}), \
|
||||
mock.patch.object(vc, "check_doi", return_value={"status": "pass"}):
|
||||
result = vc.verify(arxiv_id="9999.99999", doi="10.1/x")
|
||||
self.assertEqual(result["verdict"], "suspect")
|
||||
|
||||
def test_all_checks_skipped_is_unverified_not_verified(self):
|
||||
with mock.patch.object(vc, "check_arxiv_id", return_value={"status": "skipped", "reason": "timeout"}):
|
||||
result = vc.verify(arxiv_id="1234.5678")
|
||||
self.assertEqual(result["verdict"], "unverified")
|
||||
|
||||
def test_no_identifiers_given_produces_no_checks_and_unverified(self):
|
||||
result = vc.verify(title=None, arxiv_id=None, doi=None)
|
||||
self.assertEqual(result["checks"], {})
|
||||
self.assertEqual(result["verdict"], "unverified")
|
||||
|
||||
|
||||
class TestTitleSimilarity(unittest.TestCase):
|
||||
def test_identical_titles_score_one(self):
|
||||
self.assertEqual(vc.title_similarity("Same Title", "same title"), 1.0)
|
||||
|
||||
def test_empty_or_missing_inputs_score_zero(self):
|
||||
self.assertEqual(vc.title_similarity("", "x"), 0.0)
|
||||
self.assertEqual(vc.title_similarity(None, "x"), 0.0)
|
||||
self.assertEqual(vc.title_similarity("x", None), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user