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:
109
backend/tests/analysis_ocr.rs
Normal file
109
backend/tests/analysis_ocr.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -680,24 +680,123 @@ async fn aggregate_rejects_invalid_order(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_with_text_param_is_501_with_stable_code(pool: PgPool) {
|
||||
// OCR text search isn't built yet; the param is accepted so adding
|
||||
// OCR won't break the wire shape, but rejected with a distinct
|
||||
// status + code. The code is the wire contract — clients pin on
|
||||
// `text_search_not_yet_supported`, not the message.
|
||||
let h = common::harness(pool);
|
||||
async fn aggregate_with_text_param_filters_by_ocr(pool: PgPool) {
|
||||
// OCR text search: a page tagged `funny` whose OCR contains "guts" is
|
||||
// returned for `&text=guts` on both aggregation endpoints, and excluded
|
||||
// for a query its OCR doesn't contain. The filter runs against the same
|
||||
// precomputed `search_doc` the OCR worker writes via `persist_analysis`.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
|
||||
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
|
||||
|
||||
// Persist OCR text exactly as the ocrs backend would (via the same mapper).
|
||||
let page_uuid = Uuid::parse_str(&page_id).unwrap();
|
||||
let analysis =
|
||||
mangalord::analysis::ocr::lines_to_analysis(vec!["spilling the guts here".to_string()]);
|
||||
mangalord::repo::page_analysis::persist_analysis(&pool, page_uuid, &analysis, "ocrs")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for endpoint in ["chapters", "mangas"] {
|
||||
// Matching text → the tagged+OCR'd row is returned.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=guts"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "{endpoint} matching");
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 1, "{endpoint} matching items");
|
||||
assert_eq!(body["page"]["total"], 1, "{endpoint} matching total");
|
||||
|
||||
// Non-matching text → excluded.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=zzzznomatch"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "{endpoint} non-matching");
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 0, "{endpoint} non-matching items");
|
||||
assert_eq!(body["page"]["total"], 0, "{endpoint} non-matching total");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_text_param_counts_only_matching_pages(pool: PgPool) {
|
||||
// A chapter with TWO tagged pages where only ONE page's OCR matches the
|
||||
// text. `match_count` / `total` must reflect the filtered count (1), not
|
||||
// the tag-only count (2), and the sample thumbnails must contain only the
|
||||
// matching page. This is what a single-page test can't distinguish — it
|
||||
// pins the JOIN/placeholder wiring and the sample-subquery filter.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
|
||||
let (_, page_ids) = seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 2).await;
|
||||
for pid in &page_ids {
|
||||
assert_eq!(add_tag(&h.app, &cookie, pid, "funny").await, StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// Page 0 OCR contains "guts"; page 1 OCR contains only "filler".
|
||||
let p0 = Uuid::parse_str(&page_ids[0]).unwrap();
|
||||
let p1 = Uuid::parse_str(&page_ids[1]).unwrap();
|
||||
let a0 = mangalord::analysis::ocr::lines_to_analysis(vec!["the guts spill out".to_string()]);
|
||||
let a1 = mangalord::analysis::ocr::lines_to_analysis(vec!["just filler text".to_string()]);
|
||||
mangalord::repo::page_analysis::persist_analysis(&pool, p0, &a0, "ocrs").await.unwrap();
|
||||
mangalord::repo::page_analysis::persist_analysis(&pool, p1, &a1, "ocrs").await.unwrap();
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny&text=guts",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "text_search_not_yet_supported");
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1, "one chapter row");
|
||||
assert_eq!(body["page"]["total"], 1);
|
||||
// Filtered match_count is the matching-page count, not the tag count.
|
||||
assert_eq!(items[0]["match_count"], 1, "only one page's OCR matches");
|
||||
// Sample thumbnails reflect the filter: only the matching page's key.
|
||||
let samples = items[0]["sample_storage_keys"].as_array().unwrap();
|
||||
assert_eq!(samples.len(), 1, "thumbnails restricted to matching pages");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn aggregate_blank_text_param_is_tag_only(pool: PgPool) {
|
||||
// A blank `text=` must not filter — it falls back to tag-only aggregation
|
||||
// (the page has a tag but no analysis row at all).
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
|
||||
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
"/api/v1/me/page-tags/chapters?tag=funny&text=",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
|
||||
Reference in New Issue
Block a user