tts-voice-creator-clone-and.../routes/magazine.py
mARTin-B78 813e06c914 feat: complete magazine P1 (images, play-all, karaoke, driving mode, sidebar exclusion) + OCR engines page + second OCR engine
Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
  links each figure to the article whose column it falls in; the client
  shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
  automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
  forced-alignment/timestamp source exists): each word's share of the
  sentence's real measured audio duration is proportional to its
  character length. A checkbox turns it off; sentence highlighting
  (measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
  whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
  (mark_sidebars: an anomalously narrow column vs. the page's regular
  grid). Fixed a real defect found while building this: a full-width
  headline was left in the column-width analysis and could bridge two
  genuinely separate columns into one, hiding a real sidebar column
  entirely -- caught by a failing test before the fix.

Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
  engine list, first usable result wins, tesseract last resort).
  Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
  plain text with NO coordinate markers, unlike Baidu's labeled regions.
  _whole_page_region treats the whole response as one ambiguous
  full-page block (gated by ocr_quality_score) rather than discarding
  usable text just because it lacks layout structure.

OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
  markup at all -- renderLocalContainers already supported an OCR grid
  generically, but $('dc-grid-ocr') was always null, so the card grid
  silently never rendered. Added an Active OCR Engine panel mirroring
  Active Language Model exactly, plus the Local grid -- both configured
  engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
  every .section-head in the document (confirmed live on the Engines
  page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
  there). Changed from a blocklist (naming each unrelated section as
  noticed) to an allowlist of the pipeline's three actual destinations.

Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.

20 new backend tests (156 total in the magazine module).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:39:34 +02:00

562 lines
23 KiB
Python
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.

"""Magazine projects: layout-aware ingestion, article review, podcast scripts.
Magazines live in the same store as Read Aloud documents
(<CONFIG_DIR>/reader_library/<id>/) and are distinguished by meta.json's
``kind: "magazine"``, so the existing library listing, cover, progress and
audio endpoints keep working unchanged. Alongside those, a magazine adds:
layout.json every classified block on every page, with bboxes
articles.json detected articles; nothing is narrated until approved
pages/NNNN.png rendered pages for the PDF view and OCR
images/*.png extracted figures
podcast/<id>.json generated two/three-host scripts
Ingestion is a restart-safe background job because an 84-page issue takes
minutes, and the browser must not have to stay open for it.
"""
from __future__ import annotations
import asyncio
import json
import re
import time
import uuid
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse
from core import magazine as mag
from core.config import _load_settings
from core.constants import CONFIG_DIR
from core.database import (
background_job_create,
background_job_get,
background_job_update,
)
router = APIRouter()
_LIB = CONFIG_DIR / "reader_library"
_MAX_HOSTS = 3
_MIN_HOSTS = 2
# ── Path safety ──────────────────────────────────────────────────────────────
def _doc_dir(doc_id: str) -> Path:
"""Resolve a document directory, rejecting anything but a generated id."""
if not doc_id or not doc_id.isalnum() or len(doc_id) > 40:
raise HTTPException(400, "Bad document id")
d = _LIB / doc_id
if not d.is_dir():
raise HTTPException(404, "Document not found")
return d
def _article_id(article_id: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", article_id or ""):
raise HTTPException(400, "Bad article id")
return article_id
def _read_meta(d: Path) -> dict[str, Any]:
try:
return json.loads((d / "meta.json").read_text("utf-8"))
except (OSError, ValueError):
return {}
def _write_meta(d: Path, meta: dict[str, Any]) -> None:
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
def _require_magazine(doc_id: str) -> tuple[Path, dict[str, Any]]:
d = _doc_dir(doc_id)
meta = _read_meta(d)
if meta.get("kind") != "magazine":
raise HTTPException(400, "Not a magazine project")
return d, meta
# ── Project creation ─────────────────────────────────────────────────────────
@router.post("/api/magazine/docs")
async def magazine_create(request: Request):
"""Create an empty magazine project; the PDF is uploaded separately."""
body = await request.json()
title = str(body.get("title") or "").strip()[:300] or "Untitled magazine"
doc_id = uuid.uuid4().hex
d = _LIB / doc_id
d.mkdir(parents=True, exist_ok=True)
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_write_meta(d, {
"kind": "magazine",
"title": title,
"issue": str(body.get("issue") or "").strip()[:120],
"publisher": str(body.get("publisher") or "").strip()[:120],
"created": now,
"updated": now,
"ingest": {"status": "empty"},
"default_voice": str(body.get("default_voice") or "").strip()[:200],
"podcast": {"hosts": []},
"progress": {},
})
return {"id": doc_id}
# ── Stage A–C: ingestion ─────────────────────────────────────────────────────
def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_url: str | None, vision_ocr_model: str | None,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None) -> None:
"""Run the extraction pipeline; recorded as a restart-safe job."""
d = _LIB / doc_id
pdf = d / "source.pdf"
try:
if not pdf.is_file():
raise FileNotFoundError("No source.pdf uploaded for this magazine")
def progress(page: int, total: int) -> None:
background_job_update(
job_id, stage="extract",
detail=f"Analysing page {page} of {total}",
)
background_job_update(job_id, stage="extract", detail="Opening PDF")
layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang,
vision_ocr_url=vision_ocr_url,
vision_ocr_model=vision_ocr_model,
vision_ocr_fallback_url=vision_ocr_fallback_url,
vision_ocr_fallback_model=vision_ocr_fallback_model,
progress=progress)
mag.save_layout(d, layout)
background_job_update(job_id, stage="articles", detail="Detecting articles")
articles = mag.build_articles(layout)
mag.save_articles(d, articles)
meta = _read_meta(d)
meta["pageCount"] = layout.get("page_count", 0)
meta["ingest"] = {
"status": "done",
"finished": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"articles": len(articles),
"ocr_pages": sum(
1 for b in layout.get("blocks", []) if b.get("source") == "ocr"
),
"vision_ocr_pages": layout.get("vision_ocr_pages", 0),
}
_write_meta(d, meta)
background_job_update(job_id, stage="done", done=True,
detail=f"{len(articles)} articles detected",
result={"articles": len(articles)})
except Exception as exc: # noqa: BLE001 - surfaced to UI
meta = _read_meta(d)
meta["ingest"] = {"status": "failed", "error": str(exc)[:500]}
_write_meta(d, meta)
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
@router.post("/api/magazine/{doc_id}/ingest")
async def magazine_ingest(doc_id: str, request: Request):
d, meta = _require_magazine(doc_id)
if not (d / "source.pdf").is_file():
raise HTTPException(400, "Upload the PDF first")
body = {}
try:
body = await request.json()
except Exception:
pass
ocr_lang = str(body.get("ocr_lang") or "deu+eng")[:40]
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_lang):
raise HTTPException(400, "Bad OCR language")
try:
render_dpi = max(72, min(200, int(body.get("dpi") or 110)))
except (TypeError, ValueError):
render_dpi = 110
# A page needing OCR (or whose existing text scores as mostly garbage --
# see ocr_quality_score) is escalated to this engine before falling back
# to tesseract. Off by default per-request unless the deployment enabled
# it in Settings; a request can still force it off with vision_ocr:false
# even when enabled, e.g. for a fast re-run once boundaries are already
# approved and only re-detection is wanted.
settings = _load_settings()
vision_ocr_url = vision_ocr_model = None
vision_ocr_fallback_url = vision_ocr_fallback_model = None
want_vision = body.get("vision_ocr")
if want_vision is None:
want_vision = bool(settings.get("vision_ocr_enabled"))
if want_vision:
vision_ocr_url = str(body.get("vision_ocr_url") or settings.get("vision_ocr_url") or "").strip()
vision_ocr_model = str(body.get("vision_ocr_model") or settings.get("vision_ocr_model") or "").strip()
if not (vision_ocr_url and vision_ocr_model):
vision_ocr_url = vision_ocr_model = None
vision_ocr_fallback_url = str(
body.get("vision_ocr_fallback_url") or settings.get("vision_ocr_fallback_url") or "").strip()
vision_ocr_fallback_model = str(
body.get("vision_ocr_fallback_model") or settings.get("vision_ocr_fallback_model") or "").strip()
if not (vision_ocr_fallback_url and vision_ocr_fallback_model):
vision_ocr_fallback_url = vision_ocr_fallback_model = None
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-ingest", fingerprint=doc_id)
meta["ingest"] = {"status": "running", "job_id": job_id}
_write_meta(d, meta)
asyncio.create_task(
asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi,
vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model)
)
return {"job_id": job_id}
@router.get("/api/magazine/{doc_id}/ingest/status")
async def magazine_ingest_status(doc_id: str):
_, meta = _require_magazine(doc_id)
job_id = (meta.get("ingest") or {}).get("job_id")
job = background_job_get(job_id) if job_id else None
return {"ingest": meta.get("ingest") or {"status": "empty"}, "job": job}
# ── Layout and articles ──────────────────────────────────────────────────────
@router.get("/api/magazine/{doc_id}/layout")
async def magazine_layout(doc_id: str, page: int | None = None):
"""The layout document, optionally narrowed to one page."""
d, _ = _require_magazine(doc_id)
layout = mag.load_layout(d)
if not layout:
raise HTTPException(404, "Not ingested yet")
if page is not None:
layout = dict(layout)
layout["blocks"] = [b for b in layout.get("blocks", []) if b.get("page") == page]
layout["pages"] = [p for p in layout.get("pages", []) if p.get("page") == page]
return layout
@router.get("/api/magazine/{doc_id}/articles")
async def magazine_articles(doc_id: str):
d, meta = _require_magazine(doc_id)
articles = mag.load_articles(d)
# Lets the frontend skip the per-article podcast GET when nothing was ever
# generated, instead of a 404 on every article open.
for a in articles:
a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file()
return {
"articles": articles,
"progress": meta.get("progress") or {},
"default_voice": meta.get("default_voice") or "",
"podcast": meta.get("podcast") or {"hosts": []},
}
@router.patch("/api/magazine/{doc_id}/articles/{article_id}")
async def magazine_update_article(doc_id: str, article_id: str, request: Request):
"""Stage D: the human review that gates narration.
Only fields a reviewer can legitimately change are writable; block ids are
validated against the layout so a bad edit cannot point an article at
blocks that do not exist.
"""
d, _ = _require_magazine(doc_id)
article_id = _article_id(article_id)
articles = mag.load_articles(d)
target = next((a for a in articles if a.get("id") == article_id), None)
if target is None:
raise HTTPException(404, "Article not found")
body = await request.json()
if "status" in body:
status = str(body["status"])
if status not in ("detected", "approved", "rejected"):
raise HTTPException(400, "Bad status")
target["status"] = status
if "title" in body:
target["title"] = str(body["title"]).strip()[:300]
if "author" in body:
target["author"] = str(body["author"]).strip()[:120]
if "section" in body:
target["section"] = str(body["section"]).strip()[:120]
if "blocks" in body:
known = {b["id"] for b in mag.load_layout(d).get("blocks", [])}
requested = body["blocks"]
if not isinstance(requested, list):
raise HTTPException(400, "blocks must be a list")
unknown = [b for b in requested if b not in known]
if unknown:
raise HTTPException(400, f"Unknown block ids: {unknown[:5]}")
target["blocks"] = requested
layout_blocks = {b["id"]: b for b in mag.load_layout(d).get("blocks", [])}
target["pages"] = sorted({
layout_blocks[b]["page"] for b in requested if b in layout_blocks
})
if "pages" in body:
pages = body["pages"]
if not isinstance(pages, list) or not all(isinstance(p, int) for p in pages):
raise HTTPException(400, "pages must be a list of integers")
target["pages"] = sorted(set(pages))
mag.save_articles(d, articles)
return target
@router.get("/api/magazine/{doc_id}/articles/{article_id}/text")
async def magazine_article_text(doc_id: str, article_id: str,
captions: int = 0):
"""The narratable text of one article, per block, in reading order."""
d, _ = _require_magazine(doc_id)
article_id = _article_id(article_id)
articles = mag.load_articles(d)
target = next((a for a in articles if a.get("id") == article_id), None)
if target is None:
raise HTTPException(404, "Article not found")
blocks = mag.article_text(mag.load_layout(d), target,
include_captions=bool(captions))
return {"article": target, "blocks": blocks}
@router.patch("/api/magazine/{doc_id}/progress")
async def magazine_progress(doc_id: str, request: Request):
"""Per-article resume position, so each article remembers its own place."""
d, meta = _require_magazine(doc_id)
body = await request.json()
article_id = _article_id(str(body.get("article") or ""))
progress = dict(meta.get("progress") or {})
entry = dict(progress.get(article_id) or {})
if "sentence" in body:
entry["sentence"] = max(0, int(body["sentence"]))
if "seconds" in body:
entry["seconds"] = max(0, float(body["seconds"]))
progress[article_id] = entry
meta["progress"] = progress
meta["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_write_meta(d, meta)
return {"ok": True}
# ── Page renders and figures ─────────────────────────────────────────────────
@router.get("/api/magazine/{doc_id}/pages/{page}")
async def magazine_page_image(doc_id: str, page: int):
d, _ = _require_magazine(doc_id)
if page < 1 or page > 10000:
raise HTTPException(400, "Bad page")
png = d / "pages" / f"{page:04d}.png"
if not png.is_file():
raise HTTPException(404, "Page not rendered")
return FileResponse(png, media_type="image/png")
@router.get("/api/magazine/{doc_id}/images/{name}")
async def magazine_image(doc_id: str, name: str):
d, _ = _require_magazine(doc_id)
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}\.png", name or ""):
raise HTTPException(400, "Bad image name")
path = d / "images" / name
if not path.is_file():
raise HTTPException(404, "Image not found")
return FileResponse(path, media_type="image/png")
# ── Podcast mode ─────────────────────────────────────────────────────────────
_PODCAST_SYSTEM = (
"You write podcast scripts in which {n} hosts discuss a magazine article "
"for listeners who have not read it. Cover the article's actual content "
"and conclusions faithfully -- never invent facts, statistics, quotes or "
"study results that are not in the source text. If the article is "
"inconclusive, let the hosts say so rather than manufacturing a verdict.\n"
"\n"
"Return STRICT JSON, no markdown fence, in this shape:\n"
'{{"title": "...", "turns": [{{"host": "A", "text": "..."}}]}}\n'
"\n"
"Rules:\n"
"- Use only host labels {labels}.\n"
"- 12-30 turns. Each turn is one or two spoken sentences.\n"
"- Open by naming the article and its author, close with a short takeaway.\n"
"- Write speech, not prose: contractions, plain words, no bullet points, "
"no stage directions, no sound-effect cues.\n"
"- Write in {language}.\n"
)
def _podcast_dir(d: Path) -> Path:
p = d / "podcast"
p.mkdir(parents=True, exist_ok=True)
return p
def _parse_script(raw: str, labels: list[str]) -> dict[str, Any]:
"""Parse the model's JSON, tolerating a stray markdown fence."""
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", text).strip()
start, end = text.find("{"), text.rfind("}")
if start != -1 and end > start:
text = text[start:end + 1]
try:
data = json.loads(text)
except ValueError as exc:
raise HTTPException(502, f"LLM did not return usable JSON: {exc}") from exc
turns_in = data.get("turns")
if not isinstance(turns_in, list) or not turns_in:
raise HTTPException(502, "LLM returned no turns")
allowed = set(labels)
turns: list[dict[str, str]] = []
for turn in turns_in:
if not isinstance(turn, dict):
continue
host = str(turn.get("host") or "").strip().upper()[:8]
line = str(turn.get("text") or "").strip()
if not line:
continue
# An unknown label would otherwise silently drop to no voice at all.
if host not in allowed:
host = labels[len(turns) % len(labels)]
turns.append({"host": host, "text": line[:2000]})
if not turns:
raise HTTPException(502, "LLM returned no usable turns")
return {"title": str(data.get("title") or "").strip()[:300], "turns": turns}
@router.post("/api/magazine/{doc_id}/articles/{article_id}/podcast")
async def magazine_podcast_script(doc_id: str, article_id: str, request: Request):
"""Turn one approved article into a 2-3 host podcast script.
The script is generated and stored here; synthesis reuses the existing TTS
pipeline, one voice per host, exactly as multi-speaker audiobook segments
are rendered today.
"""
from routes.conversation import ( # local import avoids a cycle
_post_llm_chat_completion,
_request_timeout_seconds,
)
d, meta = _require_magazine(doc_id)
article_id = _article_id(article_id)
articles = mag.load_articles(d)
target = next((a for a in articles if a.get("id") == article_id), None)
if target is None:
raise HTTPException(404, "Article not found")
if target.get("status") != "approved":
# Generating from unreviewed boundaries wastes an LLM call on text that
# may contain a neighbouring article or an advert.
raise HTTPException(400, "Approve the article before generating a podcast")
body = await request.json()
hosts = body.get("hosts")
if not isinstance(hosts, list) or not (_MIN_HOSTS <= len(hosts) <= _MAX_HOSTS):
raise HTTPException(400, f"Pick {_MIN_HOSTS} or {_MAX_HOSTS} host voices")
parsed_hosts: list[dict[str, str]] = []
for i, host in enumerate(hosts):
if not isinstance(host, dict):
raise HTTPException(400, "Bad host entry")
voice = str(host.get("voice") or "").strip()[:200]
if not voice:
raise HTTPException(400, "Every host needs a voice")
parsed_hosts.append({
"label": chr(ord("A") + i),
"voice": voice,
"name": str(host.get("name") or "").strip()[:60] or f"Host {chr(ord('A') + i)}",
})
labels = [h["label"] for h in parsed_hosts]
blocks = mag.article_text(mag.load_layout(d), target,
include_captions=bool(body.get("captions")))
source = "\n\n".join(b["text"] for b in blocks).strip()
if len(source) < 200:
raise HTTPException(400, "Article has too little text for a podcast")
# Keep the prompt inside a sane context window; the tail of a very long
# feature contributes little to a discussion of its argument.
source = source[:24000]
settings = _load_settings()
llm_url = (body.get("llm_url") or settings.get("llm_url")
or "http://localhost:11434/v1")
model = (body.get("model") or settings.get("llm_model") or "").strip()
if not model:
raise HTTPException(400, "No LLM model configured")
language = str(body.get("language") or "the language of the article")[:60]
system = _PODCAST_SYSTEM.format(
n=len(parsed_hosts),
labels=", ".join(labels),
language=language,
)
roster = "\n".join(f"{h['label']} = {h['name']}" for h in parsed_hosts)
user = (
f"Hosts:\n{roster}\n\n"
f"Article title: {target.get('title') or 'Untitled'}\n"
f"Author: {target.get('author') or 'unknown'}\n\n"
f"Article text:\n{source}"
)
payload = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": float(body.get("temperature") or 0.7),
"stream": False,
}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
timeout = _request_timeout_seconds(body.get("timeout"), default=300.0)
resp = await asyncio.to_thread(
_post_llm_chat_completion, llm_url, payload, headers, timeout
)
if resp.status_code >= 400:
raise HTTPException(502, f"LLM error {resp.status_code}: {resp.text[:300]}")
try:
content = resp.json()["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError) as exc:
raise HTTPException(502, "Unexpected LLM response shape") from exc
script = _parse_script(content, labels)
script.update({
"article": article_id,
"hosts": parsed_hosts,
"model": model,
"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
})
(_podcast_dir(d) / f"{article_id}.json").write_text(
json.dumps(script, ensure_ascii=False), "utf-8")
# Remember the cast so the next article defaults to the same hosts.
meta["podcast"] = {"hosts": parsed_hosts}
_write_meta(d, meta)
return script
@router.get("/api/magazine/{doc_id}/articles/{article_id}/podcast")
async def magazine_podcast_get(doc_id: str, article_id: str):
d, _ = _require_magazine(doc_id)
article_id = _article_id(article_id)
path = d / "podcast" / f"{article_id}.json"
if not path.is_file():
raise HTTPException(404, "No podcast script generated yet")
try:
return json.loads(path.read_text("utf-8"))
except (OSError, ValueError) as exc:
raise HTTPException(500, "Podcast script unreadable") from exc
@router.delete("/api/magazine/{doc_id}/articles/{article_id}/podcast")
async def magazine_podcast_delete(doc_id: str, article_id: str):
d, _ = _require_magazine(doc_id)
article_id = _article_id(article_id)
(d / "podcast" / f"{article_id}.json").unlink(missing_ok=True)
return {"ok": True}