Bulk delete/type for a click- or rectangle-selected set; every destructive
region change returns an undo token (one level, refused once something newer
happened); text inside a region that becomes a picture is folded into it.
TODO: model-based layout detection (PP-DocLayout, LayoutReader) with licence
notes. v1.39.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split at the cursor and merge-with-next keep article membership right; typed
letters in the editor never fire shortcuts; focus returns to the same row
after a redraw. v1.38.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rules sized on a real 204-page issue before writing them; each re-types
(not read aloud, reversible), none deletes. A run-on author line's sentence
becomes its own paragraph instead of being glued to "the next paragraph",
which was right in only 1 of 5 real cases. Runs on import and re-OCR, and on
demand with a layout backup. v1.37.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hand-ordered page is locked: Fix reading order, type changes and newly drawn
boxes leave it alone until Automatic order. v1.36.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Columns are found from where column-width text starts, not from the whitespace
between them (loose OCR boxes shrank one gutter to 4-6 pt and a pull-quote
bridged it, collapsing page 38 into one interleaved column). Headlines get
their own band, pull-quotes are read at the end of the page. Measured over a
204-page issue: 89 sentence continuations kept vs 85.
Each review row gets a type picker (text kinds, page number, source, ad,
image) and its own delete button; text<->image conversions keep the region
in its article. Fix reading order re-reads existing issues without OCR and
leaves hand-ordered pages alone. The shared confirm dialog is now an
alertdialog for screen readers. v1.35.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verify / recast now run span-natively (no text re-emission): a second differently-windowed
pass marks lines agreed/disputed and records the other answer, without changing any speaker
(~9 min vs ~49 min). Measured on the real pipeline, overruling disputed lines with a reasoning
adjudication made them worse (66.5% -> 61.4%), so that step was removed; agreed lines were 83%
right vs 48.5% for disputed ones, which is the signal used for confidence and review.
Auto-repair no longer runs after a span cast; segments carry a spanId.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split the book into narration/dialogue spans from its own quotation marks and ask
the LLM only who speaks each dialogue span (POST /api/attribute-spans), so text can
no longer be fused, dropped or duplicated and a novel takes ~10 min instead of ~4 h.
Book 1 vs the hand-corrected reference: every-word-right 87.0% -> 92.3%, type
agreement 93.6% -> 97.4%; Books 2/3 (not tuned on) 90.8% / 89.9%.
- static/js/audiobook-spans.js (pure, unit-tested) + audiobook-cast-spans.js
- core/attribution_spans.py, route, tests; scripts/score_attribution.py
- default on; localStorage ab-span-mode=0 restores the classic engine
- architecture/audiobook-attribution.md, Manual, CHANGELOG 1.32.0, TODO
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds "Edit boxes" mode to article review: drag an already-detected
region to move it, its corner handle to resize it, or press its x to
delete it -- for correcting a box the layout engine got wrong, not one
it missed entirely ("Draw it" still covers that case). Moving or
resizing opens a confirm panel; "Save" keeps the region's existing
text at its new position, and for text regions "Save & re-OCR" reads
the corrected area again and replaces the text.
New core/magazine.py update_region()/delete_region() and PATCH/DELETE
/api/magazine/{doc}/pages/{page}/regions/{block_id}. An image region
is re-cropped from the page render whenever its bbox changes, since
its saved file is exactly that crop.
Live-verified against the real ingested c't KI Wissen 2026 magazine:
add -> move -> move+re-OCR (correctly kept old text when the corrected
area had nothing readable) -> delete -> confirmed gone, repeat delete
404s.
275 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_docker_get_json/_docker_post called sock.connect(sock_path) a second
time after _docker_connection() had already connected the socket --
sock_path was never defined in either function, so every call raised
NameError. Surfaced in the UI as every engine card reporting "Not
installed" and the Active OCR Engine model dropdown reporting "Not
Found" regardless of what was actually running, found while diagnosing
a user report of that dropdown. Docker socket API calls (list/status/
start/stop/restart) were affected for every engine card, not just OCR.
Added tests/test_docker_client.py, which did not exist before, to
catch this class of regression going forward.
Also records a request to make the magazine ingestion "watching it
work" panel draw detected region boxes live over the page image as
each is classified, instead of today's static page-image-plus-text-
list (TODO.md; no code change for that yet).
254 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
User-requested candidates to install as Docker containers alongside the
existing engines and add through the Engines tab, to be benchmarked
against the two already wired (baidu/Unlimited-OCR, KDL-Frontier-Parser-
nano) for both page-text transcription and open-ended figure/chart
description. No code change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "Describe" action per figure in article review: sends the cropped
image to the configured vision engine (with fallback) for a type-aware
one- or two-sentence description -- a chart gets a prompt about what it
measures and its trend, a table about headings and what stands out, etc.
Editable before approval. A new "Read figure descriptions" toggle in the
reading controls speaks these where they occur; purely additive, off by
default, and a figure with no description behaves exactly as before.
New core/magazine.py describe_visual_region()/_chain() reuse the same
reliability guards as page OCR (repetition-loop detection, sentence-
boundary truncation). New routes: POST .../images/{id}/describe
(background job), PATCH .../images/{id} (manual edit), descriptions=1 on
the article-text endpoint.
Live-verified end to end against the ingested c't KI Wissen 2026
magazine. Also found and documented a real, config-dependent limitation:
the currently-configured primary vision engine is transcription-only and
returns empty content for any descriptive prompt (confirmed with three
different prompts) -- not a code defect, but description generation
needs a genuinely caption-capable engine reachable as the fallback.
251 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Connect and "Use as LLM" on the LiteLLM Gateway card now resolve that
card's own saved key (engine_api_keys[name]) server-side in
routes/docker.py, ahead of the old global llm_api_key fallback. The
browser can no longer reliably forward a saved key at all -- it's
masked to "__configured__" in every /api/settings response the moment
it's saved -- so relying on a frontend-forwarded header alone left
Connect silently using a stale global key after LiteLLM's own key was
rotated, surfacing a misleading /api/version 404 instead of the real
401.
"Use as LLM" now goes through a dedicated /use-as-llm endpoint that
saves the exact credential Connect just verified (llm_url normalized
to .../v1, llm_api_key copied from the card), rather than a
separately-typed value that could drift again.
Also documents per-user provider-key storage as an explicit item for
the deferred multi-user/identity planning pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No code change. User set explicit priority for upcoming feature work:
Magazine reader roadmap first, then the character-identity system,
then audiobook speaker attribution. Recorded in TODO.md so it survives
a context reset. Deployment and the deferred multi-user initiative
stay explicitly parked, not part of this order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The NFS blocker from TODO.md's deployment entry is resolved (export
set up by the user on the DGX); picks up from there and completes the
deployment end to end.
- Mounted the DGX's TTS_Voices export read-write on the NUC at
/mnt/dgx-tts-voices, persisted in /etc/fstab -- confirmed writable
and confirmed the NUC instance sees the DGX's real voice library
(344 voices), not a separate copy.
- Deployed by rsyncing the working tree from the DGX (excluding .git,
config/, logs/ and other local-only paths) and building the image
directly on the NUC, rather than pulling ghcr.io/martin-b78/... --
that registry copy turned out to be both several versions stale and
private with no credentials configured on the NUC, and the source
was already right there to build from.
- Found and fixed a real gap while wiring it up: vision_ocr_url and
vision_ocr_fallback_url default to host.docker.internal, which
resolves to whichever host the container is running on -- correct
on the DGX itself, silently wrong on the NUC (would have pointed at
the NUC's own nonexistent OCR engine). Pinned both explicitly to the
DGX's LAN IP via environment overrides.
- The container crash-looped on first start (sqlite3.OperationalError:
unable to open database file) because docker compose had created
config/ and logs/ root-owned under this session's root SSH access to
the NUC, while the image's app user is uid 1000 -- chown -R 1000:1000
fixed it.
- New docker-compose.nuc.yml, tracked here as the reviewable reference
for that host (docker-compose.yml stays the DGX's own -- different
build/network/socket-proxy shape). Deployed to the NUC as its own
docker-compose.yml, since that host has no .git checkout to diff
against, only the rsynced tree.
Verified: container stable (no restart loop), HTTP 200, settings
confirm all four engine URLs correctly resolve to the DGX, voice count
matches the DGX exactly, restart policy is unless-stopped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No code change. Added to TODO.md:
- Magazine contents view (#s-magazine): logged three candidate layouts
as open options rather than picking one -- three-column page/text/
figures, synthesis-state-and-inline-play (the recommended quick win
under any layout), and a flipbook with content columns underneath.
Needs a decision before implementation.
- Browsing free PDF magazine sources: the two sites given as examples
(freemagazines.top, pdf-magazines.org) redistribute current commercial
titles without licence, so no scraper was built against them. Scoped
instead against OPDS catalogues, archive.org's public-domain
periodicals, publisher-run free issues, and open-access journals.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three research passes into what multi-user hosting would require found the
app has zero multi-tenancy today: one global settings/database/voice
library, no browser-UI authentication at all, global (not per-user)
concurrency limits, and no consent/audit mechanism for voice cloning.
The user wants a single shared, interconnected login/profile system across
all their self-hosted apps rather than a per-app login, extending the
GoTrue+Postgres+PostgREST stack OurFlashcards already runs on the NUC — so
this is recorded as a deferred, cross-repo initiative rather than
implemented now. One policy decision was made and is captured for future
implementation: voice cloning will require a consent attestation with an
audit trail once accounts exist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recorded the exact NFS setup needed on the DGX (export the voice
library read-write, scoped to the NUC's IP) so the app's voice list
never drifts from what the DGX's TTS engine actually has on disk, plus
the accepted cross-host limitation on voice-change auto-restart.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
A page needing OCR (or whose already-extracted text -- native or
tesseract alike -- scores as mostly garbage via ocr_quality_score) is
now escalated to a connected vision OCR engine before falling back to
tesseract. Falls back automatically on any failure (unreachable engine,
timeout, unparseable output); never fatal to ingestion.
Verified live against ocr-baidu-unlimited-ocr on a real 28-page scanned
c't magazine excerpt (Epson ES-580W, no scanner OCR): every garbled
article title tesseract produced ("K | soll Arbeit abnehmen", ". |",
"@)X", "Owns mee") was replaced by the real headline ("1. Irrtum: KI
ist ein allgemeiner Jobkiller", etc.), and furniture detection jumped
from 44 to 151 blocks because the engine labels running headers/
footers/page numbers directly instead of needing fuzzy post-hoc
matching. 26 of 28 pages used the vision path; the remaining 2 fell
back to tesseract automatically.
Also fixes two real defects found investigating the same real-world
data, both regression-tested against the actual garbled strings
observed:
- Exact-match repeated-line detection (running header/footer) silently
failed once tesseract OCR'd the same line differently each time --
cluster_running_furniture fuzzy-matches near-edge blocks instead.
- ocr_page() built one Block per OCR text-LINE rather than per
paragraph, so a single anomalous line (a chart number, scan noise, a
wrapped headline continuation) could independently trigger title
classification -- now grouped by paragraph like the native PyMuPDF
path, using the paragraph's median line height rather than its total
bbox height for font size.
A vision-engine label (footer/title/page_number/caption) is trusted
outright and skips classify_blocks' font-size heuristics entirely,
since the engine gives no reliable font metric; an ambiguous "text"
region gets a synthesized estimate from its paragraph area and length
(estimate_font_size_from_area) and still goes through normal
classification.
Off unless vision_ocr_url/vision_ocr_model are configured
(vision_ocr_enabled setting, default on if reachable); per-request
override via the ingest endpoint body. Default engine URL uses
host.docker.internal, confirmed live -- the OCR container runs on its
own Docker network, not dgx_net, so a container-name default would not
resolve from the app container.
28 new tests (136 total in the module). ocr_quality_score is
deliberately coarse and documented as such: it catches text that is
mostly symbol noise, not a plausible single-word misread inside an
otherwise normal sentence (confirmed live: a misread "KI" -> "K |"
still scored 0.81) -- that class of error needs a dictionary or
language-model check, out of scope here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Studio -> Magazines: library, contents tree grouped by section with
confidence/pages/listening-time/audio status, the Stage D review
screen (tick/untick layout blocks, correct title/author, approve or
reject), a reflowed article view with click-to-seek sentence
highlighting, a linked original-page view showing the source bounding
box, and a podcast panel (2-3 host pickers, script generation,
turn-by-turn playback).
Wired into the existing lazy-module system (module-manifest.json,
loader.js, nav.js) rather than a separate app. Reuses /api/tts-preview
for both narration and podcast turns -- no new audio pipeline.
Fixes found building this:
- The pane toggle used role="tab" without the full ARIA tabs pattern
(aria-selected, roving tabindex, arrow-key nav) -- fails WCAG 2.2 AA
and hid the buttons from role-based tooling. Replaced with a button
group + aria-pressed, matching the plain-click interaction it
actually has.
- Print layout cues ("(continued on page 8)") were being sent to TTS
verbatim -- narration would have spoken the parenthetical aloud.
Stripped from the narration copy only; layout.json keeps the
original for the review screen.
- has_podcast now comes from the articles listing so opening an
article without a generated script skips a doomed GET instead of a
routine 404 on every open.
Verified against the real API contract with Playwright (library,
review pre-selection, approval, sentence highlighting + PDF-page
linking, podcast approval gate, zero console errors across the flow)
and manually end-to-end against a real ingested fixture, screenshots
included. Full suite: 94 Python tests, 33/34 desktop Playwright tests
(one unrelated pre-existing flaky DOM-count assertion).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>