From de6eaf9f8b8e3dbc30c94c1acb5b8be480a90523 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 28 Jun 2026 20:02:59 +0200 Subject: [PATCH] feat(analysis): make OCR the active backend, keep vision dormant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker now always dispatches through the in-process ocrs engine. `AnalysisConfig::effective_backend()` returns Ocr regardless of the parsed `ANALYSIS_BACKEND` (warning if `vision` was requested), and `spawn_analysis_daemon` selects on it. The vision dispatcher, client, and readiness probe stay compiled and intact behind the unreachable match arm, so re-enabling is a one-line change. The startup-reclaim regression test no longer leaned on the vision arm to skip model loading; it now points the engine at a missing model path and asserts the orphaned lease is still reclaimed before the (failing) engine build — proving reclaim runs independently of engine readiness. Also gitignore backend/models/*.rten so locally-downloaded ocrs models for native `cargo run` aren't committed. Co-Authored-By: Claude Opus 4.8 --- backend/.gitignore | 4 ++++ backend/src/app.rs | 33 +++++++++++++++++---------------- backend/src/config.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index b691d12..68a188c 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,7 @@ /target /.sqlx .env + +# Local OCR models for native dev (downloaded, not source) +models/ +*.rten diff --git a/backend/src/app.rs b/backend/src/app.rs index 2d69bd0..c616783 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -434,7 +434,7 @@ async fn spawn_analysis_daemon( let (dispatcher, readiness): ( Arc, Option>, - ) = match cfg.backend { + ) = match cfg.effective_backend() { crate::config::AnalysisBackend::Ocr => { // Load the `.rten` models once; a bad path is a loud boot error. let engine = crate::analysis::ocr::OcrsEngine::from_model_paths( @@ -1473,29 +1473,30 @@ mod tests { // Bind to a local so the TempDir lives for the rest of the test. // `tempfile::tempdir().unwrap().path()` would drop the TempDir // at end-of-expression and `LocalStorage` would hold a path to - // a deleted directory. (Today this is fine because the dispatch - // fails before storage is touched, but it makes the test fragile - // to any future code rearrangement.) + // a deleted directory. let storage_dir = tempfile::tempdir().unwrap(); let storage: Arc = Arc::new(LocalStorage::new(storage_dir.path())); let mut cfg = crate::config::AnalysisConfig::default(); - // Use the vision backend so the daemon doesn't try to load the ocrs - // `.rten` models (absent in unit CI). This test only exercises the - // pre-worker lease reclaim, which is engine-agnostic; no dispatch runs. - cfg.backend = crate::config::AnalysisBackend::Vision; + // The worker always runs OCR now (vision is dormant — see + // `effective_backend`), and the `.rten` models aren't shipped to unit + // CI. Point the engine at a path that can't exist so the *engine build* + // fails deterministically. Reclaim runs at the very top of + // `spawn_analysis_daemon`, before — and independently of — engine + // readiness, so the row must still be reclaimed even though spawn + // returns Err. That's exactly the regression this test guards (an + // analysis-only deploy must reclaim orphaned leases at startup). + cfg.ocr_detection_model = "/nonexistent/text-detection.rten".to_string(); + cfg.ocr_recognition_model = "/nonexistent/text-recognition.rten".to_string(); cfg.workers = 1; cfg.job_timeout = Duration::from_secs(1); let events = Arc::new(crate::analysis::events::AnalysisEvents::new()); - let handle = spawn_analysis_daemon(pool.clone(), storage, &cfg, events) - .await - .expect("spawn"); - // Immediately shut down — we're only here to prove reclaim ran. - // The workers may briefly pick up the now-pending row; that's - // fine, but we cancel before any real dispatch (the LocalStorage - // key doesn't exist, so a dispatch would fail anyway). - handle.shutdown().await; + let spawned = spawn_analysis_daemon(pool.clone(), storage, &cfg, events).await; + assert!( + spawned.is_err(), + "engine build must fail with a missing model path" + ); // The reclaim must have moved the row back to pending with the // attempt refunded (attempts goes from 1 → 0). Reaching it on the diff --git a/backend/src/config.rs b/backend/src/config.rs index 4f76170..750f0bc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -262,6 +262,24 @@ 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 { @@ -840,6 +858,19 @@ mod tests { 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());