//! 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 { 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::from_model_str(raw) } } /// One result row from the page content-search (`GET /v1/me/page-search`). /// One row per matching page, carrying the breadcrumb plus the moderation /// flags and the text-search rank so the UI can badge and order results. #[derive(Debug, Clone, Serialize, FromRow)] pub struct PageSearchItem { pub page_id: Uuid, pub chapter_id: Uuid, pub manga_id: Uuid, pub page_number: i32, pub chapter_number: i32, pub chapter_title: Option, pub manga_title: String, pub storage_key: String, pub is_nsfw: bool, /// Deduped content warnings on this page (canonical lowercase names). pub content_warnings: Vec, /// `ts_rank` against the text query; `0` for tag/warning-only searches. pub rank: f32, } /// Analysis coverage for one manga (admin overview): how many of its pages /// have a completed analysis vs. how many exist. #[derive(Debug, Clone, Serialize, FromRow)] pub struct MangaCoverage { pub manga_id: Uuid, pub title: String, pub total_pages: i64, pub analyzed_pages: i64, } /// Analysis coverage for one chapter. #[derive(Debug, Clone, Serialize, FromRow)] pub struct ChapterCoverage { pub chapter_id: Uuid, pub number: i32, pub title: Option, pub total_pages: i64, pub analyzed_pages: i64, } /// Per-page analysis status for a chapter's page grid. `status` is one of /// `done` | `failed` | `queued` (a pending/running job) | `none`. #[derive(Debug, Clone, Serialize, FromRow)] pub struct PageStatusItem { pub page_id: Uuid, pub page_number: i32, pub status: String, } /// One row in the admin analysis-history table: a completed or failed /// analysis pass resolved to its page/chapter/manga context. Sourced from /// the persistent `page_analysis` table (not the reaped job queue), so it /// survives indefinitely. `status` is `done` | `failed`. #[derive(Debug, Clone, Serialize, FromRow)] pub struct AnalysisHistoryRow { pub page_id: Uuid, pub page_number: i32, pub chapter_id: Uuid, pub chapter_number: i32, pub manga_id: Uuid, pub manga_title: String, pub status: String, pub is_nsfw: bool, pub model: Option, pub error: Option, pub analyzed_at: Option>, /// Wall-clock the worker spent on this page; `None` for rows analyzed /// before duration tracking landed. pub duration_ms: Option, } /// Per-model average analysis duration (admin Metrics tab). #[derive(Debug, Clone, Serialize, FromRow)] pub struct ModelDuration { pub model: Option, pub avg_ms: Option, pub n: i64, } /// Aggregate analysis timing/outcome roll-up over a time window. #[derive(Debug, Clone, Serialize)] pub struct AnalysisMetrics { pub n: i64, pub ok: i64, pub failed: i64, pub avg_ms: Option, pub by_model: Vec, } /// One OCR line in the page-detail view. #[derive(Debug, Clone, Serialize, FromRow)] pub struct OcrLine { pub kind: OcrKind, pub text: String, } /// Full analysis result for one page, for the admin detail modal. `status` /// is `done` | `failed` | `none` (no analysis row yet). #[derive(Debug, Clone, Serialize)] pub struct PageAnalysisDetail { pub page_id: Uuid, pub page_number: i32, pub chapter_id: Uuid, pub manga_id: Uuid, pub status: String, pub is_nsfw: bool, pub scene_description: Option, pub model: Option, pub error: Option, pub analyzed_at: Option>, pub ocr: Vec, pub tags: Vec, pub content_warnings: Vec, } /// One persisted `page_analysis` row. #[derive(Debug, Clone, Serialize, FromRow)] pub struct PageAnalysis { pub page_id: Uuid, pub status: AnalysisStatus, pub scene_description: Option, pub is_nsfw: bool, pub model: Option, pub error: Option, pub analyzed_at: Option>, } // --- 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, #[serde(default)] pub tagging_results: Vec, #[serde(default)] pub scene_description: String, #[serde(default)] pub safety_flag: SafetyFlag, } #[derive(Debug, Clone, Default, 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, /// Optional vertical center of the text as a fraction (0.0 top … 1.0 /// bottom) of the slice image, requested in the OCR pass to dedup /// duplicates across slice seams by position. `None` for the combined /// single-call path. Internal-only — not persisted. #[serde(default, skip_serializing_if = "Option::is_none")] pub y: Option, } #[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, } #[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); } }