tts-voice-creator-clone-and.../architecture/magazine-ingestion.md
mARTin-B78 736aeaaeac feat: add magazine projects with layout-aware article extraction
Magazines break the audiobook pipeline's assumptions: several unrelated
articles share a page, columns run in parallel, adverts and sidebars
interrupt body copy, and a feature routinely jumps twenty pages to its
tail. Narrating page-by-page walks out of an article mid-sentence and
into an advert, so the canonical object is the article, not the page.

core/magazine.py reconstructs articles from a flat PDF: PyMuPDF extracts
blocks with bboxes and font metrics, tesseract supplies a TSV fallback so
scanned pages join the same column grouping, blocks are classified and
ordered column-major, and articles are anchored by the parsed table of
contents with "continued on page N" tails linked back.

routes/magazine.py adds 13 endpoints. Ingestion is a restart-safe
background job. Nothing is narrated until a human approves the
boundaries; podcast generation refuses unapproved articles.

Podcast mode renders an approved article as a 2-3 host discussion using
the configured LLM and voices the user picks, alongside narration rather
than instead of it.

Verified end-to-end over HTTP on a synthetic 8-page issue: the feature
came back as pages [3, 8] with its jump tail absorbed and the advert page
excluded, and an image-only rendering recovered all 47 blocks via OCR.
44 new tests; full suite 90 passing.

The Studio UI is not built — this is reachable only via the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:46:54 +02:00

161 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SOP — Magazine ingestion
Status: **P0 complete (backend)**. P1 (listening workflow UI) not started.
## Why magazines are not books
The audiobook pipeline assumes one continuous text in reading order. A magazine
breaks every part of that assumption: several unrelated articles share a page,
columns run in parallel, adverts and sidebars interrupt body copy, and a feature
routinely jumps twenty pages to its tail. Narrating a magazine page-by-page
produces audio that walks out of an article mid-sentence and into an advert.
**The canonical object is therefore the article, not the page.** Everything
below exists to reconstruct articles from a flat PDF.
## Pipeline
Implemented in [`core/magazine.py`](../core/magazine.py); driven by
[`routes/magazine.py`](../routes/magazine.py).
```
PDF
→ extract_pages() PyMuPDF: text blocks with bbox, font size/name, weight
→ ocr_page() tesseract TSV fallback when a page has <120 native chars
→ classify_blocks() title / deck / byline / body / caption / pullquote /
sidebar / furniture / ad / figure
→ mark_advert_pages() spread an "ADVERTISEMENT" label across its whole page
→ mark_pullquotes() demote display text that merely repeats body copy
→ assign_reading_order() column detection, then top-to-bottom within a column
→ score/parse_contents() Stage A: locate and parse the table of contents
→ group_articles() Stage C: group blocks, link "continued on page N"
→ human review Stage D, in the UI — nothing is narrated until approved
```
### Why server-side, not in the browser
Text extraction for plain PDFs runs client-side (PDF.js), and that stays. Layout
analysis does not, for three reasons: PyMuPDF exposes real font metrics and
embedded-image rectangles that PDF.js does not; OCR coordinates from tesseract
merge with those bboxes in one coordinate space; and the result is expensive
enough (minutes for an issue) that it must be computed once and persisted, not
recomputed per browser session.
PDF.js remains responsible for *rendering* in the PDF view.
Docling/Unstructured were considered and rejected: they pull torch plus layout
model weights (multi-GB, ARM64 wheel risk on the DGX Spark, GPU contention with
the TTS stack) to improve on heuristics that already work on the cases we hit.
Revisit only if real issues show grouping failures the heuristics cannot fix.
## Decisions that are easy to get wrong
**Font baseline is document-wide, never per page.** `classify_blocks` judges
size as a ratio against `base_size`. A page carrying a full-bleed photograph and
one caption has no body copy of its own, so a per-page baseline makes that
caption its own reference and classifies it as body text. `extract_pages`
measures the baseline once across every block in the issue.
**Reading order is column-major.** Sorting blocks by `y` interleaves parallel
columns, splicing the left column's sentence into the right column's. Columns
are inferred from vertical whitespace (where blocks are *absent*), because
magazines change column count per page — a fixed grid is wrong. Blocks spanning
>60% of the page width sort ahead of both columns, so a full-width headline
precedes the body it introduces.
**Furniture is identified by repetition, not position.** A running head and a
headline both sit at the top of a page. What separates them is that the running
head recurs on many pages at the same edge; `find_repeated_lines` needs the
whole document, so classification waits until every page has been read.
**A page is claimed only if it contributes.** An advert between two features
starts no article, so without an explicit check it gets swallowed by the
preceding one and highlighted as part of it in the PDF view.
**Pull quotes are demoted.** Narrating one reads the same sentence twice a few
seconds apart. A display-sized block whose text already appears in the body copy
is a pull quote, not a deck.
## Confidence, and why review is mandatory
| Signal | Confidence |
|---|---|
| Anchored by a contents entry | 0.90 |
| Started by a headline, no contents entry | 0.55 |
| Article whose blocks contain no body copy | ×0.4 |
Nothing is narrated until a human sets `status: "approved"`. This is deliberate:
a five-second correction beats a 30-minute recording with a sidebar in the
middle. `POST …/podcast` refuses unapproved articles outright.
## Storage
Magazines live in the Read Aloud store and are distinguished by
`meta.json → kind: "magazine"`, so the existing library listing, cover, progress
and audio endpoints keep working.
```
<CONFIG_DIR>/reader_library/<doc_id>/
meta.json kind, title, issue, ingest status, default_voice,
podcast hosts, per-article progress
source.pdf the original
layout.json every classified block, with bbox + reading order
articles.json detected articles; status gates narration
pages/NNNN.png rendered pages (PDF view + OCR input)
images/*.png extracted figures ≥64px
podcast/<id>.json generated host scripts
audio/<idx>.mp3 synthesised audio (existing reader endpoints)
```
`layout.json` is the join table between audio, text, image and PDF coordinates:
every block carries `page` + `bbox`, so a sentence in the reflowed view can jump
to its source region on the rendered page.
## Podcast mode
Per article, alongside narration — not instead of it. An approved article's
narratable blocks are sent to the configured LLM (`llm_url` / `llm_model`,
reusing `_post_llm_chat_completion` from `routes/conversation.py`) with a system
prompt that forbids inventing facts and demands strict JSON. The parser tolerates
a markdown fence and surrounding prose, and reassigns unknown host labels rather
than dropping the turn — an unrecognised label would otherwise leave a line with
no voice. 2–3 hosts, each bound to a voice the user picks; synthesis reuses the
multi-speaker path the audiobook casting already uses.
## Tests
- [`tests/test_magazine.py`](../tests/test_magazine.py) — 34 tests on the pure
geometry/classification logic: column reading order, the document-baseline
regression, furniture, pull quotes, contents parsing, continuation linking,
advert pages.
- [`tests/test_magazine_routes.py`](../tests/test_magazine_routes.py) — 10 tests: path
traversal rejection, review gating, LLM script parsing including malformed
output.
Both run without PyMuPDF: the I/O functions import it lazily so the logic worth
testing needs no native dependency or sample PDF.
Verified end-to-end against a synthetic 8-page issue (contents page, two-column
feature with a byline and a page-8 jump, full-page advert, second feature,
reviews) and against an image-only rendering of the same file to exercise OCR.
## OCR engine selection (not yet wired)
Engines → OCR & Document Parsing lists layout-aware parsers (KDL Frontier Parser
nano, Baidu Unlimited-OCR) as connection-probed cards. Those are presentation
and health-check only — there is no server-side dispatch, so ingestion currently
always uses local tesseract via `ocr_lang`. Routing a page image to one of those
vLLM endpoints and mapping its structured output onto `Block` is real work, not
a config change; it is the natural home for TODO P2's per-page fallback
selection and benchmark comparison.
## Known limitations
- Sidebar detection (`BLOCK_SIDEBAR`) is defined but not yet inferred; sidebars
currently classify as body and must be excluded by hand in review. P2.
- `continued from page N` is parsed but not used for linking — only the forward
`continued on page N` reference drives it.
- Reviews-style pages with no headline are absorbed by the preceding article
when the contents page does not list them.
- Word-level timing is not available; P1 targets sentence highlighting.