Skip to content

fix: validate PDF content and filter phantom papers from fallback chain - #93

Open
heliowap wants to merge 1 commit into
openags:mainfrom
heliowap:fix/phantom-papers-fallback-validation
Open

fix: validate PDF content and filter phantom papers from fallback chain#93
heliowap wants to merge 1 commit into
openags:mainfrom
heliowap:fix/phantom-papers-fallback-validation

Conversation

@heliowap

@heliowap heliowap commented Jul 7, 2026

Copy link
Copy Markdown

Problem

The MCP fallback chain (search results + download_with_fallback) currently returns unrelated or non-citable items as if they were papers. Two failure modes observed in production:

  1. CrossRef returns sub-components as papers: a search for myodural bridge returns entries like Review for "The morphology of the suboccipital region" (type=peer-review) and Figure 5: The myodural bridge (type=figure) as if they were citable papers. These have real DOIs but are not papers.

  2. Repository/Unpaywall fallback returns wrong PDF: when the primary downloader fails, _try_repository_fallback and the Unpaywall resolver pick the first paper with a pdf_url and download it — without checking that the PDF actually corresponds to the requested paper. Reproduction: download_with_fallback('europepmc', 'PMC10912660', doi='10.1038/s41598-024-55069-7', title='Evidence for chronic headaches induced by pathological changes of myodural bridge complex') returned a PDF about solar-cell chemistry ([NH3(CH2)2NH3]CuBr4).

This is the root cause of the 'phantom paper' bug class where LLMs cite papers that don't exist or attribute wrong content to real PMIDs/DOIs.

Fix — three complementary changes

Fix 1: PDF content verification (server.py)

After downloading a PDF via _download_from_url, extract text from the first 3 pages and check:

  • ≥40% of significant title tokens (length > 4) appear in the PDF text, OR
  • the expected DOI appears verbatim in the PDF text

When mismatch, the PDF is deleted and None is returned, so the caller continues to the next fallback. Conservative thresholds chosen to tolerate editorial front-matter that pushes title tokens to page 2–3, while still rejecting gross mismatches (a chemistry paper will not contain myodural, bridge, headache, chronic, pathological).

Fix 2: CrossRef non-paper type filtering (crossref.py)

Added NON_PAPER_TYPES denylist covering: peer-review, peer-review-material, review, component, figure, dataset, report, report-component, standard, standard-series. _parse_crossref_item short-circuits to None for these types, so they are excluded from search results. Real citable types (journal-article, book-chapter, posted-content, etc.) pass through unchanged.

Fix 3: Repository fallback title matching (server.py)

Before downloading a candidate from a fallback repository (OpenAIRE/CORE/Europe PMC/PMC), check title similarity via difflib.SequenceMatcher (threshold 0.6, case-insensitive, whitespace-normalized). Skip candidates whose title is topically unrelated to the requested paper. Prevents the gross-mismatch case where a repo search for a DOI returns a topically-unrelated hit.

Backward compatibility

  • _download_from_url and _try_repository_fallback gained optional expected_title / expected_doi params. Existing callers (and existing tests) that don't pass them continue to work — no filtering is applied when no title claim is made.
  • download_with_fallback propagates its existing title arg as expected_title to both helpers, so the validation is active by default without changing the public tool signature.
  • Issue download_with_fallback crashes when repository fallback returns a non-string paper_id #57 regression test (numeric paper_id) preserved and passing.

Tests

  • test_crossref.py: +4 tests covering the type filter (peer-review/figure/dataset rejected, journal-article passes, denylist covers observed phantom types).
  • test_fallback.py: +14 tests covering _title_similarity, _pdf_matches_expected (including a regression test using a real PDF fixture that reproduces the chemistry-vs-myodural mismatch), title-matching in fallback (dissimilar skipped, similar downloaded, empty-title backward-compat), and propagation of expected_title from download_with_fallback.
  • Existing tests untouched except a sanity-check assertion fix (assertInany(... in ...) for author substring).

Validation

30/30 tests in test_fallback.py + test_crossref.py pass
Full suite: 145 tests, 0 regressions
  (2 pre-existing errors in test_biorxiv/test_medrxiv download tests due
   to 403 from upstream biorxiv.org — unrelated, confirmed via git stash)
End-to-end smoke test:
  download_with_fallback('europepmc', 'PMC10912660',
    doi='10.1038/s41598-024-55069-7',
    title='Evidence for chronic headaches induced by pathological changes of myodural bridge complex')
  → logs 'mismatch (sim=0.18): Single-crystal...' (chemistry PDF rejected)
  → returns correct PDF: europepmc_PMID_38438423.pdf
  → PDF content verified: contains 'myodural', 'headache', 'bleomycin', expected DOI

Files changed

 paper_search_mcp/academic_platforms/crossref.py |  39 +++-
 paper_search_mcp/server.py                      | 152 ++++++++++++-
 tests/test_crossref.py                          |  54 +++++
 tests/test_fallback.py                          | 274 +++++++++++++++++++++++-
 4 files changed, 508 insertions(+), 11 deletions(-)

Three complementary fixes for the 'phantom paper' bug where search and
download fallbacks returned unrelated or non-citable items as if they
were papers.

Fix 1 — PDF content verification (server.py):
  After downloading a PDF via fallback, extract text from the first 3
  pages and check token overlap with the expected title (>=40%) or DOI
  verbatim match. Reject and continue to next fallback when mismatch.
  Catches the case where Unpaywall/CORE/OpenAIRE resolve to a wrong
  document with the same DOI/title hint.

Fix 2 — CrossRef non-paper type filtering (crossref.py):
  CrossRef returns peer-review material, figures, dataset components,
  and other sub-components with real DOIs. These pollute search results
  as 'phantom papers' that have a DOI but no citable content. Added
  NON_PAPER_TYPES denylist and short-circuit in _parse_crossref_item.

Fix 3 — Repository fallback title matching (server.py):
  Before downloading a candidate from a fallback repository, check
  title similarity (difflib SequenceMatcher, threshold 0.6). Skip
  candidates whose title is topically unrelated to the requested paper.
  Prevents the gross-mismatch case (e.g. solar-cell PDF returned for a
  myodural-bridge search).

Tests:
  - 4 new tests in test_crossref.py covering the type filter
  - 14 new tests in test_fallback.py covering _title_similarity,
    _pdf_matches_expected, title-matching in fallback, and backward
    compatibility for numeric paper_id (issue openags#57)
  - Regression test using real PDF fixtures reproduces the original
    phantom-PDF bug (chemistry PDF returned for myodural-bridge query)
    and confirms it is now rejected

Validation:
  - 30/30 tests in test_fallback.py + test_crossref.py pass
  - Full suite: 145 tests, 0 regressions (2 pre-existing errors in
    biorxiv/medrxiv download tests due to 403 from upstream, unrelated)
  - End-to-end smoke test: download_with_fallback for PMC10912660 now
    logs 'mismatch (sim=0.18)' for the wrong chemistry PDF, skips it,
    and returns the correct myodural-bridge paper PDF
@Robin-Reiche

Copy link
Copy Markdown

Independent confirmation of the same failure mode, from a different source in the chain.

On v0.1.4 this call returned a file and reported success:

download_with_fallback(
    source="semantic",
    paper_id="DOI:10.3233/jsa-200411",
    doi="10.3233/jsa-200411",
    title="A football player rating system",
)

What landed on disk was core_4228321.pdf, "Do Job Search Rules and Reemployment Services Reduce Insured Unemployment?", an Upjohn Institute working paper from 2005. A football analytics paper was requested and a labor economics paper was delivered, with no error anywhere.

Your description names OpenAIRE and Europe PMC, so CORE is one more source that produces this. The part that worries me is not the wrong file, it is that the call reported success. We only noticed because we read the first page of every PDF before citing it. In a literature review that file would have been cited as Wolf et al. 2020 and nobody would have looked again.

With this PR applied the same call returns an explicit failure and writes nothing. Tested on a branch with #93 and #96 merged together, both apply cleanly, 76 tests pass in test_fallback.py, test_crossref.py and test_openalex_graph.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants