Summarize PDF Source
This skill exists because the HTML-first pipeline (BeautifulSoup over the raw
bytes inside call-gemini.py) is fundamentally wrong for PDFs. When given a
PDF, BS4 produces garbled text — and if the garbled text clears the 500-char
blocked-content guard, Gemini will fabricate a plausible-looking summary from
the URL slug + domain. The summary passes schema validation, nothing downstream
notices, and the editor finds out only after the journal has been published
(see Issue #141 for the incident report on the Nork Research 2026-05-13 press
release, ID 199 in journal 2026-05-30).
The fix is to make PDF handling a detection decision, not a recovery
decision. The PDF path is taken before any extraction runs, based on a
Content-Type / URL-suffix probe. The PDF path is also fail-closed: if
extraction does not produce a meaningful amount of text, the script emits a
BLOCKED-PDF: stub rather than letting Gemini hallucinate. This guarantees
that small PDFs cannot silently produce URL-slug fabrications.
When this skill runs
You will rarely need to invoke this skill explicitly. The plumbing is wired in:
scripts/call-gemini.py --url <URL>probes the URL up front (HEAD + Content-Type, plus a.pdfsuffix shortcut) and, when the URL is a PDF, delegates to the PDF pipeline automatically.scripts/bulk_summarize.pycallscall-gemini.py --url, so batch runs pick up the PDF routing transparently. A PDF URL inworkdesk/sources.mdis handled without any human intervention.
Use this skill directly when:
- A PDF summary was produced by the legacy HTML path and needs to be
re-generated against the correct extraction. Trigger phrases:
- "re-summarize the PDF for [ID]"
- "regenerate [ID] as PDF"
- "this is a PDF, redo it"
- You want to force the PDF path for a URL whose Content-Type lies (e.g.
application/octet-streamfor a PDF-by-suffix). - You need to cap pages for a very large PDF (front-matter mode for the Stanford HAI Index Report or similar 400-page reports).
Contract
Inputs:
- ID (3-digit, zero-padded; e.g.
199) — used in the output filename. - URL — the PDF URL. Must resolve to a real PDF, not an HTML landing page.
- pages (optional, integer) — cap the number of leading pages read. Use for huge reports where front matter is the relevant section.
Outputs:
workdesk/summaries/${ID}_${domain}.json— schema-valid v1.0 summary, withcontent.urlpinned to the input URL.- On extraction failure: a
BLOCKED-PDF:stub at the same path (NOT a fake JSON file). The stub is what the human reviewer sees during STEP_02 review.
Side effects:
- Downloads the PDF to
.playwright-mcp/pdf_<random>.pdfand cleans it up before returning. The directory is gitignored. - Marks the URL as
[x]inworkdesk/sources.mdonly when the invocation comes throughbulk_summarize.pyand the result is a real summary or a blocked-stub (which carries actionable BLOCKED-PDF context).
Steps the skill performs
Probe —
scripts/modules/pdf_router.is_pdf_url(url)runs a HEAD with redirect-following and a.pdfURL-suffix check. The suffix wins when present, because some CDNs serve PDFs withContent-Type: application/octet-streamand the HEAD alone would miss them.Download —
scripts/modules/pdf_extractor.download_pdf(url, tmp_path)pulls the bytes via curl (same User-Agent as the HTML path; redirects followed; 120-second timeout default).Extract — pypdf is invoked through
uv run --with pypdfso the dependency stays optional. Per-page text is collected;pages=Ncaps the read at the first N pages.Verify (fail-closed) — the extraction is rejected if total chars are below
PDF_MIN_TOTAL_CHARS(currently 400) OR if no page produced at leastPDF_MIN_CHARS_PER_NONEMPTY_PAGE(currently 50) characters. These thresholds reject scanned/image-only PDFs and corrupted downloads — the "never allow generation from filename, URL slug, or partial binary artifacts" guarantee from the issue's outside review.Truncate (if needed) — the extracted text is capped at
PDF_MAX_CHARS(currently 60,000) by selecting the leading N pages that fit in the budget. Truncation is recorded in metrics for editor audit.Build prompt —
_build_url_mode_prompt_with_textinscripts/call-gemini.pyloadsprompts/summarize-json.prompt, substitutes{{url}}, replaces the{{fetch:"..."}}directive with the extracted text, and resolves the remaining{{file:...}}includes (criteria, scoring, editorial persona).Generate — the URL-mode JSON pipeline runs as usual:
call_gemini_structured(prompt, 'gemini-3-flash-preview')with the native schema enforced.Post-process — schema validation, HTML sanitization, URL pinning (
content.urlforced to the input URL),originalTitleinvariant enforcement, then write toworkdesk/summaries/${ID}_${domain}.json.Report — stderr lines name the pipeline used (
PDF: routing ...), the extraction metrics (7 pages, used 7, 45749 chars from 1208863 bytes), and the success/failure verdict. Capture these in the run notes.
Direct CLI invocations
When you do need to call it explicitly, use call-gemini.py directly. The
detection runs by default; pass --pages to control truncation.
# Small PDF — full document. Most common case.
uv run scripts/call-gemini.py \
--url "https://www.norkresearch.co.jp/pdf/2025DevTool_rel4.pdf" \
--output "workdesk/summaries/199_norkresearch_co_jp.json"
# Huge report — front-matter only.
uv run scripts/call-gemini.py \
--url "https://hai.stanford.edu/assets/files/ai_index_report_2026.pdf" \
--pages 25 \
--output "workdesk/summaries/073_hai_stanford_edu.json"
# Force the HTML path even though it's a PDF URL (debugging only).
uv run scripts/call-gemini.py \
--url "https://example.com/file.pdf" \
--no-pdf-routing \
--output /tmp/debug.json
Escape hatch: --content for externally extracted text
When the standard pipeline cannot reach a PDF (e.g. requires auth, served via
a Playwright-rendered page), extract the text out of band and feed it through
the same JSON pipeline via the new --content flag (Issue #141 follow-up).
This replaces the older "build a --file prompt by hand" workaround
documented in summarize-source Step 5.
# 1. Extract text however you can (Playwright, manual OCR, etc.) into a temp file.
cat > /tmp/199_text.txt <<'EOF'
[the article body]
EOF
# 2. Run the URL-mode pipeline against the supplied text.
# --url is required (it gets pinned into content.url).
uv run scripts/call-gemini.py \
--url "https://www.norkresearch.co.jp/pdf/2025DevTool_rel4.pdf" \
--content /tmp/199_text.txt \
--output workdesk/summaries/199_norkresearch_co_jp.json
The --content path uses the exact same prompt template, schema enforcement,
URL pinning, and originalTitle invariant checks as the URL path. Use it
whenever you have the body text but cannot trust (or use) the automatic fetch.
Blocked-PDF stubs
When the extraction fails the quality gate (or the download fails), the skill
writes a stub that begins with BLOCKED-PDF: to the output path. This is the
same pattern as the HTML BLOCKED: stub from Issue #121 — the file is
deliberately not schema-valid so it cannot be promoted into a published
journal, and the marker is easy to grep for during STEP_02 review.
Recovery options for a blocked PDF:
- The PDF may be a scanned image. Use an OCR pipeline (e.g.
ocrmypdf input.pdf out.pdf) and feed the OCRed text through--content. - The PDF may be auth-walled. Fetch it via an authenticated session and use
--content. - The PDF may be huge and the front matter happens to be empty (table of
contents only). Try
--pages 50or similar to advance past the front matter.
Quality / regression guard
If you ever see a PDF summary whose summaryBody is generic ("Java/PHP/Python,
low-code, generative AI for coding") or whose title matches a translation of
the URL slug, treat that as a regression in the routing layer:
- Re-run extraction directly and verify it produces ≥ 400 chars of real text
(
python3 -c "from modules.pdf_extractor import extract_pdf_from_url; ..."). - Confirm
is_pdf_url(URL)returns True (suffix or HEAD). - If extraction is healthy and routing returns True but the LLM still
hallucinates, the prompt assembly likely lost the article text — inspect
_build_url_mode_prompt_with_textand the# Article Contentmarker.
Project standards
- All summaries — including PDF-sourced ones — must be in Japanese (see EDITOR_PERSONALITY.md).
- All summaries must pass
scripts/validate_summary.pybefore being written toworkdesk/summaries/(the URL-mode pipeline enforces this inline). - Never mark a URL as
[x]insources.mduntil validation passes OR a BLOCKED-PDF stub has been written. (The blocked stub IS a valid outcome — it surfaces the URL for human review rather than hiding the failure.)
File locations
- Router:
scripts/modules/pdf_router.py - Extractor:
scripts/modules/pdf_extractor.py - Caller:
scripts/call-gemini.py(the--urlbranch's PDF block) - Prompt template:
prompts/summarize-json.prompt - Output dir:
workdesk/summaries/${ID}_${domain}.json
Relationship to other skills
- Triggered by
summarize-source: when batch processing hits a PDF URL, it ends up in this pipeline viacall-gemini.py's automatic routing. No separate invocation needed. - Falls back to
summarize-sourceStep 6 (Playwright): when a PDF is blocked AND the URL is actually an HTML page hiding behind a.pdfURL (rare but possible), the operator can disable detection with--no-pdf-routingand use the existing recovery chain.