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>
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user