Skip to main content

Fetch URLs and verify that quoted text appears on the page. Extracted from proof-engine.

Project description

proof-citations

Verify scholarly citations: fetch URLs, resolve identifiers, compare bibliographic claims against authoritative registries, and confirm quoted text appears on the cited page.

Extracted from Proof Engine — the same engine that powers the proof corpus on proofengine.info.

Install

pip install proof-citations

Python 3.10+. Single hard dependency on requests; pdfplumber / PyPDF2 are pulled in for PDF citations.

What it does

LLMs (and humans) hallucinate citations. Real citation fraud is now sophisticated — papers cite a real PMID that resolves to a real paper, but the claimed journal/year/volume are forged. This package handles three tiers of verification:

Tier API Catches
Quote-on-page verify_citation(url, quote) The quoted text isn't actually on the cited page (paraphrased, fabricated, retracted).
Identifier resolution resolve("pmid:12345") The identifier doesn't exist.
Metadata comparison verify_citation_record(identifier, expected) The identifier resolves to a different paper than the claim describes ("metadata chimera" fraud).

All three return structured Python dicts and emit JSON for batch pipelines.

Quick start

Verify a quote on a page

from proof_citations import verify_citation

r = verify_citation(
    "https://www.ncbi.nlm.nih.gov/pubmed/33538338",
    "Global Cancer Statistics 2020",
    fact_id="B1",
)
print(r["status"])  # "verified" | "partial" | "not_found" | "fetch_failed"

Resolve an identifier to structured bibliographic metadata

from proof_citations import resolve

record = resolve(("pmid", "33538338"))
print(record.title)         # "Global Cancer Statistics 2020..."
print(record.venue)         # "CA: a cancer journal for clinicians"
print(record.year)          # 2021
print(record.doi)           # "10.3322/caac.21660"
print(record.update_status) # None | "retracted" | "expression_of_concern" | "corrigendum"

Supported identifier types out of the box: pmid, pmc, doi, arxiv, isbn, swhid, handle, url. PubMed and PubMed Central both use the NCBI E-utilities JSON API (set NCBI_API_KEY for ~10 req/sec; default ~3). pmc resolves via db=pmc esummary; because that endpoint omits pubtype / issn / lang, the backend makes a best-effort follow-up db=pubmed call when an articleids PMID cross-reference is present, to enrich retraction status, ISSN, and language. DOIs route through DataCite first, Crossref on 404. arXiv and Open Library handled natively.

Catch metadata-chimera fraud

from proof_citations import verify_citation_record

r = verify_citation_record(("pmid", "23260561"), expected={
    "title": "Ureteroenteric anastomotic strictures after radical cystectomy",
    "journal": "J Urol",
    "year": 2023,                                 # ← forged: real year is 2013
    "doi": "10.1016/j.juro.2012.11.001",          # ← forged
})

print(r["status"])      # "metadata_chimera"
print(r["verdict"])     # "metadata_chimera"
for m in r["mismatches"]:
    print(f"  {m['field']}: claimed={m['claimed']!r} resolved={m['resolved']!r}")
# year: claimed=2023 resolved=2013
# doi:  claimed='10.1016/j.juro.2012.11.001' resolved='10.1016/j.juro.2012.09.034'

The pure comparator is also exposed:

from proof_citations import compare_metadata, resolve

record = resolve(("pmid", "23260561"))
result = compare_metadata(record, expected={"title": "...", "year": 2023})
# result["verdict"] ∈ {"genuine", "metadata_chimera", "title_chimera", "partial_match", "no_expected"}

Batch-audit a reference list (CLI)

proof-citations verify-records --input audit.json --output report.json --pretty

Input shape:

{
  "references": [
    {"ref_id": "B1",
     "identifier": "pmid:33538338",
     "expected": {"title": "...", "journal": "...", "year": 2021, "doi": "..."}}
  ]
}

Emits per-reference verdicts plus a summary block (total, by_status, verified, chimeras, unresolvable). Exit code is 1 if any reference is anything other than verified or resolved. The CLI is the canonical entry point for batch audits — manuscript review, automated reference-list checks, post-publication audits.

Verify a quote AND its bibliographic claim in one call (v1.40.0+)

For single facts where you have both a URL with structured identifier (PMID / DOI / arXiv) AND a quote, pass expected_metadata to verify_citation to run both checks:

r = verify_citation(
    "https://pubmed.ncbi.nlm.nih.gov/33538338/",
    "Global Cancer Statistics 2020",
    fact_id="B1",
    expected_metadata={
        "title": "Global Cancer Statistics 2020",
        "journal": "CA: a cancer journal for clinicians",
        "year": 2021,
        "doi": "10.3322/caac.21660",
    },
)

# `status` continues to reflect the quote-on-page outcome only — meaning preserved from v1.34.x.
assert r["status"] == "verified"

# `metadata_result` (new in v1.40.0) carries the metadata-chimera check.
# Key is ALWAYS present in the return dict — value is None when expected_metadata isn't passed.
assert r["metadata_result"]["verdict"] == "genuine"

# Joint pass: compose explicitly when you want it.
joint_pass = r["status"] == "verified" and r["metadata_result"]["verdict"] == "genuine"

When the URL has no structured identifier (e.g., a plain blog URL), metadata_result["status"] is "skipped_no_structured_identifier" — OG-extraction from arbitrary pages is too noisy to compare against claimed bibliographic fields. Similarly, identifier types without a registered resolver backend yield "skipped_no_resolver".

verify_all_citations(empirical_facts, ...) accepts expected_metadata as an optional per-fact dict field, with the same semantics.

CLI policy: the proof-citations verify subcommand stays quote-only; for batch metadata audits use proof-citations verify-records --input audit.json (the canonical batch path).

Caching

Identifier resolution hits authoritative APIs that rate-limit (NCBI ~3 req/sec, Crossref polite-pool requires mailto, arXiv ≤1 req/3s). Two caches ship in the box:

from proof_citations import resolve, FileCache, InMemoryCache

# Persistent file cache (default location: ~/.cache/proof-citations/cache.json)
cache = FileCache()
record = resolve(("pmid", "33538338"), cache=cache)

# In-memory cache (for tests, short-lived scripts)
record = resolve(("pmid", "33538338"), cache=InMemoryCache())

Caches implement a tiny Cache protocol (get, put) — bring your own if neither default fits.

Polite HTTP

A shared HTTPSession is used by all backends. It sets a polite User-Agent (proof-citations/{version} (https://proofengine.info/; mailto={email}) — satisfies Crossref's polite-pool policy and NCBI's tool= recommendation), honors Retry-After on 429s, and reuses a single TCP pool. Contact email comes from PROOF_CITATIONS_CONTACT env var; falls back to a placeholder.

export PROOF_CITATIONS_CONTACT=you@example.org
proof-citations verify-records --input audit.json

Compatibility — what landed when

For min-version pinning. Each capability landed in a specific release:

Capability Since
verify_citation(url, quote) quote-on-page check v1.28.0 (initial extraction)
verify_all_citations(facts) batch quote-on-page v1.28.0
proof_citations.resolvers.* identifier resolution (PMID, DOI, arXiv, ISBN, SWHID, Handle, URL) v1.35.0 (called .registry until v1.39.0)
ResolvedRecord, Author, Cache/FileCache/InMemoryCache, HTTPSession, ResolutionError, identify() v1.35.0
compare_metadata(resolved, expected), verify_citation_record(identifier, expected) v1.36.0
proof-citations verify-records --input audit.json CLI v1.36.0
verify_citation(..., expected_metadata=...) integrated path; metadata_result key in return dict v1.40.0

If you're writing new code: pin proof-citations>=1.40.0 to get all of the above.

Public API surface

Top-level imports (the stable contract):

from proof_citations import (
    # Quote-on-page verification (v1.28.0+ semantics preserved; v1.40.0 added optional expected_metadata=)
    verify_citation, verify_all_citations,
    # Identifier → ResolvedRecord
    resolve, identify,
    # Bibliographic-claim verification
    verify_citation_record, compare_metadata,
    # Types
    Author, ResolvedRecord, Cache, InMemoryCache, FileCache, HTTPSession,
    ResolutionError,
    # Backend extension
    register_backend,
)

verify_all_citations vs verify-records — both do "batch verification," but at different layers and via different inputs:

  • verify_all_citations(empirical_facts) is the library function for batch quote-on-page verification (optionally with expected_metadata per fact). Inputs are a Python dict of {fact_id: {url, quote, expected_metadata?, …}}. Used inside proof scripts (proof.py) and as a programmatic API.
  • proof-citations verify-records --input audit.json is the CLI subcommand for batch bibliographic-claim audits. Inputs are a JSON file with {references: [{ref_id, identifier, expected: {…}}]}. No quote-on-page check — pure metadata-comparison. Used for manuscript review / post-publication audits where the goal is "are these citations real?"

Backend submodules (proof_citations.resolvers.pubmed — covers both pmid and pmc.doi, .arxiv, .isbn, .swhid, .handle, .url) are accessible for direct use but the dispatch via resolve() is the supported entry point.

Adding a custom backend

from proof_citations import register_backend, ResolvedRecord
from proof_citations.resolvers.base import now_iso

def resolve_my_thing(value, *, session):
    resp = session.get(f"https://my.registry.invalid/api/{value}")
    return ResolvedRecord(
        identifier_type="my_thing", identifier_value=value,
        canonical_url=f"https://my.registry.invalid/{value}",
        title=resp.json()["title"],
        resolved_at=now_iso(),
        source_api="my.registry.invalid",
    )

register_backend("my_thing", resolve_my_thing)
record = resolve(("my_thing", "abc"))

A note on naming — resolvers vs. "Registry Protocol"

There are two unrelated "registry" things in the Proof Engine ecosystem; this package's submodule used to be one of them and was renamed in v1.39.0 to remove the collision:

  • proof_citations.resolvers (this package) — Python module for resolving identifiers (PMID/DOI/arXiv/…) to bibliographic records via authoritative APIs. Returns ResolvedRecord dataclasses.
  • Registry Protocol — JSON-over-HTTPS spec (docs/registry-protocol.md in the parent repo) for a catalog of proofs. Implemented by the separately-published proof-engine-registry package and the proof-registry serve CLI. Returns proof verdicts + DOIs.

Different concepts, different packages, no shared code. The asymmetry in the names is intentional after the v1.39.0 rename.

Errors

ResolutionError carries a kind attribute so downstream code can branch on failure mode without parsing exception messages:

from proof_citations import resolve, ResolutionError

try:
    record = resolve(("pmid", "99999999"))
except ResolutionError as e:
    if e.kind == "not_found":     # identifier doesn't exist
        ...
    elif e.kind == "rate_limited": # 429 — back off and retry with NCBI_API_KEY
        ...
    elif e.kind == "fetch_failed": # network/timeout, retries exhausted
        ...
    elif e.kind == "malformed_response":
        ...

Status

Used by the Proof Engine to verify the proof corpus at proofengine.info, and as a standalone audit tool for external reference-list audits (manuscripts, post-publication reviews of LLM-generated bibliographies). See the Proof Engine repo for the parent project.

License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

proof_citations-1.45.0.tar.gz (91.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

proof_citations-1.45.0-py3-none-any.whl (75.4 kB view details)

Uploaded Python 3

File details

Details for the file proof_citations-1.45.0.tar.gz.

File metadata

  • Download URL: proof_citations-1.45.0.tar.gz
  • Upload date:
  • Size: 91.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.4

File hashes

Hashes for proof_citations-1.45.0.tar.gz
Algorithm Hash digest
SHA256 5d551cf0a83601a73aa1fd3c794f365cf0fd699d8ac7252c08b3c633a7a36ea5
MD5 df3da3fb8faa16ade95bda389b7d4639
BLAKE2b-256 3088bbc94c9ce77abaff8fc275c9bf5ab5db805ee6d208054c7789637ff9490e

See more details on using hashes here.

File details

Details for the file proof_citations-1.45.0-py3-none-any.whl.

File metadata

File hashes

Hashes for proof_citations-1.45.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c5a1116a9532c1fb7fc526ff2b104e96590461735e6591e7da33fd96e7bd4014
MD5 1d95a224b4fe142c01809c810d6e0440
BLAKE2b-256 55a28835ae8e6b0e98ca939249e7f06f86c0a6dc3eb67b126afd8db95af53c4d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page