新增 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>
95 lines
4.4 KiB
Python
95 lines
4.4 KiB
Python
#!/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()
|