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:
@@ -247,6 +247,11 @@ pub async fn distinct_tags_for_user(
|
||||
/// storage keys (page-number ascending) so the row can render a
|
||||
/// thumbnail strip without a follow-up fetch.
|
||||
///
|
||||
/// When `text` is non-blank, results are further restricted to pages whose
|
||||
/// analysis `search_doc` matches the query (OCR text search), and both
|
||||
/// `match_count` and the sample thumbnails reflect that filtered set —
|
||||
/// i.e. `match_count` counts tagged pages whose OCR also matches `text`.
|
||||
///
|
||||
/// `order` is inlined via `format!()` — the enum value space is
|
||||
/// closed (`ASC` / `DESC`) so this is not a SQL-injection vector.
|
||||
pub async fn aggregate_chapters_for_tag(
|
||||
@@ -256,7 +261,30 @@ pub async fn aggregate_chapters_for_tag(
|
||||
order: Order,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
text: Option<&str>,
|
||||
) -> AppResult<(Vec<TaggedChapterAggregate>, i64)> {
|
||||
// OCR text filter: when present, additionally require the page's analysis
|
||||
// `search_doc` to match the query. Reuses the precomputed tsvector exactly
|
||||
// like `repo::page_analysis::page_search`. `None`/blank ⇒ tag-only.
|
||||
let text = text.map(str::trim).filter(|s| !s.is_empty());
|
||||
let (text_join, text_where) = match text {
|
||||
// `$5` in the main query, `$3` in the count query (see binds below).
|
||||
Some(_) => (
|
||||
"JOIN page_analysis pa ON pa.page_id = p.id",
|
||||
"AND pa.search_doc @@ plainto_tsquery('simple', {n})",
|
||||
),
|
||||
None => ("", ""),
|
||||
};
|
||||
// Same filter inside the correlated sample-thumbnail subquery (its page is
|
||||
// aliased `p`), so the thumbnails match what the text search matched. The
|
||||
// subquery lives in the main query, so it reuses the `$5` text bind.
|
||||
let (sample_join, sample_where) = match text {
|
||||
Some(_) => (
|
||||
"JOIN page_analysis pa2 ON pa2.page_id = p.id",
|
||||
"AND pa2.search_doc @@ plainto_tsquery('simple', $5)",
|
||||
),
|
||||
None => ("", ""),
|
||||
};
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -274,9 +302,11 @@ pub async fn aggregate_chapters_for_tag(
|
||||
FROM pages p
|
||||
JOIN page_tags pt2 ON pt2.page_id = p.id
|
||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||
{sample_join}
|
||||
WHERE p.chapter_id = ch.id
|
||||
AND pt2.user_id = $1
|
||||
AND lower(t2.name) = $2
|
||||
{sample_where}
|
||||
ORDER BY p.page_number ASC
|
||||
LIMIT 3
|
||||
) p2
|
||||
@@ -288,43 +318,60 @@ pub async fn aggregate_chapters_for_tag(
|
||||
JOIN pages p ON p.id = pt.page_id
|
||||
JOIN chapters ch ON ch.id = p.chapter_id
|
||||
JOIN mangas m ON m.id = ch.manga_id
|
||||
{text_join}
|
||||
WHERE pt.user_id = $1
|
||||
AND lower(t.name) = $2
|
||||
{text_where}
|
||||
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
|
||||
ORDER BY match_count {dir}, ch.id
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
dir = order.as_sql(),
|
||||
text_join = text_join,
|
||||
text_where = text_where.replace("{n}", "$5"),
|
||||
sample_join = sample_join,
|
||||
sample_where = sample_where,
|
||||
);
|
||||
let rows = sqlx::query_as::<_, TaggedChapterAggregate>(&sql)
|
||||
let mut q = sqlx::query_as::<_, TaggedChapterAggregate>(&sql)
|
||||
.bind(user_id)
|
||||
.bind(tag)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
.bind(offset);
|
||||
if let Some(text) = text {
|
||||
q = q.bind(text);
|
||||
}
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
let (total,): (i64,) = sqlx::query_as(
|
||||
let count_sql = format!(
|
||||
r#"
|
||||
SELECT count(*) FROM (
|
||||
SELECT 1
|
||||
FROM page_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id
|
||||
JOIN pages p ON p.id = pt.page_id
|
||||
{text_join}
|
||||
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
||||
{text_where}
|
||||
GROUP BY p.chapter_id
|
||||
) c
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(tag)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
text_join = text_join,
|
||||
text_where = text_where.replace("{n}", "$3"),
|
||||
);
|
||||
let mut cq = sqlx::query_as::<_, (i64,)>(&count_sql).bind(user_id).bind(tag);
|
||||
if let Some(text) = text {
|
||||
cq = cq.bind(text);
|
||||
}
|
||||
let (total,) = cq.fetch_one(pool).await?;
|
||||
Ok((rows, total))
|
||||
}
|
||||
|
||||
/// Paged list of mangas containing pages tagged `tag` for `user_id`,
|
||||
/// ranked by `match_count` summed across all their chapters.
|
||||
///
|
||||
/// `text` behaves as in [`aggregate_chapters_for_tag`]: non-blank restricts to
|
||||
/// pages whose OCR `search_doc` matches, and both `match_count` and the sample
|
||||
/// thumbnails reflect that filtered set.
|
||||
pub async fn aggregate_mangas_for_tag(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -332,7 +379,26 @@ pub async fn aggregate_mangas_for_tag(
|
||||
order: Order,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
text: Option<&str>,
|
||||
) -> AppResult<(Vec<TaggedMangaAggregate>, i64)> {
|
||||
// OCR text filter — see `aggregate_chapters_for_tag` for the rationale.
|
||||
let text = text.map(str::trim).filter(|s| !s.is_empty());
|
||||
let (text_join, text_where) = match text {
|
||||
Some(_) => (
|
||||
"JOIN page_analysis pa ON pa.page_id = p.id",
|
||||
"AND pa.search_doc @@ plainto_tsquery('simple', {n})",
|
||||
),
|
||||
None => ("", ""),
|
||||
};
|
||||
// Same filter inside the sample-thumbnail subquery (page aliased `p`),
|
||||
// reusing the main query's `$5` text bind.
|
||||
let (sample_join, sample_where) = match text {
|
||||
Some(_) => (
|
||||
"JOIN page_analysis pa2 ON pa2.page_id = p.id",
|
||||
"AND pa2.search_doc @@ plainto_tsquery('simple', $5)",
|
||||
),
|
||||
None => ("", ""),
|
||||
};
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -349,9 +415,11 @@ pub async fn aggregate_mangas_for_tag(
|
||||
JOIN chapters ch2 ON ch2.id = p.chapter_id
|
||||
JOIN page_tags pt2 ON pt2.page_id = p.id
|
||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||
{sample_join}
|
||||
WHERE ch2.manga_id = m.id
|
||||
AND pt2.user_id = $1
|
||||
AND lower(t2.name) = $2
|
||||
{sample_where}
|
||||
ORDER BY p.page_number ASC
|
||||
LIMIT 3
|
||||
) p2
|
||||
@@ -363,23 +431,31 @@ pub async fn aggregate_mangas_for_tag(
|
||||
JOIN pages p ON p.id = pt.page_id
|
||||
JOIN chapters ch ON ch.id = p.chapter_id
|
||||
JOIN mangas m ON m.id = ch.manga_id
|
||||
{text_join}
|
||||
WHERE pt.user_id = $1
|
||||
AND lower(t.name) = $2
|
||||
{text_where}
|
||||
GROUP BY m.id, m.title, m.cover_image_path
|
||||
ORDER BY match_count {dir}, m.id
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
dir = order.as_sql(),
|
||||
text_join = text_join,
|
||||
text_where = text_where.replace("{n}", "$5"),
|
||||
sample_join = sample_join,
|
||||
sample_where = sample_where,
|
||||
);
|
||||
let rows = sqlx::query_as::<_, TaggedMangaAggregate>(&sql)
|
||||
let mut q = sqlx::query_as::<_, TaggedMangaAggregate>(&sql)
|
||||
.bind(user_id)
|
||||
.bind(tag)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
.bind(offset);
|
||||
if let Some(text) = text {
|
||||
q = q.bind(text);
|
||||
}
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
let (total,): (i64,) = sqlx::query_as(
|
||||
let count_sql = format!(
|
||||
r#"
|
||||
SELECT count(*) FROM (
|
||||
SELECT 1
|
||||
@@ -387,15 +463,20 @@ pub async fn aggregate_mangas_for_tag(
|
||||
JOIN tags t ON t.id = pt.tag_id
|
||||
JOIN pages p ON p.id = pt.page_id
|
||||
JOIN chapters ch ON ch.id = p.chapter_id
|
||||
{text_join}
|
||||
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
||||
{text_where}
|
||||
GROUP BY ch.manga_id
|
||||
) m
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(tag)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
text_join = text_join,
|
||||
text_where = text_where.replace("{n}", "$3"),
|
||||
);
|
||||
let mut cq = sqlx::query_as::<_, (i64,)>(&count_sql).bind(user_id).bind(tag);
|
||||
if let Some(text) = text {
|
||||
cq = cq.bind(text);
|
||||
}
|
||||
let (total,) = cq.fetch_one(pool).await?;
|
||||
Ok((rows, total))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user