From dbde6e02c4eaffc6e2732bb15a54e502ad44a229 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 13 Jun 2026 18:45:57 +0200 Subject: [PATCH] feat(analysis): vision client + system prompt + AnalysisConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI-compatible vision client and the bounded prompt/output handling for the analysis worker (no DB, fully unit-tested): - analysis::prompt: terse system prompt carrying the JSON schema, the OCR kind / content-warning vocabularies, and the output-size caps. - analysis::vision: VisionClient (downscale via the new `image` dep → base64 data-URL → chat/completions with an image_url part), plus pure parse_chat_completion (fence/prose-tolerant) and sanitize (drop empty OCR, truncate, clamp tags to 10 via the shared page-tag normalizer, filter unknown warnings). - config::AnalysisConfig extended with endpoint/model/api_key/timeouts/ max_tokens/max_image_dim/max_image_bytes + from_env. - Deps: add `image` (jpeg/png/webp), reqwest `json` feature. Expose api::page_tags::normalize_tag as pub(crate) for reuse. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 100 +++++++++++- backend/Cargo.toml | 7 +- backend/src/analysis/mod.rs | 11 ++ backend/src/analysis/prompt.rs | 90 +++++++++++ backend/src/analysis/vision.rs | 280 +++++++++++++++++++++++++++++++++ backend/src/api/page_tags.rs | 2 +- backend/src/config.rs | 105 ++++++++++++- backend/src/lib.rs | 1 + frontend/package.json | 2 +- 9 files changed, 587 insertions(+), 11 deletions(-) create mode 100644 backend/src/analysis/mod.rs create mode 100644 backend/src/analysis/prompt.rs create mode 100644 backend/src/analysis/vision.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 33b6948..605e293 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -256,12 +256,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -745,6 +757,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1323,6 +1344,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1470,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.66.0" +version = "0.67.0" dependencies = [ "anyhow", "argon2", @@ -1486,6 +1533,7 @@ dependencies = [ "futures-core", "futures-util", "http-body-util", + "image", "infer", "mime", "nix 0.29.0", @@ -1582,6 +1630,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2078,6 +2136,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2143,6 +2214,18 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -4169,3 +4252,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index aad8afc..1bcb25e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.66.0" +version = "0.67.0" edition = "2021" default-run = "mangalord" @@ -37,6 +37,9 @@ rand = "0.8" sha2 = "0.10" subtle = "2" base64 = "0.22" +# Image decode + downscale for the analysis worker (keep the page image +# under the local vision model's token budget). Only the manga page formats. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } axum-extra = { version = "0.9", features = ["cookie", "typed-header"] } time = "0.3" infer = "0.16" @@ -48,7 +51,7 @@ chromiumoxide = { version = "0.7", features = ["tokio-runtime", "_fetcher-rusttl sysinfo = { version = "0.32", default-features = false, features = ["system"] } nix = { version = "0.29", features = ["fs"] } scraper = "0.20" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream", "json"] } [dev-dependencies] tempfile = "3" diff --git a/backend/src/analysis/mod.rs b/backend/src/analysis/mod.rs new file mode 100644 index 0000000..3cd9028 --- /dev/null +++ b/backend/src/analysis/mod.rs @@ -0,0 +1,11 @@ +//! AI content-analysis worker: calls a local OpenAI-compatible vision +//! model on each page image and turns the result into OCR text, global +//! auto-tags, a scene description, and NSFW content warnings. +//! +//! * [`prompt`] — the system prompt + the bounded output vocabulary and +//! sanitization helpers (pure, unit-tested). +//! * [`vision`] — the HTTP client: downscale → request → parse → sanitize. +//! * [`daemon`] — the job-leasing worker loop (added in the worker phase). + +pub mod prompt; +pub mod vision; diff --git a/backend/src/analysis/prompt.rs b/backend/src/analysis/prompt.rs new file mode 100644 index 0000000..35f491d --- /dev/null +++ b/backend/src/analysis/prompt.rs @@ -0,0 +1,90 @@ +//! The vision model's system prompt and the bounds applied to its output. +//! +//! The system prompt is deliberately terse: with a context budget as low +//! as ~8192 tokens, the page image dominates, so the instructions + schema +//! must stay small and the output is capped via `max_tokens` plus the +//! length bounds enforced in [`crate::analysis::vision::sanitize`]. + +/// OCR text kinds the model may emit. Kept in sync with the +/// `page_ocr_text.kind` CHECK and [`crate::domain::page_analysis::OcrKind`]. +pub const OCR_KINDS: [&str; 6] = + ["speech", "thought", "narration", "sfx", "title", "caption"]; + +/// Closed content-warning vocabulary. Kept in sync with the +/// `page_content_warnings.warning` CHECK and +/// [`crate::domain::page_analysis::ContentWarning`]. +pub const CONTENT_WARNINGS: [&str; 5] = + ["sexual", "nudity", "gore", "violence", "disturbing"]; + +/// Target tag count we ask the model for (a hint in the prompt; not +/// hard-enforced beyond the [`MAX_TAGS`] cap). +pub const MIN_TAGS: usize = 5; +pub const MAX_TAGS: usize = 10; + +/// Output bounds enforced at sanitize time so one verbose response can't +/// bloat the row or the search document. +pub const MAX_OCR_PIECES: usize = 60; +pub const MAX_OCR_TEXT_CHARS: usize = 500; +pub const MAX_SCENE_CHARS: usize = 1000; + +/// The system prompt. Self-contained: it carries the exact JSON schema so +/// the same string drives both the model and (implicitly) the +/// [`crate::domain::page_analysis::VisionAnalysis`] parser. +pub const SYSTEM_PROMPT: &str = concat!( + "You are a manga/comic page analyzer. Look at the single page image and ", + "return ONLY one minified JSON object — no prose, no markdown fences.\n", + "Schema:\n", + "{\"ocr_results\":[{\"text\":string,\"kind\":", + "\"speech|thought|narration|sfx|title|caption\"}],", + "\"tagging_results\":[string],", + "\"scene_description\":string,", + "\"safety_flag\":{\"is_nsfw\":boolean,\"content_type\":[", + "\"sexual|nudity|gore|violence|disturbing\"]}}\n", + "Rules: transcribe every visible text element verbatim into ocr_results ", + "with its kind; if there is no text use []. tagging_results: 5-10 short, ", + "lowercase content tags (characters, actions, setting, mood, genre); ", + "include explicit/sexual tags when present. scene_description: one or two ", + "sentences describing setting, characters, and action. safety_flag.", + "content_type: only values from the listed set, [] if none; set is_nsfw ", + "true if the page contains sexual, nudity, gore, violence, or disturbing ", + "content. Output must be valid minified JSON and nothing else." +); + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::page_analysis::{ContentWarning, OcrKind}; + + #[test] + fn vocabularies_match_the_domain_enums() { + // Every prompt OCR kind must parse back to a real OcrKind (no + // fallback to narration via a typo). + for k in OCR_KINDS { + assert_eq!( + OcrKind::from_model_str(k), + match k { + "speech" => OcrKind::Speech, + "thought" => OcrKind::Thought, + "narration" => OcrKind::Narration, + "sfx" => OcrKind::Sfx, + "title" => OcrKind::Title, + "caption" => OcrKind::Caption, + _ => unreachable!(), + } + ); + } + for w in CONTENT_WARNINGS { + assert!( + ContentWarning::from_model_str(w).is_some(), + "prompt warning {w} not in the domain vocabulary" + ); + } + } + + #[test] + fn system_prompt_mentions_the_schema_keys() { + for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] { + assert!(SYSTEM_PROMPT.contains(key), "prompt missing {key}"); + } + } +} diff --git a/backend/src/analysis/vision.rs b/backend/src/analysis/vision.rs new file mode 100644 index 0000000..3c8f627 --- /dev/null +++ b/backend/src/analysis/vision.rs @@ -0,0 +1,280 @@ +//! The OpenAI-compatible vision HTTP client. +//! +//! Flow: downscale the page image to fit the model's context budget → +//! base64 data-URL → chat/completions request with an `image_url` content +//! part → parse `choices[0].message.content` as JSON → sanitize/bound the +//! result. Parsing and sanitization are pure functions so they're tested +//! without a live model; only [`VisionClient::analyze`] does I/O. + +use std::io::Cursor; + +use anyhow::{anyhow, Context}; +use base64::Engine; +use serde_json::json; + +use crate::analysis::prompt::{ + self, MAX_OCR_PIECES, MAX_OCR_TEXT_CHARS, MAX_SCENE_CHARS, MAX_TAGS, +}; +use crate::config::AnalysisConfig; +use crate::domain::page_analysis::{ContentWarning, VisionAnalysis}; + +/// Vision client built from [`AnalysisConfig`]. Cheap to clone (holds a +/// `reqwest::Client`, which is internally `Arc`-backed). +#[derive(Clone)] +pub struct VisionClient { + http: reqwest::Client, + endpoint: String, + model: String, + api_key: Option, + max_tokens: u32, + max_image_dim: u32, +} + +impl VisionClient { + pub fn new(http: reqwest::Client, cfg: &AnalysisConfig) -> Self { + Self { + http, + endpoint: cfg.endpoint.clone(), + model: cfg.model.clone(), + api_key: cfg.api_key.clone(), + max_tokens: cfg.max_tokens, + max_image_dim: cfg.max_image_dim, + } + } + + /// Analyze one page image. `mime` is the stored content type (used for + /// the data-URL when no downscale happens). + pub async fn analyze(&self, image: &[u8], mime: &str) -> anyhow::Result { + let (bytes, mime) = match downscale(image, self.max_image_dim) { + Some((b, m)) => (b, m.to_string()), + None => (image.to_vec(), mime.to_string()), + }; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let data_url = format!("data:{mime};base64,{b64}"); + + let body = json!({ + "model": self.model, + "temperature": 0, + "max_tokens": self.max_tokens, + "response_format": { "type": "json_object" }, + "messages": [ + { "role": "system", "content": prompt::SYSTEM_PROMPT }, + { "role": "user", "content": [ + { "type": "image_url", "image_url": { "url": data_url } } + ]} + ] + }); + + let mut req = self.http.post(&self.endpoint).json(&body); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + let resp = req + .send() + .await + .context("vision request failed")? + .error_for_status() + .context("vision endpoint returned an error status")?; + let value: serde_json::Value = + resp.json().await.context("vision response was not JSON")?; + parse_chat_completion(&value) + } +} + +/// Downscale `bytes` so its longest edge is `max_dim`, re-encoding as JPEG. +/// Returns `None` (use the original bytes) when the image already fits or +/// can't be decoded — a non-decodable page is passed through untouched and +/// the model server can reject it if it wants. +fn downscale(bytes: &[u8], max_dim: u32) -> Option<(Vec, &'static str)> { + let img = image::load_from_memory(bytes).ok()?; + if img.width().max(img.height()) <= max_dim { + return None; + } + let scaled = img.resize(max_dim, max_dim, image::imageops::FilterType::Triangle); + let mut buf = Vec::new(); + scaled + .to_rgb8() + .write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Jpeg) + .ok()?; + Some((buf, "image/jpeg")) +} + +/// Extract the model's JSON object from a chat/completions response. +pub fn parse_chat_completion(value: &serde_json::Value) -> anyhow::Result { + let content = value + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| anyhow!("vision response missing choices[0].message.content"))?; + let json_slice = extract_json_object(content); + let parsed: VisionAnalysis = serde_json::from_str(json_slice) + .with_context(|| format!("vision content was not valid analysis JSON: {content:?}"))?; + Ok(sanitize(parsed)) +} + +/// Return the substring from the first `{` to the last `}` (inclusive), +/// tolerating markdown fences or stray prose around the JSON object. Falls +/// back to the trimmed input if no braces are found (so the parse fails +/// with a clear error rather than silently matching nothing). +fn extract_json_object(s: &str) -> &str { + match (s.find('{'), s.rfind('}')) { + (Some(start), Some(end)) if end >= start => &s[start..=end], + _ => s.trim(), + } +} + +/// Bound the model output: drop empty OCR text, truncate over-long fields, +/// clamp tags to [`MAX_TAGS`] via the page-tag normalizer, and keep only +/// recognized content-warning values. Persist applies the same rules as a +/// safety net, but doing it here keeps the row tidy and the worker's logs +/// honest about what it stored. +pub fn sanitize(mut a: VisionAnalysis) -> VisionAnalysis { + a.ocr_results.retain(|r| !r.text.trim().is_empty()); + a.ocr_results.truncate(MAX_OCR_PIECES); + for r in &mut a.ocr_results { + r.text = truncate_chars(r.text.trim(), MAX_OCR_TEXT_CHARS); + } + + // Normalize tags through the shared page-tag rules (lowercase, collapse + // whitespace, reject wildcards/control/invisible chars); drop the + // unmappable, dedup, cap. + let mut seen = std::collections::HashSet::new(); + let mut tags = Vec::new(); + for raw in std::mem::take(&mut a.tagging_results) { + if let Ok(norm) = crate::api::page_tags::normalize_tag(&raw) { + if seen.insert(norm.clone()) { + tags.push(norm); + if tags.len() >= MAX_TAGS { + break; + } + } + } + } + a.tagging_results = tags; + + a.scene_description = truncate_chars(a.scene_description.trim(), MAX_SCENE_CHARS); + + // Keep only recognized warnings (canonical lowercase), deduped. + let mut seen_w = std::collections::HashSet::new(); + a.safety_flag.content_type = std::mem::take(&mut a.safety_flag.content_type) + .into_iter() + .filter_map(|w| ContentWarning::from_model_str(&w).map(|_| w.trim().to_lowercase())) + .filter(|w| seen_w.insert(w.clone())) + .collect(); + + a +} + +/// Truncate to at most `n` chars (not bytes), avoiding a panic on a +/// multi-byte boundary. +fn truncate_chars(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chat_body(content: &str) -> serde_json::Value { + json!({ "choices": [ { "message": { "content": content } } ] }) + } + + #[test] + fn parses_a_clean_json_response() { + let body = chat_body( + r#"{"ocr_results":[{"text":"Hi","kind":"speech"}], + "tagging_results":["action","city"], + "scene_description":"A street.", + "safety_flag":{"is_nsfw":false,"content_type":[]}}"#, + ); + let v = parse_chat_completion(&body).unwrap(); + assert_eq!(v.ocr_results.len(), 1); + assert_eq!(v.tagging_results, vec!["action", "city"]); + assert!(!v.safety_flag.is_nsfw); + } + + #[test] + fn strips_markdown_fences_and_prose() { + let body = chat_body( + "Here you go:\n```json\n{\"scene_description\":\"x\"}\n```\nthanks", + ); + let v = parse_chat_completion(&body).unwrap(); + assert_eq!(v.scene_description, "x"); + } + + #[test] + fn rejects_non_json_content() { + let body = chat_body("I cannot analyze this image."); + assert!(parse_chat_completion(&body).is_err()); + } + + #[test] + fn rejects_missing_content() { + assert!(parse_chat_completion(&json!({"choices": []})).is_err()); + } + + #[test] + fn sanitize_clamps_tags_and_drops_invalid() { + let body = chat_body( + r#"{"tagging_results":["A","a","b","b","c","d","e","f","g","h","i","j", + "bad%wild"," "]}"#, + ); + let v = parse_chat_completion(&body).unwrap(); + // "A"/"a" dedup to one; wildcard + blank dropped; capped at 10. + assert!(v.tagging_results.len() <= MAX_TAGS); + assert!(v.tagging_results.contains(&"a".to_string())); + assert!(!v.tagging_results.iter().any(|t| t.contains('%'))); + } + + #[test] + fn sanitize_drops_empty_ocr_and_truncates() { + let long = "x".repeat(MAX_OCR_TEXT_CHARS + 50); + let body = chat_body(&format!( + r#"{{"ocr_results":[{{"text":" ","kind":"speech"}}, + {{"text":"{long}","kind":"narration"}}]}}"# + )); + let v = parse_chat_completion(&body).unwrap(); + assert_eq!(v.ocr_results.len(), 1, "blank OCR dropped"); + assert_eq!(v.ocr_results[0].text.chars().count(), MAX_OCR_TEXT_CHARS); + } + + #[test] + fn sanitize_filters_unknown_warnings() { + let body = chat_body( + r#"{"safety_flag":{"is_nsfw":true,"content_type":["Sexual","spicy","gore","gore"]}}"#, + ); + let v = parse_chat_completion(&body).unwrap(); + assert_eq!(v.safety_flag.content_type, vec!["sexual", "gore"]); + } + + #[test] + fn downscale_passes_through_small_images() { + // 8x8 PNG — well under any sane max_dim, so no re-encode. + let img = image::RgbImage::from_pixel(8, 8, image::Rgb([10, 20, 30])); + let mut buf = Vec::new(); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png) + .unwrap(); + assert!(downscale(&buf, 1024).is_none()); + } + + #[test] + fn downscale_shrinks_large_images_to_max_dim() { + let img = image::RgbImage::from_pixel(2000, 1000, image::Rgb([1, 2, 3])); + let mut buf = Vec::new(); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png) + .unwrap(); + let (out, mime) = downscale(&buf, 512).expect("should downscale"); + assert_eq!(mime, "image/jpeg"); + let decoded = image::load_from_memory(&out).unwrap(); + assert_eq!(decoded.width().max(decoded.height()), 512); + } + + #[test] + fn downscale_passes_through_undecodable_bytes() { + assert!(downscale(b"not an image", 512).is_none()); + } +} diff --git a/backend/src/api/page_tags.rs b/backend/src/api/page_tags.rs index 7607d56..e85835c 100644 --- a/backend/src/api/page_tags.rs +++ b/backend/src/api/page_tags.rs @@ -115,7 +115,7 @@ fn is_invisible_format(c: char) -> bool { ) } -fn normalize_tag(input: &str) -> AppResult { +pub(crate) fn normalize_tag(input: &str) -> AppResult { // Fast-fail on absurd input before allocating in lowercase / // whitespace passes. Cheap and bounds worst-case work. if input.len() > MAX_TAG_BYTES { diff --git a/backend/src/config.rs b/backend/src/config.rs index d7224dd..78f4b7e 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -66,27 +66,74 @@ impl Default for UploadConfig { } } -/// AI content-analysis worker configuration. Phase 2 only needs the -/// `enabled` gate (so page-create paths know whether to enqueue analysis -/// jobs); later phases extend this with the vision endpoint, model, and -/// worker knobs. +/// AI content-analysis worker configuration: the enable gate, the local +/// OpenAI-compatible vision endpoint, and the worker / request knobs. #[derive(Clone, Debug)] pub struct AnalysisConfig { /// Master switch (`ANALYSIS_ENABLED`). When `false`, no analysis jobs /// are enqueued and no worker runs. Defaults to `false`. pub enabled: bool, + /// Number of concurrent analysis workers (`ANALYSIS_WORKERS`). + pub workers: usize, + /// OpenAI-compatible chat/completions URL (`ANALYSIS_VISION_URL`). + pub endpoint: String, + /// Model id to request (`ANALYSIS_MODEL`). + pub model: String, + /// Optional bearer token (`ANALYSIS_API_KEY`); local servers usually + /// don't need one. + pub api_key: Option, + /// Per-request HTTP timeout (`ANALYSIS_REQUEST_TIMEOUT_SECS`). + pub request_timeout: Duration, + /// Whole-job timeout in the worker (`ANALYSIS_JOB_TIMEOUT_SECS`). + pub job_timeout: Duration, + /// Output token cap sent as `max_tokens` (`ANALYSIS_MAX_TOKENS`). + pub max_tokens: u32, + /// Longest image edge (px) before downscaling (`ANALYSIS_MAX_IMAGE_DIM`). + pub max_image_dim: u32, + /// Hard cap on a page image's stored size; larger pages are skipped + /// (`ANALYSIS_MAX_IMAGE_BYTES`). + pub max_image_bytes: usize, } impl Default for AnalysisConfig { fn default() -> Self { - Self { enabled: false } + Self { + enabled: false, + workers: 1, + endpoint: "http://localhost:8000/v1/chat/completions".to_string(), + model: String::new(), + api_key: None, + request_timeout: Duration::from_secs(120), + job_timeout: Duration::from_secs(180), + max_tokens: 900, + max_image_dim: 1024, + max_image_bytes: 8 * 1024 * 1024, + } } } impl AnalysisConfig { pub fn from_env() -> Self { + let d = AnalysisConfig::default(); Self { - enabled: env_bool("ANALYSIS_ENABLED", false), + enabled: env_bool("ANALYSIS_ENABLED", d.enabled), + workers: env_usize("ANALYSIS_WORKERS", d.workers).max(1), + endpoint: std::env::var("ANALYSIS_VISION_URL").unwrap_or(d.endpoint), + model: std::env::var("ANALYSIS_MODEL").unwrap_or(d.model), + api_key: std::env::var("ANALYSIS_API_KEY") + .ok() + .filter(|s| !s.is_empty()), + request_timeout: Duration::from_secs(env_u64( + "ANALYSIS_REQUEST_TIMEOUT_SECS", + d.request_timeout.as_secs(), + )), + job_timeout: Duration::from_secs(env_u64( + "ANALYSIS_JOB_TIMEOUT_SECS", + d.job_timeout.as_secs(), + )), + max_tokens: env_u64("ANALYSIS_MAX_TOKENS", d.max_tokens as u64) as u32, + max_image_dim: env_u64("ANALYSIS_MAX_IMAGE_DIM", d.max_image_dim as u64) as u32, + max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes), } } } @@ -484,6 +531,52 @@ mod tests { assert_eq!(cfg.browser_restart_threshold, 7); } + #[test] + fn analysis_config_defaults_when_unset() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + for k in [ + "ANALYSIS_ENABLED", + "ANALYSIS_WORKERS", + "ANALYSIS_VISION_URL", + "ANALYSIS_MODEL", + "ANALYSIS_API_KEY", + "ANALYSIS_MAX_TOKENS", + "ANALYSIS_MAX_IMAGE_DIM", + ] { + std::env::remove_var(k); + } + let cfg = AnalysisConfig::from_env(); + assert!(!cfg.enabled); + assert_eq!(cfg.workers, 1); + assert_eq!(cfg.max_image_dim, 1024); + assert!(cfg.api_key.is_none()); + } + + #[test] + fn analysis_config_parses_from_env() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("ANALYSIS_ENABLED", "true"); + std::env::set_var("ANALYSIS_WORKERS", "4"); + std::env::set_var("ANALYSIS_VISION_URL", "http://vis/v1/chat"); + std::env::set_var("ANALYSIS_MODEL", "qwen2-vl"); + std::env::set_var("ANALYSIS_MAX_IMAGE_DIM", "768"); + let cfg = AnalysisConfig::from_env(); + for k in [ + "ANALYSIS_ENABLED", + "ANALYSIS_WORKERS", + "ANALYSIS_VISION_URL", + "ANALYSIS_MODEL", + "ANALYSIS_MAX_IMAGE_DIM", + ] { + std::env::remove_var(k); + } + assert!(cfg.enabled); + assert_eq!(cfg.workers, 4); + assert_eq!(cfg.endpoint, "http://vis/v1/chat"); + assert_eq!(cfg.model, "qwen2-vl"); + assert_eq!(cfg.max_image_dim, 768); + } + #[test] fn private_mode_env_parses_true() { let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 637daab..37cde37 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,3 +1,4 @@ +pub mod analysis; pub mod api; pub mod app; pub mod auth; diff --git a/frontend/package.json b/frontend/package.json index c5d442d..0890197 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.66.0", + "version": "0.67.0", "private": true, "type": "module", "scripts": {