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