feat(analysis): add ocrs OCR backend and OCR-driven page text search
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

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>
This commit is contained in:
MechaCat02
2026-06-30 19:52:43 +02:00
parent 4fb98e4a1e
commit 83c2899373
24 changed files with 1335 additions and 443 deletions

View File

@@ -113,6 +113,32 @@ impl ResponseFormat {
}
}
/// Which engine the analysis worker dispatches each page through. A
/// deploy-time choice (the engine is either installed or not), so it lives in
/// env only and is not part of the admin-editable `AnalysisSettings`.
///
/// * `Ocr` — in-process [`crate::analysis::ocr`] (the `ocrs` engine): fast,
/// CPU-only, English text. Writes OCR text only (no tags/scene/safety).
/// * `Vision` — the local OpenAI-compatible LLM in [`crate::analysis::vision`]:
/// full OCR + tags + scene + safety, but heavy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnalysisBackend {
Ocr,
Vision,
}
impl AnalysisBackend {
/// Lenient env parse. Defaults to `Ocr` (the Pi-friendly path) for any
/// unset or unrecognized value; `vision` opts back into the LLM engine.
fn from_str(s: &str) -> AnalysisBackend {
match s.trim().to_lowercase().as_str() {
"vision" | "llm" => AnalysisBackend::Vision,
// Default (incl. "ocr", "ocrs", and anything unrecognized).
_ => AnalysisBackend::Ocr,
}
}
}
/// AI content-analysis worker configuration: the enable gate, the local
/// OpenAI-compatible vision endpoint, and the worker / request knobs.
#[derive(Clone, Debug)]
@@ -120,6 +146,16 @@ pub struct AnalysisConfig {
/// Master switch (`ANALYSIS_ENABLED`). When `false`, no analysis jobs
/// are enqueued and no worker runs. Defaults to `false`.
pub enabled: bool,
/// Which engine the worker dispatches through (`ANALYSIS_BACKEND`):
/// `ocr` (default, the in-process `ocrs` engine) or `vision` (the local
/// LLM). Deploy-time, env-only — see [`AnalysisBackend`].
pub backend: AnalysisBackend,
/// Path to the ocrs text-*detection* `.rten` model
/// (`OCRS_DETECTION_MODEL`). Only read when `backend == Ocr`.
pub ocr_detection_model: String,
/// Path to the ocrs text-*recognition* `.rten` model
/// (`OCRS_RECOGNITION_MODEL`). Only read when `backend == Ocr`.
pub ocr_recognition_model: String,
/// Number of concurrent analysis workers (`ANALYSIS_WORKERS`).
pub workers: usize,
/// OpenAI-compatible chat/completions URL (`ANALYSIS_VISION_URL`).
@@ -195,6 +231,9 @@ impl Default for AnalysisConfig {
fn default() -> Self {
Self {
enabled: false,
backend: AnalysisBackend::Ocr,
ocr_detection_model: "/models/text-detection.rten".to_string(),
ocr_recognition_model: "/models/text-recognition.rten".to_string(),
workers: 1,
endpoint: "http://localhost:8000/v1/chat/completions".to_string(),
vision_health_url: None,
@@ -223,10 +262,39 @@ impl Default for AnalysisConfig {
}
impl AnalysisConfig {
/// The backend the worker actually dispatches through.
///
/// Vision is **temporarily disabled**: the engine code (`analysis::vision`,
/// `RealAnalyzeDispatcher`, the readiness probe) is kept intact but never
/// selected. Until it's re-enabled, this returns [`AnalysisBackend::Ocr`]
/// regardless of the parsed `backend`, logging a warning if `vision` was
/// requested so an env override isn't silently ignored. Re-enabling vision
/// is then a one-line change here (return `self.backend`).
pub fn effective_backend(&self) -> AnalysisBackend {
if self.backend == AnalysisBackend::Vision {
tracing::warn!(
"ANALYSIS_BACKEND=vision requested but the vision backend is temporarily \
disabled; running OCR instead"
);
}
AnalysisBackend::Ocr
}
pub fn from_env() -> Self {
let d = AnalysisConfig::default();
Self {
enabled: env_bool("ANALYSIS_ENABLED", d.enabled),
backend: std::env::var("ANALYSIS_BACKEND")
.map(|s| AnalysisBackend::from_str(&s))
.unwrap_or(d.backend),
ocr_detection_model: std::env::var("OCRS_DETECTION_MODEL")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(d.ocr_detection_model),
ocr_recognition_model: std::env::var("OCRS_RECOGNITION_MODEL")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(d.ocr_recognition_model),
workers: env_usize("ANALYSIS_WORKERS", d.workers).max(1),
endpoint: std::env::var("ANALYSIS_VISION_URL").unwrap_or(d.endpoint),
vision_health_url: std::env::var("ANALYSIS_VISION_HEALTH_URL")
@@ -734,11 +802,18 @@ mod tests {
"ANALYSIS_MAX_SLICES",
"ANALYSIS_RESPONSE_FORMAT",
"ANALYSIS_FREQUENCY_PENALTY",
"ANALYSIS_BACKEND",
"OCRS_DETECTION_MODEL",
"OCRS_RECOGNITION_MODEL",
] {
std::env::remove_var(k);
}
let cfg = AnalysisConfig::from_env();
assert!(!cfg.enabled);
// OCR is the default engine (the Pi-friendly path).
assert_eq!(cfg.backend, AnalysisBackend::Ocr);
assert_eq!(cfg.ocr_detection_model, "/models/text-detection.rten");
assert_eq!(cfg.ocr_recognition_model, "/models/text-recognition.rten");
assert_eq!(cfg.workers, 1);
assert_eq!(cfg.max_pixels, 1_000_000);
assert_eq!(cfg.min_slice_height, 640);
@@ -765,6 +840,49 @@ mod tests {
std::env::remove_var("ANALYSIS_RESPONSE_FORMAT");
}
#[test]
fn analysis_backend_parses_and_defaults_to_ocr() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
for (raw, want) in [
("ocr", AnalysisBackend::Ocr),
("ocrs", AnalysisBackend::Ocr),
("vision", AnalysisBackend::Vision),
("llm", AnalysisBackend::Vision),
("anything-else", AnalysisBackend::Ocr),
] {
std::env::set_var("ANALYSIS_BACKEND", raw);
assert_eq!(AnalysisConfig::from_env().backend, want, "raw={raw}");
}
// Unset → OCR.
std::env::remove_var("ANALYSIS_BACKEND");
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
}
#[test]
fn effective_backend_is_ocr_even_when_vision_requested() {
// Vision is temporarily disabled: the worker always runs OCR no matter
// what `ANALYSIS_BACKEND` parsed to. `backend` still reflects the raw
// request (so the override is visible/loggable), but `effective_backend`
// is the value the daemon actually dispatches through.
let mut cfg = AnalysisConfig::default();
cfg.backend = AnalysisBackend::Vision;
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
cfg.backend = AnalysisBackend::Ocr;
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
}
#[test]
fn ocr_model_paths_parse_from_env() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
std::env::set_var("OCRS_DETECTION_MODEL", "/opt/det.rten");
std::env::set_var("OCRS_RECOGNITION_MODEL", "/opt/rec.rten");
let cfg = AnalysisConfig::from_env();
std::env::remove_var("OCRS_DETECTION_MODEL");
std::env::remove_var("OCRS_RECOGNITION_MODEL");
assert_eq!(cfg.ocr_detection_model, "/opt/det.rten");
assert_eq!(cfg.ocr_recognition_model, "/opt/rec.rten");
}
#[test]
fn analysis_config_parses_from_env() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());