Add Crossref bibliographic-query resolver as fallback (step 5)
Browse filesDiagnoses + fixes the dominant "unverifiable" failure mode: the extractor
under-populates structured fields (title/doi/arxiv_id) for journal classics
like Landauer 1961, Tononi 2004, Shannon 1948 even when the raw citation
string is intact. The verifier then has nothing to feed its catalog searches.
Three additive changes (no existing path removed, no behavior downgraded):
- submission_intake.search_crossref_bibliographic: hits
/works?query.bibliographic=<raw> which accepts the whole raw citation
string and returns ranked candidate works. Mirrors fetch_crossref_metadata
style; returns up to N candidates with score field.
- citation_verify._parse_title_from_raw: conservative regex lift of the title
from a raw "Author. (Year). Title. Venue, ..." citation. Used at the top
of verify_citation when the extractor left title empty, so the existing
arXiv-search + Semantic-Scholar steps also benefit. Returns "" if the
pattern doesn't cleanly match -- never invents data.
- citation_verify._search_crossref_bibliographic + step 5 in verify_citation:
feeds the raw string AS-IS to Crossref. Catches the class of refs with no
structured fields but a valid raw citation. Uses the same title/author/year
co-confirmation thresholds as steps 3-4. Crossref-only (no S2 fallback
here -- S2's free tier is rate-limited and the extractor-failure class is
precisely the one Crossref-biblio handles best).
Impact (re-processed all 5 Thornhill citation JSONs locally): 12 -> 85
verified (+73 recovered). Foundational refs that were silently dropped
before now resolve cleanly: Shannon 1948, Landauer 1961, Tononi 2004 IIT,
Friston 2010 free-energy, Bennett 1982, Hinton 2006, Pearson 1901 PCA,
Bellman 1961, Langton 1990, Scheffer 2009 early-warning signals, etc.
Idempotent re-processing is safe -- verifier only upgrades unverified ->
verified, never downgrades.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- citation_verify.py +95 -0
- submission_intake.py +63 -0
|
@@ -403,6 +403,61 @@ def _search_semanticscholar(query: str, year: int | None = None) -> dict | None:
|
|
| 403 |
}
|
| 404 |
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
def _normalize_for_match(s: str) -> str:
|
| 407 |
"""Canonicalize a string for fuzzy comparison."""
|
| 408 |
if not s:
|
|
@@ -469,6 +524,15 @@ def verify_citation(c: dict) -> dict:
|
|
| 469 |
"reason": "",
|
| 470 |
}
|
| 471 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
# 1. arXiv exact-id
|
| 473 |
if c.get("arxiv_id"):
|
| 474 |
r = _fetch_arxiv(c["arxiv_id"])
|
|
@@ -600,6 +664,37 @@ def verify_citation(c: dict) -> dict:
|
|
| 600 |
)
|
| 601 |
return out
|
| 602 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
out["reason"] = "No exact identifier and no title for catalog search."
|
| 604 |
return out
|
| 605 |
|
|
|
|
| 403 |
}
|
| 404 |
|
| 405 |
|
| 406 |
+
_RAW_TITLE_RE = re.compile(
|
| 407 |
+
r"\(\s*(?P<year>\d{4})[a-z]?\s*\)\.\s*(?P<title>[^.]+?)\.\s+(?:[A-Z][a-z]|\d)",
|
| 408 |
+
re.S,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _parse_title_from_raw(raw: str) -> str:
|
| 413 |
+
"""Conservative regex lift of the title from a raw citation string like
|
| 414 |
+
'Landauer, R. (1961). Irreversibility and Heat Generation... IBM J...'.
|
| 415 |
+
|
| 416 |
+
Used only when the extractor left ``title`` empty. Returns "" if the
|
| 417 |
+
pattern doesn't cleanly match; never invents data.
|
| 418 |
+
"""
|
| 419 |
+
if not raw:
|
| 420 |
+
return ""
|
| 421 |
+
m = _RAW_TITLE_RE.search(raw)
|
| 422 |
+
if not m:
|
| 423 |
+
return ""
|
| 424 |
+
title = m.group("title").strip()
|
| 425 |
+
# reject candidates that look like author lists or are too short to be a title
|
| 426 |
+
if len(title) < 12 or title.count(",") > 3:
|
| 427 |
+
return ""
|
| 428 |
+
return title
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _search_crossref_bibliographic(raw: str, year: int | None = None) -> dict | None:
|
| 432 |
+
"""Crossref bibliographic-query resolver — feeds the raw citation string
|
| 433 |
+
AS-IS to /works?query.bibliographic=. Catches the class of refs the
|
| 434 |
+
extractor couldn't structure (no ``doi``/``arxiv_id``/``title``) but still
|
| 435 |
+
have the raw citation intact. Returns the top candidate in verifier shape,
|
| 436 |
+
or None on miss / network error.
|
| 437 |
+
"""
|
| 438 |
+
try:
|
| 439 |
+
results = submission_intake.search_crossref_bibliographic(raw, rows=5)
|
| 440 |
+
except Exception:
|
| 441 |
+
return None
|
| 442 |
+
if not results:
|
| 443 |
+
return None
|
| 444 |
+
if year:
|
| 445 |
+
with_year = [r for r in results if r.get("year") and abs(int(r["year"]) - int(year)) <= 1]
|
| 446 |
+
if with_year:
|
| 447 |
+
results = with_year
|
| 448 |
+
top = results[0]
|
| 449 |
+
if not top.get("title") or not top.get("doi"):
|
| 450 |
+
return None
|
| 451 |
+
return {
|
| 452 |
+
"resolver": "crossref",
|
| 453 |
+
"resolved_id": top.get("doi"),
|
| 454 |
+
"title": top.get("title", ""),
|
| 455 |
+
"abstract": top.get("abstract") or "",
|
| 456 |
+
"year": top.get("year"),
|
| 457 |
+
"authors": top.get("authors") or [],
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
|
| 461 |
def _normalize_for_match(s: str) -> str:
|
| 462 |
"""Canonicalize a string for fuzzy comparison."""
|
| 463 |
if not s:
|
|
|
|
| 524 |
"reason": "",
|
| 525 |
}
|
| 526 |
|
| 527 |
+
# Repair: if the extractor left `title` empty but the raw citation
|
| 528 |
+
# string is intact, lift the title from raw so the catalog searches
|
| 529 |
+
# below have something to query with. Mutates a local copy only.
|
| 530 |
+
if not c.get("title") and c.get("raw"):
|
| 531 |
+
parsed = _parse_title_from_raw(c["raw"])
|
| 532 |
+
if parsed:
|
| 533 |
+
c = dict(c)
|
| 534 |
+
c["title"] = parsed
|
| 535 |
+
|
| 536 |
# 1. arXiv exact-id
|
| 537 |
if c.get("arxiv_id"):
|
| 538 |
r = _fetch_arxiv(c["arxiv_id"])
|
|
|
|
| 664 |
)
|
| 665 |
return out
|
| 666 |
|
| 667 |
+
# 5. Crossref bibliographic-query — feeds the raw citation string AS-IS.
|
| 668 |
+
# Catches refs with no DOI/arXiv/title in the structured fields but a
|
| 669 |
+
# valid raw citation (the dominant failure mode for journal classics
|
| 670 |
+
# like Landauer/Shannon/Tononi/Friston that the extractor under-structures).
|
| 671 |
+
if c.get("raw") and len(c["raw"]) >= 20:
|
| 672 |
+
r = _search_crossref_bibliographic(c["raw"], year=c.get("year"))
|
| 673 |
+
if r:
|
| 674 |
+
title_ok = _title_matches(c.get("title"), r["title"]) if c.get("title") else False
|
| 675 |
+
authors_ok = _author_overlap(c.get("authors") or [], r.get("authors") or [])
|
| 676 |
+
year_ok = (
|
| 677 |
+
c.get("year") and r.get("year")
|
| 678 |
+
and abs(int(c["year"]) - int(r["year"])) <= 1
|
| 679 |
+
)
|
| 680 |
+
if (title_ok and authors_ok) or (title_ok and year_ok) or (authors_ok and year_ok):
|
| 681 |
+
conf = "title-author-match" if (title_ok and authors_ok) else "title-only-match"
|
| 682 |
+
out.update({
|
| 683 |
+
"verified": True,
|
| 684 |
+
"resolver": r["resolver"],
|
| 685 |
+
"resolved_id": r["resolved_id"],
|
| 686 |
+
"title": r["title"],
|
| 687 |
+
"abstract": r["abstract"],
|
| 688 |
+
"confidence": conf,
|
| 689 |
+
"reason": "Crossref bibliographic-query matched on raw citation string.",
|
| 690 |
+
})
|
| 691 |
+
return out
|
| 692 |
+
out["reason"] = (
|
| 693 |
+
"Crossref bibliographic-query returned a candidate but "
|
| 694 |
+
"title + author + year did not co-confirm."
|
| 695 |
+
)
|
| 696 |
+
return out
|
| 697 |
+
|
| 698 |
out["reason"] = "No exact identifier and no title for catalog search."
|
| 699 |
return out
|
| 700 |
|
|
@@ -427,6 +427,69 @@ def fetch_crossref_metadata(doi: str) -> dict | None:
|
|
| 427 |
}
|
| 428 |
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
def search_semanticscholar(query: str) -> list[dict]:
|
| 431 |
"""Semantic Scholar Graph API search.
|
| 432 |
|
|
|
|
| 427 |
}
|
| 428 |
|
| 429 |
|
| 430 |
+
def search_crossref_bibliographic(query: str, rows: int = 5) -> list[dict]:
|
| 431 |
+
"""Crossref REST: GET /works?query.bibliographic=<query>
|
| 432 |
+
|
| 433 |
+
Feed the raw citation string AS-IS — e.g. "Landauer, R. (1961).
|
| 434 |
+
Irreversibility and Heat Generation in the Computing Process. IBM J.
|
| 435 |
+
Res. Dev. 5(3), 183-191." Crossref indexes the whole reference and
|
| 436 |
+
returns ranked candidate works (DOI + title + authors + year).
|
| 437 |
+
|
| 438 |
+
Returns up to ``rows`` candidate dicts in the same shape as
|
| 439 |
+
fetch_crossref_metadata, with an extra ``score`` field (Crossref relevance
|
| 440 |
+
score). Returns [] on miss / network error / very short query.
|
| 441 |
+
"""
|
| 442 |
+
if not query or len(query.strip()) < 20:
|
| 443 |
+
return []
|
| 444 |
+
safe = urllib.parse.quote_plus(query.strip())
|
| 445 |
+
url = f"https://api.crossref.org/works?query.bibliographic={safe}&rows={rows}"
|
| 446 |
+
req = urllib.request.Request(
|
| 447 |
+
url,
|
| 448 |
+
headers={
|
| 449 |
+
"User-Agent": "ICSAC-pipeline/1.0 (mailto:info@icsacinstitute.org)",
|
| 450 |
+
"Accept": "application/json",
|
| 451 |
+
},
|
| 452 |
+
)
|
| 453 |
+
try:
|
| 454 |
+
with urllib.request.urlopen(req, timeout=CITATION_HTTP_TIMEOUT) as resp:
|
| 455 |
+
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
| 456 |
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
| 457 |
+
return []
|
| 458 |
+
items = (data.get("message") or {}).get("items") or []
|
| 459 |
+
out = []
|
| 460 |
+
for msg in items:
|
| 461 |
+
title_list = msg.get("title") or []
|
| 462 |
+
title = title_list[0].strip() if title_list else ""
|
| 463 |
+
if not title:
|
| 464 |
+
continue
|
| 465 |
+
abstract = msg.get("abstract") or ""
|
| 466 |
+
if abstract:
|
| 467 |
+
abstract = re.sub(r"<[^>]+>", "", abstract).strip()
|
| 468 |
+
authors = []
|
| 469 |
+
for a in msg.get("author", []) or []:
|
| 470 |
+
family = (a.get("family") or "").strip()
|
| 471 |
+
given = (a.get("given") or "").strip()
|
| 472 |
+
full = (f"{given} {family}").strip() or family or given
|
| 473 |
+
if full:
|
| 474 |
+
authors.append(full)
|
| 475 |
+
year = None
|
| 476 |
+
issued = msg.get("issued") or msg.get("published-print") or msg.get("published-online")
|
| 477 |
+
if issued and isinstance(issued.get("date-parts"), list) and issued["date-parts"]:
|
| 478 |
+
first = issued["date-parts"][0]
|
| 479 |
+
if first and isinstance(first[0], int):
|
| 480 |
+
year = first[0]
|
| 481 |
+
out.append({
|
| 482 |
+
"doi": (msg.get("DOI") or "").lower(),
|
| 483 |
+
"title": title,
|
| 484 |
+
"authors": authors,
|
| 485 |
+
"abstract": abstract,
|
| 486 |
+
"year": year,
|
| 487 |
+
"type": msg.get("type", ""),
|
| 488 |
+
"score": msg.get("score", 0.0),
|
| 489 |
+
})
|
| 490 |
+
return out
|
| 491 |
+
|
| 492 |
+
|
| 493 |
def search_semanticscholar(query: str) -> list[dict]:
|
| 494 |
"""Semantic Scholar Graph API search.
|
| 495 |
|