新增 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>
167 lines
7.2 KiB
Python
167 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Independently cross-verify a single candidate citation. This is the anti-
|
|
hallucination check: it never trusts a single source. If a check cannot be
|
|
run at all (e.g. no network), that check is reported as "skipped" -- never
|
|
silently counted as a pass.
|
|
|
|
CLI usage:
|
|
python3 verify_citation.py --title "Compensation of magnetic ..." \\
|
|
--arxiv-id 2401.12345 --doi 10.1109/TGRS.2024.1234567
|
|
|
|
Importable:
|
|
from verify_citation import verify
|
|
"""
|
|
import sys
|
|
import os
|
|
import json
|
|
import argparse
|
|
import difflib
|
|
import urllib.parse
|
|
import urllib.error
|
|
import xml.etree.ElementTree as ET
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from http_utils import fetch
|
|
|
|
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
|
|
|
|
|
def title_similarity(a, b):
|
|
if not a or not b:
|
|
return 0.0
|
|
return difflib.SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
|
|
|
|
|
|
def check_arxiv_id(arxiv_id, timeout=20):
|
|
"""Confirm an arXiv ID actually resolves to a real paper."""
|
|
try:
|
|
url = f"http://export.arxiv.org/api/query?id_list={urllib.parse.quote(arxiv_id)}"
|
|
data = fetch(url, timeout=timeout)
|
|
root = ET.fromstring(data)
|
|
entry = root.find("atom:entry", ATOM_NS)
|
|
if entry is None:
|
|
return {"status": "fail", "reason": "arXiv ID not found"}
|
|
title_el = entry.find("atom:title", ATOM_NS)
|
|
title = " ".join(title_el.text.split()) if title_el is not None else None
|
|
return {"status": "pass", "canonical_title": title}
|
|
except Exception as e:
|
|
return {"status": "skipped", "reason": str(e)}
|
|
|
|
|
|
def check_doi(doi, timeout=20):
|
|
"""Confirm a DOI actually resolves via Crossref."""
|
|
try:
|
|
url = f"https://api.crossref.org/works/{urllib.parse.quote(doi)}"
|
|
data = json.loads(fetch(url, headers={"User-Agent": "literature-search-verify-skill/1.0"}, timeout=timeout))
|
|
titles = data.get("message", {}).get("title") or []
|
|
return {"status": "pass", "canonical_title": titles[0] if titles else None}
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return {"status": "fail", "reason": "DOI not found in Crossref"}
|
|
return {"status": "skipped", "reason": f"HTTP {e.code}"}
|
|
except Exception as e:
|
|
return {"status": "skipped", "reason": str(e)}
|
|
|
|
|
|
def _best_title_match(candidates, title, title_field="title"):
|
|
best = max(candidates, key=lambda p: title_similarity(title, p.get(title_field, "") or ""))
|
|
sim = title_similarity(title, best.get(title_field, "") or "")
|
|
return sim, best.get(title_field)
|
|
|
|
|
|
def check_title_cross_source_s2(title, timeout=20):
|
|
"""Independently re-search by title on Semantic Scholar and require a
|
|
near-exact title match. This is what catches a plausible-sounding but
|
|
entirely invented title/author combination."""
|
|
try:
|
|
params = {"query": title, "limit": 3, "fields": "title"}
|
|
url = f"https://api.semanticscholar.org/graph/v1/paper/search?{urllib.parse.urlencode(params)}"
|
|
data = json.loads(fetch(url, timeout=timeout))
|
|
candidates = data.get("data", []) or []
|
|
if not candidates:
|
|
return {"status": "fail", "reason": "no matching title found on Semantic Scholar"}
|
|
sim, matched = _best_title_match(candidates, title)
|
|
status = "pass" if sim >= 0.9 else "fail"
|
|
return {"status": status, "similarity": round(sim, 3), "matched_title": matched}
|
|
except Exception as e:
|
|
return {"status": "skipped", "reason": str(e)}
|
|
|
|
|
|
def check_title_cross_source_openalex(title, timeout=20):
|
|
"""Same idea as check_title_cross_source_s2 but against OpenAlex -- a
|
|
second, independent source for the title cross-check so a single source
|
|
(S2 in particular has real-world rate-limit issues) being unavailable
|
|
doesn't drop this whole check to 'skipped' for a batch of candidates."""
|
|
try:
|
|
params = {"search": title, "per_page": 3, "select": "display_name"}
|
|
url = f"https://api.openalex.org/works?{urllib.parse.urlencode(params)}"
|
|
data = json.loads(fetch(url, timeout=timeout))
|
|
candidates = data.get("results", []) or []
|
|
if not candidates:
|
|
return {"status": "fail", "reason": "no matching title found on OpenAlex"}
|
|
sim, matched = _best_title_match(candidates, title, title_field="display_name")
|
|
status = "pass" if sim >= 0.9 else "fail"
|
|
return {"status": status, "similarity": round(sim, 3), "matched_title": matched}
|
|
except Exception as e:
|
|
return {"status": "skipped", "reason": str(e)}
|
|
|
|
|
|
def _combine_title_checks(s2_result, openalex_result):
|
|
"""Merge the two independent title cross-checks into one verdict-relevant
|
|
check. Coverage differs between S2 and OpenAlex, so one source not
|
|
indexing a (real) paper yet is common and must not, by itself, read as
|
|
"actively contradicted" the way a single-source design would have taken
|
|
it -- a pass from either source is enough; it only counts as an active
|
|
fail once neither source could corroborate it and at least one of them
|
|
genuinely searched (wasn't just skipped for network reasons)."""
|
|
sources = {"semantic_scholar": s2_result, "openalex": openalex_result}
|
|
passing = [r for r in sources.values() if r["status"] == "pass"]
|
|
if passing:
|
|
best = max(passing, key=lambda r: r.get("similarity", 0))
|
|
return {"status": "pass", "sources": sources, "similarity": best.get("similarity"),
|
|
"matched_title": best.get("matched_title")}
|
|
non_skipped = [r for r in sources.values() if r["status"] != "skipped"]
|
|
if non_skipped:
|
|
return {"status": "fail", "sources": sources,
|
|
"reason": "no title match on either Semantic Scholar or OpenAlex"}
|
|
return {"status": "skipped", "sources": sources, "reason": "both sources unavailable"}
|
|
|
|
|
|
def verify(title=None, arxiv_id=None, doi=None):
|
|
checks = {}
|
|
if arxiv_id:
|
|
checks["arxiv_id_check"] = check_arxiv_id(arxiv_id)
|
|
if doi:
|
|
checks["doi_check"] = check_doi(doi)
|
|
if title:
|
|
checks["title_cross_source_check"] = _combine_title_checks(
|
|
check_title_cross_source_s2(title),
|
|
check_title_cross_source_openalex(title),
|
|
)
|
|
|
|
passed = [c for c in checks.values() if c["status"] == "pass"]
|
|
failed = [c for c in checks.values() if c["status"] == "fail"]
|
|
|
|
if failed:
|
|
verdict = "suspect" # something actively contradicted it
|
|
elif passed:
|
|
verdict = "verified" # at least one independent check passed
|
|
else:
|
|
verdict = "unverified" # everything skipped (e.g. no network) -- NOT the same as verified
|
|
|
|
return {"title": title, "arxiv_id": arxiv_id, "doi": doi, "verdict": verdict, "checks": checks}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--title", default=None)
|
|
ap.add_argument("--arxiv-id", default=None)
|
|
ap.add_argument("--doi", default=None)
|
|
args = ap.parse_args()
|
|
if not any([args.title, args.arxiv_id, args.doi]):
|
|
print(json.dumps({"error": "provide at least one of --title/--arxiv-id/--doi"}))
|
|
sys.exit(1)
|
|
result = verify(args.title, args.arxiv_id, args.doi)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|