Skip to content

feat: registered downloads + local VLM OCR (GLM/Unlimited) + aglaia ocr - #54

Open
yb85 wants to merge 37 commits into
feat/serverfrom
feat/download-registry
Open

feat: registered downloads + local VLM OCR (GLM/Unlimited) + aglaia ocr#54
yb85 wants to merge 37 commits into
feat/serverfrom
feat/download-registry

Conversation

@yb85

@yb85 yb85 commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Stacked on #53 (base feat/server) — this PR is just the 6 commits on top; review #53 first. Three coupled pieces, each shippable on its own.

1. Central download registry (retires model-list.json)

Core assets and drop-in plugins register fetchable targets in Python instead of a static JSON catalogue.

  • aglaia/app_data/downloads.py: DownloadTarget + in-memory _REGISTRY + register_download/registry/is_downloaded; reconciling download_status(); sync downloader; the 5 core assets registered in Python.
  • db.py: new downloads table (status/sha/size) mirroring the plugins-table convention. Disk stays ground truth; download_status() reconciles the table against it (a model deleted out-of-band flips back to not-downloaded).
  • models.py reduced to a back-compat shim so ModelDownloaderTab / OnboardingWizard / setup_cli need no change. The GUI downloader now records its lifecycle into the table too.

2. OCR-agnostic local VLM server (aglaia/workers/vlm/)

Generalised from PaddleOCR-VL's private _MlxVlmServer.

  • LocalVlmServer.ensure(model_path, backend=…) — lazy spawn keyed per model, free-port, health-wait, log-tail, process-group SIGKILL teardown (the orphan-worker footgun).
  • MlxBackend (Apple Silicon) + VllmBackend (CUDA) + pick_backend() (platform preference, AGLAIA_VLM_BACKEND override).
  • paddle_vl refactored onto it (−226 lines), behaviour preserved.

3. Abstract OpenAI-compat OCR + two local engines

  • OpenAiCompatVlmOcr base: backend-pick → server → DPI downsample → chat → grounding-token parse → OcrResult (meta.markdown for md_export; <|det|> boxes → per-line bboxes for the searchable PDF layer).
  • --ocr glm (GLM-OCR) and --ocr unlimited (Baidu Unlimited-OCR), ~25-line config subclasses; each registers MLX + vLLM download targets tagged by platform.
  • vLLM is intentionally not a managed extra (hard-pins torch/CUDA, conflicts with the other OCR stacks, destabilises the opencv pins) — per-box uv pip install vllm; the backend just needs it importable.

Plus

  • aglaia ocr PATHS… — OCR PDFs/images (or re-OCR a .agl) with no processing chain, for already-clean docs. Verified end-to-end (image → md via Apple Vision).
  • Opt-in real-hardware VLM round-trip harness (AGLAIA_VLM_SMOKE=1).

Testing

Full suite green. New: test_downloads.py, test_vlm.py, test_openai_compat_ocr.py, test_ocr_command.py, test_vlm_integration.py (opt-in). The live VLM spawn/HTTP path is stubbed in CI and exercised by the opt-in harness on real hardware.

🤖 Generated with Claude Code

yb85 and others added 30 commits June 29, 2026 22:26
Phase 1 of the download-registry refactor. Core assets and drop-in plugins
now register fetchable targets in Python instead of a static model-list.json:

- new aglaia/app_data/downloads.py: DownloadTarget + in-memory _REGISTRY +
  register_download()/registry()/target_for(); disk-truth is_downloaded();
  reconciling download_status(); the sync urllib downloader; and the 5 core
  assets registered via _register_core_targets().
- db.py: new `downloads` table (key/status/sha/size_bytes/updated_at) +
  set/get/clear_download_status + download_statuses, mirroring the `plugins`
  table convention (absence of a row = never fetched).
- models.py: reduced to a back-compat shim re-exporting the old names
  (ModelSpec→DownloadTarget, MODEL_SPECS, is_model_installed→is_downloaded,
  download_model, spec_for, _load_model_specs) so ModelDownloaderTab /
  OnboardingWizard / setup_cli need no change.
- model-list.json deleted; removed from Aglaia.spec; paddle_vl comments updated.

Disk stays ground truth: is_downloaded() is a cheap DB-free presence check;
download_status() reconciles the table against disk (a model deleted
out-of-band flips back to not_downloaded). platform field added for the
upcoming MLX/vLLM backend filtering. 8 new tests in tests/test_downloads.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… onto it

Phase 2 of the OCR refactor. Extracts paddle_vl's private _MlxVlmServer into a
reusable, model-neutral serving layer so any local VLM (Paddle now; GLM /
Unlimited next) runs locally on the best backend for the machine.

- aglaia/workers/vlm/backends.py: VlmBackend protocol + MlxBackend
  (mlx_vlm.server, Apple Silicon) + VllmBackend (vllm OpenAI api_server,
  CUDA/other) + pick_backend() (platform preference, AGLAIA_VLM_BACKEND
  override). Each backend builds its own spawn argv + served-model-name.
- aglaia/workers/vlm/server.py: LocalVlmServer.ensure(model_path, backend=…)
  — lazy spawn keyed per model, free-port pick, health-wait, log-tail through
  a pluggable sink, and the process-group SIGKILL teardown (killpg on the
  start_new_session group — avoids orphaning the backend's native threads).
- paddle_vl.py: drops _MlxVlmServer (~226 lines) and routes through
  LocalVlmServer.ensure(..., backend=MlxBackend(), log=engine_log); pins MLX
  since its weights are MLX-4bit. Behaviour preserved (same argv, teardown,
  model-name pinning); timeout path now LocalVlmServer.stop(model_path).

12 new tests (backend selection, env override, command shapes, free-port,
no-backend error, log routing) in tests/workers/test_vlm.py. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted-OCR

Phase 3 of the OCR refactor. Adds two end-to-end document VLMs that run locally
and auto-managed (MLX on Apple Silicon, vLLM on CUDA via the Phase-2 server).

- openai_compat.py: OpenAiCompatVlmOcr base — declares mlx/vllm download targets,
  prompt, extra_body; does backend pick → LocalVlmServer.ensure → DPI downsample
  → chat → grounding-token parse → OcrResult. parse_grounded_markdown() strips
  <|ref|>/<|det|> to clean Markdown (meta.markdown for md_export) and decodes the
  det boxes to per-line bboxes (real searchable PDF text layer), with a
  full-page fallback. Per-page failures are isolated, not fatal.
- glm_ocr.py (`--ocr glm`) and unlimited_ocr.py (`--ocr unlimited`): ~25-line
  config subclasses; each registers two download targets (*_mlx / *_vllm) tagged
  by platform. Unlimited carries the recipe's <image> prompt + skip_special_tokens
  + R-SWA xargs. Registered in ocr/__init__.py; both appear in `list ocr`.
- Paddle stays separate (layout orchestrator, not whole-page chat).
- vLLM is intentionally NOT a managed extra: it hard-pins torch/CUDA and conflicts
  with every other OCR stack (would destabilise the opencv pins), so it's a
  per-box `uv pip install vllm`; the VllmBackend only needs it importable.

Also fixes a test-pollution bug: tests/test_downloads.py reloaded the downloads
module, wiping engine-registered targets for later tests — now snapshots/restores
_REGISTRY and relies on live env reads (no reload). 13 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unit tests stub the server + HTTP; this exercises the real spawn → health →
chat path on actual hardware (MLX on Apple Silicon, vLLM on CUDA). Skipped
unless AGLAIA_VLM_SMOKE=1; each engine self-skips when its backend/weights
aren't ready. Run: AGLAIA_VLM_SMOKE=1 uv run pytest tests/workers/test_vlm_integration.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Model Downloader now mirrors its lifecycle (downloading / downloaded /
failed) into the central downloads table via downloads.record_status — same as
the CLI download path — so the registry's persisted status reflects GUI
downloads, not just disk reconciliation. Also drops stale model-list.json
references (message + comments) and three pre-existing unused imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
For already-clean docs (born-digital PDFs, flat scans) that don't need
dewarp/binarize/page-split. Ingests each page as the raw COLOR root node + a
single branch pointing straight at it (no IntegratedProcessingChain — no worker
pool, no geometric ops), then reuses run()'s OCR + export primitives verbatim.

- workers/headless.py: run_ocr_only() + chain-free ingest (_ingest_images_no_chain
  / _ingest_pdfs_no_chain via _persist_raw + BranchRepo.upsert). A synthetic
  zero-step "ocr-passthrough" pipeline_version backs the nodes. Pass a .agl to
  re-OCR (or --check-ocr to poll a Mistral batch).
- cli/commands/ocr.py + cli/shared.py ocr_config() + registered in cli/__init__.py
  (KNOWN_COMMANDS + app). Same OCR/export flags as run, minus
  --pipeline/--workers/--force-proc; engine defaults to auto.
- app.py: _run_ocr_only handler (no setup gate — OCR needs no detection model).
- Docs: README, docs/cli.md (new `ocr` section), CLAUDE.md.
- Tests: chain-free ingest yields exactly one OCR-able branch (= raw page),
  engine defaults to auto, no-inputs → exit 2. Verified end-to-end with Apple
  Vision (image → md round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MLX VLM backend is no longer tied to the `paddle` extra — it's the serving
layer for all local VLM OCR (glm / unlimited / paddle_vl), so move it to the
platform extras:

- `macos` += mlx-vlm (arm64 marker) → `--extra macos` now ships a working local
  VLM backend out of the box.
- `cuda` += vllm (linux marker) → `--extra cuda` ships the Linux VLM backend
  (GPU with a CUDA torch, CPU otherwise via the pytorch-cpu index).
- `paddle` = just paddleocr + paddlepaddle (the layout orchestrator); PaddleOCR-VL
  now needs `--extra macos --extra paddle`.

Platform markers keep mlx-vlm (darwin) and vllm (linux) on disjoint splits, which
dissolves their llguidance clash. Only surya↔cuda remains a hard conflict (vLLM's
torch/openai≥2 vs surya-ocr's torch≥2.7/openai<2) — declared via [tool.uv]
conflicts so the universal lock splits them. Also pin opencv-python-headless to
4.11 alongside opencv-python so a re-lock can't reintroduce the cv2 version skew.

Engine "backend missing" hints + README/CLAUDE now point at `--extra macos` /
`--extra cuda`. Verified: universal lock resolves, cv2 stays 4.11, mlx-vlm
installs via macos, pick_backend()→mlx on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
paddleocr pulls opencv-contrib-python, a third distribution writing the shared
cv2/ dir; unpinned it installed 4.10 and clobbered cv2/ to a half-written/skewed
state (override only pinned opencv-python + -headless). Pin all three to
4.11.0.86 so the shared cv2/ stays one consistent payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surya 2 is a Qwen3.5-VL document model, so serve it through the shared
LocalVlmServer like glm/unlimited instead of the old surya-ocr + llama-server +
GGUF path (which pinned torch / huggingface-hub<1 / openai<2 and was mutually
exclusive with the MLX/vLLM stack).

- surya.py: 485 lines → a ~20-line OpenAiCompatVlmOcr subclass (name unchanged).
  MLX on Apple Silicon, vLLM on CUDA. Output is HTML-flavoured Markdown.
- downloads.py: replace the GGUF `surya` target with `surya_mlx`
  (aglaia-models/surya-ocr-2-mlx, pinned to an immutable commit) + `surya_vllm`
  (datalab-to/surya-ocr-2). DownloadTarget gains a `revision` field; the
  downloader fetches /resolve/<revision>/ (commit-pinned = immutable).
- pyproject: drop the `surya` extra (no more surya-ocr/torch) AND the
  `conflicts` declaration — with torch-surya gone, surya↔cuda no longer clashes.
- setup_cli / OnboardingWizard / test_downloads: surya → surya_mlx key.

The MLX weight is plain safetensors + configs (no executable code), pinned by
sha — verified mlx_vlm.server serves it on an OpenAI /v1 endpoint and reads a
test page. Full suite green; nothing imports the surya-ocr package anymore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1) CLI downloader is now resumable (HTTP Range): a stalled read on slow/flaky
   Wi-Fi times out (60s) and we resume from the .partialdl byte offset instead
   of restarting, retrying up to 6×. Fixes the "times out at 300 MB, loses
   everything" failure on the ~800 MB VLM weights (mirrors the GUI downloader).
   Handles 416 (already complete) and a server ignoring Range (200 → restart).

2) Surya emits HTML (<p>/<table>/<div>); add OpenAiCompatVlmOcr.output_html +
   html_to_markdown() so its .md is clean Markdown (tables → Markdown tables),
   not raw HTML soup. Uses html2text (pure-Python, no compiled extension →
   bundle-safe for the frozen .app), with a tag-strip fallback if absent.

Tests: Range resume / 416 / retry-after-timeout (mocked urlopen); HTML→MD
paragraphs + tables + the surya engine's output_html path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OCR is valuable, and we now have many engines — so keep ONE layer per engine
per branch (rerun same engine replaces; different engines coexist) and let
export pick which layer.

- OcrRepo: branches_needing_ocr(engine=…) evaluates "needs OCR" per engine, so
  re-running a different engine adds its layer instead of being a no-op (the old
  CLI no-op). finish() prunes superseded same-engine done rows → max 1 per
  engine. available_ocr_layers() lists engines with a done layer, latest-
  generated first (MAX(id)). Stale/fresh icon path (branch_status_map,
  mark_stale_for_engine_switch) left untouched.
- Export takes an engine selector (default = latest layer, back-compat):
  write_markdown(engine=…) + ocr_engine_suffix(engine=…); create_pdf_from_db(
  engine=…) → _ocr_results_for_rows(engine=…).
- CLI: `--export "pdf:g4:ocr=surya+md:ocr=apple"` (engine via the existing
  param parser; aliases honoured). _run_exports validates the layer and lists
  available ones on a miss. _run_ocr / OcrWorker pass the engine to
  branches_needing_ocr.
- Surya HTML→MD now uses pad_tables=True (canonical GFM tables).

GUI layer selector is Slice B. 5 storage tests; full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice B of multi-engine OCR. ExportTab gains an "OCR layer" combo (populated
from OcrRepo.available_ocr_layers, latest-generated first; hidden until ≥1 layer
exists) applying to both the PDF text layer and Markdown. MainWindow populates
it on every OCR-state refresh and threads the selection into create_pdf_from_db
(via _run_pdf_maker) / write_markdown / ocr_engine_suffix. "Latest layer" (None)
keeps the prior behaviour. Stale/fresh icon path untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status bar shares _done_snaps between the pipeline's mark_done (branch_ready,
deduped by scan_id) and OCR's mark_tick. With multiple OCR engine layers now
kept, a re-OCR runs while the chain may still emit branch_ready — those
mark_done()s piled into the same set as the OCR ticks, so the OCR bar read
prior+current (e.g. 322 + 12 = 334/322, hitting 100% in 1s).

mark_tick already kept a private monotonic _tick_count; the displayed count just
read len(_done_snaps). Add a _tick_mode flag (set by mark_tick, cleared by
reset) and a _done_count() that returns _tick_count in OCR mode — so a
concurrent pipeline mark_done can't inflate an active OCR pass. ratio / label /
is_finished / force_complete all go through _done_count().

Test: 12 OCR ticks interleaved with 12 pipeline mark_done → done stays 12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The local VLM (Surya/glm/unlimited) runs on the GPU/ANE, not CPU, and a small
model rarely saturates it — yet pages were sent to the server strictly serially,
leaving throughput (and the M-series GPU) underused. Add OpenAiCompatVlmOcr
concurrency: dispatch N pages to the server in parallel via a ThreadPoolExecutor
(urllib releases the GIL on the socket read), order-preserving. Default 1 (safe,
serialized); tune via AGLAIA_VLM_CONCURRENCY or `--ocr <engine>:concurrency=N`.

Per-page errors stay isolated (a concurrent failure → empty page, not an abort),
so 2–3 is safe to try even though a single mlx_vlm.server's continuous batcher
can choke on mismatched-size concurrent prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real cause of "OCR · 322/322 in 3s": the chain is idle for the whole OCR
run (OCR doesn't use the pipeline), so the pipeline-idle watchdog
(_maybe_force_progress_complete → force_complete) fired after ~6s and snapped
the bar to N/N — but the bar was showing OCR. force_complete is now a no-op in
OCR (tick) mode; OCR drives its own completion via mark_tick / _on_ocr_finished.
(Also drops the earlier mistaken tick-count snap in force_complete.)

Tests: force_complete is a no-op mid-OCR, still reconciles in pipeline mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…complement

The Apple Document engine offloads scripts Vision can't read (e.g. Greek) to a
"complement" engine, but the eligible set was hardcoded to surya/paddle in two
places (apple_docs._VALID_COMPLEMENTS + OcrTab._COMPLEMENT_CHOICES), so the new
local VLMs (glm / unlimited / the MLX Surya) couldn't be picked.

Add a DirectBlockOCR trait (engine recognises a cropped block directly — no
layout pass) + engine.direct_block + direct_block_engines(). Compose it onto
OpenAiCompatVlmOcr (glm/unlimited/surya) and paddle_vl. apple_docs now validates
the complement against the registered DirectBlockOCR engines (+ none) and the
OCR tab's complement dropdown lists every AVAILABLE one with its display name —
so a new qualifying engine appears automatically, no edit. Default stays surya.

Tests: trait membership (VLMs in, Vision/cloud out), resolve_complement accepts
any direct-block engine + env + default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OCR runs in its own QThread, but every completed page fired ocr_state_changed →
a main-thread refresh that runs branch_status_map() DB joins (twice — also in
_update_ocr_frame_state), available_ocr_layers(), and repaints every scan
widget's badge. The VLM completes pages in bursts of 4, so 4 of these heavy
refreshes fired back-to-back → visible UI hitches.

Coalesce: _on_ocr_state_changed now (re)starts a 350 ms single-shot timer whose
timeout runs the actual refresh (_refresh_ocr_ui + _update_ocr_frame_state +
_refresh_alt_views_if_visible) once. The progress tick stays immediate (cheap).
No correctness change — badges just settle ~350 ms after the last event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OCR counts per branch (one branch ≈ one page; a 2-up photo is 1 scan but 2 OCR
units), so the final-state "N/N · …s/scan" was misleading. Use "page" in OCR
(tick) mode, keep "scan" for the pipeline (which counts whole scans).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ETA used a moving average over the last 8 completion timestamps, but the
VLM completes pages in bursts of 4 (a batch returns all at once, then a gap), so
the windowed rate swung wildly across batch boundaries — "chaotic and not true".
Switch to cumulative throughput (elapsed-since-start ÷ done × remaining): smooth,
converges to the real pace, includes the one-time warmup so it settles rather
than jumps. (Surya's model load is fast, so it's accurate within a few pages.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three OCR-correctness fixes surfaced by the delbrel-colloque test run:

- Surya `.md` was layout JSON, not text. The generic "convert to Markdown"
  prompt triggered Surya 2's *layout* task ([{label,bbox,count}] JSON we were
  exporting as garbage). Use its exact HIGH_ACCURACY_BBOX_PROMPT (OCR→HTML
  <div data-bbox> blocks) + output_html so html_to_markdown yields real text.

- apple_docs + a recognition-only VLM complement (Surya/GLM) could splice a
  repetition-loop hallucination into the result — observed 390× "(N) Ibid.,
  p. 188." polluting an export (the loop, crammed onto the block's first line
  by _split_block_text's count-mismatch path). Add _complement_degenerate:
  reject output that explodes the block line count or collapses to few
  distinct lines (after stripping a leading enumerator, so real footnotes
  whose page numbers vary survive). Fail-safe → keep Vision's text.

- The "VLM OCR; dense pages can take a few minutes" / "loading the model"
  progress hint fired for every engine, including fast Apple Vision. Gate it
  on a new served_vlm trait (True on the local-server VLMs + PaddleOCR-VL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The indeterminate phase before the first OCR tick is the local VLM model
spinning up (or the Cloud whole-doc round-trip), so 'loading…' is more
precise than 'working…' and matches the worker's first-run log line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DBnet split scans 26/31 of morales-slim wrong: the top-left page number
became the "left page" and the whole spread the "right page". Apple/EAST
were fine. Two root causes, both fixed:

- merge_layouts grouped boxes by pairwise x-OVERLAP, starting a new group at
  ANY gap. DBnet placed the page-number box flush against (touching, 0 px)
  the body column, so it formed its own group → 3 groups, max_pages=2.
  Replace with xy_cut: the X-projection step of the classic recursive XY-cut.
  A column boundary is a vertical whitespace gutter wider than
  gutter_min_frac (2% of page width), not the mere absence of overlap — so a
  flush page number stays in its column. Single interpretable knob vs the
  merge scorer's weights; Y-cut omitted (a page is vertically continuous).

- smart_merge's capacity-forced branch merged the highest-scoring pair, which
  on an over-split spread fused the two REAL pages. Now it absorbs the
  SMALLEST region into its best-scoring neighbour (small→large), keeping real
  pages intact. Belt-and-suspenders with xy_cut for >2-region cases.

Verified on morales-slim scans 26/31: both backends now yield clean
left/right pages, page number riding with the left page; Apple unchanged.
Adds tests/processors/test_xy_cut.py + a smart_merge speck regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some pages early-exit the optimiser at the cold seed cubic_slopes=[0,0] — a
flat local optimum → the page is left undewarped while its facing page fits
real curl (morales-slim 163b: branch A curl≈0.18 in 1080ms, branch B ≈0 in
181ms). Curl varies slowly across a book, so seed it from recent same-side
fits to lift the optimiser into the right basin.

- Worker-local cache `_warm_curl[page_side] -> ring of (cubic_slopes, spline
  extras)`. Seed read in `_build_dewarp_problem` (feeds BOTH the inline and
  the batched paths); stored in `process()` + `apply_result`. Transfers ONLY
  the global shape — pose (rvec/tvec) + per-span keypoints stay page-specific.
  Respects the shape-bucket batcher (seed set before submit, cache updated as
  results return); no cross-process state.
- Robustness: seeding the SINGLE last fit let a one-off over-curl compound
  page-to-page into a runaway (127a/132a/136a/137a/138a). Seed is now the
  median of the last 3 fits — componentwise median scaled to the MEDIAN
  magnitude (a lone outlier moves neither) — then hard magnitude-capped
  (_WARM_CURL_MAX=0.5). The cap bounds only the SEED, not the fit: a legit
  strong-curl page can still climb past it, a runaway can't START astray.
  Non-finite fits are dropped before they poison the median.

Tests: test_dewarp_warmstart.py (seed/store, median-ignores-runaway, cap,
side isolation, non-finite). test_dewarp_batchable.py now uses fresh instances
per path (warm-start is cross-page state, broke the byte-identity invariant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ache

The round stage-toggle on a thumbnail silently stopped working after any
(re)process. cell_disable_states is memoised per scan and keyed by NODE id,
but invalidated ONLY in set_step_disabled. A (re)processed scan gets fresh
node ids, so the cached map missed every new node → the toggle resolved to
(toggleable=False) and locked → and since a locked button can't fire
set_step_disabled, the only invalidator never ran: permanent lockout.

Drop the scan's cached entry on branch_ready (fires when a branch finishes
(re)processing — exactly when its node ids change). Boot stays fast: loaded
scans don't fire branch_ready, so their cache stays warm.

Test: test_step_toggle_cache.py — branch_ready invalidates the stale entry
(leaving other scans'), and the no-cache path is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s alias

The debug viewer showed "no debug renderer for this step" for the layout step
(its page-bbox overlay never appeared — noticed while diagnosing a DBnet
mis-split). _RENDERERS is keyed by processor CLASS name ("PageDetector"), but
a node stores the CONFIGURED step name, and the shipped pipelines name the
step "LayoutDetector" → _RENDERERS.get() missed → _default_renderer.

Resolve the renderer by mapping the configured name through the registry to
its class name when the direct lookup misses (generic — fixes any aliased
step). Extracted as _resolve_renderer for testing.

Test: tests/storage/test_debug_renderer_alias.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the column-edge fit collapses, the trap rectifies a sliver quad and
shears the page — a visible slant (morales-slim 288_A: quad aspect 0.23 vs
~0.6-0.9 for good pages). Add two self-checks that fall back to passthrough
(identity) instead of emitting a skewed page; same philosophy as the dewarp
warm-start guards — when the estimate is unreliable, don't apply it.

- min_column_aspect (0.5): reject an implausibly thin column quad. Catches
  the obvious slant case; across all 586 trap pages this discards only 2.2%,
  all genuinely degenerate (good pages sit well above 0.5). Tighter than the
  pre-existing max_aspect_ratio floor (1/5 = 0.2) that 0.23 squeaked past.
- max_added_tilt_deg (1.5): push the detected baselines through the
  correction and reject if it ADDS median tilt beyond the bound — guards the
  rotation failure mode (a correct keystone keeps text horizontal).

Both gates run before the warp (cheap scalar checks). The subtle
extrapolation-slant cases (geometrically-fine quad, slant only in the footer
projected past the column) are deliberately NOT gated — their only signals
overlap ~10-18% of good pages.

Tests: test_trap_autodiscard.py. Validated against morales-slim 288_A
(discarded), 145_B / 26_A (kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e timing

Two fixes surfaced while benchmarking OCR configs on a 24-page corpus.

1. apple_docs export filename now folds in the complement engine. apple_docs
   alone / +surya / +glm produce DIFFERENT text but all got `_apple_docsOCR`
   → they silently overwrote each other. ocr_engine_suffix now appends the
   dominant complement (from result meta.complement_used): `_apple_docs_surya
   OCR`, `_apple_docs_glmOCR`, vs `_apple_docsOCR` when none.

2. Headless OCR timing separated model-load from page processing. The old
   per-page timer charged the first page with the whole model spin-up (VLMs
   load the server inside recognize). Added an OcrEngine.warmup() hook (no-op
   base; the served VLMs ensure their LocalVlmServer), called + timed before
   the page loop, and a steady-state summary: "model load Xs" + "N page(s) in
   Ys (mean/median s/page)". Apple Vision reports ~0 s load, as expected.

Tests: test_ocr_export_naming.py (complement folded in, variants distinct,
plain engines unaffected, dominant-engine path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tral)

The headless OCR driver looped recognize() per page. For Mistral that meant
one uploaded PDF — one billed API call — PER PAGE (24 calls for 24 pages),
instead of the single whole-document request the engine is built for (and the
GUI's OcrWorker already uses via recognize_rows: all pages → one PDF → one
/v1/ocr call).

Route any engine exposing recognize_rows() (the whole-doc interface) through a
single recognize_rows call: assemble every page's image row, one request,
distribute the per-page results back. Per-page engines (Apple, Surya, GLM,
Paddle, Unlimited) are unchanged. Timing reports the one request + amortized
s/page, kept separate from model load.

NOTE: the CLI and GUI still have two separate OCR drivers (_run_ocr here vs
OcrWorker) — this fix aligns the cloud path, but the divergence as a whole
warrants an audit/unification (they should share one driver).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_run_ocr called recognize() without src_dpi, so downsample_to_dpi saw
src_dpi=0 and used its coarse 12-inch longest-edge fallback — which barely
shrinks a 300-dpi page. Result: the CLI OCR'd ~2.25× the pixels (4.1 MP vs the
1.8 MP a true 200-dpi downsample gives) at the wrong resolution, ~2× slower
than the GUI (which passes the real dpi). Pass images.dpi as src_dpi.

With AGLAIA_OCR_DPI honoured end-to-end, a 100/150/200 dpi sweep now actually
changes the pixels the engine sees. Another CLI/GUI divergence (see audit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
yb85 and others added 7 commits July 1, 2026 02:19
…oops (#56)

Greedy decoding (temp 0) + no repetition penalty + 8192 max_tokens let the
served VLMs fall into a token loop on low-context crops (an apple_docs
complement block), emitting the same line until they exhaust the budget —
e.g. "(195) Ibid., p. 188." ×100. The _complement_degenerate guard discarded
the garbage but the runaway generation still ran to ~8192 tokens, which is
what made the complement path crawl / time out (#55 is a consequence of #56).

Send repetition_penalty=1.15 + repetition_context_size=64 on every VLM chat
request (the MLX server honours both via make_logits_processors). Moderate
penalty over a short window breaks pathological long-sequence loops while
leaving OCR's legit short repeats (spaces, digits, "Ibid.") intact; temp
stays 0.0 (deterministic). Per-engine tunable via the class attrs / extra_body.

Validated on the exact config that timed out (apple_docs + surya, 24 Greek
pages): >1200s TIMEOUT → 218s complete (9.1s/page); degenerate-loop warnings
many → 1; corrections now real (e.g. "4. Ibidem, q 39 a 8 arg 1.").

Closes #56. Largely resolves #55 (the slowness was the loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Benchmark of every OCR engine x {100,150,200,300} dpi on a French+Greek
corpus (docs/ocr-benchmark.md): timing (model-load vs page), word-overlap
accuracy vs native-300 and vs mistral@300. Conclusion: a single global 200
dpi default — 300 buys nothing over 200, 200 is safe for the local VLMs, and
Apple loses only 0.14 s/page vs 100.

Make the CLI match the GUI: resolve_ocr_dpi default 150 -> 200, and add a
--ocr-dpi override to the ocr command (sets AGLAIA_OCR_DPI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple Vision renders ancient Greek as confusable CYRILLIC — and often with HIGH
confidence, so the confidence-only gate leaks those misreads uncorrected
(measured: ~10 of the residual Cyrillic lines had confidence >= 0.70). A line
carrying letters from a script the document's languages don't cover is a
near-certain misread — measured 100% precision / ~96% recall on this corpus —
so it should go to the complement regardless of confidence.

Add _has_unexpected_script(text, languages): flags a line with >=2 letters
outside the scripts its OCR languages imply (Cyrillic in a fr+el doc). Wire it
into the complement gate alongside the confidence test. Innocent of the loop
guard: legit Greek/French never trips it; a Russian doc's Cyrillic is expected.

NOTE (separate finding): the Cyrillic is entirely VISION's, not the VLMs' —
standalone glm/surya emit 0-12 Cyrillic chars vs Vision's 2200+. For
Greek-heavy material a standalone VLM beats Vision+complement outright; this
fix improves the complement for MIXED (mostly-Latin) documents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…guage UI

Replace the domain-default (Latin/Greek) garbage detector with a general,
script-level module backed by langcodes (CLDR) + regex \p{sc}. Apple Vision,
handed a script it can't read, falls back to a confusable one it can — ancient
Greek → Cyrillic — WITH HIGH CONFIDENCE, so the confidence gate leaks it.

- aglaia/workers/ocr/text_scripts.py: scripts_for_language() maps any BCP-47 /
  ISO 639-1/2/3 code (explicit script subtag or CLDR likely-subtags, aggregate
  writing systems expanded: ja→Han+Hira+Kana, ko→Hang+Han; grc→Grek quirk fix)
  to ISO 15924 scripts; has_unexpected_script() flags a line with >=2 letters
  outside the chosen languages' scripts (regex V1 set subtraction). Coverage is
  the full ISO/Unicode set — plugin VLMs and unknown-language/known-script docs
  work. NO domain default: empty languages → no judgement.
- apple_docs complement gate is now a DOUBLE gate: confidence < 0.7 OR
  unexpected script.
- OpenAiCompatVlmOcr: append the chosen languages to the prompt (via
  langcodes display names) so surya/glm/… know which scripts to expect.
- UI: language picker reactivated for the VLMs (was Apple-only) — it feeds
  Apple recognition + the VLM prompt hint + the script gate. Dropped the
  "(Apple only)" caption; LanguageTagInput now offers a 141-language completer
  and accepts ANY valid BCP-47 tag (tag_is_valid); 'Auto' only for cloud
  engines that ignore languages (Mistral).

Deps: langcodes + language_data. Tests: test_text_scripts.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When >30% of a page's lines are flagged for the complement (low confidence OR
unexpected script), Vision is failing on the page as a whole, so one clean
whole-page pass by the complement VLM replaces the per-block re-OCR + splice
(fragile, and the VLM reads a full page cleanly anyway). Below the threshold,
targeted block re-OCR keeps Vision's fast/good majority. Threshold 0.30, tunable
via FULL_PAGE_COMPLEMENT_FRAC.

Tests: test_complement_fullpage.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The complement's block re-OCR was leaking: on a line-count mismatch,
_split_block_text puts the whole corrected block on the first line and blanks
the rest, but the splice did 'if not txt: continue' — leaving those blanked
lines as Vision's original (Cyrillic) text. So most re-OCR'd lines kept their
garbage even though the block WAS corrected.

Set every block line (clearing the blanks); the corrected text is already on
line 1. End-to-end on the Greek corpus (apple_docs+glm@200): Cyrillic chars
2232 (Vision) → 901 (old complement) → 876 (+script gate) → 12 (+this) — a
99.5% reduction, 5 residual lines (isolated single confusables below the
2-char gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two "engine doesn't work" bugs found while benchmarking:

- unlimited: the prompt began with a literal "<image>", but the OpenAI-compat
  server ALSO inserts an image token for the image_url content part → the chat
  template counted 2 image tokens for 1 image → HTTP 500 ("image tokens ... 2
  != 1") on every page. Drop the literal marker. (A deeper mlx-vlm deepseekocr
  MLX-stream bug in the 4-bit build remains — upstream; try an 8-bit/bf16
  conversion.)

- paddle_vl: runs its OWN PaddleOCRVL pipeline (not our openai_compat path), so
  the repetition-penalty fix never reached it — on a script it can't read
  (Greek) it looped, filling the token budget (95 KB of "Ở đòn" ×hundreds).
  PaddleOCRVL.predict() accepts repetition_penalty/max_new_tokens; pass 1.15 /
  4096. Loop markers hundreds→3, output 95 KB→69 KB; clean French + partial
  Greek (recognition itself is a model limitation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant