Files
Mangalord/backend/src/repo/page_analysis.rs
2026-07-01 07:16:08 +02:00

910 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Persistence for AI page-analysis results.
//!
//! [`persist_analysis`] is the single transactional writer: it replaces a
//! page's OCR text, global auto-tags, and content warnings, upserts the
//! `page_analysis` row, and computes the kind-weighted `search_doc`
//! tsvector — all atomically, so re-analysis (delete+reinsert) is
//! idempotent and never leaves a half-written page. It deliberately does
//! NOT touch the per-user `page_tags` table.
//!
//! Auto-tag names resolve to the shared `tags` vocabulary via
//! [`crate::repo::tag::upsert_by_name`], the same path manga tags and
//! personal page tags use.
use sqlx::PgPool;
use uuid::Uuid;
use crate::crawler::jobs::{self, JobPayload};
use crate::domain::page_analysis::{
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, ContentWarning, MangaCoverage,
ModelDuration, OcrKind, OcrLine, PageAnalysis, PageAnalysisDetail, PageSearchItem,
PageStatusItem, VisionAnalysis,
};
use chrono::{DateTime, Utc};
use crate::error::AppResult;
/// Filter set for [`page_search`]. `tags` are AND-ed (a page must carry
/// every one, satisfiable by EITHER the user's page tag or a global auto
/// tag); `text` ranks via the weighted tsvector; `cw_include` requires all
/// listed warnings; `cw_exclude` rejects any. All string values arrive
/// pre-normalized (tags lowercased, warnings validated) from the handler.
#[derive(Debug, Default, Clone)]
pub struct PageSearchQuery {
pub user_id: uuid::Uuid,
pub tags: Vec<String>,
pub text: Option<String>,
pub cw_include: Vec<String>,
pub cw_exclude: Vec<String>,
pub limit: i64,
pub offset: i64,
}
/// Longest tag the shared `tags` table accepts (`upsert_by_name` enforces
/// the same bound). Auto-tags over this are dropped here rather than
/// aborting the whole page's analysis on one bad model output.
const MAX_TAG_CHARS: usize = 64;
/// Outcome of [`enqueue_for_page`] — surfaces whether the admin's intent
/// actually landed as a worker-visible change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnqueueForPageOutcome {
/// A fresh `analyze_page` job row was inserted.
Inserted,
/// A pending `force=false` row was upgraded in-place to `force=true`,
/// because the partial unique index `crawler_jobs_analyze_page_dedup_idx`
/// blocks a second pending insert per page. Returned only for force
/// requests.
UpgradedToForce,
/// A matching `(pending|running)` job already encodes the request;
/// nothing changed. For force requests this is the retry-race tail:
/// the INSERT was Skipped, the UPDATE matched zero rows (the sibling
/// drained between the two), and the second INSERT was Skipped too
/// (a fresh sibling landed). The next ack of either job will leave
/// the page in the admin's intended state without further action.
AlreadyEnqueued,
}
/// Enqueue an `analyze_page` job for `page_id`. `force` re-analyzes a page
/// that is already `done`.
///
/// **Force-vs-dedup correctness.** Migration 0031's partial unique index
/// is keyed on `page_id` (not on `(page_id, force)`), so a plain
/// `jobs::enqueue` of a `force=true` payload when a `force=false` job is
/// already pending silently goes to `Skipped` — the worker would then
/// pick up the non-force row, hit the skip-if-done net, and ack done
/// without re-analyzing. The admin's force request was lost.
///
/// This function fixes that by: (1) trying the insert; (2) on Skipped
/// with `force=true`, running an UPDATE that flips the existing pending
/// row's `force` flag to `true`; (3) reporting which path ran so the
/// caller can audit accurately.
/// Pool convenience wrapper for [`enqueue_for_page_conn`]. Use this from
/// callers that don't need to share a transaction (chapter upload, the
/// crawler). The admin force-reanalyze handler uses the `_conn` form so the
/// enqueue and its `admin_audit` row commit together.
pub async fn enqueue_for_page(
pool: &PgPool,
page_id: Uuid,
force: bool,
) -> AppResult<EnqueueForPageOutcome> {
let mut conn = pool.acquire().await?;
enqueue_for_page_conn(&mut conn, page_id, force).await
}
/// Enqueue (or force-upgrade) an `analyze_page` job on a caller-supplied
/// connection, so the admin handler can run it inside the same transaction as
/// its audit insert. The body is a sequence of single statements, each
/// reborrowing `&mut *conn`.
pub async fn enqueue_for_page_conn(
conn: &mut sqlx::PgConnection,
page_id: Uuid,
force: bool,
) -> AppResult<EnqueueForPageOutcome> {
use crate::crawler::jobs::EnqueueResult;
match jobs::enqueue(&mut *conn, &JobPayload::AnalyzePage { page_id, force }).await? {
EnqueueResult::Inserted(_) => Ok(EnqueueForPageOutcome::Inserted),
EnqueueResult::Skipped if !force => Ok(EnqueueForPageOutcome::AlreadyEnqueued),
EnqueueResult::Skipped => {
// Force-specific upgrade. The WHERE clause matches the same
// partial index predicate (`pending|running` + `analyze_page`
// + this page_id) and adds `force=false` so we never overwrite
// a row that's already what the admin wants. RETURNING tells
// us whether the upgrade actually moved anything AND its
// pre-update state so the running-state race fix below can
// release the in-flight lease.
let upgraded: Vec<(Uuid, String)> = sqlx::query_as(
"UPDATE crawler_jobs \
SET payload = jsonb_set(payload, '{force}', 'true'::jsonb), \
updated_at = now() \
WHERE state IN ('pending', 'running') \
AND payload->>'kind' = 'analyze_page' \
AND payload->>'page_id' = $1 \
AND (payload->>'force')::boolean = false \
RETURNING id, state",
)
.bind(page_id.to_string())
.fetch_all(&mut *conn)
.await?;
if upgraded.is_empty() {
// Race: between the skipped INSERT and the UPDATE the
// sibling row could have been completed (job state moves
// out of pending/running). At that point a re-INSERT
// would succeed. Retry once to close the race without
// unbounded loops.
return Ok(
match jobs::enqueue(&mut *conn, &JobPayload::AnalyzePage { page_id, force })
.await?
{
EnqueueResult::Inserted(_) => EnqueueForPageOutcome::Inserted,
EnqueueResult::Skipped => EnqueueForPageOutcome::AlreadyEnqueued,
},
);
}
// Race fix for the running-state branch (0.87.20 followup):
// a worker that has already leased the row holds the
// pre-upgrade `force=false` in its in-memory `lease.payload`
// (see `analysis::daemon::WorkerContext::process_lease`).
// Even though we've flipped the row's payload to
// `force=true`, the worker's skip-if-done check uses its
// own pre-upgrade value and will ack done without
// re-analyzing. `release_unowned` returns the row to
// `pending` AND bumps `lease_generation` so the original
// worker's later `ack_done(id, old_generation)` finds no
// matching row and is a no-op — the successor's lease (a
// fresh generation) is preserved. The state-only guard
// that previously protected this (and was the 0.87.20
// narrowing) wasn't enough on its own: a successor that
// re-leased the id would re-enter `state='running'`, and
// the original's ack would clobber it.
for (id, state) in &upgraded {
if state == "running" {
let _ = jobs::release_unowned(&mut *conn, *id).await;
}
}
Ok(EnqueueForPageOutcome::UpgradedToForce)
}
}
}
/// How wide a bulk re-enqueue reaches.
#[derive(Debug, Clone, Copy)]
pub enum ReenqueueScope {
/// Every page in the library.
All,
/// Every page across all chapters of one manga.
Manga(uuid::Uuid),
/// Every page of one chapter.
Chapter(uuid::Uuid),
}
/// Bulk-enqueue `analyze_page` jobs for existing pages within `scope` — the
/// admin backfill / re-analyze path.
///
/// When `only_unanalyzed` is true, pages that already have a `done`
/// analysis row are skipped and the enqueued jobs carry `force = false`.
/// When false, ALL in-scope pages are enqueued with `force = true` so the
/// worker re-analyzes even already-done pages (otherwise the worker's
/// skip-if-done net would no-op them). Pages with a pending/running
/// `analyze_page` job are always skipped so repeated calls don't pile up
/// duplicates. Returns the number of jobs enqueued.
pub async fn enqueue_pages<'e, E>(
executor: E,
scope: ReenqueueScope,
only_unanalyzed: bool,
) -> AppResult<u64>
where
E: sqlx::PgExecutor<'e>,
{
// Scope predicate; the bound uuid (when present) is always $2.
let scope_clause = match scope {
ReenqueueScope::All => "",
ReenqueueScope::Manga(_) => {
"AND p.chapter_id IN (SELECT id FROM chapters WHERE manga_id = $2)"
}
ReenqueueScope::Chapter(_) => "AND p.chapter_id = $2",
};
// The `NOT EXISTS(... pending|running ...)` pre-check used to live in
// this query, but it races with concurrent admin clicks (two requests
// both see "no in-flight job" and both INSERT). The partial unique
// index `crawler_jobs_analyze_page_dedup_idx` (migration 0031) covers
// (payload->>'page_id') WHERE state IN ('pending', 'running') AND
// kind = 'analyze_page', so `ON CONFLICT DO NOTHING` is now the
// atomic primitive — duplicates can't land and concurrent enqueue
// calls are both observably correct.
let sql = format!(
r#"
INSERT INTO crawler_jobs (payload)
SELECT jsonb_build_object('kind', 'analyze_page', 'page_id', p.id, 'force', NOT $1)
FROM pages p
WHERE ($1 = false OR NOT EXISTS (
SELECT 1 FROM page_analysis pa
WHERE pa.page_id = p.id AND pa.status = 'done'))
{scope_clause}
ON CONFLICT DO NOTHING
"#
);
let query = sqlx::query(&sql).bind(only_unanalyzed);
let query = match scope {
ReenqueueScope::All => query,
ReenqueueScope::Manga(id) | ReenqueueScope::Chapter(id) => query.bind(id),
};
Ok(query.execute(executor).await?.rows_affected())
}
/// Per-manga analysis coverage for the admin overview. Only mangas that
/// have at least one page appear (nothing to analyze otherwise). `search`
/// filters by title (case-insensitive substring). Ordered by title.
/// Returns the page plus the total matching-manga count for pagination.
pub async fn manga_coverage(
pool: &PgPool,
search: Option<&str>,
limit: i64,
offset: i64,
) -> AppResult<(Vec<MangaCoverage>, i64)> {
let rows = sqlx::query_as::<_, MangaCoverage>(
r#"
SELECT m.id AS manga_id, m.title,
count(p.id) AS total_pages,
count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages
FROM mangas m
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
GROUP BY m.id, m.title
ORDER BY lower(m.title), m.id
LIMIT $2 OFFSET $3
"#,
)
.bind(search)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*) FROM (
SELECT m.id
FROM mangas m
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
GROUP BY m.id
) x
"#,
)
.bind(search)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Library-wide analysis coverage `(analyzed_pages, total_pages)` for the
/// admin overview. `total_pages` is every page; `analyzed_pages` are those
/// with a `done` analysis row.
pub async fn library_coverage(pool: &PgPool) -> AppResult<(i64, i64)> {
let row: (i64, i64) = sqlx::query_as(
r#"
SELECT
count(*) FILTER (WHERE pa.status = 'done')::bigint AS analyzed,
count(p.id)::bigint AS total
FROM pages p
LEFT JOIN page_analysis pa ON pa.page_id = p.id
"#,
)
.fetch_one(pool)
.await?;
Ok(row)
}
/// Per-chapter analysis coverage for one manga, ordered by chapter number.
pub async fn chapter_coverage(
pool: &PgPool,
manga_id: uuid::Uuid,
) -> AppResult<Vec<ChapterCoverage>> {
let rows = sqlx::query_as::<_, ChapterCoverage>(
r#"
SELECT c.id AS chapter_id, c.number, c.title,
count(p.id) AS total_pages,
count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages
FROM chapters c
JOIN pages p ON p.chapter_id = c.id
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE c.manga_id = $1
GROUP BY c.id, c.number, c.title
ORDER BY c.number, c.id
"#,
)
.bind(manga_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Per-page status grid for a chapter. `status` resolves to the analysis
/// row's state (`done`/`failed`), else `queued` when a pending/running
/// analyze_page job exists, else `none`.
pub async fn chapter_page_status(
pool: &PgPool,
chapter_id: uuid::Uuid,
) -> AppResult<Vec<PageStatusItem>> {
let rows = sqlx::query_as::<_, PageStatusItem>(
r#"
SELECT p.id AS page_id, p.page_number,
COALESCE(
pa.status,
CASE WHEN EXISTS (
SELECT 1 FROM crawler_jobs j
WHERE j.payload->>'kind' = 'analyze_page'
AND j.payload->>'page_id' = p.id::text
AND j.state IN ('pending', 'running')
) THEN 'queued' ELSE 'none' END
) AS status
FROM pages p
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE p.chapter_id = $1
ORDER BY p.page_number
"#,
)
.bind(chapter_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Filters for [`list_history`]. `None` widens the scope.
#[derive(Debug, Default, Clone)]
pub struct AnalysisHistoryFilter<'a> {
/// Exact analysis status (`done` or `failed`).
pub status: Option<&'a str>,
/// Only NSFW-flagged pages.
pub nsfw_only: bool,
/// Case-insensitive manga-title substring.
pub search: Option<&'a str>,
}
/// Paginated, newest-first analysis history — every completed/failed page
/// analysis resolved to its manga/chapter/page context. Reads the
/// persistent `page_analysis` table, so unlike the crawler job history it
/// is not bounded by job reaping. Returns the page slice plus the filtered
/// total. `pending` rows are excluded — history is terminal outcomes only.
pub async fn list_history(
pool: &PgPool,
filter: AnalysisHistoryFilter<'_>,
limit: i64,
offset: i64,
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.filter(|p| p.len() > 2);
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
r#"
SELECT
pa.page_id,
p.page_number,
p.chapter_id,
c.number AS chapter_number,
c.manga_id,
m.title AS manga_title,
pa.status,
pa.is_nsfw,
pa.model,
pa.error,
pa.analyzed_at,
pa.duration_ms
FROM page_analysis pa
JOIN pages p ON p.id = pa.page_id
JOIN chapters c ON c.id = p.chapter_id
JOIN mangas m ON m.id = c.manga_id
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
LIMIT $4 OFFSET $5
"#,
)
.bind(filter.status)
.bind(filter.nsfw_only)
.bind(&search_pat)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*)
FROM page_analysis pa
JOIN pages p ON p.id = pa.page_id
JOIN chapters c ON c.id = p.chapter_id
JOIN mangas m ON m.id = c.manga_id
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
"#,
)
.bind(filter.status)
.bind(filter.nsfw_only)
.bind(&search_pat)
.fetch_one(pool)
.await?;
Ok((items, total))
}
/// Full analysis detail for one page. `None` when the page itself doesn't
/// exist; an existing-but-unanalyzed page returns `status = "none"` with
/// empty OCR/tags/warnings so the UI can show "not analyzed yet".
pub async fn page_detail(
pool: &PgPool,
page_id: uuid::Uuid,
) -> AppResult<Option<PageAnalysisDetail>> {
let Some((page_number, chapter_id, manga_id)): Option<(i32, uuid::Uuid, uuid::Uuid)> =
sqlx::query_as(
"SELECT p.page_number, p.chapter_id, c.manga_id \
FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE p.id = $1",
)
.bind(page_id)
.fetch_optional(pool)
.await?
else {
return Ok(None);
};
let analysis = load(pool, page_id).await?;
let ocr = sqlx::query_as::<_, OcrLine>(
"SELECT kind, text FROM page_ocr_text WHERE page_id = $1 ORDER BY ord, id",
)
.bind(page_id)
.fetch_all(pool)
.await?;
let tags: Vec<String> = sqlx::query_scalar(
"SELECT t.name FROM page_auto_tags pat JOIN tags t ON t.id = pat.tag_id \
WHERE pat.page_id = $1 ORDER BY t.name",
)
.bind(page_id)
.fetch_all(pool)
.await?;
let content_warnings = sqlx::query_scalar::<_, ContentWarning>(
"SELECT warning FROM page_content_warnings WHERE page_id = $1 ORDER BY warning",
)
.bind(page_id)
.fetch_all(pool)
.await?;
Ok(Some(PageAnalysisDetail {
page_id,
page_number,
chapter_id,
manga_id,
status: analysis
.as_ref()
.map(|a| format!("{:?}", a.status).to_lowercase())
.unwrap_or_else(|| "none".to_string()),
is_nsfw: analysis.as_ref().map(|a| a.is_nsfw).unwrap_or(false),
scene_description: analysis.as_ref().and_then(|a| a.scene_description.clone()),
model: analysis.as_ref().and_then(|a| a.model.clone()),
error: analysis.as_ref().and_then(|a| a.error.clone()),
analyzed_at: analysis.as_ref().and_then(|a| a.analyzed_at),
ocr,
tags,
content_warnings,
}))
}
/// Deduplicated union of content warnings across all of a manga's pages,
/// ordered alphabetically. Powers the manga-detail content-warning banner.
pub async fn warnings_for_manga(
pool: &PgPool,
manga_id: uuid::Uuid,
) -> AppResult<Vec<ContentWarning>> {
let rows = sqlx::query_scalar::<_, ContentWarning>(
r#"
SELECT DISTINCT pw.warning
FROM page_content_warnings pw
JOIN pages p ON p.id = pw.page_id
JOIN chapters c ON c.id = p.chapter_id
WHERE c.manga_id = $1
ORDER BY pw.warning
"#,
)
.bind(manga_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Load a page's analysis row, if it has one.
pub async fn load(pool: &PgPool, page_id: Uuid) -> AppResult<Option<PageAnalysis>> {
let row = sqlx::query_as::<_, PageAnalysis>(
r#"
SELECT page_id, status, scene_description, is_nsfw, model, error, analyzed_at
FROM page_analysis
WHERE page_id = $1
"#,
)
.bind(page_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Record that analysis failed terminally for a page (retries exhausted /
/// dead-lettered). Leaves a `failed` row so the page's state is
/// observable; it simply contributes nothing to search.
pub async fn mark_failed(pool: &PgPool, page_id: Uuid, error: &str) -> AppResult<()> {
sqlx::query(
r#"
INSERT INTO page_analysis (page_id, status, error)
VALUES ($1, 'failed', $2)
ON CONFLICT (page_id) DO UPDATE
SET status = 'failed', error = EXCLUDED.error, updated_at = now()
"#,
)
.bind(page_id)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
/// Record the wall-clock the worker spent on a page. Best-effort: updates the
/// existing `page_analysis` row (written by `persist_analysis`/`mark_failed`);
/// a transient failure with no row yet simply updates nothing.
pub async fn record_duration(
pool: &PgPool,
page_id: Uuid,
duration_ms: i64,
) -> AppResult<()> {
sqlx::query("UPDATE page_analysis SET duration_ms = $2 WHERE page_id = $1")
.bind(page_id)
.bind(duration_ms)
.execute(pool)
.await?;
Ok(())
}
/// Aggregate analysis timing + outcome roll-up over an optional time window
/// (`since = None` → all time). Mean duration ignores NULL `duration_ms`
/// (pre-tracking rows); `by_model` breaks the mean out per model.
pub async fn analysis_metrics(
pool: &PgPool,
since: Option<DateTime<Utc>>,
) -> AppResult<AnalysisMetrics> {
let (n, ok, failed, avg_ms): (i64, i64, i64, Option<f64>) = sqlx::query_as(
r#"
SELECT
COUNT(*) AS n,
COUNT(*) FILTER (WHERE status = 'done') AS ok,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
-- Mean over successful pages only, so it reconciles with the
-- by-model breakdown below (which is done-only). Failed /
-- timed-out durations would otherwise skew the headline up.
AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms
FROM page_analysis
WHERE status <> 'pending'
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
"#,
)
.bind(since)
.fetch_one(pool)
.await?;
let by_model = sqlx::query_as::<_, ModelDuration>(
r#"
SELECT model, AVG(duration_ms)::float8 AS avg_ms, COUNT(*) AS n
FROM page_analysis
WHERE status = 'done'
AND duration_ms IS NOT NULL
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
GROUP BY model
ORDER BY n DESC
"#,
)
.bind(since)
.fetch_all(pool)
.await?;
Ok(AnalysisMetrics { n, ok, failed, avg_ms, by_model })
}
/// Bucketed analysis throughput/success/duration series over an optional
/// window, for the Analysis-tab trend charts. `ok`/`failed` follow the
/// `done`/`failed` statuses; mean duration is over completed pages only.
/// Empty intervals are absent (client fills the axis). The
/// `page_analysis_analyzed_at_idx` (migration 0030) backs the scan.
pub async fn analysis_series(
pool: &PgPool,
bucket: crate::repo::crawl_metrics::Bucket,
since: Option<DateTime<Utc>>,
) -> AppResult<Vec<crate::domain::crawl_metrics::MetricsBucket>> {
let rows = sqlx::query_as::<_, crate::domain::crawl_metrics::MetricsBucket>(
r#"
SELECT
date_trunc($1, analyzed_at) AS t,
COUNT(*) AS n,
COUNT(*) FILTER (WHERE status = 'done') AS ok,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms
FROM page_analysis
WHERE status <> 'pending'
AND analyzed_at IS NOT NULL
AND ($2::timestamptz IS NULL OR analyzed_at >= $2)
GROUP BY t
ORDER BY t
"#,
)
.bind(bucket.as_str())
.bind(since)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Persist a completed analysis for a page, replacing any previous result.
///
/// The model's free-form `kind` / `content_type` strings are mapped onto
/// the closed vocabularies here ([`OcrKind::from_model_str`] /
/// [`ContentWarning::from_model_str`]); unrecognized warnings are dropped,
/// unknown OCR kinds fall back to `narration`. Empty / over-long tags and
/// empty OCR text are skipped so one bad item never aborts the page.
pub async fn persist_analysis(
pool: &PgPool,
page_id: Uuid,
analysis: &VisionAnalysis,
model: &str,
) -> AppResult<()> {
// Kind-weighted text buckets for the tsvector: A=speech/title,
// B=narration/thought/caption, C=scene, D=sfx.
let mut bucket_a = String::new();
let mut bucket_b = String::new();
let bucket_c = analysis.scene_description.trim().to_string();
let mut bucket_d = String::new();
let mut ocr_rows: Vec<(OcrKind, String)> = Vec::new();
for r in &analysis.ocr_results {
let text = r.text.trim();
if text.is_empty() {
continue;
}
let kind = OcrKind::from_model_str(&r.kind);
match kind.weight() {
'A' => push_token(&mut bucket_a, text),
'B' => push_token(&mut bucket_b, text),
'D' => push_token(&mut bucket_d, text),
_ => {}
}
ocr_rows.push((kind, text.to_string()));
}
// Dedup tags case-insensitively, preserving first-seen order; drop
// empties and over-long names rather than failing the transaction.
let mut seen_tags = std::collections::HashSet::new();
let mut tags: Vec<String> = Vec::new();
for raw in &analysis.tagging_results {
let t = raw.trim();
if t.is_empty() || t.chars().count() > MAX_TAG_CHARS {
continue;
}
if seen_tags.insert(t.to_lowercase()) {
tags.push(t.to_string());
}
}
// Map + dedup warnings.
let mut seen_warn = std::collections::HashSet::new();
let mut warnings: Vec<ContentWarning> = Vec::new();
for raw in &analysis.safety_flag.content_type {
if let Some(w) = ContentWarning::from_model_str(raw) {
if seen_warn.insert(w) {
warnings.push(w);
}
}
}
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM page_ocr_text WHERE page_id = $1")
.bind(page_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM page_auto_tags WHERE page_id = $1")
.bind(page_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM page_content_warnings WHERE page_id = $1")
.bind(page_id)
.execute(&mut *tx)
.await?;
for (ord, (kind, text)) in ocr_rows.iter().enumerate() {
sqlx::query(
"INSERT INTO page_ocr_text (page_id, kind, text, ord) VALUES ($1, $2, $3, $4)",
)
.bind(page_id)
.bind(kind)
.bind(text)
.bind(ord as i32)
.execute(&mut *tx)
.await?;
}
for tag in &tags {
let tag_row = crate::repo::tag::upsert_by_name(&mut *tx, tag).await?;
sqlx::query(
"INSERT INTO page_auto_tags (page_id, tag_id) VALUES ($1, $2) \
ON CONFLICT (page_id, tag_id) DO NOTHING",
)
.bind(page_id)
.bind(tag_row.id)
.execute(&mut *tx)
.await?;
}
for w in &warnings {
sqlx::query(
"INSERT INTO page_content_warnings (page_id, warning) VALUES ($1, $2) \
ON CONFLICT (page_id, warning) DO NOTHING",
)
.bind(page_id)
.bind(w)
.execute(&mut *tx)
.await?;
}
let scene = if bucket_c.is_empty() {
None
} else {
Some(bucket_c.as_str())
};
sqlx::query(
r#"
INSERT INTO page_analysis
(page_id, status, scene_description, is_nsfw, model, analyzed_at, search_doc)
VALUES
($1, 'done', $2, $3, $4, now(),
setweight(to_tsvector('simple', $5), 'A') ||
setweight(to_tsvector('simple', $6), 'B') ||
setweight(to_tsvector('simple', $7), 'C') ||
setweight(to_tsvector('simple', $8), 'D'))
ON CONFLICT (page_id) DO UPDATE SET
status = 'done',
scene_description = EXCLUDED.scene_description,
is_nsfw = EXCLUDED.is_nsfw,
model = EXCLUDED.model,
analyzed_at = now(),
error = NULL,
search_doc = EXCLUDED.search_doc,
updated_at = now()
"#,
)
.bind(page_id)
.bind(scene)
.bind(analysis.safety_flag.is_nsfw)
.bind(model)
.bind(&bucket_a)
.bind(&bucket_b)
.bind(&bucket_c)
.bind(&bucket_d)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Content search over pages: multi-tag AND across (user page tags
/// global auto tags), optional weighted text search over the OCR/scene
/// document, and content-warning include/exclude. Returns one row per
/// matching page plus the total for pagination.
///
/// Empty `tags` makes the tag clause vacuously true (text- or
/// warning-only search), mirroring the manga search's `unnest` idiom. The
/// caller is responsible for requiring at least one positive filter so
/// this never degenerates into "every page".
pub async fn page_search(
pool: &PgPool,
q: &PageSearchQuery,
) -> AppResult<(Vec<PageSearchItem>, i64)> {
// `text` participates in three places ($3): the rank, the match
// predicate, and the order key. Empty string disables text filtering.
let text = q.text.as_deref().unwrap_or("").trim();
const WHERE: &str = r#"
NOT EXISTS (
SELECT 1 FROM unnest($2::text[]) AS req(name)
WHERE NOT EXISTS (
SELECT 1 FROM page_tags ut
JOIN tags t ON t.id = ut.tag_id
WHERE ut.page_id = p.id AND ut.user_id = $1 AND lower(t.name) = req.name
UNION ALL
SELECT 1 FROM page_auto_tags at
JOIN tags t ON t.id = at.tag_id
WHERE at.page_id = p.id AND lower(t.name) = req.name
)
)
AND ($3 = '' OR pa.search_doc @@ plainto_tsquery('simple', $3))
AND NOT EXISTS (
SELECT 1 FROM unnest($4::text[]) AS req(w)
WHERE NOT EXISTS (
SELECT 1 FROM page_content_warnings pw
WHERE pw.page_id = p.id AND pw.warning = req.w
)
)
AND NOT EXISTS (
SELECT 1 FROM page_content_warnings pw
WHERE pw.page_id = p.id AND pw.warning = ANY($5::text[])
)
"#;
let rows_sql = format!(
r#"
SELECT
p.id AS page_id,
p.chapter_id AS chapter_id,
ch.manga_id AS manga_id,
p.page_number AS page_number,
ch.number AS chapter_number,
ch.title AS chapter_title,
m.title AS manga_title,
p.storage_key AS storage_key,
COALESCE(pa.is_nsfw, false) AS is_nsfw,
COALESCE(
(SELECT array_agg(pw.warning ORDER BY pw.warning)
FROM page_content_warnings pw WHERE pw.page_id = p.id),
ARRAY[]::text[]
) AS content_warnings,
COALESCE(ts_rank(pa.search_doc, plainto_tsquery('simple', $3)), 0)::real AS rank
FROM pages p
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE {WHERE}
ORDER BY (CASE WHEN $3 = '' THEN 0 ELSE 1 END) DESC, rank DESC, p.id
LIMIT $6 OFFSET $7
"#
);
let rows = sqlx::query_as::<_, PageSearchItem>(&rows_sql)
.bind(q.user_id)
.bind(&q.tags)
.bind(text)
.bind(&q.cw_include)
.bind(&q.cw_exclude)
.bind(q.limit)
.bind(q.offset)
.fetch_all(pool)
.await?;
let count_sql = format!(
"SELECT count(*) FROM pages p \
JOIN chapters ch ON ch.id = p.chapter_id \
JOIN mangas m ON m.id = ch.manga_id \
LEFT JOIN page_analysis pa ON pa.page_id = p.id \
WHERE {WHERE}"
);
let (total,): (i64,) = sqlx::query_as(&count_sql)
.bind(q.user_id)
.bind(&q.tags)
.bind(text)
.bind(&q.cw_include)
.bind(&q.cw_exclude)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Append a token to a tsvector text bucket with a trailing space.
fn push_token(bucket: &mut String, text: &str) {
bucket.push_str(text);
bucket.push(' ');
}