Files
Mangalord/backend/tests/analysis_ocr.rs
MechaCat02 cb34eeb82e feat(analysis): add in-process ocrs OCR backend
Add a CPU-only, English-first OCR engine (the `ocrs` crate on the `rten`
runtime) as an alternative to the local-LLM "vision" backend, which ran
too slowly on the target Raspberry Pi 5.

- `ANALYSIS_BACKEND` (default `ocr`) selects the engine; `vision` keeps the
  existing LLM path. Deploy-time, env-only — not admin-tunable.
- `analysis::ocr` adds an `OcrEngine` trait (testable seam), the `OcrsEngine`
  impl (models loaded once, inference on the blocking pool), and an
  `OcrAnalyzeDispatcher` that plugs into the existing `AnalyzeDispatcher`
  seam and reuses `persist_analysis`, so OCR text lands in `page_ocr_text`
  and the `search_doc` tsvector exactly as the vision path produces them.
- The backend image bakes the two `.rten` models into /models, where
  `OCRS_DETECTION_MODEL` / `OCRS_RECOGNITION_MODEL` default.

Because `/v1/me/page-search` ranks on `search_doc`, OCR text search works
the moment pages are processed. Auto-tagging, scene description and NSFW
flags remain the vision backend's job (deferred).

Bumps to 0.90.0 (minor) in both manifests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:12:01 +02:00

110 lines
3.9 KiB
Rust

//! Integration tests for the OCR analysis backend
//! (`analysis::ocr::OcrAnalyzeDispatcher`). A stub OCR engine stands in for
//! `ocrs` (whose `.rten` models aren't shipped to CI), so these pin the
//! storage→OCR→persist wiring: the dispatcher reads the page image, runs the
//! engine, and persists the lines via the shared `persist_analysis` path —
//! landing `page_ocr_text` rows and a populated `search_doc` exactly like the
//! vision backend. Each `#[sqlx::test]` gets a fresh migrated DB.
mod common;
use std::sync::Arc;
use mangalord::analysis::daemon::AnalyzeDispatcher;
use mangalord::analysis::ocr::test_support::StubOcrEngine;
use mangalord::analysis::ocr::OcrAnalyzeDispatcher;
use mangalord::domain::page_analysis::AnalysisStatus;
use mangalord::repo;
use mangalord::storage::{LocalStorage, Storage};
use sqlx::PgPool;
use tempfile::TempDir;
use uuid::Uuid;
/// Seed a manga → chapter → page chain whose page points at `storage_key`,
/// and return the page id.
async fn seed_page(pool: &PgPool, storage_key: &str) -> Uuid {
let manga_id: Uuid =
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
.fetch_one(pool)
.await
.unwrap();
let chapter_id: Uuid = sqlx::query_scalar(
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
)
.bind(manga_id)
.fetch_one(pool)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
VALUES ($1, 1, $2, 'image/png') RETURNING id",
)
.bind(chapter_id)
.bind(storage_key)
.fetch_one(pool)
.await
.unwrap()
}
fn ocr_dispatcher(
pool: &PgPool,
storage: Arc<dyn Storage>,
lines: &[&str],
) -> OcrAnalyzeDispatcher {
OcrAnalyzeDispatcher {
db: pool.clone(),
storage,
engine: StubOcrEngine::new(lines),
max_image_bytes: 8 * 1024 * 1024,
}
}
#[sqlx::test(migrations = "./migrations")]
async fn dispatch_persists_ocr_lines_and_search_doc(pool: PgPool) {
let dir = TempDir::new().unwrap();
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(dir.path()));
let key = "mangas/x/p1.png";
storage.put(key, &common::fake_png_bytes()).await.unwrap();
let page_id = seed_page(&pool, key).await;
let dispatcher = ocr_dispatcher(&pool, Arc::clone(&storage), &["Hello there", "general"]);
dispatcher.dispatch(page_id).await.unwrap();
// Two OCR rows, in order, with the recognized text.
let rows: Vec<(String, i32)> = sqlx::query_as(
"SELECT text, ord FROM page_ocr_text WHERE page_id = $1 ORDER BY ord",
)
.bind(page_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, "Hello there");
assert_eq!(rows[1].0, "general");
// The analysis row is `done`, stamped with the ocrs model label, and has a
// non-empty tsvector so text search works.
let row = repo::page_analysis::load(&pool, page_id).await.unwrap().unwrap();
assert_eq!(row.status, AnalysisStatus::Done);
assert_eq!(row.model.as_deref(), Some("ocrs"));
let has_doc: bool = sqlx::query_scalar(
"SELECT search_doc IS NOT NULL AND search_doc != ''::tsvector \
FROM page_analysis WHERE page_id = $1",
)
.bind(page_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(has_doc, "search_doc must be populated from OCR text");
}
#[sqlx::test(migrations = "./migrations")]
async fn dispatch_missing_page_is_noop(pool: PgPool) {
let dir = TempDir::new().unwrap();
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(dir.path()));
// A page id that was never inserted — the dispatcher must treat it as a
// deleted page and succeed without writing anything.
let dispatcher = ocr_dispatcher(&pool, storage, &["whatever"]);
dispatcher.dispatch(Uuid::new_v4()).await.unwrap();
}