Files
Mangalord/HYBRID-OCR-VISION.md
MechaCat02 83c2899373
All checks were successful
deploy / test-backend (push) Successful in 28m40s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 11m8s
deploy / deploy (push) Successful in 12s
feat(analysis): add ocrs OCR backend and OCR-driven page text search
Adds an in-process ocrs OCR backend as the active analysis engine
(vision left dormant), enables OCR text search on the page-tag
aggregation endpoints, and reshapes the admin analysis/settings UI to
present the OCR-only surface. Bumps version 0.89.0 -> 0.90.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:52:43 +02:00

7.1 KiB
Raw Blame History

Hybrid OCR→Vision analysis pipeline — design brief for the dev agent

Status: design only (not implemented). Forward-looking model for a future ANALYSIS_BACKEND=hybrid, on top of the shipped ocr (default) and vision backends. Constraint that shapes it: ocrs and the vision LLM must not be resident at the same time (Pi RAM ceiling). OCR has priority; vision is a preemptible, only-when-OCR-is-drained background phase.

The key realization: this is the crawl mutex, again

The repo already runs a vision-manager sidecar (vision-manager/manager.sh, VISION-AUTOSCALE.md) that owns the mangalord-vision (llama.cpp, ~4 GiB) container lifecycle via a scoped docker-socket-proxy — the backend gets no Docker access. It already:

  • Starts vision when pending_analysis() > 0 (counts analyze_page jobs).
  • Idle-stops it after STOP_DEBOUNCE (anti-thrash) once the queue drains.
  • Defers starting vision while crawl_running() > 0 — the "RAM mutex": the crawler's browser and the LLM can't both fit, so vision waits for crawl.
  • Memory-yield: SIGTERMs a running vision at a HIGH host-RAM watermark, blocks starting at a LOW watermark (see VISION-MEMORY-YIELD.md).

The OCR↔vision relationship is the relationship that already exists between crawl and vision. OCR is just a third RAM-competing, higher-priority workload that vision must defer to. Extend the existing arbiter rather than build a new one.

Job model: split one queue into two

Today there is one job kind, analyze_page. Split it:

  • ocr_page — the in-process ocrs pass (cheap, fast, no container).
  • ground_page — the vision grounding pass (needs the LLM container).

Pipeline (hybrid mode): upload → enqueue ocr_page. The OCR daemon runs ocrs, writes page_ocr_text + search_doc (text searchable immediately), then enqueues ground_page for that page. The grounding daemon later adds tags/scene/safety. Each kind gets its own dedup index (mirror migration 0031).

Two daemons, two queues.

Arbitration: OCR priority + memory exclusivity

Responsibilities split cleanly between the in-backend daemons and the manager:

Backend — OCR daemon (already built): leases ocr_page, always allowed to run (ocrs is small/local). This is the priority phase.

Backend — grounding daemon: leases ground_page, but gates leasing on both:

  1. VisionReadiness (the existing /health gate — vision container up), AND
  2. ocr_backlog == 0 (a cheap count(ocr_page WHERE state IN pending,running)).

Gate #2 is the whole priority mechanism, in-process, no aborts: the instant OCR work appears, the grounding daemon finishes its current page (the in-flight lease completes normally) and then stops leasing new pages and parks — "it finishes, then yields." It resumes only when OCR has fully drained.

vision-manager: two one-line changes to the existing logic:

  1. pending_analysis() counts ground_page (not ocr_page) — vision only starts when there is grounding work.
  2. Add an OCR start-block mutex identical to RESPECT_CRAWL_MUTEX: defer starting vision while ocr_page backlog > 0. (RESPECT_OCR_MUTEX=1.)

Why this yields memory exclusivity: while OCR backlog > 0, the grounding daemon won't lease → vision goes idle → the manager's idle-debounce stops the container (and never restarts it under the OCR mutex). Only ocrs (small) is resident. When OCR drains, grounding resumes, pending_analysis() > 0 again, the manager starts vision. Only the big consumer (the LLM container) is ever mutually exclusive with active OCR — which is the actual RAM constraint.

The one overlap window: if OCR work arrives mid-grounding-page, ocrs (small, in-process) briefly coexists with the one in-flight vision page before the grounding daemon parks. ocrs's footprint makes this a non-issue in practice. If a deployment needs hard exclusivity even there, the OCR daemon can additionally wait for vision_running == false before its first dispatch. No deadlock: OCR's wait is transient (one grounding page, bounded by job_timeout), while vision's deferral on OCR backlog is the persistent, priority-respecting side.

Anti-thrash: the manager's STOP_DEBOUNCE already prevents a stray OCR page from cold-cycling the 4 GiB model. Chapter uploads arrive as bursts, so OCR drains a whole batch in one phase before vision resumes — the natural good case.

Data-model split (the real refactor)

persist_analysis currently writes OCR + tags + scene + safety in one transaction and derives search_doc from the OCR rows (+ scene weight C). Two passes means splitting it, sharing the tsvector builder:

  • persist_ocr(page, lines) — writes page_ocr_text, sets search_doc from OCR (A/B/D buckets), marks the page text-searchable. Status reflects OCR completion.
  • persist_grounding(page, tags, scene, safety) — writes auto-tags, warnings, scene; recomputes search_doc to fold in the scene (weight C) on top of the existing OCR buckets. Records grounding completion (e.g. a grounded_at column or tags-present sentinel).

This keeps text search live after the cheap OCR phase, with semantic search/tags arriving after the (deferred) grounding phase.

Reusable pieces

  • OCR side: the shipped OcrsEngine + OcrAnalyzeDispatcher (backend/src/analysis/ocr.rs) — retarget to ocr_page, and on success enqueue ground_page.
  • Vision side: factor VisionClient::ground(image, mime, ocr_text) out of analyze()'s Pass-B block (backend/src/analysis/vision.rs ~240261) — pure refactor, existing tests cover it. The grounding daemon is the existing analysis daemon retargeted to ground_page with the extra ocr_backlog==0 lease gate.
  • Manager: pending_analysis() kind swap + RESPECT_OCR_MUTEX block, mirroring the crawl-mutex branch already at vision-manager/manager.sh.

Scope / sequencing

Larger than an inline hybrid dispatcher: job-kind split + dedup migrations, persist_* split + search_doc-builder extraction, pipeline enqueue (OCR→ground), grounding daemon lease gate, and the manager mutex. No new privileged surface (reuses the docker-socket-proxy). Suggested order:

  1. Split persist_analysispersist_ocr / persist_grounding (+ tests).
  2. Split job kinds + dedup migrations; retarget the OCR daemon to ocr_page.
  3. VisionClient::ground() refactor + grounding daemon (ground_page, gated on ocr_backlog==0).
  4. OCR→ground enqueue on OCR success (hybrid mode only).
  5. vision-manager: ground_page counting + RESPECT_OCR_MUTEX.

Pure-ocr (shipped default) and vision (legacy) backends are unaffected; this is the hybrid backend's runtime model.

Open decision

Hard exclusivity in the one overlap window (OCR daemon waits for vision_running == false before its first dispatch) — needed only if ocrs's few-hundred-MB footprint can't coexist with a single in-flight grounding page on the target Pi's RAM. Default: don't gate (rely on the grounding daemon parking fast).