feat(analysis): page-analysis schema, domain types, and analyze_page job kind

Adds the persistence foundation for the AI content-analysis worker:

- Migration 0025: page_analysis (status + scene + is_nsfw + weighted
  search_doc tsvector), page_ocr_text (kind-tagged pieces), global
  page_auto_tags (shared tags vocabulary, separate from per-user
  page_tags), and page_content_warnings (closed vocabulary).
- domain::page_analysis: AnalysisStatus/OcrKind/ContentWarning enums with
  kind->tsvector-weight mapping and lenient model-string parsing, plus the
  VisionAnalysis response DTOs.
- JobPayload::AnalyzePage { page_id, force } + KIND_ANALYZE_PAGE, reusing
  the existing crawler_jobs queue.
- repo::page_analysis: enqueue_for_page, load, mark_failed, and the single
  transactional persist_analysis (delete+reinsert; idempotent; never
  touches user page_tags; computes the A/B/C/D-weighted search_doc).

Tests: repo integration (persist/idempotency/user-tags-untouched/enqueue/
mark_failed/speech>sfx ranking) + unit (weights, parsing, DTO, job serde).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 18:27:08 +02:00
parent f30600162e
commit 63e1aa5484
10 changed files with 814 additions and 3 deletions

View File

@@ -34,6 +34,15 @@ pub enum JobPayload {
chapter_id: Uuid,
source_chapter_key: String,
},
/// Run AI content-analysis (OCR, auto-tags, scene description, NSFW
/// moderation) on a single page image via the local vision model.
/// `force` re-analyzes a page that is already `done` (manual admin
/// re-trigger); otherwise the worker skips it.
AnalyzePage {
page_id: Uuid,
#[serde(default)]
force: bool,
},
}
#[derive(Clone, Copy, Debug, sqlx::Type, Serialize, Deserialize)]
@@ -52,6 +61,10 @@ pub enum JobState {
/// without re-spelling the literal.
pub const KIND_SYNC_CHAPTER_CONTENT: &str = "sync_chapter_content";
/// Kind discriminator for AI page-analysis jobs. The analysis daemon
/// leases with this filter so it never contends with crawl jobs.
pub const KIND_ANALYZE_PAGE: &str = "analyze_page";
#[derive(Debug)]
pub enum EnqueueResult {
Inserted(Uuid),
@@ -323,6 +336,29 @@ pub async fn reap_done(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64>
mod tests {
use super::*;
#[test]
fn analyze_page_payload_round_trips_through_tagged_json() {
let page_id = Uuid::new_v4();
let payload = JobPayload::AnalyzePage {
page_id,
force: true,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["kind"], "analyze_page");
assert_eq!(json["page_id"], page_id.to_string());
assert_eq!(json["force"], true);
// `force` defaults to false when an older enqueue omitted it.
let legacy = serde_json::json!({ "kind": "analyze_page", "page_id": page_id });
match serde_json::from_value::<JobPayload>(legacy).unwrap() {
JobPayload::AnalyzePage { page_id: pid, force } => {
assert_eq!(pid, page_id);
assert!(!force);
}
other => panic!("expected AnalyzePage, got {other:?}"),
}
}
#[test]
fn backoff_base_grows_exponentially_and_caps_at_one_hour() {
// attempts == 1 → 60s, doubling each step.

View File

@@ -7,6 +7,7 @@ pub mod collection;
pub mod genre;
pub mod manga;
pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod patch;
pub mod read_progress;
@@ -26,6 +27,10 @@ pub use collection::{Collection, CollectionPageItem, CollectionSummary};
pub use genre::{Genre, GenreRef};
pub use manga::{Manga, MangaCard, MangaDetail};
pub use page::Page;
pub use page_analysis::{
AnalysisStatus, ContentWarning, OcrKind, OcrResult, PageAnalysis, SafetyFlag,
VisionAnalysis,
};
pub use page_tag::{
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
TaggedPageItem,

View File

@@ -0,0 +1,212 @@
//! AI page-analysis domain types.
//!
//! Two distinct shapes live here:
//!
//! * The **persisted** row types (`PageAnalysis`) and the closed
//! vocabularies (`OcrKind`, `ContentWarning`, `AnalysisStatus`) that the
//! `page_analysis` / `page_ocr_text` / `page_content_warnings` tables
//! constrain via CHECKs.
//! * The **vision-response** DTOs (`VisionAnalysis` and friends) that the
//! worker deserializes from the local model's JSON. These are
//! deliberately lenient — `kind` and `content_type` arrive as free
//! strings because a small local model can emit anything; mapping to the
//! closed enums (and dropping the unmappable) happens at persist time via
//! [`OcrKind::from_model_str`] / [`ContentWarning::from_model_str`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
/// Lifecycle of a page's analysis row.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AnalysisStatus {
Pending,
Done,
Failed,
}
/// Kind of an OCR'd text piece. Drives the search-ranking weight:
/// `Speech`/`Title` → A, `Narration`/`Thought`/`Caption` → B, `Sfx` → D
/// (the scene description is weighted C separately). See
/// [`OcrKind::weight`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum OcrKind {
Speech,
Thought,
Narration,
Sfx,
Title,
Caption,
}
impl OcrKind {
/// Postgres `tsvector` weight label for this kind. The default
/// `ts_rank` weights `{D,C,B,A} = {0.1,0.2,0.4,1.0}` then realize the
/// intended relevance ordering: speech/title most important, sfx least.
pub fn weight(self) -> char {
match self {
OcrKind::Speech | OcrKind::Title => 'A',
OcrKind::Narration | OcrKind::Thought | OcrKind::Caption => 'B',
OcrKind::Sfx => 'D',
}
}
/// Map a model-supplied kind string onto the closed vocabulary. Unknown
/// or unparseable kinds fall back to `Narration` (the neutral
/// mid-weight bucket) rather than dropping the text — losing the
/// transcription entirely is worse than mis-weighting it.
pub fn from_model_str(raw: &str) -> OcrKind {
match raw.trim().to_lowercase().as_str() {
"speech" => OcrKind::Speech,
"thought" => OcrKind::Thought,
"narration" => OcrKind::Narration,
"sfx" => OcrKind::Sfx,
"title" => OcrKind::Title,
"caption" => OcrKind::Caption,
_ => OcrKind::Narration,
}
}
}
/// A content-warning category from the closed moderation vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ContentWarning {
Sexual,
Nudity,
Gore,
Violence,
Disturbing,
}
impl ContentWarning {
/// Map a model-supplied content-type string onto the closed
/// vocabulary, or `None` if it isn't one we recognize. Unlike OCR
/// kinds, an unknown warning is *dropped* — flagging a page with a
/// category we can't filter on is meaningless.
pub fn from_model_str(raw: &str) -> Option<ContentWarning> {
match raw.trim().to_lowercase().as_str() {
"sexual" => Some(ContentWarning::Sexual),
"nudity" => Some(ContentWarning::Nudity),
"gore" => Some(ContentWarning::Gore),
"violence" => Some(ContentWarning::Violence),
"disturbing" => Some(ContentWarning::Disturbing),
_ => None,
}
}
/// Parse a wire/query-param value strictly (no fallback). Used by the
/// API layer to validate `cw_include` / `cw_exclude` filters.
pub fn parse_strict(raw: &str) -> Option<ContentWarning> {
ContentWarning::from_model_str(raw)
}
}
/// One persisted `page_analysis` row.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageAnalysis {
pub page_id: Uuid,
pub status: AnalysisStatus,
pub scene_description: Option<String>,
pub is_nsfw: bool,
pub model: Option<String>,
pub error: Option<String>,
pub analyzed_at: Option<DateTime<Utc>>,
}
// --- Vision-response DTOs (deserialized from the local model) ---------
/// The full JSON object the vision model returns for one page. Lenient by
/// design: see the module docs.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct VisionAnalysis {
#[serde(default)]
pub ocr_results: Vec<OcrResult>,
#[serde(default)]
pub tagging_results: Vec<String>,
#[serde(default)]
pub scene_description: String,
#[serde(default)]
pub safety_flag: SafetyFlag,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct OcrResult {
#[serde(default)]
pub text: String,
/// Free string from the model; mapped via [`OcrKind::from_model_str`].
#[serde(default)]
pub kind: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct SafetyFlag {
#[serde(default)]
pub is_nsfw: bool,
/// Free strings; mapped via [`ContentWarning::from_model_str`].
#[serde(default)]
pub content_type: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ocr_kind_weights_match_spec() {
assert_eq!(OcrKind::Speech.weight(), 'A');
assert_eq!(OcrKind::Title.weight(), 'A');
assert_eq!(OcrKind::Narration.weight(), 'B');
assert_eq!(OcrKind::Thought.weight(), 'B');
assert_eq!(OcrKind::Caption.weight(), 'B');
assert_eq!(OcrKind::Sfx.weight(), 'D');
}
#[test]
fn ocr_kind_from_model_str_falls_back_to_narration() {
assert_eq!(OcrKind::from_model_str("SPEECH"), OcrKind::Speech);
assert_eq!(OcrKind::from_model_str(" sfx "), OcrKind::Sfx);
assert_eq!(OcrKind::from_model_str("dialogue"), OcrKind::Narration);
assert_eq!(OcrKind::from_model_str(""), OcrKind::Narration);
}
#[test]
fn content_warning_from_model_str_drops_unknown() {
assert_eq!(
ContentWarning::from_model_str("Sexual"),
Some(ContentWarning::Sexual)
);
assert_eq!(
ContentWarning::from_model_str(" gore"),
Some(ContentWarning::Gore)
);
assert_eq!(ContentWarning::from_model_str("spicy"), None);
assert_eq!(ContentWarning::from_model_str(""), None);
}
#[test]
fn vision_analysis_deserializes_sample_and_tolerates_missing_fields() {
let json = r#"{
"ocr_results": [{"text":"Hi","kind":"speech"}],
"tagging_results": ["action","city"],
"scene_description": "A street.",
"safety_flag": {"is_nsfw": false, "content_type": []}
}"#;
let v: VisionAnalysis = serde_json::from_str(json).unwrap();
assert_eq!(v.ocr_results.len(), 1);
assert_eq!(v.tagging_results, vec!["action", "city"]);
assert!(!v.safety_flag.is_nsfw);
// Missing optional fields default rather than failing the parse.
let sparse: VisionAnalysis = serde_json::from_str("{}").unwrap();
assert!(sparse.ocr_results.is_empty());
assert_eq!(sparse.scene_description, "");
assert!(!sparse.safety_flag.is_nsfw);
}
}

View File

@@ -9,6 +9,7 @@ pub mod crawler;
pub mod genre;
pub mod manga;
pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod read_progress;
pub mod session;

View File

@@ -0,0 +1,224 @@
//! 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::{ContentWarning, OcrKind, PageAnalysis, VisionAnalysis};
use crate::error::AppResult;
/// 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;
/// Enqueue an `analyze_page` job for `page_id`. `force` re-analyzes a page
/// that is already `done`. Enqueue is idempotent at the job level only in
/// that duplicate pending jobs are harmless — processing is idempotent.
pub async fn enqueue_for_page(pool: &PgPool, page_id: Uuid, force: bool) -> AppResult<()> {
jobs::enqueue(pool, &JobPayload::AnalyzePage { page_id, force }).await?;
Ok(())
}
/// 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(())
}
/// 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(())
}
/// 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(' ');
}