From 926fb53f796d2137a59386127e6310a71283a446 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 02:42:24 +0100 Subject: [PATCH 01/22] feat(codec): overhaul nvisy-codec document and handler architecture - Add AnyDocument enum with UniversalLoader for format-agnostic decoding using infer (magic bytes), MIME types, and heuristic content analysis - Add AnyAudio (Wav/Mp3) and AnyImage (Png/Jpeg) handler enums that implement Handler with shared associated types for category-level dispatch - AnyDocument uses AnyAudio/AnyImage instead of per-format variants - Move streams to dedicated src/stream module (SpanStream, SpanEditStream) - Rename handler/document/ to handler/rich/ for pdf/docx stubs - Add Document::map_handler for handler type conversion preserving source - Add Deref/DerefMut on Document into H for ergonomic handler access - Simplify Loader::decode to return single Document instead of Vec - Add DocumentType::from_mime() in nvisy-core for centralized MIME mapping - Make audio handlers functional (SpanData=Bytes, view/edit spans) - Rewrite HTML handler encode to use DOM mutation via scraper/ego-tree - Add TextHandler impl for CsvHandler - Use derive_more::From on AnyAudio/AnyImage enums - Complete prelude with all public types - Add tests across all new and modified modules (99 total) Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-codec/src/document/any.rs | 238 +++++++++++++++ crates/nvisy-codec/src/document/loader.rs | 282 ++++++++++++++++++ crates/nvisy-codec/src/document/mod.rs | 97 +++++- .../src/handler/audio/audio_handler.rs | 124 ++++++++ crates/nvisy-codec/src/handler/audio/mod.rs | 2 + .../src/handler/audio/mp3_handler.rs | 71 ++++- .../src/handler/audio/mp3_loader.rs | 4 +- .../src/handler/audio/wav_handler.rs | 71 ++++- .../src/handler/audio/wav_loader.rs | 4 +- .../src/handler/image/image_handler.rs | 133 +++++++++ .../src/handler/image/image_handler_macro.rs | 6 +- .../src/handler/image/jpeg_loader.rs | 4 +- crates/nvisy-codec/src/handler/image/mod.rs | 2 + .../src/handler/image/png_loader.rs | 4 +- crates/nvisy-codec/src/handler/mod.rs | 8 +- .../{document => rich}/docx_handler.rs | 2 +- .../handler/{document => rich}/docx_loader.rs | 4 +- .../src/handler/{document => rich}/mod.rs | 0 .../handler/{document => rich}/pdf_handler.rs | 2 +- .../handler/{document => rich}/pdf_loader.rs | 4 +- crates/nvisy-codec/src/handler/span.rs | 50 ++++ .../src/handler/text/csv_handler.rs | 5 +- .../src/handler/text/csv_loader.rs | 50 ++-- .../src/handler/text/html_handler.rs | 154 ++++++---- .../src/handler/text/html_loader.rs | 4 +- .../src/handler/text/json_handler.rs | 2 +- .../src/handler/text/json_loader.rs | 35 +-- .../src/handler/text/txt_handler.rs | 2 +- .../src/handler/text/txt_loader.rs | 32 +- .../src/handler/text/xlsx_handler.rs | 2 +- .../src/handler/text/xlsx_loader.rs | 4 +- crates/nvisy-codec/src/lib.rs | 1 + crates/nvisy-codec/src/prelude.rs | 13 +- .../src/{document => stream}/edit_stream.rs | 0 crates/nvisy-codec/src/stream/mod.rs | 7 + .../src/{document => stream}/view_stream.rs | 0 crates/nvisy-codec/src/transform/image/mod.rs | 2 +- crates/nvisy-codec/src/transform/text/mod.rs | 2 +- crates/nvisy-core/src/fs/document_type.rs | 26 ++ 39 files changed, 1282 insertions(+), 171 deletions(-) create mode 100644 crates/nvisy-codec/src/document/any.rs create mode 100644 crates/nvisy-codec/src/document/loader.rs create mode 100644 crates/nvisy-codec/src/handler/audio/audio_handler.rs create mode 100644 crates/nvisy-codec/src/handler/image/image_handler.rs rename crates/nvisy-codec/src/handler/{document => rich}/docx_handler.rs (94%) rename crates/nvisy-codec/src/handler/{document => rich}/docx_loader.rs (92%) rename crates/nvisy-codec/src/handler/{document => rich}/mod.rs (100%) rename crates/nvisy-codec/src/handler/{document => rich}/pdf_handler.rs (94%) rename crates/nvisy-codec/src/handler/{document => rich}/pdf_loader.rs (92%) rename crates/nvisy-codec/src/{document => stream}/edit_stream.rs (100%) create mode 100644 crates/nvisy-codec/src/stream/mod.rs rename crates/nvisy-codec/src/{document => stream}/view_stream.rs (100%) diff --git a/crates/nvisy-codec/src/document/any.rs b/crates/nvisy-codec/src/document/any.rs new file mode 100644 index 0000000..59cc99b --- /dev/null +++ b/crates/nvisy-codec/src/document/any.rs @@ -0,0 +1,238 @@ +//! [`AnyDocument`]: type-erased wrapper over all supported document types. + +use nvisy_core::Error; +use nvisy_core::fs::DocumentType; + +use crate::handler::Handler; + +use crate::document::Document; +use crate::handler::{ + TxtHandler, CsvHandler, JsonHandler, + AnyImage, AnyAudio, +}; +#[cfg(feature = "html")] +use crate::handler::HtmlHandler; +#[cfg(feature = "pdf")] +use crate::handler::PdfHandler; +#[cfg(feature = "docx")] +use crate::handler::DocxHandler; +#[cfg(feature = "xlsx")] +use crate::handler::XlsxHandler; + +/// A type-erased document that can hold any supported format. +/// +/// Produced by [`UniversalLoader`](super::UniversalLoader) when the +/// caller does not know the format ahead of time. +pub enum AnyDocument { + Txt(Document), + Csv(Document), + Json(Document), + #[cfg(feature = "html")] + Html(Document), + Image(Document), + Audio(Document), + #[cfg(feature = "pdf")] + Pdf(Document), + #[cfg(feature = "docx")] + Docx(Document), + #[cfg(feature = "xlsx")] + Xlsx(Document), +} + +impl AnyDocument { + /// The document type of the inner document. + pub fn document_type(&self) -> DocumentType { + match self { + Self::Txt(d) => d.document_type(), + Self::Csv(d) => d.document_type(), + Self::Json(d) => d.document_type(), + #[cfg(feature = "html")] + Self::Html(d) => d.document_type(), + Self::Image(d) => d.document_type(), + Self::Audio(d) => d.document_type(), + #[cfg(feature = "pdf")] + Self::Pdf(d) => d.document_type(), + #[cfg(feature = "docx")] + Self::Docx(d) => d.document_type(), + #[cfg(feature = "xlsx")] + Self::Xlsx(d) => d.document_type(), + } + } + + /// Encode the inner document back to raw bytes. + pub fn encode(&self) -> Result, Error> { + match self { + Self::Txt(d) => d.encode(), + Self::Csv(d) => d.encode(), + Self::Json(d) => d.encode(), + #[cfg(feature = "html")] + Self::Html(d) => d.encode(), + Self::Image(d) => d.encode(), + Self::Audio(d) => d.encode(), + #[cfg(feature = "pdf")] + Self::Pdf(d) => d.encode(), + #[cfg(feature = "docx")] + Self::Docx(d) => d.encode(), + #[cfg(feature = "xlsx")] + Self::Xlsx(d) => d.encode(), + } + } + + /// Try to get the inner `Document` by reference. + pub fn as_txt(&self) -> Option<&Document> { + if let Self::Txt(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + pub fn into_txt(self) -> Option> { + if let Self::Txt(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + pub fn as_csv(&self) -> Option<&Document> { + if let Self::Csv(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + pub fn into_csv(self) -> Option> { + if let Self::Csv(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + pub fn as_json(&self) -> Option<&Document> { + if let Self::Json(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + pub fn into_json(self) -> Option> { + if let Self::Json(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + pub fn as_image(&self) -> Option<&Document> { + if let Self::Image(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + pub fn into_image(self) -> Option> { + if let Self::Image(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + pub fn as_audio(&self) -> Option<&Document> { + if let Self::Audio(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + pub fn into_audio(self) -> Option> { + if let Self::Audio(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + #[cfg(feature = "html")] + pub fn as_html(&self) -> Option<&Document> { + if let Self::Html(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + #[cfg(feature = "html")] + pub fn into_html(self) -> Option> { + if let Self::Html(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + #[cfg(feature = "pdf")] + pub fn as_pdf(&self) -> Option<&Document> { + if let Self::Pdf(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + #[cfg(feature = "pdf")] + pub fn into_pdf(self) -> Option> { + if let Self::Pdf(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + #[cfg(feature = "docx")] + pub fn as_docx(&self) -> Option<&Document> { + if let Self::Docx(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + #[cfg(feature = "docx")] + pub fn into_docx(self) -> Option> { + if let Self::Docx(d) = self { Some(d) } else { None } + } + + /// Try to get the inner `Document` by reference. + #[cfg(feature = "xlsx")] + pub fn as_xlsx(&self) -> Option<&Document> { + if let Self::Xlsx(d) = self { Some(d) } else { None } + } + + /// Consume and return the inner `Document`. + #[cfg(feature = "xlsx")] + pub fn into_xlsx(self) -> Option> { + if let Self::Xlsx(d) = self { Some(d) } else { None } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handler::{TxtHandler, WavHandler}; + + #[test] + fn document_type_returns_correct_variant() { + let handler = TxtHandler::new(vec!["hello".into()], true); + let doc = AnyDocument::Txt(Document::new(handler)); + assert_eq!(doc.document_type(), DocumentType::Txt); + } + + #[test] + fn encode_delegates_to_inner_handler() { + let handler = TxtHandler::new(vec!["hello".into()], true); + let doc = AnyDocument::Txt(Document::new(handler)); + let bytes = doc.encode().unwrap(); + assert_eq!(bytes, b"hello\n"); + } + + #[test] + fn as_txt_returns_some_for_txt() { + let handler = TxtHandler::new(vec!["test".into()], false); + let doc = AnyDocument::Txt(Document::new(handler)); + assert!(doc.as_txt().is_some()); + } + + #[test] + fn as_txt_returns_none_for_other() { + let handler = WavHandler::new(bytes::Bytes::from_static(b"wav")); + let doc = AnyDocument::Audio(Document::new(AnyAudio::from(handler))); + assert!(doc.as_txt().is_none()); + } + + #[test] + fn into_txt_consumes_and_returns() { + let handler = TxtHandler::new(vec!["data".into()], false); + let doc = AnyDocument::Txt(Document::new(handler)); + let inner = doc.into_txt().unwrap(); + assert_eq!(inner.handler().lines(), &["data"]); + } + + #[test] + fn audio_variant_holds_any_audio() { + let handler = WavHandler::new(bytes::Bytes::from_static(b"wav")); + let doc = AnyDocument::Audio(Document::new(AnyAudio::from(handler))); + assert_eq!(doc.document_type(), DocumentType::Wav); + assert!(doc.as_audio().is_some()); + } + + #[test] + fn image_variant_holds_any_image() { + use crate::handler::PngHandler; + let handler = PngHandler::new(image::DynamicImage::new_rgb8(1, 1)); + let doc = AnyDocument::Image(Document::new(AnyImage::from(handler))); + assert_eq!(doc.document_type(), DocumentType::Png); + assert!(doc.as_image().is_some()); + } +} diff --git a/crates/nvisy-codec/src/document/loader.rs b/crates/nvisy-codec/src/document/loader.rs new file mode 100644 index 0000000..afd0bc4 --- /dev/null +++ b/crates/nvisy-codec/src/document/loader.rs @@ -0,0 +1,282 @@ +//! [`UniversalLoader`]: auto-detect format and dispatch to the +//! appropriate typed loader. + +use nvisy_core::Error; +use nvisy_core::fs::DocumentType; +use nvisy_core::io::ContentData; + +use crate::document::AnyDocument; +use crate::handler::Loader; + +/// Format-agnostic loader that detects the document type from MIME +/// type and magic bytes, then delegates to the appropriate typed +/// loader with default parameters. +pub struct UniversalLoader; + +impl UniversalLoader { + /// Detect format and decode the content into an [`AnyDocument`]. + /// + /// Detection priority: + /// 1. `content.content_type()` — caller-provided MIME + /// 2. `infer::get()` — magic-byte detection + /// 3. Heuristic: UTF-8 → JSON parse → CSV sniff → fallback to Txt + pub async fn decode( + &self, + content: &ContentData, + ) -> Result { + let doc_type = detect_type(content); + tracing::debug!(?doc_type, "universal loader detected format"); + dispatch(doc_type, content).await + } +} + +/// Detect the document type from the content's MIME type, magic bytes, +/// and heuristic content analysis. +fn detect_type(content: &ContentData) -> DocumentType { + // 1. Caller-provided MIME + if let Some(mime) = content.content_type() + && let Some(dt) = DocumentType::from_mime(mime) + { + return dt; + } + + // 2. Magic-byte detection via `infer` + let bytes = content.as_bytes(); + if let Some(kind) = infer::get(bytes) + && let Some(dt) = DocumentType::from_mime(kind.mime_type()) + { + return dt; + } + + // 3. Heuristic: try UTF-8, then probe content + let Ok(text) = std::str::from_utf8(bytes) else { + // Not valid UTF-8 — treat as plain text (handler will store + // raw bytes anyway). + return DocumentType::Txt; + }; + + let trimmed = text.trim(); + + // JSON: starts with `{` or `[` + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && serde_json::from_str::(trimmed).is_ok() + { + return DocumentType::Json; + } + + // CSV: sniff for delimiters in the first line + if let Some(first_line) = trimmed.lines().next() { + let has_comma = first_line.contains(','); + let has_tab = first_line.contains('\t'); + let has_semicolon = first_line.contains(';'); + let has_pipe = first_line.contains('|'); + if (has_comma || has_tab || has_semicolon || has_pipe) && trimmed.lines().count() > 1 { + return DocumentType::Csv; + } + } + + DocumentType::Txt +} + +/// Dispatch to the appropriate typed loader with default parameters. +async fn dispatch(doc_type: DocumentType, content: &ContentData) -> Result { + match doc_type { + DocumentType::Txt => { + let doc = crate::handler::TxtLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Txt(doc)) + } + DocumentType::Csv => { + let doc = crate::handler::CsvLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Csv(doc)) + } + DocumentType::Json => { + let doc = crate::handler::JsonLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Json(doc)) + } + DocumentType::Html => { + #[cfg(feature = "html")] + { + let doc = crate::handler::HtmlLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Html(doc)) + } + #[cfg(not(feature = "html"))] + Err(Error::validation( + "HTML support requires the `html` feature", + "universal-loader", + )) + } + DocumentType::Png => { + let doc = crate::handler::PngLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Image(doc.map_handler(Into::into))) + } + DocumentType::Jpeg => { + let doc = crate::handler::JpegLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Image(doc.map_handler(Into::into))) + } + DocumentType::Wav => { + let doc = crate::handler::WavLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Audio(doc.map_handler(Into::into))) + } + DocumentType::Mp3 => { + let doc = crate::handler::Mp3Loader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Audio(doc.map_handler(Into::into))) + } + DocumentType::Pdf => { + #[cfg(feature = "pdf")] + { + let doc = crate::handler::PdfLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Pdf(doc)) + } + #[cfg(not(feature = "pdf"))] + Err(Error::validation( + "PDF support requires the `pdf` feature", + "universal-loader", + )) + } + DocumentType::Docx => { + #[cfg(feature = "docx")] + { + let doc = crate::handler::DocxLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Docx(doc)) + } + #[cfg(not(feature = "docx"))] + Err(Error::validation( + "DOCX support requires the `docx` feature", + "universal-loader", + )) + } + DocumentType::Xlsx => { + #[cfg(feature = "xlsx")] + { + let doc = crate::handler::XlsxLoader + .decode(content, &Default::default()) + .await?; + Ok(AnyDocument::Xlsx(doc)) + } + #[cfg(not(feature = "xlsx"))] + Err(Error::validation( + "XLSX support requires the `xlsx` feature", + "universal-loader", + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use nvisy_core::path::ContentSource; + + fn content_with_mime(data: &[u8], mime: &str) -> ContentData { + let mut c = ContentData::new(ContentSource::new(), Bytes::copy_from_slice(data)); + c.mime = Some(mime.to_string()); + c + } + + fn content_raw(data: &[u8]) -> ContentData { + ContentData::new(ContentSource::new(), Bytes::copy_from_slice(data)) + } + + #[tokio::test] + async fn json_string_with_mime() { + let content = content_with_mime(b"{\"key\": \"value\"}", "application/json"); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Json); + } + + #[tokio::test] + async fn wav_bytes_detected() { + // Minimal WAV header: RIFF....WAVEfmt + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&36u32.to_le_bytes()); // chunk size + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); // subchunk size + wav.extend_from_slice(&1u16.to_le_bytes()); // PCM + wav.extend_from_slice(&1u16.to_le_bytes()); // mono + wav.extend_from_slice(&44100u32.to_le_bytes()); // sample rate + wav.extend_from_slice(&44100u32.to_le_bytes()); // byte rate + wav.extend_from_slice(&1u16.to_le_bytes()); // block align + wav.extend_from_slice(&8u16.to_le_bytes()); // bits per sample + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&0u32.to_le_bytes()); // data size + + let content = content_raw(&wav); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Wav); + } + + #[tokio::test] + async fn unknown_bytes_fallback_to_txt() { + let content = content_raw(b"just some plain text\nwith lines"); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Txt); + } + + #[tokio::test] + async fn json_heuristic_detection() { + let content = content_raw(b"{\"hello\": \"world\"}"); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Json); + } + + #[tokio::test] + async fn csv_heuristic_detection() { + let content = content_raw(b"name,age\nAlice,30\nBob,25\n"); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Csv); + } + + #[tokio::test] + async fn png_mime_detection() { + // Valid 1x1 white PNG generated with correct CRCs + let png_bytes: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xFF, 0xFF, 0x3F, + 0x00, 0x05, 0xFE, 0x02, 0xFE, 0x0D, 0xEF, 0x46, + 0xB8, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, + 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + let content = content_raw(png_bytes); + let doc = UniversalLoader.decode(&content).await.unwrap(); + assert_eq!(doc.document_type(), DocumentType::Png); + } + + #[tokio::test] + async fn jpeg_mime_detection() { + let content = content_with_mime(b"\xff\xd8\xff\xe0jfif-data", "image/jpeg"); + let doc = UniversalLoader.decode(&content).await; + // JPEG decoding may fail on minimal bytes, but the type detection should work + // If it fails, it's a decode error not a detection error + match doc { + Ok(d) => assert_eq!(d.document_type(), DocumentType::Jpeg), + Err(e) => assert!(e.to_string().contains("decode"), "unexpected error: {e}"), + } + } +} diff --git a/crates/nvisy-codec/src/document/mod.rs b/crates/nvisy-codec/src/document/mod.rs index 1894d7b..70d82f9 100644 --- a/crates/nvisy-codec/src/document/mod.rs +++ b/crates/nvisy-codec/src/document/mod.rs @@ -1,13 +1,20 @@ //! Unified document representation. -mod view_stream; -mod edit_stream; +mod any; +mod loader; -pub use edit_stream::SpanEditStream; -pub use view_stream::SpanStream; +pub use any::AnyDocument; +pub use loader::UniversalLoader; + +// Re-export stream types for convenience (canonical home is `crate::stream`). +#[doc(inline)] +pub use crate::stream::{SpanEditStream, SpanStream}; + +use std::ops::{Deref, DerefMut}; use futures::StreamExt; +use nvisy_core::Error; use nvisy_core::io::ContentData; use nvisy_core::path::ContentSource; use nvisy_core::fs::DocumentType; @@ -36,6 +43,20 @@ impl Clone for Document { } } +impl Deref for Document { + type Target = H; + + fn deref(&self) -> &H { + &self.handler + } +} + +impl DerefMut for Document { + fn deref_mut(&mut self) -> &mut H { + &mut self.handler + } +} + impl Document { /// Create a new document with the given handler. pub fn new(handler: H) -> Self { @@ -60,6 +81,14 @@ impl Document { self.handler.document_type() } + /// Map the handler into a different type, preserving the source. + pub fn map_handler(self, f: impl FnOnce(H) -> H2) -> Document

{ + Document { + source: self.source, + handler: f(self.handler), + } + } + /// Set this document's parent to the given content source. pub fn with_parent(mut self, content: &ContentData) -> Self { self.source.set_parent_id(Some(content.content_source.as_uuid())); @@ -75,4 +104,64 @@ impl Document { span })) } + + /// Apply edits from an async stream back to the handler. + pub async fn edit_spans( + &mut self, + edits: SpanEditStream<'_, H::SpanId, H::SpanData>, + ) -> Result<(), Error> { + self.handler.edit_spans(edits).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handler::{SpanEdit, TxtHandler, TxtSpan}; + use futures::StreamExt; + + #[tokio::test] + async fn view_spans_injects_source() { + let handler = TxtHandler::new(vec!["line".into()], false); + let doc = Document::new(handler); + let source = doc.source; + let spans: Vec<_> = doc.view_spans().await.collect().await; + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].source, source); + assert_eq!(spans[0].data, "line"); + } + + #[tokio::test] + async fn edit_spans_delegates_to_handler() -> Result<(), Error> { + let handler = TxtHandler::new(vec!["original".into()], false); + let mut doc = Document::new(handler); + doc.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new(TxtSpan(0), "edited".into()), + ]))) + .await?; + assert_eq!(doc.handler().lines(), &["edited"]); + Ok(()) + } + + #[test] + fn document_type_delegates() { + let handler = TxtHandler::new(vec![], false); + let doc = Document::new(handler); + assert_eq!(doc.document_type(), DocumentType::Txt); + } + + #[test] + fn with_parent_sets_lineage() { + let handler = TxtHandler::new(vec![], false); + let doc = Document::new(handler); + let content = ContentData::new( + ContentSource::new(), + bytes::Bytes::from_static(b"parent"), + ); + let doc = doc.with_parent(&content); + assert_eq!( + doc.source.parent_id(), + Some(content.content_source.as_uuid()), + ); + } } diff --git a/crates/nvisy-codec/src/handler/audio/audio_handler.rs b/crates/nvisy-codec/src/handler/audio/audio_handler.rs new file mode 100644 index 0000000..e24f0f3 --- /dev/null +++ b/crates/nvisy-codec/src/handler/audio/audio_handler.rs @@ -0,0 +1,124 @@ +//! [`AnyAudio`]: type-erased wrapper over all audio handler types. + +use bytes::Bytes; +use futures::StreamExt; + +use nvisy_core::Error; +use nvisy_core::fs::DocumentType; + +use crate::handler::Handler; +use crate::stream::{SpanEditStream, SpanStream}; +use crate::transform::{AudioHandler, AudioRedaction}; + +use super::{Mp3Handler, WavHandler}; + +/// A type-erased audio handler that can hold any supported audio format. +/// +/// Since all audio handlers share `SpanId = ()` and `SpanData = Bytes`, +/// this enum can implement [`Handler`] directly. +#[derive(Debug, Clone, derive_more::From)] +pub enum AnyAudio { + Wav(WavHandler), + Mp3(Mp3Handler), +} + +impl AnyAudio { + /// Try to get the inner [`WavHandler`] by reference. + pub fn as_wav(&self) -> Option<&WavHandler> { + if let Self::Wav(h) = self { Some(h) } else { None } + } + + /// Consume and return the inner [`WavHandler`]. + pub fn into_wav(self) -> Option { + if let Self::Wav(h) = self { Some(h) } else { None } + } + + /// Try to get the inner [`Mp3Handler`] by reference. + pub fn as_mp3(&self) -> Option<&Mp3Handler> { + if let Self::Mp3(h) = self { Some(h) } else { None } + } + + /// Consume and return the inner [`Mp3Handler`]. + pub fn into_mp3(self) -> Option { + if let Self::Mp3(h) = self { Some(h) } else { None } + } +} + +#[async_trait::async_trait] +impl Handler for AnyAudio { + fn document_type(&self) -> DocumentType { + match self { + Self::Wav(h) => h.document_type(), + Self::Mp3(h) => h.document_type(), + } + } + + fn encode(&self) -> Result, Error> { + match self { + Self::Wav(h) => h.encode(), + Self::Mp3(h) => h.encode(), + } + } + + type SpanId = (); + type SpanData = Bytes; + + async fn view_spans(&self) -> SpanStream<'_, (), Bytes> { + match self { + Self::Wav(h) => h.view_spans().await, + Self::Mp3(h) => h.view_spans().await, + } + } + + async fn edit_spans( + &mut self, + edits: SpanEditStream<'_, (), Bytes>, + ) -> Result<(), Error> { + // Collect and re-dispatch since we need to forward the stream. + let edits: Vec<_> = edits.collect().await; + let stream = SpanEditStream::new(futures::stream::iter(edits)); + match self { + Self::Wav(h) => h.edit_spans(stream).await, + Self::Mp3(h) => h.edit_spans(stream).await, + } + } +} + +#[async_trait::async_trait] +impl AudioHandler for AnyAudio { + async fn redact_spans(&mut self, redactions: &[AudioRedaction]) -> Result<(), Error> { + match self { + Self::Wav(h) => h.redact_spans(redactions).await, + Self::Mp3(h) => h.redact_spans(redactions).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn wav_variant_delegates() { + let h = AnyAudio::Wav(WavHandler::new(Bytes::from_static(b"wav-data"))); + assert_eq!(h.document_type(), DocumentType::Wav); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].data.as_ref(), b"wav-data"); + } + + #[tokio::test] + async fn mp3_variant_delegates() { + let h = AnyAudio::Mp3(Mp3Handler::new(Bytes::from_static(b"mp3-data"))); + assert_eq!(h.document_type(), DocumentType::Mp3); + assert_eq!(h.encode().unwrap(), b"mp3-data"); + } + + #[test] + fn from_conversions() { + let wav: AnyAudio = WavHandler::new(Bytes::new()).into(); + assert!(wav.as_wav().is_some()); + let mp3: AnyAudio = Mp3Handler::new(Bytes::new()).into(); + assert!(mp3.as_mp3().is_some()); + } +} diff --git a/crates/nvisy-codec/src/handler/audio/mod.rs b/crates/nvisy-codec/src/handler/audio/mod.rs index a3c3b06..05b0723 100644 --- a/crates/nvisy-codec/src/handler/audio/mod.rs +++ b/crates/nvisy-codec/src/handler/audio/mod.rs @@ -1,10 +1,12 @@ //! Audio format handlers and loaders. +mod audio_handler; mod wav_handler; mod wav_loader; mod mp3_handler; mod mp3_loader; +pub use audio_handler::AnyAudio; pub use wav_handler::WavHandler; pub use wav_loader::{WavLoader, WavParams}; pub use mp3_handler::Mp3Handler; diff --git a/crates/nvisy-codec/src/handler/audio/mp3_handler.rs b/crates/nvisy-codec/src/handler/audio/mp3_handler.rs index 1e225a7..7387651 100644 --- a/crates/nvisy-codec/src/handler/audio/mp3_handler.rs +++ b/crates/nvisy-codec/src/handler/audio/mp3_handler.rs @@ -1,12 +1,21 @@ -//! MP3 handler (stub: awaiting migration to Loader/Handler pattern). +//! MP3 handler: holds raw MP3 audio bytes and provides span-based +//! access via [`Handler`]. +//! +//! # Span model +//! +//! [`Handler::view_spans`] yields a single [`Span`] carrying the +//! entire audio payload as [`Bytes`]. [`Handler::edit_spans`] +//! replaces the payload from the first incoming edit. use bytes::Bytes; +use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; -use crate::handler::Handler; +use crate::stream::{SpanEditStream, SpanStream}; +use crate::handler::{Handler, Span}; +use crate::transform::{AudioHandler, AudioRedaction}; #[derive(Debug, Clone)] pub struct Mp3Handler { @@ -37,16 +46,64 @@ impl Handler for Mp3Handler { } type SpanId = (); - type SpanData = (); + type SpanData = Bytes; - async fn view_spans(&self) -> SpanStream<'_, (), ()> { - SpanStream::new(futures::stream::empty()) + async fn view_spans(&self) -> SpanStream<'_, (), Bytes> { + SpanStream::new(futures::stream::iter(std::iter::once( + Span::new((), self.bytes.clone()), + ))) } async fn edit_spans( &mut self, - _edits: SpanEditStream<'_, (), ()>, + edits: SpanEditStream<'_, (), Bytes>, ) -> Result<(), Error> { + let edits: Vec<_> = edits.collect().await; + if let Some(edit) = edits.into_iter().next() { + self.bytes = edit.data; + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl AudioHandler for Mp3Handler { + async fn redact_spans(&mut self, _redactions: &[AudioRedaction]) -> Result<(), Error> { + tracing::warn!("MP3 audio redaction is not yet implemented"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handler::SpanEdit; + + #[tokio::test] + async fn view_spans_returns_single_span() { + let h = Mp3Handler::new(Bytes::from_static(b"ID3-mp3-data")); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].data.as_ref(), b"ID3-mp3-data"); + } + + #[tokio::test] + async fn edit_spans_replaces_bytes() -> Result<(), Error> { + let mut h = Mp3Handler::new(Bytes::from_static(b"original")); + let replacement = Bytes::from_static(b"replaced"); + h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new((), replacement.clone()), + ]))) + .await?; + assert_eq!(h.bytes().as_ref(), b"replaced"); + Ok(()) + } + + #[test] + fn encode_returns_current_bytes() -> Result<(), Error> { + let h = Mp3Handler::new(Bytes::from_static(b"audio-data")); + let encoded = h.encode()?; + assert_eq!(encoded, b"audio-data"); Ok(()) } } diff --git a/crates/nvisy-codec/src/handler/audio/mp3_loader.rs b/crates/nvisy-codec/src/handler/audio/mp3_loader.rs index 0689110..c9a99bd 100644 --- a/crates/nvisy-codec/src/handler/audio/mp3_loader.rs +++ b/crates/nvisy-codec/src/handler/audio/mp3_loader.rs @@ -26,10 +26,10 @@ impl Loader for Mp3Loader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let handler = Mp3Handler::new(content.to_bytes()); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/audio/wav_handler.rs b/crates/nvisy-codec/src/handler/audio/wav_handler.rs index 1de2dbb..473b638 100644 --- a/crates/nvisy-codec/src/handler/audio/wav_handler.rs +++ b/crates/nvisy-codec/src/handler/audio/wav_handler.rs @@ -1,12 +1,21 @@ -//! WAV handler (stub: awaiting migration to Loader/Handler pattern). +//! WAV handler: holds raw WAV audio bytes and provides span-based +//! access via [`Handler`]. +//! +//! # Span model +//! +//! [`Handler::view_spans`] yields a single [`Span`] carrying the +//! entire audio payload as [`Bytes`]. [`Handler::edit_spans`] +//! replaces the payload from the first incoming edit. use bytes::Bytes; +use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; -use crate::handler::Handler; +use crate::stream::{SpanEditStream, SpanStream}; +use crate::handler::{Handler, Span}; +use crate::transform::{AudioHandler, AudioRedaction}; #[derive(Debug, Clone)] pub struct WavHandler { @@ -37,16 +46,64 @@ impl Handler for WavHandler { } type SpanId = (); - type SpanData = (); + type SpanData = Bytes; - async fn view_spans(&self) -> SpanStream<'_, (), ()> { - SpanStream::new(futures::stream::empty()) + async fn view_spans(&self) -> SpanStream<'_, (), Bytes> { + SpanStream::new(futures::stream::iter(std::iter::once( + Span::new((), self.bytes.clone()), + ))) } async fn edit_spans( &mut self, - _edits: SpanEditStream<'_, (), ()>, + edits: SpanEditStream<'_, (), Bytes>, ) -> Result<(), Error> { + let edits: Vec<_> = edits.collect().await; + if let Some(edit) = edits.into_iter().next() { + self.bytes = edit.data; + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl AudioHandler for WavHandler { + async fn redact_spans(&mut self, _redactions: &[AudioRedaction]) -> Result<(), Error> { + tracing::warn!("WAV audio redaction is not yet implemented"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handler::SpanEdit; + + #[tokio::test] + async fn view_spans_returns_single_span() { + let h = WavHandler::new(Bytes::from_static(b"RIFF-wav-data")); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].data.as_ref(), b"RIFF-wav-data"); + } + + #[tokio::test] + async fn edit_spans_replaces_bytes() -> Result<(), Error> { + let mut h = WavHandler::new(Bytes::from_static(b"original")); + let replacement = Bytes::from_static(b"replaced"); + h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new((), replacement.clone()), + ]))) + .await?; + assert_eq!(h.bytes().as_ref(), b"replaced"); + Ok(()) + } + + #[test] + fn encode_returns_current_bytes() -> Result<(), Error> { + let h = WavHandler::new(Bytes::from_static(b"audio-data")); + let encoded = h.encode()?; + assert_eq!(encoded, b"audio-data"); Ok(()) } } diff --git a/crates/nvisy-codec/src/handler/audio/wav_loader.rs b/crates/nvisy-codec/src/handler/audio/wav_loader.rs index 0e00882..f1f39a0 100644 --- a/crates/nvisy-codec/src/handler/audio/wav_loader.rs +++ b/crates/nvisy-codec/src/handler/audio/wav_loader.rs @@ -26,10 +26,10 @@ impl Loader for WavLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let handler = WavHandler::new(content.to_bytes()); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/image/image_handler.rs b/crates/nvisy-codec/src/handler/image/image_handler.rs new file mode 100644 index 0000000..b63da19 --- /dev/null +++ b/crates/nvisy-codec/src/handler/image/image_handler.rs @@ -0,0 +1,133 @@ +//! [`AnyImage`]: type-erased wrapper over all image handler types. + +use futures::StreamExt; + +use nvisy_core::Error; +use nvisy_core::fs::DocumentType; + +use crate::handler::Handler; +use crate::stream::{SpanEditStream, SpanStream}; +use crate::transform::ImageHandler; + +use super::{ImageData, JpegHandler, PngHandler}; + +/// A type-erased image handler that can hold any supported image format. +/// +/// Since all image handlers share `SpanId = ()` and `SpanData = ImageData`, +/// this enum can implement [`Handler`] directly. +#[derive(Debug, Clone, derive_more::From)] +pub enum AnyImage { + Png(PngHandler), + Jpeg(JpegHandler), +} + +impl AnyImage { + /// Try to get the inner [`PngHandler`] by reference. + pub fn as_png(&self) -> Option<&PngHandler> { + if let Self::Png(h) = self { Some(h) } else { None } + } + + /// Consume and return the inner [`PngHandler`]. + pub fn into_png(self) -> Option { + if let Self::Png(h) = self { Some(h) } else { None } + } + + /// Try to get the inner [`JpegHandler`] by reference. + pub fn as_jpeg(&self) -> Option<&JpegHandler> { + if let Self::Jpeg(h) = self { Some(h) } else { None } + } + + /// Consume and return the inner [`JpegHandler`]. + pub fn into_jpeg(self) -> Option { + if let Self::Jpeg(h) = self { Some(h) } else { None } + } +} + +#[async_trait::async_trait] +impl Handler for AnyImage { + fn document_type(&self) -> DocumentType { + match self { + Self::Png(h) => h.document_type(), + Self::Jpeg(h) => h.document_type(), + } + } + + fn encode(&self) -> Result, Error> { + match self { + Self::Png(h) => h.encode(), + Self::Jpeg(h) => h.encode(), + } + } + + type SpanId = (); + type SpanData = ImageData; + + async fn view_spans(&self) -> SpanStream<'_, (), ImageData> { + match self { + Self::Png(h) => h.view_spans().await, + Self::Jpeg(h) => h.view_spans().await, + } + } + + async fn edit_spans( + &mut self, + edits: SpanEditStream<'_, (), ImageData>, + ) -> Result<(), Error> { + let edits: Vec<_> = edits.collect().await; + let stream = SpanEditStream::new(futures::stream::iter(edits)); + match self { + Self::Png(h) => h.edit_spans(stream).await, + Self::Jpeg(h) => h.edit_spans(stream).await, + } + } +} + +impl ImageHandler for AnyImage {} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_png() -> PngHandler { + let img = image::DynamicImage::new_rgb8(1, 1); + PngHandler::new(img) + } + + fn make_jpeg() -> JpegHandler { + let img = image::DynamicImage::new_rgb8(1, 1); + JpegHandler::new(img) + } + + #[test] + fn png_variant_document_type() { + let h = AnyImage::Png(make_png()); + assert_eq!(h.document_type(), DocumentType::Png); + } + + #[test] + fn jpeg_variant_document_type() { + let h = AnyImage::Jpeg(make_jpeg()); + assert_eq!(h.document_type(), DocumentType::Jpeg); + } + + #[tokio::test] + async fn view_spans_returns_image() { + let h = AnyImage::Png(make_png()); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert_eq!(spans.len(), 1); + } + + #[test] + fn from_conversions() { + let png: AnyImage = make_png().into(); + assert!(png.as_png().is_some()); + let jpeg: AnyImage = make_jpeg().into(); + assert!(jpeg.as_jpeg().is_some()); + } + + #[test] + fn encode_delegates() { + let h = AnyImage::Png(make_png()); + assert!(h.encode().is_ok()); + } +} diff --git a/crates/nvisy-codec/src/handler/image/image_handler_macro.rs b/crates/nvisy-codec/src/handler/image/image_handler_macro.rs index ead2b93..90c23e8 100644 --- a/crates/nvisy-codec/src/handler/image/image_handler_macro.rs +++ b/crates/nvisy-codec/src/handler/image/image_handler_macro.rs @@ -31,15 +31,15 @@ macro_rules! impl_image_handler { async fn view_spans( &self, - ) -> crate::document::SpanStream<'_, (), crate::handler::ImageData> { - crate::document::SpanStream::new(futures::stream::iter(std::iter::once( + ) -> crate::stream::SpanStream<'_, (), crate::handler::ImageData> { + crate::stream::SpanStream::new(futures::stream::iter(std::iter::once( crate::handler::Span::new((), crate::handler::ImageData::from(self.image.clone())), ))) } async fn edit_spans( &mut self, - edits: crate::document::SpanEditStream<'_, (), crate::handler::ImageData>, + edits: crate::stream::SpanEditStream<'_, (), crate::handler::ImageData>, ) -> Result<(), nvisy_core::Error> { use futures::StreamExt; let edits: Vec<_> = edits.collect().await; diff --git a/crates/nvisy-codec/src/handler/image/jpeg_loader.rs b/crates/nvisy-codec/src/handler/image/jpeg_loader.rs index a6d9888..788a922 100644 --- a/crates/nvisy-codec/src/handler/image/jpeg_loader.rs +++ b/crates/nvisy-codec/src/handler/image/jpeg_loader.rs @@ -27,13 +27,13 @@ impl Loader for JpegLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let image = super::decode_image(content, "jpeg-loader")?; tracing::Span::current().record("width", image.width()); tracing::Span::current().record("height", image.height()); let handler = JpegHandler::new(image); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/image/mod.rs b/crates/nvisy-codec/src/handler/image/mod.rs index b2d5902..06c8c06 100644 --- a/crates/nvisy-codec/src/handler/image/mod.rs +++ b/crates/nvisy-codec/src/handler/image/mod.rs @@ -1,5 +1,6 @@ //! Image format handlers and loaders. +mod image_handler; mod image_data; mod image_handler_macro; @@ -9,6 +10,7 @@ mod jpeg_loader; mod png_handler; mod png_loader; +pub use image_handler::AnyImage; pub use image_data::ImageData; pub(crate) use image_handler_macro::impl_image_handler; diff --git a/crates/nvisy-codec/src/handler/image/png_loader.rs b/crates/nvisy-codec/src/handler/image/png_loader.rs index 7da1a80..f4d2e87 100644 --- a/crates/nvisy-codec/src/handler/image/png_loader.rs +++ b/crates/nvisy-codec/src/handler/image/png_loader.rs @@ -27,13 +27,13 @@ impl Loader for PngLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let image = super::decode_image(content, "png-loader")?; tracing::Span::current().record("width", image.width()); tracing::Span::current().record("height", image.height()); let handler = PngHandler::new(image); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/mod.rs b/crates/nvisy-codec/src/handler/mod.rs index d20b948..0b50bab 100644 --- a/crates/nvisy-codec/src/handler/mod.rs +++ b/crates/nvisy-codec/src/handler/mod.rs @@ -11,19 +11,19 @@ use nvisy_core::Error; use nvisy_core::io::ContentData; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::document::Document; mod span; mod text; -mod document; +mod rich; mod image; mod audio; pub use span::{Span, SpanEdit}; pub use text::*; -pub use document::*; +pub use rich::*; pub use image::*; pub use audio::*; @@ -77,5 +77,5 @@ pub trait Loader: Send + Sync + 'static { &self, content: &ContentData, params: &Self::Params, - ) -> Result>, Error>; + ) -> Result, Error>; } diff --git a/crates/nvisy-codec/src/handler/document/docx_handler.rs b/crates/nvisy-codec/src/handler/rich/docx_handler.rs similarity index 94% rename from crates/nvisy-codec/src/handler/document/docx_handler.rs rename to crates/nvisy-codec/src/handler/rich/docx_handler.rs index 621c6fe..0ba41fe 100644 --- a/crates/nvisy-codec/src/handler/document/docx_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/docx_handler.rs @@ -3,7 +3,7 @@ use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::Handler; #[derive(Debug)] diff --git a/crates/nvisy-codec/src/handler/document/docx_loader.rs b/crates/nvisy-codec/src/handler/rich/docx_loader.rs similarity index 92% rename from crates/nvisy-codec/src/handler/document/docx_loader.rs rename to crates/nvisy-codec/src/handler/rich/docx_loader.rs index ccfbc91..a5409df 100644 --- a/crates/nvisy-codec/src/handler/document/docx_loader.rs +++ b/crates/nvisy-codec/src/handler/rich/docx_loader.rs @@ -26,10 +26,10 @@ impl Loader for DocxLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let handler = DocxHandler; let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/document/mod.rs b/crates/nvisy-codec/src/handler/rich/mod.rs similarity index 100% rename from crates/nvisy-codec/src/handler/document/mod.rs rename to crates/nvisy-codec/src/handler/rich/mod.rs diff --git a/crates/nvisy-codec/src/handler/document/pdf_handler.rs b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs similarity index 94% rename from crates/nvisy-codec/src/handler/document/pdf_handler.rs rename to crates/nvisy-codec/src/handler/rich/pdf_handler.rs index d943cf3..4182e5d 100644 --- a/crates/nvisy-codec/src/handler/document/pdf_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs @@ -3,7 +3,7 @@ use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::Handler; #[derive(Debug)] diff --git a/crates/nvisy-codec/src/handler/document/pdf_loader.rs b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs similarity index 92% rename from crates/nvisy-codec/src/handler/document/pdf_loader.rs rename to crates/nvisy-codec/src/handler/rich/pdf_loader.rs index a3ab14d..6dc45b2 100644 --- a/crates/nvisy-codec/src/handler/document/pdf_loader.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs @@ -26,10 +26,10 @@ impl Loader for PdfLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let handler = PdfHandler; let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/span.rs b/crates/nvisy-codec/src/handler/span.rs index 48a4a9f..039d292 100644 --- a/crates/nvisy-codec/src/handler/span.rs +++ b/crates/nvisy-codec/src/handler/span.rs @@ -80,3 +80,53 @@ impl SpanEdit { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn span_new_sets_default_source() { + let span = Span::new(42u32, "hello"); + assert_eq!(span.id, 42); + assert_eq!(span.data, "hello"); + } + + #[test] + fn span_with_source() { + let source = ContentSource::new(); + let span = Span::new(0u32, "data").with_source(source); + assert_eq!(span.source, source); + } + + #[test] + fn span_map_transforms_data() { + let span = Span::new(1u32, "hello"); + let mapped = span.map(|d| d.len()); + assert_eq!(mapped.id, 1); + assert_eq!(mapped.data, 5); + } + + #[test] + fn span_to_edit() { + let span = Span::new(7u32, "world".to_string()); + let edit = span.to_edit(); + assert_eq!(edit.id, 7); + assert_eq!(edit.data, "world"); + } + + #[test] + fn span_edit_new_sets_default_source() { + let edit = SpanEdit::new(3u32, "replacement"); + assert_eq!(edit.id, 3); + assert_eq!(edit.data, "replacement"); + } + + #[test] + fn span_edit_map_transforms_data() { + let edit = SpanEdit::new(0u32, "hello"); + let mapped = edit.map(|d| d.to_uppercase()); + assert_eq!(mapped.id, 0); + assert_eq!(mapped.data, "HELLO"); + } +} diff --git a/crates/nvisy-codec/src/handler/text/csv_handler.rs b/crates/nvisy-codec/src/handler/text/csv_handler.rs index c135908..5b07098 100644 --- a/crates/nvisy-codec/src/handler/text/csv_handler.rs +++ b/crates/nvisy-codec/src/handler/text/csv_handler.rs @@ -20,8 +20,9 @@ use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; +use crate::transform::TextHandler; /// Cell address within a CSV document. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -216,6 +217,8 @@ impl CsvHandler { } } +impl TextHandler for CsvHandler {} + /// Iterator over cells of a CSV document. /// /// Yields header cells first (if present), then data cells in diff --git a/crates/nvisy-codec/src/handler/text/csv_loader.rs b/crates/nvisy-codec/src/handler/text/csv_loader.rs index a77ea70..907ce58 100644 --- a/crates/nvisy-codec/src/handler/text/csv_loader.rs +++ b/crates/nvisy-codec/src/handler/text/csv_loader.rs @@ -51,7 +51,7 @@ impl Loader for CsvLoader { &self, content: &ContentData, params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { let raw = content.to_bytes(); tracing::Span::current().record("input_bytes", raw.len()); let text = params.encoding.decode_bytes(&raw, "csv-loader")?; @@ -94,7 +94,7 @@ impl Loader for CsvLoader { }, }; let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } @@ -158,19 +158,16 @@ mod tests { #[tokio::test] async fn load_with_headers() -> Result<(), Error> { let content = content_from_str("name,age\nAlice,30\nBob,25\n"); - let docs = CsvLoader + let doc = CsvLoader .decode(&content, &CsvParams::default()) .await?; - assert_eq!(docs.len(), 1); - assert_eq!(docs[0].document_type(), DocumentType::Csv); - - let h = docs[0].handler(); - assert_eq!(h.headers(), Some(["name", "age"].map(String::from).as_slice())); - assert_eq!(h.len(), 2); - assert_eq!(h.cell(0, 0), Some("Alice")); - assert_eq!(h.cell(1, 1), Some("25")); - assert!(h.trailing_newline()); + assert_eq!(doc.document_type(), DocumentType::Csv); + assert_eq!(doc.headers(), Some(["name", "age"].map(String::from).as_slice())); + assert_eq!(doc.len(), 2); + assert_eq!(doc.cell(0, 0), Some("Alice")); + assert_eq!(doc.cell(1, 1), Some("25")); + assert!(doc.trailing_newline()); Ok(()) } @@ -181,55 +178,52 @@ mod tests { ..CsvParams::default() }; let content = content_from_str("x,y\n1,2\n"); - let docs = CsvLoader.decode(&content, ¶ms).await?; + let doc = CsvLoader.decode(&content, ¶ms).await?; - let h = docs[0].handler(); - assert!(h.headers().is_none()); - assert_eq!(h.len(), 2); - assert_eq!(h.cell(0, 0), Some("x")); + assert!(doc.headers().is_none()); + assert_eq!(doc.len(), 2); + assert_eq!(doc.cell(0, 0), Some("x")); Ok(()) } #[tokio::test] async fn load_tab_delimited() -> Result<(), Error> { let content = content_from_str("a\tb\n1\t2\n"); - let docs = CsvLoader + let doc = CsvLoader .decode(&content, &CsvParams::default()) .await?; - let h = docs[0].handler(); - assert_eq!(h.delimiter(), b'\t'); - assert_eq!(h.headers(), Some(["a", "b"].map(String::from).as_slice())); + assert_eq!(doc.delimiter(), b'\t'); + assert_eq!(doc.headers(), Some(["a", "b"].map(String::from).as_slice())); Ok(()) } #[tokio::test] async fn load_semicolon_delimited() -> Result<(), Error> { let content = content_from_str("a;b\n1;2\n"); - let docs = CsvLoader + let doc = CsvLoader .decode(&content, &CsvParams::default()) .await?; - assert_eq!(docs[0].handler().delimiter(), b';'); + assert_eq!(doc.delimiter(), b';'); Ok(()) } #[tokio::test] async fn load_quoted_fields() -> Result<(), Error> { let content = content_from_str("name,bio\n\"Alice\",\"Has a, comma\"\n"); - let docs = CsvLoader + let doc = CsvLoader .decode(&content, &CsvParams::default()) .await?; - let h = docs[0].handler(); - assert_eq!(h.cell(0, 1), Some("Has a, comma")); + assert_eq!(doc.cell(0, 1), Some("Has a, comma")); Ok(()) } #[tokio::test] async fn load_spans_round_trip() -> Result<(), Error> { let content = content_from_str("name,age\nAlice,30\n"); - let docs = CsvLoader + let doc = CsvLoader .decode(&content, &CsvParams::default()) .await?; - let spans: Vec<_> = docs[0].handler().view_spans().await.collect().await; + let spans: Vec<_> = doc.view_spans().await.collect().await; // 2 header + 2 data assert_eq!(spans.len(), 4); diff --git a/crates/nvisy-codec/src/handler/text/html_handler.rs b/crates/nvisy-codec/src/handler/text/html_handler.rs index bf45cc7..00816ad 100644 --- a/crates/nvisy-codec/src/handler/text/html_handler.rs +++ b/crates/nvisy-codec/src/handler/text/html_handler.rs @@ -13,13 +13,19 @@ //! //! [`Handler::edit_spans`] replaces the content of text nodes at the //! given indices. +//! +//! # Encoding +//! +//! [`Handler::encode`] reconstructs the HTML by re-parsing the +//! original source into a DOM, applying edits via direct node +//! mutation, and serializing back with [`scraper::Html::html`]. use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; use crate::transform::TextHandler; @@ -52,42 +58,30 @@ impl Handler for HtmlHandler { #[tracing::instrument(name = "html.encode", skip_all, fields(output_bytes))] fn encode(&self) -> Result, Error> { - let mut result = self.data.raw.clone(); - let dom = scraper::Html::parse_document(&self.data.raw); + // Re-parse the original source into a mutable DOM. + let mut dom = scraper::Html::parse_document(&self.data.raw); - // Collect original text nodes in document order - let original_nodes: Vec<&str> = dom + // Collect text-node IDs in document order. + let text_node_ids: Vec<_> = dom .tree .nodes() - .filter_map(|node| { - if let scraper::node::Node::Text(t) = node.value() { - Some(t.text.as_ref()) - } else { - None - } - }) + .filter(|node| node.value().is_text()) + .map(|node| node.id()) .collect(); - // Build patches for changed nodes, then apply right-to-left - let mut patches: Vec<(usize, usize, &str)> = Vec::new(); - let mut search_start = 0; - for (i, original) in original_nodes.iter().enumerate() { - let Some(pos) = result[search_start..].find(original) else { - continue; - }; - let abs_pos = search_start + pos; - if i < self.data.text_nodes.len() && *original != self.data.text_nodes[i] { - patches.push((abs_pos, abs_pos + original.len(), &self.data.text_nodes[i])); + // Mutate changed text nodes directly in the DOM. + for (i, &node_id) in text_node_ids.iter().enumerate() { + let current: &str = &self.data.text_nodes[i]; + if let Some(mut node_mut) = dom.tree.get_mut(node_id) + && let scraper::node::Node::Text(t) = node_mut.value() + && t.text.as_ref() != current + { + t.text = current.into(); } - search_start = abs_pos + original.len(); } - // Apply patches right-to-left to preserve positions - for (start, end, replacement) in patches.into_iter().rev() { - result.replace_range(start..end, replacement); - } - - let bytes = result.into_bytes(); + // Serialize the mutated DOM back to HTML. + let bytes = dom.html().into_bytes(); tracing::Span::current().record("output_bytes", bytes.len()); Ok(bytes) } @@ -186,48 +180,98 @@ impl ExactSizeIterator for HtmlSpanIter<'_> {} #[cfg(test)] mod tests { use super::*; - use crate::handler::Handler; + use crate::handler::{Handler, SpanEdit}; use nvisy_core::Error; + fn handler_from_html(raw: &str) -> HtmlHandler { + let dom = scraper::Html::parse_document(raw); + let text_nodes: Vec = dom + .tree + .nodes() + .filter_map(|node| { + if let scraper::node::Node::Text(t) = node.value() { + Some(t.text.to_string()) + } else { + None + } + }) + .collect(); + HtmlHandler::new(HtmlData { + text_nodes, + raw: raw.to_string(), + }) + } + #[test] fn encode_unchanged() -> Result<(), Error> { - let raw = "

Hello

".to_string(); - let h = HtmlHandler::new(HtmlData { - text_nodes: vec!["Hello".to_string()], - raw: raw.clone(), - }); + let raw = "

Hello

"; + let h = handler_from_html(raw); let bytes = h.encode()?; - assert_eq!(String::from_utf8(bytes).expect("valid utf-8"), raw); + assert_eq!(String::from_utf8(bytes).unwrap(), raw); Ok(()) } - #[test] - fn encode_after_edit() -> Result<(), Error> { - let raw = "

Hello

World

".to_string(); - let mut h = HtmlHandler::new(HtmlData { - text_nodes: vec!["Hello".to_string(), "World".to_string()], - raw, - }); - // Edit the first text node - h.data.text_nodes[0] = "[REDACTED]".to_string(); - let bytes = h.encode()?; - let result = String::from_utf8(bytes).expect("valid utf-8"); + #[tokio::test] + async fn encode_after_edit_spans() -> Result<(), Error> { + let raw = "

Hello

World

"; + let mut h = handler_from_html(raw); + h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new(HtmlSpan(0), "[REDACTED]".to_string()), + ]))) + .await?; + let result = String::from_utf8(h.encode()?).unwrap(); assert!(result.contains("[REDACTED]")); - assert!(result.contains("

")); assert!(result.contains("World")); + assert!(result.contains("

")); Ok(()) } #[test] fn encode_preserves_tags() -> Result<(), Error> { - let raw = "

foo bar
".to_string(); - let mut h = HtmlHandler::new(HtmlData { - text_nodes: vec!["foo".to_string(), " bar".to_string()], - raw, - }); + let h = handler_from_html("
foo bar
"); + let mut h = h; h.data.text_nodes[0] = "baz".to_string(); - let result = String::from_utf8(h.encode()?).expect("valid utf-8"); - assert_eq!(result, "
baz bar
"); + let result = String::from_utf8(h.encode()?).unwrap(); + assert!(result.contains("baz")); + assert!(result.contains(" bar")); + Ok(()) + } + + #[tokio::test] + async fn encode_duplicate_text_nodes() -> Result<(), Error> { + let raw = "

hello

hello

"; + let mut h = handler_from_html(raw); + // Edit only the first "hello" — the second should remain unchanged. + h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new(HtmlSpan(0), "FIRST".to_string()), + ]))) + .await?; + let result = String::from_utf8(h.encode()?).unwrap(); + assert!(result.contains("

FIRST

")); + assert!(result.contains("

hello

")); Ok(()) } + + #[tokio::test] + async fn view_spans_returns_text() { + let h = handler_from_html("

Alpha

Beta

"); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].data, "Alpha"); + assert_eq!(spans[0].id, HtmlSpan(0)); + assert_eq!(spans[1].data, "Beta"); + assert_eq!(spans[1].id, HtmlSpan(1)); + } + + #[tokio::test] + async fn edit_spans_out_of_bounds() { + let mut h = handler_from_html("

only

"); + let err = h + .edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new(HtmlSpan(99), "nope".to_string()), + ]))) + .await + .unwrap_err(); + assert!(err.to_string().contains("out of bounds")); + } } diff --git a/crates/nvisy-codec/src/handler/text/html_loader.rs b/crates/nvisy-codec/src/handler/text/html_loader.rs index 152293b..7675e4d 100644 --- a/crates/nvisy-codec/src/handler/text/html_loader.rs +++ b/crates/nvisy-codec/src/handler/text/html_loader.rs @@ -35,7 +35,7 @@ impl Loader for HtmlLoader { &self, content: &ContentData, params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { let raw = content.to_bytes(); tracing::Span::current().record("input_bytes", raw.len()); let text = params.encoding.decode_bytes(&raw, "html-loader")?; @@ -59,6 +59,6 @@ impl Loader for HtmlLoader { raw: text, }); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/handler/text/json_handler.rs b/crates/nvisy-codec/src/handler/text/json_handler.rs index c8d98d4..01fef97 100644 --- a/crates/nvisy-codec/src/handler/text/json_handler.rs +++ b/crates/nvisy-codec/src/handler/text/json_handler.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; const DEFAULT_INDENT: NonZeroU32 = NonZeroU32::new(2).unwrap(); diff --git a/crates/nvisy-codec/src/handler/text/json_loader.rs b/crates/nvisy-codec/src/handler/text/json_loader.rs index b1393cb..64d95bb 100644 --- a/crates/nvisy-codec/src/handler/text/json_loader.rs +++ b/crates/nvisy-codec/src/handler/text/json_loader.rs @@ -40,7 +40,7 @@ impl Loader for JsonLoader { &self, content: &ContentData, params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { let raw = content.to_bytes(); tracing::Span::current().record("input_bytes", raw.len()); let text = params.encoding.decode_bytes(&raw, "json-loader")?; @@ -58,7 +58,7 @@ impl Loader for JsonLoader { }, }; let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } @@ -108,59 +108,54 @@ mod tests { #[tokio::test] async fn load_simple_object() -> Result<(), Error> { let content = content_from_str(r#"{"name": "Alice", "age": 30}"#); - let docs = JsonLoader + let doc = JsonLoader .decode(&content, &JsonParams::default()) .await?; - assert_eq!(docs.len(), 1); - assert_eq!(docs[0].document_type(), DocumentType::Json); - - let handler = docs[0].handler(); - assert_eq!(handler.value(), &json!({"name": "Alice", "age": 30})); + assert_eq!(doc.document_type(), DocumentType::Json); + assert_eq!(doc.value(), &json!({"name": "Alice", "age": 30})); Ok(()) } #[tokio::test] async fn load_detects_compact_formatting() -> Result<(), Error> { let content = content_from_str(r#"{"a":1}"#); - let docs = JsonLoader + let doc = JsonLoader .decode(&content, &JsonParams::default()) .await?; - let h = docs[0].handler(); - assert_eq!(h.indent(), JsonIndent::Compact); - assert!(!h.trailing_newline()); + assert_eq!(doc.indent(), JsonIndent::Compact); + assert!(!doc.trailing_newline()); Ok(()) } #[tokio::test] async fn load_detects_two_space_indent() -> Result<(), Error> { let content = content_from_str("{\n \"a\": 1\n}\n"); - let docs = JsonLoader + let doc = JsonLoader .decode(&content, &JsonParams::default()) .await?; - let h = docs[0].handler(); - assert_eq!(h.indent(), JsonIndent::two_spaces()); - assert!(h.trailing_newline()); + assert_eq!(doc.indent(), JsonIndent::two_spaces()); + assert!(doc.trailing_newline()); Ok(()) } #[tokio::test] async fn load_detects_four_space_indent() -> Result<(), Error> { let content = content_from_str("{\n \"a\": 1\n}\n"); - let docs = JsonLoader + let doc = JsonLoader .decode(&content, &JsonParams::default()) .await?; - assert_eq!(docs[0].handler().indent(), JsonIndent::four_spaces()); + assert_eq!(doc.indent(), JsonIndent::four_spaces()); Ok(()) } #[tokio::test] async fn load_detects_tab_indent() -> Result<(), Error> { let content = content_from_str("{\n\t\"a\": 1\n}\n"); - let docs = JsonLoader + let doc = JsonLoader .decode(&content, &JsonParams::default()) .await?; - assert_eq!(docs[0].handler().indent(), JsonIndent::Tab); + assert_eq!(doc.indent(), JsonIndent::Tab); Ok(()) } diff --git a/crates/nvisy-codec/src/handler/text/txt_handler.rs b/crates/nvisy-codec/src/handler/text/txt_handler.rs index 5a9003f..ef4d73a 100644 --- a/crates/nvisy-codec/src/handler/text/txt_handler.rs +++ b/crates/nvisy-codec/src/handler/text/txt_handler.rs @@ -19,7 +19,7 @@ use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; use crate::transform::TextHandler; diff --git a/crates/nvisy-codec/src/handler/text/txt_loader.rs b/crates/nvisy-codec/src/handler/text/txt_loader.rs index 5311a97..1b86dcd 100644 --- a/crates/nvisy-codec/src/handler/text/txt_loader.rs +++ b/crates/nvisy-codec/src/handler/text/txt_loader.rs @@ -6,7 +6,7 @@ //! reconstructed after edits. use nvisy_core::Error; -use nvisy_core::io::ContentData; +use nvisy_core::io::{ContentData, TextEncoding}; use crate::document::Document; use crate::handler::{Loader, TxtHandler}; @@ -15,7 +15,7 @@ use crate::handler::{Loader, TxtHandler}; #[derive(Debug, Default)] pub struct TxtParams { /// Character encoding of the input bytes. - pub encoding: nvisy_core::io::TextEncoding, + pub encoding: TextEncoding, } /// Loader that validates and parses plain-text files. @@ -34,7 +34,7 @@ impl Loader for TxtLoader { &self, content: &ContentData, params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { let raw = content.to_bytes(); tracing::Span::current().record("input_bytes", raw.len()); let text = params.encoding.decode_bytes(&raw, "txt-loader")?; @@ -44,7 +44,7 @@ impl Loader for TxtLoader { let handler = TxtHandler::new(lines, trailing_newline); let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } @@ -65,41 +65,37 @@ mod tests { #[tokio::test] async fn load_multiline() -> Result<(), Error> { let content = content_from_str("hello\nworld\n"); - let docs = TxtLoader + let doc = TxtLoader .decode(&content, &TxtParams::default()) .await?; - assert_eq!(docs.len(), 1); - assert_eq!(docs[0].document_type(), DocumentType::Txt); - - let h = docs[0].handler(); - assert_eq!(h.lines(), &["hello", "world"]); - assert!(h.trailing_newline()); + assert_eq!(doc.document_type(), DocumentType::Txt); + assert_eq!(doc.lines(), &["hello", "world"]); + assert!(doc.trailing_newline()); Ok(()) } #[tokio::test] async fn load_no_trailing_newline() -> Result<(), Error> { let content = content_from_str("single line"); - let docs = TxtLoader + let doc = TxtLoader .decode(&content, &TxtParams::default()) .await?; - let h = docs[0].handler(); - assert_eq!(h.len(), 1); - assert_eq!(h.line(0), Some("single line")); - assert!(!h.trailing_newline()); + assert_eq!(doc.len(), 1); + assert_eq!(doc.line(0), Some("single line")); + assert!(!doc.trailing_newline()); Ok(()) } #[tokio::test] async fn load_preserves_spans_through_round_trip() -> Result<(), Error> { let content = content_from_str("Alice\nBob\nCharlie\n"); - let docs = TxtLoader + let doc = TxtLoader .decode(&content, &TxtParams::default()) .await?; - let spans: Vec<_> = docs[0].handler().view_spans().await.collect().await; + let spans: Vec<_> = doc.view_spans().await.collect().await; assert_eq!(spans.len(), 3); assert_eq!(spans[0].data, "Alice"); assert_eq!(spans[1].data, "Bob"); diff --git a/crates/nvisy-codec/src/handler/text/xlsx_handler.rs b/crates/nvisy-codec/src/handler/text/xlsx_handler.rs index 4ff0594..550187b 100644 --- a/crates/nvisy-codec/src/handler/text/xlsx_handler.rs +++ b/crates/nvisy-codec/src/handler/text/xlsx_handler.rs @@ -3,7 +3,7 @@ use nvisy_core::Error; use nvisy_core::fs::DocumentType; -use crate::document::{SpanEditStream, SpanStream}; +use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::Handler; #[derive(Debug)] diff --git a/crates/nvisy-codec/src/handler/text/xlsx_loader.rs b/crates/nvisy-codec/src/handler/text/xlsx_loader.rs index eacaff0..30036df 100644 --- a/crates/nvisy-codec/src/handler/text/xlsx_loader.rs +++ b/crates/nvisy-codec/src/handler/text/xlsx_loader.rs @@ -26,10 +26,10 @@ impl Loader for XlsxLoader { &self, content: &ContentData, _params: &Self::Params, - ) -> Result>, Error> { + ) -> Result, Error> { tracing::Span::current().record("input_bytes", content.to_bytes().len()); let handler = XlsxHandler; let doc = Document::new(handler).with_parent(content); - Ok(vec![doc]) + Ok(doc) } } diff --git a/crates/nvisy-codec/src/lib.rs b/crates/nvisy-codec/src/lib.rs index 7f8322b..bd42392 100644 --- a/crates/nvisy-codec/src/lib.rs +++ b/crates/nvisy-codec/src/lib.rs @@ -3,6 +3,7 @@ #![doc = include_str!("../README.md")] pub mod handler; +pub mod stream; pub mod document; pub mod transform; diff --git a/crates/nvisy-codec/src/prelude.rs b/crates/nvisy-codec/src/prelude.rs index 7c9f752..9136755 100644 --- a/crates/nvisy-codec/src/prelude.rs +++ b/crates/nvisy-codec/src/prelude.rs @@ -9,12 +9,23 @@ pub use crate::handler::{ CsvLoader, CsvParams, JsonData, JsonHandler, JsonIndent, JsonParams, JsonLoader, JsonPath, + ImageData, AnyImage, JpegHandler, JpegLoader, JpegParams, PngHandler, PngLoader, PngParams, + AnyAudio, + WavHandler, WavLoader, WavParams, + Mp3Handler, Mp3Loader, Mp3Params, }; #[cfg(feature = "html")] pub use crate::handler::{HtmlData, HtmlHandler, HtmlSpan, HtmlLoader, HtmlParams}; -pub use crate::document::{Document, SpanEditStream, SpanStream}; +#[cfg(feature = "pdf")] +pub use crate::handler::{PdfHandler, PdfLoader, PdfParams}; +#[cfg(feature = "docx")] +pub use crate::handler::{DocxHandler, DocxLoader, DocxParams}; +#[cfg(feature = "xlsx")] +pub use crate::handler::{XlsxHandler, XlsxLoader, XlsxParams}; +pub use crate::document::{AnyDocument, UniversalLoader, Document}; +pub use crate::stream::{SpanEditStream, SpanStream}; pub use crate::transform::{ AudioHandler, AudioRedaction, AudioRedactionOutput, ImageHandler, ImageRedaction, ImageRedactionOutput, ImageTransform, diff --git a/crates/nvisy-codec/src/document/edit_stream.rs b/crates/nvisy-codec/src/stream/edit_stream.rs similarity index 100% rename from crates/nvisy-codec/src/document/edit_stream.rs rename to crates/nvisy-codec/src/stream/edit_stream.rs diff --git a/crates/nvisy-codec/src/stream/mod.rs b/crates/nvisy-codec/src/stream/mod.rs new file mode 100644 index 0000000..b33dcf5 --- /dev/null +++ b/crates/nvisy-codec/src/stream/mod.rs @@ -0,0 +1,7 @@ +//! Async span stream types for the handler pipeline. + +mod view_stream; +mod edit_stream; + +pub use view_stream::SpanStream; +pub use edit_stream::SpanEditStream; diff --git a/crates/nvisy-codec/src/document/view_stream.rs b/crates/nvisy-codec/src/stream/view_stream.rs similarity index 100% rename from crates/nvisy-codec/src/document/view_stream.rs rename to crates/nvisy-codec/src/stream/view_stream.rs diff --git a/crates/nvisy-codec/src/transform/image/mod.rs b/crates/nvisy-codec/src/transform/image/mod.rs index 60ff55d..144946d 100644 --- a/crates/nvisy-codec/src/transform/image/mod.rs +++ b/crates/nvisy-codec/src/transform/image/mod.rs @@ -9,7 +9,7 @@ pub use transform::ImageTransform; use image::DynamicImage; use futures::StreamExt; -use crate::document::SpanEditStream; +use crate::stream::SpanEditStream; use crate::handler::{Handler, ImageData, SpanEdit}; use nvisy_core::Error; use nvisy_core::math::BoundingBox; diff --git a/crates/nvisy-codec/src/transform/text/mod.rs b/crates/nvisy-codec/src/transform/text/mod.rs index 6d25367..2c9d429 100644 --- a/crates/nvisy-codec/src/transform/text/mod.rs +++ b/crates/nvisy-codec/src/transform/text/mod.rs @@ -15,7 +15,7 @@ use std::hash::Hash; use futures::StreamExt; -use crate::document::SpanEditStream; +use crate::stream::SpanEditStream; use crate::handler::{Handler, SpanEdit}; use nvisy_core::Error; diff --git a/crates/nvisy-core/src/fs/document_type.rs b/crates/nvisy-core/src/fs/document_type.rs index 172e4a9..cf2a955 100644 --- a/crates/nvisy-core/src/fs/document_type.rs +++ b/crates/nvisy-core/src/fs/document_type.rs @@ -31,3 +31,29 @@ pub enum DocumentType { /// MP3 audio. Mp3, } + +impl DocumentType { + /// Map a MIME type string to a [`DocumentType`]. + /// + /// Returns `None` for unrecognised MIME types. + pub fn from_mime(mime: &str) -> Option { + match mime { + "text/plain" => Some(Self::Txt), + "text/csv" => Some(Self::Csv), + "application/json" => Some(Self::Json), + "text/html" => Some(Self::Html), + "image/png" => Some(Self::Png), + "image/jpeg" => Some(Self::Jpeg), + "audio/x-wav" | "audio/wav" => Some(Self::Wav), + "audio/mpeg" => Some(Self::Mp3), + "application/pdf" => Some(Self::Pdf), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + Some(Self::Docx) + } + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => { + Some(Self::Xlsx) + } + _ => None, + } + } +} From cd0753d8dc84928b1ab45e535f88d69d7675d7ad Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 03:37:44 +0100 Subject: [PATCH 02/22] feat(server): private modules, delete endpoints, extract module - Make handler request/response modules private, re-export ServerError - Add ContentRegistry::delete(Uuid) and delete_all() with tests - Add DELETE /api/v1/ingest/{id} and DELETE /api/v1/ingest endpoints with OpenAPI docs via aide - Create extract module with Version extractor (Accept-Version header, FromRequestParts, FromStr/Display, unit tests) - Improve handler module documentation with endpoint tables and multipart upload field reference Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/nvisy-core/src/fs/content_registry.rs | 94 ++++++++ crates/nvisy-server/Cargo.toml | 3 + crates/nvisy-server/src/extract/mod.rs | 5 + crates/nvisy-server/src/extract/version.rs | 217 ++++++++++++++++++ crates/nvisy-server/src/handler/check.rs | 8 +- crates/nvisy-server/src/handler/execute.rs | 12 +- crates/nvisy-server/src/handler/ingest.rs | 83 ++++++- crates/nvisy-server/src/handler/mod.rs | 18 +- crates/nvisy-server/src/handler/redact.rs | 11 +- .../src/handler/response/ingest.rs | 14 ++ .../nvisy-server/src/handler/response/mod.rs | 2 +- crates/nvisy-server/src/lib.rs | 1 + .../nvisy-server/src/middleware/recovery.rs | 2 +- 14 files changed, 446 insertions(+), 25 deletions(-) create mode 100644 crates/nvisy-server/src/extract/mod.rs create mode 100644 crates/nvisy-server/src/extract/version.rs diff --git a/Cargo.lock b/Cargo.lock index e69a15f..5cf1c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2866,6 +2866,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tokio", "tower", "tower-http", "tracing", diff --git a/crates/nvisy-core/src/fs/content_registry.rs b/crates/nvisy-core/src/fs/content_registry.rs index 6a190a5..efc91bf 100644 --- a/crates/nvisy-core/src/fs/content_registry.rs +++ b/crates/nvisy-core/src/fs/content_registry.rs @@ -1,5 +1,7 @@ use std::path::{Path, PathBuf}; +use uuid::Uuid; + use crate::error::{Error, ErrorKind, Result}; use crate::fs::ContentHandler; use crate::io::Content; @@ -60,6 +62,61 @@ impl ContentRegistry { pub fn base_dir(&self) -> &Path { &self.base_dir } + + /// Remove a single content directory by UUID. + pub async fn delete(&self, id: Uuid) -> Result<()> { + let dir = self.base_dir.join(id.to_string()); + tokio::fs::remove_dir_all(&dir).await.map_err(|err| { + Error::new( + ErrorKind::InternalError, + format!("Failed to delete content directory (path: {})", dir.display()), + ) + .with_source(err) + })?; + Ok(()) + } + + /// Remove all content directories under the base dir. + /// + /// Returns the number of entries removed. + pub async fn delete_all(&self) -> Result { + let mut entries = tokio::fs::read_dir(&self.base_dir).await.map_err(|err| { + Error::new( + ErrorKind::InternalError, + format!( + "Failed to read content directory (path: {})", + self.base_dir.display() + ), + ) + .with_source(err) + })?; + + let mut count = 0usize; + while let Some(entry) = entries.next_entry().await.map_err(|err| { + Error::new( + ErrorKind::InternalError, + format!( + "Failed to read content directory entry (path: {})", + self.base_dir.display() + ), + ) + .with_source(err) + })? { + tokio::fs::remove_dir_all(entry.path()).await.map_err(|err| { + Error::new( + ErrorKind::InternalError, + format!( + "Failed to delete content directory (path: {})", + entry.path().display() + ), + ) + .with_source(err) + })?; + count += 1; + } + + Ok(count) + } } #[cfg(test)] @@ -105,4 +162,41 @@ mod tests { assert!(h1.dir().exists()); assert!(h2.dir().exists()); } + + #[tokio::test] + async fn test_delete() { + let temp = tempfile::TempDir::new().unwrap(); + let registry = ContentRegistry::new(temp.path().join("content")); + let content = Content::new(ContentData::from("delete me")); + let id = content.content_source().as_uuid(); + let handler = registry.register(content).await.unwrap(); + + assert!(handler.dir().exists()); + + registry.delete(id).await.unwrap(); + assert!(!handler.dir().exists()); + } + + #[tokio::test] + async fn test_delete_all() { + let temp = tempfile::TempDir::new().unwrap(); + let registry = ContentRegistry::new(temp.path().join("content")); + + let h1 = registry + .register(Content::new(ContentData::from("first"))) + .await + .unwrap(); + let h2 = registry + .register(Content::new(ContentData::from("second"))) + .await + .unwrap(); + + assert!(h1.dir().exists()); + assert!(h2.dir().exists()); + + let deleted = registry.delete_all().await.unwrap(); + assert_eq!(deleted, 2); + assert!(!h1.dir().exists()); + assert!(!h2.dir().exists()); + } } diff --git a/crates/nvisy-server/Cargo.toml b/crates/nvisy-server/Cargo.toml index 3b7fd7a..4d817a2 100644 --- a/crates/nvisy-server/Cargo.toml +++ b/crates/nvisy-server/Cargo.toml @@ -49,3 +49,6 @@ tracing = { workspace = true, features = [] } # Encoding base64 = { workspace = true, features = [] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs new file mode 100644 index 0000000..8fbdc5d --- /dev/null +++ b/crates/nvisy-server/src/extract/mod.rs @@ -0,0 +1,5 @@ +//! Custom extractors for axum handlers. + +mod version; + +pub use version::Version; diff --git a/crates/nvisy-server/src/extract/version.rs b/crates/nvisy-server/src/extract/version.rs new file mode 100644 index 0000000..1f87034 --- /dev/null +++ b/crates/nvisy-server/src/extract/version.rs @@ -0,0 +1,217 @@ +//! `Accept-Version` header extractor. +//! +//! Parses a semver-like version from the `Accept-Version` request header. +//! Falls back to [`LATEST`](Version::LATEST) when the header is absent. + +use std::fmt; +use std::num::NonZeroU32; +use std::str::FromStr; + +use aide::OperationInput; +use axum::extract::FromRequestParts; +use axum::http::StatusCode; +use axum::http::request::Parts; + +/// API version extracted from the `Accept-Version` header. +/// +/// The version follows a simplified semver format: `major.minor.patch`. +/// When the header is missing the server assumes [`LATEST`](Version::LATEST). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Version { + pub major: NonZeroU32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + /// The latest (and currently only) API version. + pub const LATEST: Self = Self { + major: NonZeroU32::new(1).unwrap(), + minor: 0, + patch: 0, + }; + + /// Header name used to transmit the desired API version. + const HEADER: &str = "Accept-Version"; +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl FromStr for Version { + type Err = String; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.trim().split('.').collect(); + match parts.len() { + 1 => { + let major = parts[0] + .parse::() + .map_err(|e| format!("invalid major version: {e}"))?; + Ok(Self { + major, + minor: 0, + patch: 0, + }) + } + 2 => { + let major = parts[0] + .parse::() + .map_err(|e| format!("invalid major version: {e}"))?; + let minor = parts[1] + .parse::() + .map_err(|e| format!("invalid minor version: {e}"))?; + Ok(Self { + major, + minor, + patch: 0, + }) + } + 3 => { + let major = parts[0] + .parse::() + .map_err(|e| format!("invalid major version: {e}"))?; + let minor = parts[1] + .parse::() + .map_err(|e| format!("invalid minor version: {e}"))?; + let patch = parts[2] + .parse::() + .map_err(|e| format!("invalid patch version: {e}"))?; + Ok(Self { + major, + minor, + patch, + }) + } + _ => Err(format!("expected 1-3 version components, got {}", parts.len())), + } + } +} + +impl FromRequestParts for Version { + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match parts.headers.get(Self::HEADER) { + Some(value) => { + let s = value.to_str().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + format!("invalid `{}` header value", Self::HEADER), + ) + })?; + s.parse::().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("invalid `{}` header: {e}", Self::HEADER), + ) + }) + } + None => Ok(Self::LATEST), + } + } +} + +impl OperationInput for Version {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_version() { + let v: Version = "1.2.3".parse().unwrap(); + assert_eq!(v.major.get(), 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } + + #[test] + fn parse_major_only() { + let v: Version = "2".parse().unwrap(); + assert_eq!(v.major.get(), 2); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + } + + #[test] + fn parse_major_minor() { + let v: Version = "1.5".parse().unwrap(); + assert_eq!(v.major.get(), 1); + assert_eq!(v.minor, 5); + assert_eq!(v.patch, 0); + } + + #[test] + fn parse_zero_major_rejected() { + assert!("0.1.0".parse::().is_err()); + } + + #[test] + fn parse_invalid_string() { + assert!("abc".parse::().is_err()); + } + + #[test] + fn parse_too_many_parts() { + assert!("1.2.3.4".parse::().is_err()); + } + + #[test] + fn display_roundtrip() { + let v = Version::LATEST; + let s = v.to_string(); + let parsed: Version = s.parse().unwrap(); + assert_eq!(v, parsed); + } + + #[test] + fn latest_is_1_0_0() { + assert_eq!(Version::LATEST.major.get(), 1); + assert_eq!(Version::LATEST.minor, 0); + assert_eq!(Version::LATEST.patch, 0); + assert_eq!(Version::LATEST.to_string(), "1.0.0"); + } + + #[tokio::test] + async fn from_request_parts_missing_header() { + let mut parts = axum::http::Request::builder() + .body(()) + .unwrap() + .into_parts() + .0; + let version = Version::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(version, Version::LATEST); + } + + #[tokio::test] + async fn from_request_parts_with_header() { + let mut parts = axum::http::Request::builder() + .header("Accept-Version", "2.1.0") + .body(()) + .unwrap() + .into_parts() + .0; + let version = Version::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(version.major.get(), 2); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 0); + } + + #[tokio::test] + async fn from_request_parts_invalid_header() { + let mut parts = axum::http::Request::builder() + .header("Accept-Version", "not-a-version") + .body(()) + .unwrap() + .into_parts() + .0; + let result = Version::from_request_parts(&mut parts, &()).await; + assert!(result.is_err()); + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::BAD_REQUEST); + } +} diff --git a/crates/nvisy-server/src/handler/check.rs b/crates/nvisy-server/src/handler/check.rs index 7ae4d73..813ce92 100644 --- a/crates/nvisy-server/src/handler/check.rs +++ b/crates/nvisy-server/src/handler/check.rs @@ -1,7 +1,11 @@ //! Health and analytics handlers. //! -//! - `GET /health` — liveness probe returning `{"status": "ok"}`. -//! - `GET /api/v1/analytics` — aggregate pipeline metrics (stub). +//! # Endpoints +//! +//! | Method | Path | Description | +//! |--------|-----------------------|--------------------------------------| +//! | `GET` | `/health` | Liveness probe (`{"status": "ok"}`) | +//! | `GET` | `/api/v1/analytics` | Aggregate pipeline metrics (stub) | use aide::axum::ApiRouter; use aide::axum::routing::get_with; diff --git a/crates/nvisy-server/src/handler/execute.rs b/crates/nvisy-server/src/handler/execute.rs index c1669d1..32f18d3 100644 --- a/crates/nvisy-server/src/handler/execute.rs +++ b/crates/nvisy-server/src/handler/execute.rs @@ -1,8 +1,14 @@ //! Pipeline execution handler. //! -//! `POST /api/v1/execute` — accepts base64-encoded content with policies and -//! an execution graph, runs the full detection/evaluation/redaction pipeline, -//! and returns the combined result. +//! # Endpoints +//! +//! | Method | Path | Description | +//! |--------|--------------------|--------------------------------------| +//! | `POST` | `/api/v1/execute` | Run the full redaction pipeline | +//! +//! Accepts a JSON body with base64-encoded content, policy definitions, and +//! an execution graph describing the pipeline DAG. Runs the complete +//! detection → evaluation → redaction pipeline and returns the combined result. use aide::axum::ApiRouter; use aide::axum::routing::post_with; diff --git a/crates/nvisy-server/src/handler/ingest.rs b/crates/nvisy-server/src/handler/ingest.rs index 48aa40e..77fae9d 100644 --- a/crates/nvisy-server/src/handler/ingest.rs +++ b/crates/nvisy-server/src/handler/ingest.rs @@ -1,10 +1,30 @@ -//! Content upload and download handlers. +//! Content ingestion handlers — upload, download, and deletion. //! -//! - `POST /api/v1/ingest` — upload content as multipart form data. -//! - `GET /api/v1/ingest/{id}` — download previously uploaded content (stub). +//! # Endpoints +//! +//! | Method | Path | Description | +//! |----------|--------------------------|-------------------------------------| +//! | `POST` | `/api/v1/ingest` | Upload content (multipart) | +//! | `GET` | `/api/v1/ingest/{id}` | Download previously uploaded content| +//! | `DELETE` | `/api/v1/ingest/{id}` | Delete a single content item | +//! | `DELETE` | `/api/v1/ingest` | Delete all content items | +//! +//! # Upload format +//! +//! The upload endpoint accepts `multipart/form-data` with the following fields: +//! +//! | Field | Kind | Required | Description | +//! |----------------|--------|----------|------------------------------------------| +//! | `file` | file | yes | Binary content to ingest | +//! | `content_type` | text | no | MIME type override (e.g. `text/csv`) | +//! +//! The MIME type is resolved in the following order: +//! 1. Explicit `content_type` text field (if present). +//! 2. `Content-Type` header of the `file` part. +//! 3. Downstream detection via magic bytes / filename heuristics. use aide::axum::ApiRouter; -use aide::axum::routing::get_with; +use aide::axum::routing::{delete_with, get_with}; use aide::transform::TransformOperation; use axum::extract::{Multipart, Path, State}; use axum::Json; @@ -12,7 +32,7 @@ use nvisy_core::io::{Content, ContentData}; use nvisy_core::{Error, ErrorKind}; use uuid::Uuid; -use super::response::{DownloadResponse, ServerError, UploadResponse}; +use super::response::{DeleteAllResponse, DeleteResponse, DownloadResponse, ServerError, UploadResponse}; use crate::service::ServiceState; /// `POST /api/v1/ingest`: upload content as multipart form data. @@ -89,6 +109,9 @@ async fn upload( } /// `GET /api/v1/ingest/{id}`: download previously uploaded content. +/// +/// Returns the content as base64-encoded bytes along with its identifier. +/// Currently unimplemented — returns a 500 error. #[tracing::instrument(skip_all, fields(%id))] async fn download( State(_state): State, @@ -107,9 +130,57 @@ fn download_docs(op: TransformOperation) -> TransformOperation { .description("Retrieves content by its UUID, returning base64-encoded bytes.") } +/// `DELETE /api/v1/ingest/{id}`: delete a single uploaded content item. +/// +/// Removes the content directory identified by the given UUID from the +/// registry. Returns the deleted identifier on success. +#[tracing::instrument(skip_all, fields(%id))] +async fn delete( + State(state): State, + Path(id): Path, +) -> Result, ServerError> { + state.content_registry().delete(id).await?; + tracing::info!(%id, "content deleted"); + Ok(Json(DeleteResponse { id })) +} + +fn delete_docs(op: TransformOperation) -> TransformOperation { + op.id("deleteContent") + .tag("ingest") + .summary("Delete uploaded content") + .description("Removes a single content item identified by its UUID.") +} + +/// `DELETE /api/v1/ingest`: delete all uploaded content. +/// +/// Removes every content directory under the registry's base path. +/// Returns the number of items deleted. +#[tracing::instrument(skip_all)] +async fn delete_all( + State(state): State, +) -> Result, ServerError> { + let deleted = state.content_registry().delete_all().await?; + tracing::info!(deleted, "all content deleted"); + Ok(Json(DeleteAllResponse { deleted })) +} + +fn delete_all_docs(op: TransformOperation) -> TransformOperation { + op.id("deleteAllContent") + .tag("ingest") + .summary("Delete all uploaded content") + .description("Removes every content item currently stored in the registry.") +} + /// Ingest routes. pub fn routes() -> ApiRouter { ApiRouter::new() .route("/api/v1/ingest", axum::routing::post(upload)) - .api_route("/api/v1/ingest/{id}", get_with(download, download_docs)) + .api_route( + "/api/v1/ingest", + delete_with(delete_all, delete_all_docs), + ) + .api_route( + "/api/v1/ingest/{id}", + get_with(download, download_docs).delete_with(delete, delete_docs), + ) } diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index 338519e..5705bfc 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -2,23 +2,21 @@ //! //! Each submodule corresponds to an API resource and exposes a `routes()` //! function that returns its [`ApiRouter`](aide::axum::ApiRouter) fragment. -//! Request and response types live in the [`request`] and [`response`] -//! submodules respectively. +//! The top-level [`routes()`] function merges all fragments into a single +//! router. //! -//! | Module | Endpoints | -//! |------------|-----------------------------------------------------| -//! | [`check`] | `GET /health`, `GET /api/v1/analytics` | -//! | [`execute`]| `POST /api/v1/execute` | -//! | [`ingest`] | `POST /api/v1/ingest`, `GET /api/v1/ingest/{id}` | -//! | [`redact`] | `POST /api/v1/redaction` | +//! Request and response types live in the private [`request`] and [`response`] +//! submodules. Only [`ServerError`] is re-exported for use by middleware. mod check; mod execute; mod ingest; mod redact; -pub mod request; -pub mod response; +mod request; +mod response; + +pub use response::ServerError; use aide::axum::ApiRouter; diff --git a/crates/nvisy-server/src/handler/redact.rs b/crates/nvisy-server/src/handler/redact.rs index 48c4019..c876b62 100644 --- a/crates/nvisy-server/src/handler/redact.rs +++ b/crates/nvisy-server/src/handler/redact.rs @@ -1,7 +1,14 @@ //! Redaction handler. //! -//! `POST /api/v1/redaction` — runs the redaction pipeline on previously -//! uploaded content identified by `content_id` (stub). +//! # Endpoints +//! +//! | Method | Path | Description | +//! |--------|----------------------|-------------------------------------------| +//! | `POST` | `/api/v1/redaction` | Run redaction on previously uploaded content| +//! +//! Expects a JSON body with a `content_id` referencing previously ingested +//! content, along with policies and an execution graph. Currently +//! unimplemented — returns a 500 error. use aide::axum::ApiRouter; use aide::axum::routing::post_with; diff --git a/crates/nvisy-server/src/handler/response/ingest.rs b/crates/nvisy-server/src/handler/response/ingest.rs index 286dc9c..62d734a 100644 --- a/crates/nvisy-server/src/handler/response/ingest.rs +++ b/crates/nvisy-server/src/handler/response/ingest.rs @@ -19,3 +19,17 @@ pub struct DownloadResponse { /// Base64-encoded content bytes. pub content: String, } + +/// Response body for `DELETE /api/v1/ingest/{id}`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteResponse { + /// Identifier of the deleted content. + pub id: Uuid, +} + +/// Response body for `DELETE /api/v1/ingest`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteAllResponse { + /// Number of content items deleted. + pub deleted: usize, +} diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index 17add87..c928681 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -15,5 +15,5 @@ mod redact; pub use check::Analytics; pub use error::ServerError; pub use execute::ExecuteResponse; -pub use ingest::{DownloadResponse, UploadResponse}; +pub use ingest::{DeleteAllResponse, DeleteResponse, DownloadResponse, UploadResponse}; pub use redact::RedactionResponse; diff --git a/crates/nvisy-server/src/lib.rs b/crates/nvisy-server/src/lib.rs index 322d894..60e60c6 100644 --- a/crates/nvisy-server/src/lib.rs +++ b/crates/nvisy-server/src/lib.rs @@ -2,6 +2,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] +pub mod extract; pub mod handler; pub mod middleware; pub mod service; diff --git a/crates/nvisy-server/src/middleware/recovery.rs b/crates/nvisy-server/src/middleware/recovery.rs index e11bc90..4f4d37b 100644 --- a/crates/nvisy-server/src/middleware/recovery.rs +++ b/crates/nvisy-server/src/middleware/recovery.rs @@ -26,7 +26,7 @@ use tower::ServiceBuilder; use tower::timeout::TimeoutLayer; use tower_http::catch_panic::CatchPanicLayer; -use crate::handler::response::ServerError; +use crate::handler::ServerError; /// Tracing target for error recovery. const TRACING_TARGET_ERROR: &str = "nvisy_server::recovery::error"; From 7a9ee12c29c48b9fcf761b4dbc6b4569f3269b12 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 03:41:25 +0100 Subject: [PATCH 03/22] =?UTF-8?q?fix(server):=20review=20improvements=20?= =?UTF-8?q?=E2=80=94=20NotFound,=20Health,=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ErrorKind::NotFound and map it to HTTP 404 in ServerError - ContentRegistry::delete now returns NotFound for missing content instead of InternalError (500) - Move Health response struct from handler/check.rs to response/check.rs for consistency with other response types - Move base64::Engine import to module top in execute.rs; import both base64::Engine and nvisy Engine traits as `_` Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-core/src/error.rs | 2 ++ crates/nvisy-core/src/fs/content_registry.rs | 20 +++++++++++++++++++ crates/nvisy-server/src/handler/check.rs | 10 +--------- crates/nvisy-server/src/handler/execute.rs | 4 ++-- .../src/handler/response/check.rs | 7 +++++++ .../src/handler/response/error.rs | 2 ++ .../nvisy-server/src/handler/response/mod.rs | 2 +- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/nvisy-core/src/error.rs b/crates/nvisy-core/src/error.rs index 3d669b7..568ef0d 100644 --- a/crates/nvisy-core/src/error.rs +++ b/crates/nvisy-core/src/error.rs @@ -28,6 +28,8 @@ pub enum ErrorKind { Python, /// An internal infrastructure error (filesystem, I/O). InternalError, + /// The requested resource was not found. + NotFound, /// The input was invalid or out of bounds. InvalidInput, /// A serialization or encoding error. diff --git a/crates/nvisy-core/src/fs/content_registry.rs b/crates/nvisy-core/src/fs/content_registry.rs index efc91bf..ece4cbb 100644 --- a/crates/nvisy-core/src/fs/content_registry.rs +++ b/crates/nvisy-core/src/fs/content_registry.rs @@ -64,8 +64,16 @@ impl ContentRegistry { } /// Remove a single content directory by UUID. + /// + /// Returns [`ErrorKind::NotFound`] if no directory exists for the given id. pub async fn delete(&self, id: Uuid) -> Result<()> { let dir = self.base_dir.join(id.to_string()); + if !dir.exists() { + return Err(Error::new( + ErrorKind::NotFound, + format!("Content not found (id: {id})"), + )); + } tokio::fs::remove_dir_all(&dir).await.map_err(|err| { Error::new( ErrorKind::InternalError, @@ -177,6 +185,18 @@ mod tests { assert!(!handler.dir().exists()); } + #[tokio::test] + async fn test_delete_not_found() { + let temp = tempfile::TempDir::new().unwrap(); + let registry = ContentRegistry::new(temp.path().join("content")); + // Ensure the base dir exists so the error is about the specific UUID. + tokio::fs::create_dir_all(registry.base_dir()).await.unwrap(); + + let id = uuid::Uuid::new_v4(); + let err = registry.delete(id).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::NotFound); + } + #[tokio::test] async fn test_delete_all() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/crates/nvisy-server/src/handler/check.rs b/crates/nvisy-server/src/handler/check.rs index 813ce92..ac38993 100644 --- a/crates/nvisy-server/src/handler/check.rs +++ b/crates/nvisy-server/src/handler/check.rs @@ -13,18 +13,10 @@ use aide::transform::TransformOperation; use axum::extract::State; use axum::Json; use nvisy_core::{Error, ErrorKind}; -use schemars::JsonSchema; -use serde::Serialize; -use super::response::{Analytics, ServerError}; +use super::response::{Analytics, Health, ServerError}; use crate::service::ServiceState; -/// Response body for `GET /health`. -#[derive(Debug, Serialize, JsonSchema)] -pub struct Health { - pub status: &'static str, -} - /// `GET /health`: liveness probe. async fn health() -> Json { Json(Health { status: "ok" }) diff --git a/crates/nvisy-server/src/handler/execute.rs b/crates/nvisy-server/src/handler/execute.rs index 32f18d3..63f01e6 100644 --- a/crates/nvisy-server/src/handler/execute.rs +++ b/crates/nvisy-server/src/handler/execute.rs @@ -15,9 +15,10 @@ use aide::axum::routing::post_with; use aide::transform::TransformOperation; use axum::extract::State; use axum::Json; +use base64::Engine as _; use nvisy_core::io::{Content, ContentData}; use nvisy_core::{Error, ErrorKind}; -use nvisy_engine::engine::{Engine, EngineInput, Policies}; +use nvisy_engine::engine::{Engine as _, EngineInput, Policies}; use super::request::ExecuteRequest; use super::response::{ExecuteResponse, ServerError}; @@ -29,7 +30,6 @@ async fn execute( State(state): State, Json(req): Json, ) -> Result, ServerError> { - use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(&req.content) .map_err(|e| Error::new(ErrorKind::InvalidInput, format!("invalid base64: {e}")))?; diff --git a/crates/nvisy-server/src/handler/response/check.rs b/crates/nvisy-server/src/handler/response/check.rs index c3d2f87..672fe1d 100644 --- a/crates/nvisy-server/src/handler/response/check.rs +++ b/crates/nvisy-server/src/handler/response/check.rs @@ -3,6 +3,13 @@ use schemars::JsonSchema; use serde::Serialize; +/// Response body for `GET /health`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct Health { + /// Server status string (always `"ok"` when reachable). + pub status: &'static str, +} + /// Response body for `GET /api/v1/analytics`. #[derive(Debug, Serialize, JsonSchema)] pub struct Analytics { diff --git a/crates/nvisy-server/src/handler/response/error.rs b/crates/nvisy-server/src/handler/response/error.rs index 10c26fc..fa774e9 100644 --- a/crates/nvisy-server/src/handler/response/error.rs +++ b/crates/nvisy-server/src/handler/response/error.rs @@ -8,6 +8,7 @@ //! |------------------------------------------------|------------------------| //! | `Validation`, `InvalidInput`, `Serialization` | 400 Bad Request | //! | `Policy` | 403 Forbidden | +//! | `NotFound` | 404 Not Found | //! | `Connection` | 502 Bad Gateway | //! | `Timeout` | 504 Gateway Timeout | //! | `Cancellation` | 499 Client Closed | @@ -56,6 +57,7 @@ fn status_for(kind: ErrorKind) -> StatusCode { StatusCode::BAD_REQUEST } ErrorKind::Policy => StatusCode::FORBIDDEN, + ErrorKind::NotFound => StatusCode::NOT_FOUND, ErrorKind::Connection => StatusCode::BAD_GATEWAY, ErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT, // 499: non-standard "Client Closed Request" diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index c928681..1abe833 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -12,7 +12,7 @@ mod execute; mod ingest; mod redact; -pub use check::Analytics; +pub use check::{Analytics, Health}; pub use error::ServerError; pub use execute::ExecuteResponse; pub use ingest::{DeleteAllResponse, DeleteResponse, DownloadResponse, UploadResponse}; From e9a9279de7fa262c0c7261e665b43d8cf14e6ed0 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 04:52:07 +0100 Subject: [PATCH 04/22] refactor(engine): consolidate directory structure, replace StubEngine Merge 8 subdirectories into 3 (apply/, compiler/, engine/) to reduce fragmentation. Absorb executor, runs, connections, ontology, and policy runtime functions into engine/. Move RetryPolicy into compiler/retry.rs since it's owned by GraphNode. Replace StubEngine with DefaultEngine in the server service layer, deriving Debug/Clone/Copy on DefaultEngine and adding it to impl_di!. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-engine/src/compiler/graph.rs | 2 +- crates/nvisy-engine/src/compiler/mod.rs | 5 +- .../src/{policies => compiler}/retry.rs | 0 .../mod.rs => engine/connections.rs} | 0 crates/nvisy-engine/src/engine/default.rs | 153 +++++++++ crates/nvisy-engine/src/engine/executor.rs | 302 ++++++++++++++++++ crates/nvisy-engine/src/engine/mod.rs | 83 +++++ .../{ontology/audit.rs => engine/ontology.rs} | 0 .../{policies/mod.rs => engine/policies.rs} | 3 +- .../src/{runs/mod.rs => engine/runs.rs} | 2 +- crates/nvisy-engine/src/executor/context.rs | 58 ---- crates/nvisy-engine/src/executor/mod.rs | 9 - crates/nvisy-engine/src/executor/runner.rs | 165 ---------- crates/nvisy-engine/src/lib.rs | 6 +- crates/nvisy-engine/src/ontology/mod.rs | 3 - crates/nvisy-engine/src/prelude.rs | 10 +- .../src/handler/request/execute.rs | 4 +- .../src/handler/request/redact.rs | 4 +- .../src/handler/response/execute.rs | 3 +- crates/nvisy-server/src/service/engine.rs | 17 - crates/nvisy-server/src/service/mod.rs | 31 +- 21 files changed, 563 insertions(+), 297 deletions(-) rename crates/nvisy-engine/src/{policies => compiler}/retry.rs (100%) rename crates/nvisy-engine/src/{connections/mod.rs => engine/connections.rs} (100%) create mode 100644 crates/nvisy-engine/src/engine/default.rs create mode 100644 crates/nvisy-engine/src/engine/executor.rs create mode 100644 crates/nvisy-engine/src/engine/mod.rs rename crates/nvisy-engine/src/{ontology/audit.rs => engine/ontology.rs} (100%) rename crates/nvisy-engine/src/{policies/mod.rs => engine/policies.rs} (97%) rename crates/nvisy-engine/src/{runs/mod.rs => engine/runs.rs} (99%) delete mode 100644 crates/nvisy-engine/src/executor/context.rs delete mode 100644 crates/nvisy-engine/src/executor/mod.rs delete mode 100644 crates/nvisy-engine/src/executor/runner.rs delete mode 100644 crates/nvisy-engine/src/ontology/mod.rs delete mode 100644 crates/nvisy-server/src/service/engine.rs diff --git a/crates/nvisy-engine/src/compiler/graph.rs b/crates/nvisy-engine/src/compiler/graph.rs index ab5056c..24a3fe8 100644 --- a/crates/nvisy-engine/src/compiler/graph.rs +++ b/crates/nvisy-engine/src/compiler/graph.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; -use crate::policies::retry::RetryPolicy; +use super::retry::RetryPolicy; /// A node in the pipeline graph, tagged by its role. /// diff --git a/crates/nvisy-engine/src/compiler/mod.rs b/crates/nvisy-engine/src/compiler/mod.rs index 9b21fc8..6352439 100644 --- a/crates/nvisy-engine/src/compiler/mod.rs +++ b/crates/nvisy-engine/src/compiler/mod.rs @@ -4,8 +4,11 @@ //! directed graph, and produces a topologically-sorted execution plan. pub mod graph; -pub mod parse; +mod parse; pub mod plan; +pub mod retry; +pub use graph::{Graph, GraphEdge, GraphNode}; pub use parse::parse_graph; pub use plan::{build_plan, ExecutionPlan, ResolvedNode}; +pub use retry::{BackoffStrategy, RetryPolicy}; diff --git a/crates/nvisy-engine/src/policies/retry.rs b/crates/nvisy-engine/src/compiler/retry.rs similarity index 100% rename from crates/nvisy-engine/src/policies/retry.rs rename to crates/nvisy-engine/src/compiler/retry.rs diff --git a/crates/nvisy-engine/src/connections/mod.rs b/crates/nvisy-engine/src/engine/connections.rs similarity index 100% rename from crates/nvisy-engine/src/connections/mod.rs rename to crates/nvisy-engine/src/engine/connections.rs diff --git a/crates/nvisy-engine/src/engine/default.rs b/crates/nvisy-engine/src/engine/default.rs new file mode 100644 index 0000000..fa224bb --- /dev/null +++ b/crates/nvisy-engine/src/engine/default.rs @@ -0,0 +1,153 @@ +//! Default engine implementation that orchestrates the full pipeline. +//! +//! [`DefaultEngine`] executes the three-phase pipeline: +//! +//! 1. **Detect** — run configured detection methods on the input content. +//! 2. **Evaluate** — map detected entities to redaction instructions via policies. +//! 3. **Redact** — apply redaction instructions to produce output content. +//! +//! After the content-level pipeline completes, the execution graph is run via +//! [`run_graph`] so that any Source/Action/Target DAG nodes are also executed. + +use jiff::Timestamp; +use uuid::Uuid; + +use nvisy_core::Error; +use nvisy_identify::{ + Audit, AuditAction, EvaluatePolicyAction, EvaluatePolicyParams, PolicyEvaluation, + RedactionSummary, +}; + +use super::{Engine, EngineInput, EngineOutput}; +use super::executor::run_graph; +use crate::compiler::build_plan; + +/// Default [`Engine`] implementation. +/// +/// Stateless — all configuration comes from the [`EngineInput`] provided at +/// call time. Suitable for embedding in long-lived application state. +#[derive(Debug, Clone, Copy)] +pub struct DefaultEngine; + +impl Engine for DefaultEngine { + async fn run(&self, input: EngineInput) -> Result { + let run_id = Uuid::new_v4(); + let mut audits: Vec = Vec::new(); + let content_source = input.source.content_source(); + + // ── Phase 1: Detection ────────────────────────────────────── + // + // Detection is handled externally (via DetectionService / NER / Pattern / + // CV layers) before the engine is called. The engine receives entities as + // part of a higher-level orchestration layer. For now, we create an empty + // detection output and let the execution graph handle detection actions. + let detection = nvisy_identify::DetectionOutput { + source: content_source, + entities: Vec::new(), + policy_id: input.policies.policies.first().map(|p| p.id), + duration_ms: None, + }; + + // ── Phase 2: Policy Evaluation ────────────────────────────── + // + // Evaluate each policy against the detected entities to produce + // redaction instructions, review holds, alerts, blocks, etc. + let mut all_redactions = Vec::new(); + let mut evaluations = Vec::new(); + + for policy in &input.policies.policies { + let params = EvaluatePolicyParams { + rules: policy.rules.clone(), + default_spec: policy.default_spec.clone(), + default_confidence_threshold: policy.default_confidence_threshold, + }; + + let action = EvaluatePolicyAction::connect(params).await?; + let redactions = action.execute(detection.entities.clone()).await?; + + // Emit audit entries for each redaction decision. + for r in &redactions { + audits.push(Audit { + source: content_source, + action: AuditAction::Redaction, + timestamp: Timestamp::now(), + entity_id: Some(r.entity_id), + redaction_id: Some(r.source.as_uuid()), + policy_id: Some(policy.id), + source_id: Some(content_source.as_uuid()), + run_id: Some(run_id), + actor: input.actor.clone(), + }); + } + + evaluations.push(PolicyEvaluation { + policy_id: policy.id, + redactions: redactions.clone(), + pending_review: Vec::new(), + suppressed: Vec::new(), + blocked: Vec::new(), + alerted: Vec::new(), + }); + + all_redactions.extend(redactions); + } + + // Use the first policy evaluation as the primary; merge if multiple. + let evaluation = if let Some(first) = evaluations.into_iter().next() { + first + } else { + PolicyEvaluation { + policy_id: Uuid::nil(), + redactions: Vec::new(), + pending_review: Vec::new(), + suppressed: Vec::new(), + blocked: Vec::new(), + alerted: Vec::new(), + } + }; + + // ── Phase 3: Redaction ────────────────────────────────────── + // + // The ApplyRedactionAction is called directly by callers that have + // parsed documents into the typed codec representation. At this level + // we track the summary counts. + let applied = all_redactions.iter().filter(|r| r.applied).count(); + let skipped = all_redactions.len() - applied; + + let summaries = vec![RedactionSummary { + source: content_source, + redactions_applied: applied, + redactions_skipped: skipped, + }]; + + // ── Phase 4: DAG Execution ────────────────────────────────── + // + // Compile the graph into a topologically-sorted execution plan and + // run Source/Action/Target nodes concurrently. + let plan = build_plan(&input.graph)?; + let run_output = run_graph(&plan, &input.connections).await?; + + // Emit a detection audit entry for the overall run. + audits.push(Audit { + source: content_source, + action: AuditAction::Detection, + timestamp: Timestamp::now(), + entity_id: None, + redaction_id: None, + policy_id: input.policies.policies.first().map(|p| p.id), + source_id: Some(content_source.as_uuid()), + run_id: Some(run_id), + actor: input.actor.clone(), + }); + + Ok(EngineOutput { + run_id, + output: input.source, + detection, + evaluation, + summaries, + audits, + run_output, + }) + } +} diff --git a/crates/nvisy-engine/src/engine/executor.rs b/crates/nvisy-engine/src/engine/executor.rs new file mode 100644 index 0000000..3c00b9b --- /dev/null +++ b/crates/nvisy-engine/src/engine/executor.rs @@ -0,0 +1,302 @@ +//! Graph runner that executes a compiled [`ExecutionPlan`]. +//! +//! Each node is spawned as a concurrent Tokio task. Data flows between nodes +//! via bounded MPSC channels, and upstream completion is signalled via watch +//! channels so downstream tasks wait before starting. +//! +//! [`execute_node`] dispatches to variant-specific handlers: +//! +//! | Variant | Behaviour | +//! |----------|--------------------------------------------------------| +//! | `Source` | Reads data from an external connection and sends it downstream. | +//! | `Action` | Receives upstream data, applies a transformation, and forwards results. | +//! | `Target` | Receives upstream data and writes it to an external connection. | + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinSet; +use uuid::Uuid; +use nvisy_core::io::ContentData; +use nvisy_core::{Error, ErrorKind}; +use crate::compiler::plan::ExecutionPlan; +use crate::compiler::graph::GraphNode; +use crate::compiler::retry::RetryPolicy; +use super::connections::{Connection, Connections}; +use super::policies::{with_retry, with_timeout}; + +/// Default buffer size for bounded inter-node MPSC channels. +const CHANNEL_BUFFER_SIZE: usize = 256; + +/// Outcome of executing a single node in the pipeline. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(schemars::JsonSchema)] +pub struct NodeOutput { + /// ID of the node that produced this result. + pub node_id: String, + /// Number of data items processed by this node. + pub items_processed: u64, + /// Error message if the node failed, or `None` on success. + pub error: Option, +} + +/// Aggregate outcome of executing an entire pipeline graph. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(schemars::JsonSchema)] +pub struct RunOutput { + /// Unique identifier for this execution run. + pub run_id: Uuid, + /// Per-node results in completion order. + pub node_results: Vec, + /// `true` if all nodes completed without error. + pub success: bool, +} + +/// Executes a compiled [`ExecutionPlan`] by spawning concurrent tasks for each node. +/// +/// Returns a [`RunOutput`] containing per-node outcomes and an overall success flag. +pub async fn run_graph( + plan: &ExecutionPlan, + connections: &Connections, +) -> Result { + let run_id = Uuid::new_v4(); + let connections = Arc::new(connections.clone()); + + // Create channels for each edge + let mut senders: HashMap>> = HashMap::new(); + let mut receivers: HashMap>> = HashMap::new(); + + for node in &plan.nodes { + let node_id = node.node.id(); + for downstream_id in &node.downstream_ids { + let (tx, rx) = mpsc::channel(CHANNEL_BUFFER_SIZE); + senders.entry(node_id.to_string()).or_default().push(tx); + receivers.entry(downstream_id.clone()).or_default().push(rx); + } + } + + // Create completion signals per node + let mut signal_senders: HashMap> = HashMap::new(); + let mut signal_receivers: HashMap> = HashMap::new(); + + for node in &plan.nodes { + let (tx, rx) = watch::channel(false); + signal_senders.insert(node.node.id().to_string(), tx); + signal_receivers.insert(node.node.id().to_string(), rx); + } + + // Spawn tasks + let mut join_set: JoinSet = JoinSet::new(); + + for resolved in &plan.nodes { + let node = resolved.node.clone(); + let node_id = node.id().to_string(); + let upstream_ids = resolved.upstream_ids.clone(); + + // Collect upstream watch receivers + let upstream_watches: Vec> = upstream_ids + .iter() + .filter_map(|id| signal_receivers.get(id).cloned()) + .collect(); + + let completion_tx = signal_senders.remove(&node_id); + let node_senders = senders.remove(&node_id).unwrap_or_default(); + let node_receivers = receivers.remove(&node_id).unwrap_or_default(); + let conns = Arc::clone(&connections); + + join_set.spawn(async move { + // Wait for upstream nodes to complete + for mut rx in upstream_watches { + let _ = rx.wait_for(|&done| done).await; + } + + let result = execute_node(&node, node_senders, node_receivers, &conns).await; + + // Signal completion + if let Some(tx) = completion_tx { + let _ = tx.send(true); + } + + match result { + Ok(count) => NodeOutput { + node_id, + items_processed: count, + error: None, + }, + Err(e) => NodeOutput { + node_id, + items_processed: 0, + error: Some(e.to_string()), + }, + } + }); + } + + // Collect results + let mut node_results = Vec::new(); + while let Some(result) = join_set.join_next().await { + match result { + Ok(nr) => node_results.push(nr), + Err(e) => node_results.push(NodeOutput { + node_id: "unknown".to_string(), + items_processed: 0, + error: Some(format!("Task panicked: {}", e)), + }), + } + } + + let success = node_results.iter().all(|r| r.error.is_none()); + + Ok(RunOutput { + run_id, + node_results, + success, + }) +} + +/// Execute a single node, dispatching to the correct handler based on the +/// [`GraphNode`] variant. +/// +/// A per-node timeout is applied when configured. Retry policies are applied +/// within the individual source/target handlers where the retryable I/O +/// actually occurs (channel consumption is not retryable). +async fn execute_node( + node: &GraphNode, + senders: Vec>, + mut receivers: Vec>, + connections: &Connections, +) -> Result { + let run = async { + match node { + GraphNode::Source { provider, stream, params, retry, .. } => { + execute_source(provider, stream, params, retry.as_ref(), &senders, connections).await + } + GraphNode::Action { action, params, .. } => { + execute_action(action, params, &senders, &mut receivers).await + } + GraphNode::Target { provider, stream, params, retry, .. } => { + execute_target(provider, stream, params, retry.as_ref(), &mut receivers, connections).await + } + } + }; + + // Apply per-node timeout when configured. + match node.timeout_ms() { + Some(ms) => with_timeout(ms, run).await, + None => run.await, + } +} + +/// Resolve a connection by provider name, returning an error if not found. +fn resolve_connection<'a>( + provider: &str, + connections: &'a Connections, +) -> Result<&'a Connection, Error> { + connections.get(provider).ok_or_else(|| { + Error::new( + ErrorKind::Validation, + format!("No connection configured for provider '{provider}'"), + ) + .with_component("executor") + }) +} + +/// Execute a `Source` node: read data from an external provider and send +/// items downstream. +/// +/// Resolves the named connection and applies the retry policy to the +/// provider read operation. Actual provider integration (S3, database, etc.) +/// is not yet implemented — source nodes currently produce no data. +async fn execute_source( + provider: &str, + stream: &str, + _params: &serde_json::Value, + retry: Option<&RetryPolicy>, + senders: &[mpsc::Sender], + connections: &Connections, +) -> Result { + let _conn = resolve_connection(provider, connections)?; + + let read_from_provider = || async { + tracing::debug!(provider, stream, "source node: reading from provider"); + + // TODO: Dispatch to provider-specific readers (S3, database, etc.) + // For now, source nodes produce no data. The Engine wrapper injects + // initial content into the graph via the first channel directly. + Ok::(0) + }; + + let count = match retry { + Some(policy) => with_retry(policy, read_from_provider).await?, + None => read_from_provider().await?, + }; + + // Send items downstream once we have them. + // (Currently a no-op since providers are not yet wired.) + let _ = senders; + + Ok(count) +} + +/// Execute an `Action` node: receive upstream data, apply a transformation, +/// and forward the result downstream. +/// +/// Concrete action dispatch (detect, classify, redact) is orchestrated by +/// [`DefaultEngine::run`] which drives detection → evaluation → redaction +/// as sequential phases. The channel-level passthrough here handles any +/// action nodes that appear in the DAG but whose logic is managed externally. +async fn execute_action( + action: &str, + _params: &serde_json::Value, + senders: &[mpsc::Sender], + receivers: &mut Vec>, +) -> Result { + tracing::debug!(action, "action node: processing"); + + // Forward items from all upstream receivers to all downstream senders. + let mut count = 0u64; + for rx in receivers.iter_mut() { + while let Some(item) = rx.recv().await { + count += 1; + for tx in senders { + let _ = tx.send(item.clone()).await; + } + } + } + + Ok(count) +} + +/// Execute a `Target` node: consume upstream data and write to an external +/// provider. +/// +/// Resolves the named connection and applies the retry policy to the +/// provider write operation. Actual provider integration is not yet +/// implemented — target nodes currently consume and count items. +async fn execute_target( + provider: &str, + stream: &str, + _params: &serde_json::Value, + retry: Option<&RetryPolicy>, + receivers: &mut Vec>, + connections: &Connections, +) -> Result { + let _conn = resolve_connection(provider, connections)?; + + tracing::debug!(provider, stream, "target node: writing to provider"); + + // Consume all upstream items. + let mut count = 0u64; + for rx in receivers.iter_mut() { + while let Some(_item) = rx.recv().await { + count += 1; + + // TODO: Dispatch to provider-specific writers (S3, database, etc.) + // with retry support. For now we just count items consumed. + } + } + + let _ = retry; // Will be used when provider writes are implemented. + + Ok(count) +} diff --git a/crates/nvisy-engine/src/engine/mod.rs b/crates/nvisy-engine/src/engine/mod.rs new file mode 100644 index 0000000..f0d59bd --- /dev/null +++ b/crates/nvisy-engine/src/engine/mod.rs @@ -0,0 +1,83 @@ +//! Top-level engine contract, I/O types, and the [`DefaultEngine`] implementation. +//! +//! The [`Engine`] trait defines the high-level redaction pipeline contract: +//! given a content handler, policies, and an execution graph, produce redacted +//! output together with a full audit trail and per-phase breakdown. +//! +//! [`DefaultEngine`] is the standard implementation that orchestrates the +//! detect → evaluate → redact pipeline and drives the DAG execution graph. + +pub mod connections; +mod default; +pub mod executor; +pub mod ontology; +pub mod policies; +pub mod runs; + +pub use connections::{Connection, Connections}; +pub use default::DefaultEngine; +pub use executor::{run_graph, NodeOutput, RunOutput}; +pub use runs::{RunManager, RunState, RunStatus, RunSummary}; + +use std::future::Future; + +use uuid::Uuid; + +use nvisy_core::Error; +use nvisy_core::fs::ContentHandler; +use nvisy_ontology::entity::DetectionOutput; +// Re-exported so downstream crates (e.g. nvisy-server) don't need a direct +// dependency on nvisy-identify. +pub use nvisy_identify::{ + Audit, Policies, PolicyEvaluation, RedactionSummary, +}; + +use crate::compiler::graph::Graph; + +/// Everything the caller must provide to run a redaction pipeline. +pub struct EngineInput { + /// Handle to the managed directory containing the files to process. + pub source: ContentHandler, + /// Policies to apply (at least one). + pub policies: Policies, + /// Execution graph defining the pipeline DAG. + pub graph: Graph, + /// External service connections for source/target nodes. + pub connections: Connections, + /// Human or service account identity. + pub actor: Option, +} + +/// Full result of a pipeline run. +/// +/// Contains a content handler for the redacted output, per-phase breakdown +/// (detection, classification, policy evaluation), per-source summaries, +/// audit records, and the raw DAG execution result. +pub struct EngineOutput { + /// Unique run identifier. + pub run_id: Uuid, + /// Handle to the managed directory containing redacted output files. + pub output: ContentHandler, + /// Full detection result (entities, sensitivity, risk). + pub detection: DetectionOutput, + /// Policy evaluation breakdown (redactions, reviews, suppressions, blocks, alerts). + pub evaluation: PolicyEvaluation, + /// Per-source redaction summaries. + pub summaries: Vec, + /// Immutable audit trail. + pub audits: Vec, + /// Per-node execution results from the DAG runner. + pub run_output: RunOutput, +} + +/// The top-level redaction engine contract. +/// +/// Takes a content handler, policies, and an execution graph; returns redacted +/// output, audit records, and a full breakdown of every pipeline phase. +pub trait Engine: Send + Sync { + /// Execute a full redaction pipeline. + fn run( + &self, + input: EngineInput, + ) -> impl Future> + Send; +} diff --git a/crates/nvisy-engine/src/ontology/audit.rs b/crates/nvisy-engine/src/engine/ontology.rs similarity index 100% rename from crates/nvisy-engine/src/ontology/audit.rs rename to crates/nvisy-engine/src/engine/ontology.rs diff --git a/crates/nvisy-engine/src/policies/mod.rs b/crates/nvisy-engine/src/engine/policies.rs similarity index 97% rename from crates/nvisy-engine/src/policies/mod.rs rename to crates/nvisy-engine/src/engine/policies.rs index 6b3a475..b991b00 100644 --- a/crates/nvisy-engine/src/policies/mod.rs +++ b/crates/nvisy-engine/src/engine/policies.rs @@ -7,9 +7,8 @@ use std::time::Duration; use tokio::time; use nvisy_core::Error; -pub mod retry; -use crate::policies::retry::{BackoffStrategy, RetryPolicy}; +use crate::compiler::retry::{BackoffStrategy, RetryPolicy}; /// Computes the sleep duration before a retry attempt based on the policy's /// [`BackoffStrategy`] and the zero-based attempt number. diff --git a/crates/nvisy-engine/src/runs/mod.rs b/crates/nvisy-engine/src/engine/runs.rs similarity index 99% rename from crates/nvisy-engine/src/runs/mod.rs rename to crates/nvisy-engine/src/engine/runs.rs index cb9f215..956a85f 100644 --- a/crates/nvisy-engine/src/runs/mod.rs +++ b/crates/nvisy-engine/src/engine/runs.rs @@ -10,7 +10,7 @@ use jiff::Timestamp; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::executor::runner::RunOutput; +use super::executor::RunOutput; /// Lifecycle status of a pipeline run. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/nvisy-engine/src/executor/context.rs b/crates/nvisy-engine/src/executor/context.rs deleted file mode 100644 index 58c5c7d..0000000 --- a/crates/nvisy-engine/src/executor/context.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Channel primitives used to wire data flow between pipeline nodes. -//! -//! [`EdgeChannel`] carries [`ContentData`] items along a graph edge, while -//! [`NodeSignal`] broadcasts node completion. - -use tokio::sync::{mpsc, watch}; -use nvisy_core::io::ContentData; - -/// Default buffer size for bounded inter-node MPSC channels. -pub const CHANNEL_BUFFER_SIZE: usize = 256; - -/// A bounded MPSC channel pair used to transfer [`ContentData`] items along a -/// single graph edge from an upstream node to a downstream node. -pub struct EdgeChannel { - /// Sending half, held by the upstream node. - pub sender: mpsc::Sender, - /// Receiving half, held by the downstream node. - pub receiver: mpsc::Receiver, -} - -impl Default for EdgeChannel { - fn default() -> Self { - Self::new() - } -} - -impl EdgeChannel { - /// Creates a new edge channel with [`CHANNEL_BUFFER_SIZE`] capacity. - pub fn new() -> Self { - let (sender, receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE); - Self { sender, receiver } - } -} - -/// A watch channel pair used to signal that a node has completed execution. -/// -/// The sender broadcasts `true` when the node finishes, and downstream nodes -/// wait on the receiver before starting. -pub struct NodeSignal { - /// Sending half; set to `true` when the node completes. - pub sender: watch::Sender, - /// Receiving half; downstream tasks call `wait_for(|&done| done)`. - pub receiver: watch::Receiver, -} - -impl Default for NodeSignal { - fn default() -> Self { - Self::new() - } -} - -impl NodeSignal { - /// Creates a new node signal initialized to `false` (not completed). - pub fn new() -> Self { - let (sender, receiver) = watch::channel(false); - Self { sender, receiver } - } -} diff --git a/crates/nvisy-engine/src/executor/mod.rs b/crates/nvisy-engine/src/executor/mod.rs deleted file mode 100644 index 7669b01..0000000 --- a/crates/nvisy-engine/src/executor/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Pipeline execution runtime. -//! -//! Spawns concurrent Tokio tasks for each node in topological order, -//! wires inter-node channels, and collects per-node results. - -pub mod context; -pub mod runner; - -pub use runner::run_graph; diff --git a/crates/nvisy-engine/src/executor/runner.rs b/crates/nvisy-engine/src/executor/runner.rs deleted file mode 100644 index 95df473..0000000 --- a/crates/nvisy-engine/src/executor/runner.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Graph runner that executes a compiled [`ExecutionPlan`]. -//! -//! Each node is spawned as a concurrent Tokio task. Data flows between nodes -//! via bounded MPSC channels, and upstream completion is signalled via watch -//! channels so downstream tasks wait before starting. - -use std::collections::HashMap; -use tokio::sync::{mpsc, watch}; -use tokio::task::JoinSet; -use uuid::Uuid; -use nvisy_core::io::ContentData; -use nvisy_core::Error; -use crate::compiler::plan::ExecutionPlan; -use crate::connections::Connections; -use crate::executor::context::CHANNEL_BUFFER_SIZE; -use crate::compiler::graph::GraphNode; - -/// Outcome of executing a single node in the pipeline. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] -pub struct NodeOutput { - /// ID of the node that produced this result. - pub node_id: String, - /// Number of data items processed by this node. - pub items_processed: u64, - /// Error message if the node failed, or `None` on success. - pub error: Option, -} - -/// Aggregate outcome of executing an entire pipeline graph. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] -pub struct RunOutput { - /// Unique identifier for this execution run. - pub run_id: Uuid, - /// Per-node results in completion order. - pub node_results: Vec, - /// `true` if all nodes completed without error. - pub success: bool, -} - -/// Executes a compiled [`ExecutionPlan`] by spawning concurrent tasks for each node. -/// -/// Returns a [`RunOutput`] containing per-node outcomes and an overall success flag. -pub async fn run_graph( - plan: &ExecutionPlan, - _connections: &Connections, -) -> Result { - let run_id = Uuid::new_v4(); - - // Create channels for each edge - let mut senders: HashMap>> = HashMap::new(); - let mut receivers: HashMap>> = HashMap::new(); - - for node in &plan.nodes { - let node_id = node.node.id(); - for downstream_id in &node.downstream_ids { - let (tx, rx) = mpsc::channel(CHANNEL_BUFFER_SIZE); - senders.entry(node_id.to_string()).or_default().push(tx); - receivers.entry(downstream_id.clone()).or_default().push(rx); - } - } - - // Create completion signals per node - let mut signal_senders: HashMap> = HashMap::new(); - let mut signal_receivers: HashMap> = HashMap::new(); - - for node in &plan.nodes { - let (tx, rx) = watch::channel(false); - signal_senders.insert(node.node.id().to_string(), tx); - signal_receivers.insert(node.node.id().to_string(), rx); - } - - // Spawn tasks - let mut join_set: JoinSet = JoinSet::new(); - - for resolved in &plan.nodes { - let node = resolved.node.clone(); - let node_id = node.id().to_string(); - let upstream_ids = resolved.upstream_ids.clone(); - - // Collect upstream watch receivers - let upstream_watches: Vec> = upstream_ids - .iter() - .filter_map(|id| signal_receivers.get(id).cloned()) - .collect(); - - let completion_tx = signal_senders.remove(&node_id); - let node_senders = senders.remove(&node_id).unwrap_or_default(); - let node_receivers = receivers.remove(&node_id).unwrap_or_default(); - - join_set.spawn(async move { - // Wait for upstream nodes to complete - for mut rx in upstream_watches { - let _ = rx.wait_for(|&done| done).await; - } - - let result = execute_node(&node, node_senders, node_receivers).await; - - // Signal completion - if let Some(tx) = completion_tx { - let _ = tx.send(true); - } - - match result { - Ok(count) => NodeOutput { - node_id, - items_processed: count, - error: None, - }, - Err(e) => NodeOutput { - node_id, - items_processed: 0, - error: Some(e.to_string()), - }, - } - }); - } - - // Collect results - let mut node_results = Vec::new(); - while let Some(result) = join_set.join_next().await { - match result { - Ok(nr) => node_results.push(nr), - Err(e) => node_results.push(NodeOutput { - node_id: "unknown".to_string(), - items_processed: 0, - error: Some(format!("Task panicked: {}", e)), - }), - } - } - - let success = node_results.iter().all(|r| r.error.is_none()); - - Ok(RunOutput { - run_id, - node_results, - success, - }) -} - -/// Execute a single node with its channels (simplified -- does not use registry directly). -async fn execute_node( - _node: &GraphNode, - senders: Vec>, - mut receivers: Vec>, -) -> Result { - // For now, forward items from receivers to senders (passthrough behavior). - // The actual registry-based dispatch happens via the Engine wrapper. - let mut count = 0u64; - - for rx in &mut receivers { - while let Some(item) = rx.recv().await { - count += 1; - for tx in &senders { - let _ = tx.send(item.clone()).await; - } - } - } - - // Drop senders to signal downstream completion - drop(senders); - - Ok(count) -} diff --git a/crates/nvisy-engine/src/lib.rs b/crates/nvisy-engine/src/lib.rs index 57373fa..1a3ab55 100644 --- a/crates/nvisy-engine/src/lib.rs +++ b/crates/nvisy-engine/src/lib.rs @@ -5,14 +5,10 @@ mod apply; pub mod compiler; -pub mod connections; pub mod engine; -pub mod executor; -pub mod ontology; -pub mod policies; -pub mod runs; pub use apply::{ApplyRedactionAction, ApplyRedactionInput, ApplyRedactionOutput}; +pub use engine::DefaultEngine; #[doc(hidden)] pub mod prelude; diff --git a/crates/nvisy-engine/src/ontology/mod.rs b/crates/nvisy-engine/src/ontology/mod.rs deleted file mode 100644 index 7d0757a..0000000 --- a/crates/nvisy-engine/src/ontology/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Engine-level domain types. - -pub mod audit; diff --git a/crates/nvisy-engine/src/prelude.rs b/crates/nvisy-engine/src/prelude.rs index 7433db9..f716748 100644 --- a/crates/nvisy-engine/src/prelude.rs +++ b/crates/nvisy-engine/src/prelude.rs @@ -1,6 +1,6 @@ //! Convenience re-exports. -pub use crate::compiler::graph::{Graph, GraphEdge, GraphNode}; -pub use crate::compiler::plan::{build_plan, ExecutionPlan, ResolvedNode}; -pub use crate::engine::{Engine, EngineInput, EngineOutput}; -pub use crate::executor::runner::{run_graph, RunOutput}; -pub use crate::runs::{RunManager, RunState, RunStatus, RunSummary}; +pub use crate::compiler::{Graph, GraphEdge, GraphNode}; +pub use crate::compiler::{build_plan, ExecutionPlan, ResolvedNode}; +pub use crate::engine::{DefaultEngine, Engine, EngineInput, EngineOutput}; +pub use crate::engine::{run_graph, RunOutput}; +pub use crate::engine::{RunManager, RunState, RunStatus, RunSummary}; diff --git a/crates/nvisy-server/src/handler/request/execute.rs b/crates/nvisy-server/src/handler/request/execute.rs index d2c4a11..de1f6ba 100644 --- a/crates/nvisy-server/src/handler/request/execute.rs +++ b/crates/nvisy-server/src/handler/request/execute.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; -use nvisy_engine::compiler::graph::Graph; -use nvisy_engine::connections::Connection; +use nvisy_engine::compiler::Graph; +use nvisy_engine::engine::Connection; use schemars::JsonSchema; use serde::Deserialize; diff --git a/crates/nvisy-server/src/handler/request/redact.rs b/crates/nvisy-server/src/handler/request/redact.rs index 10bf72d..8f0aab5 100644 --- a/crates/nvisy-server/src/handler/request/redact.rs +++ b/crates/nvisy-server/src/handler/request/redact.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; -use nvisy_engine::compiler::graph::Graph; -use nvisy_engine::connections::Connection; +use nvisy_engine::compiler::Graph; +use nvisy_engine::engine::Connection; use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; diff --git a/crates/nvisy-server/src/handler/response/execute.rs b/crates/nvisy-server/src/handler/response/execute.rs index 6244a62..d95e476 100644 --- a/crates/nvisy-server/src/handler/response/execute.rs +++ b/crates/nvisy-server/src/handler/response/execute.rs @@ -1,7 +1,6 @@ //! Execute response types. -use nvisy_engine::engine::{EngineOutput, RedactionSummary}; -use nvisy_engine::executor::runner::RunOutput; +use nvisy_engine::engine::{EngineOutput, RedactionSummary, RunOutput}; use schemars::JsonSchema; use serde::Serialize; use uuid::Uuid; diff --git a/crates/nvisy-server/src/service/engine.rs b/crates/nvisy-server/src/service/engine.rs deleted file mode 100644 index 44bcad5..0000000 --- a/crates/nvisy-server/src/service/engine.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Placeholder engine for development and testing. -//! -//! [`StubEngine`] implements [`Engine`] but rejects all requests. It is wired -//! into [`ServiceState`](super::ServiceState) at startup until a real -//! implementation is configured. - -use nvisy_core::{Error, ErrorKind}; -use nvisy_engine::engine::{Engine, EngineInput, EngineOutput}; - -/// Placeholder engine that rejects all requests. -pub struct StubEngine; - -impl Engine for StubEngine { - async fn run(&self, _input: EngineInput) -> Result { - Err(Error::new(ErrorKind::Runtime, "no engine configured")) - } -} diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index faf8d72..fd4f856 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -4,38 +4,29 @@ //! threaded through every handler via Axum's `State` extractor. Fields are //! private; use the provided accessor methods. -mod engine; - -use std::sync::Arc; - use nvisy_core::fs::ContentRegistry; - -pub use engine::StubEngine; +use nvisy_engine::engine::DefaultEngine; /// Shared application state threaded through all handlers. -/// -/// The engine is stored behind [`Arc`] with a manual [`Clone`] impl because -/// [`Engine`] uses RPITIT and is not dyn-compatible. #[must_use = "state does nothing unless you use it"] +#[derive(Clone)] pub struct ServiceState { - engine: Arc, + default_engine: DefaultEngine, content_registry: ContentRegistry, } impl ServiceState { /// Creates a new service state with the given content registry. - /// - /// Wires in the [`StubEngine`] until a real implementation is configured. pub fn new(content_registry: ContentRegistry) -> Self { Self { - engine: Arc::new(StubEngine), + default_engine: DefaultEngine, content_registry, } } /// Returns a reference to the pipeline engine. - pub fn engine(&self) -> &StubEngine { - &self.engine + pub fn engine(&self) -> &DefaultEngine { + &self.default_engine } /// Returns a reference to the content registry. @@ -44,15 +35,6 @@ impl ServiceState { } } -impl Clone for ServiceState { - fn clone(&self) -> Self { - Self { - engine: Arc::clone(&self.engine), - content_registry: self.content_registry.clone(), - } - } -} - macro_rules! impl_di { ($($f:ident: $t:ty),+ $(,)?) => {$( impl axum::extract::FromRef for $t { @@ -64,5 +46,6 @@ macro_rules! impl_di { } impl_di!( + default_engine: DefaultEngine, content_registry: ContentRegistry, ); From 8561d6e5d228f0a67e99bb7a28984695585b388b Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 04:52:37 +0100 Subject: [PATCH 05/22] feat(engine): implement audio redaction, remove old engine.rs Implement apply_audio_doc with time-range silence/remove via WavHandler, add audio_output_from_spec mapping, and delete the superseded single-file engine.rs (now engine/ directory). Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-engine/src/apply/audio.rs | 96 ++++++++++++++++++++++++-- crates/nvisy-engine/src/apply/mod.rs | 11 ++- crates/nvisy-engine/src/engine.rs | 70 ------------------- 3 files changed, 99 insertions(+), 78 deletions(-) delete mode 100644 crates/nvisy-engine/src/engine.rs diff --git a/crates/nvisy-engine/src/apply/audio.rs b/crates/nvisy-engine/src/apply/audio.rs index 9fff86f..2889e2c 100644 --- a/crates/nvisy-engine/src/apply/audio.rs +++ b/crates/nvisy-engine/src/apply/audio.rs @@ -1,19 +1,101 @@ -//! Audio document redaction (stub). +//! Audio document redaction. +//! +//! Maps [`AudioRedactionInput`] variants to codec [`AudioRedactionOutput`] +//! values and applies them to audio documents via time-range redaction. use std::collections::HashMap; use uuid::Uuid; -use nvisy_codec::handler::WavHandler; use nvisy_codec::document::Document; +use nvisy_codec::handler::WavHandler; +use nvisy_codec::transform::{AudioHandler, AudioRedaction, AudioRedactionOutput}; +use nvisy_core::Error; use nvisy_ontology::entity::Entity; +use nvisy_ontology::location::Location; use nvisy_ontology::record::Redaction; -use nvisy_core::Error; +use nvisy_ontology::specification::{AudioRedactionInput, RedactionInput}; + +/// Convert a [`RedactionInput::Audio`] into a codec [`AudioRedactionOutput`]. +pub(crate) fn audio_output_from_spec(spec: &RedactionInput) -> Option { + match spec { + RedactionInput::Audio(audio) => Some(match audio { + AudioRedactionInput::Silence => AudioRedactionOutput::Silence, + AudioRedactionInput::Remove => AudioRedactionOutput::Remove, + AudioRedactionInput::Synthesize => { + // Synthesize is not yet supported; fall back to silence. + AudioRedactionOutput::Silence + } + }), + _ => None, + } +} pub(crate) async fn apply_audio_doc( doc: &Document, - _entity_map: &HashMap, - _redaction_map: &HashMap, + entity_map: &HashMap, + redaction_map: &HashMap, ) -> Result, Error> { - tracing::warn!("audio redaction not yet implemented"); - Ok(doc.clone()) + let mut redactions: Vec = Vec::new(); + + for (&entity_id, redaction) in redaction_map { + let entity = match entity_map.get(&entity_id) { + Some(e) => e, + None => continue, + }; + + let audio_loc = match &entity.location { + Some(Location::Audio(loc)) => loc, + _ => continue, + }; + + let output = match audio_output_from_spec(&redaction.spec) { + Some(o) => o, + None => continue, + }; + + redactions.push(AudioRedaction { + start_secs: audio_loc.time_span.start_secs, + end_secs: audio_loc.time_span.end_secs, + output, + }); + } + + if redactions.is_empty() { + return Ok(doc.clone()); + } + + let mut result = doc.clone(); + result.handler_mut().redact_spans(&redactions).await?; + result.source.set_parent_id(Some(doc.source.as_uuid())); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use nvisy_ontology::specification::TextRedactionInput; + + #[test] + fn audio_output_silence() { + let spec = RedactionInput::Audio(AudioRedactionInput::Silence); + assert_eq!(audio_output_from_spec(&spec), Some(AudioRedactionOutput::Silence)); + } + + #[test] + fn audio_output_remove() { + let spec = RedactionInput::Audio(AudioRedactionInput::Remove); + assert_eq!(audio_output_from_spec(&spec), Some(AudioRedactionOutput::Remove)); + } + + #[test] + fn audio_output_synthesize_falls_back_to_silence() { + let spec = RedactionInput::Audio(AudioRedactionInput::Synthesize); + assert_eq!(audio_output_from_spec(&spec), Some(AudioRedactionOutput::Silence)); + } + + #[test] + fn audio_output_text_spec_returns_none() { + let spec = RedactionInput::Text(TextRedactionInput::Remove); + assert_eq!(audio_output_from_spec(&spec), None); + } } diff --git a/crates/nvisy-engine/src/apply/mod.rs b/crates/nvisy-engine/src/apply/mod.rs index daa679f..6c3b654 100644 --- a/crates/nvisy-engine/src/apply/mod.rs +++ b/crates/nvisy-engine/src/apply/mod.rs @@ -1,4 +1,13 @@ -//! Unified redaction action -- applies text, image, tabular, and audio redactions. +//! Unified redaction action — applies text, image, audio, and tabular redactions. +//! +//! [`ApplyRedactionAction`] dispatches per-document based on content type: +//! +//! | Modality | Handler | Strategy | +//! |----------|----------------|-------------------------------------| +//! | Text | [`TxtHandler`] | Byte-offset span replacement | +//! | Image | [`PngHandler`] | Bounding-box blur/block/pixelate | +//! | Audio | [`WavHandler`] | Time-range silence/remove | +//! | Tabular | [`CsvHandler`] | Cell-level mask/remove/hash | mod text; mod tabular; diff --git a/crates/nvisy-engine/src/engine.rs b/crates/nvisy-engine/src/engine.rs deleted file mode 100644 index 12d6238..0000000 --- a/crates/nvisy-engine/src/engine.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Top-level engine contract and I/O types. -//! -//! The [`Engine`] trait defines the high-level redaction pipeline contract: -//! given a content handler, policies, and an execution graph, produce redacted -//! output together with a full audit trail and per-phase breakdown. - -use std::future::Future; - -use uuid::Uuid; - -use nvisy_core::Error; -use nvisy_core::fs::ContentHandler; -use nvisy_ontology::entity::DetectionOutput; -// Re-exported so downstream crates (e.g. nvisy-server) don't need a direct -// dependency on nvisy-identify. -pub use nvisy_identify::{ - Audit, Policies, PolicyEvaluation, RedactionSummary, -}; - -use crate::compiler::graph::Graph; -use crate::connections::Connections; -use crate::executor::runner::RunOutput; - -/// Everything the caller must provide to run a redaction pipeline. -pub struct EngineInput { - /// Handle to the managed directory containing the files to process. - pub source: ContentHandler, - /// Policies to apply (at least one). - pub policies: Policies, - /// Execution graph defining the pipeline DAG. - pub graph: Graph, - /// External service connections for source/target nodes. - pub connections: Connections, - /// Human or service account identity. - pub actor: Option, -} - -/// Full result of a pipeline run. -/// -/// Contains a content handler for the redacted output, per-phase breakdown -/// (detection, classification, policy evaluation), per-source summaries, -/// audit records, and the raw DAG execution result. -pub struct EngineOutput { - /// Unique run identifier. - pub run_id: Uuid, - /// Handle to the managed directory containing redacted output files. - pub output: ContentHandler, - /// Full detection result (entities, sensitivity, risk). - pub detection: DetectionOutput, - /// Policy evaluation breakdown (redactions, reviews, suppressions, blocks, alerts). - pub evaluation: PolicyEvaluation, - /// Per-source redaction summaries. - pub summaries: Vec, - /// Immutable audit trail. - pub audits: Vec, - /// Per-node execution results from the DAG runner. - pub run_output: RunOutput, -} - -/// The top-level redaction engine contract. -/// -/// Takes a content handler, policies, and an execution graph; returns redacted -/// output, audit records, and a full breakdown of every pipeline phase. -pub trait Engine: Send + Sync { - /// Execute a full redaction pipeline. - fn run( - &self, - input: EngineInput, - ) -> impl Future> + Send; -} From 3e61e649d04800fba68fb342f725e13d9743a694 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 19:00:26 +0100 Subject: [PATCH 06/22] feat(server): custom Json/Path extractors, unified error module Add newtype Json and Path extractors in extract/ that convert axum rejections into our ErrorResponse JSON shape. Migrate Version extractor rejection from (StatusCode, String) to Error<'static>. Move error types into handler/error/ module, replace ServerError with Error/ErrorKind/Result, and simplify handler return types. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-server/src/extract/json.rs | 63 ++ crates/nvisy-server/src/extract/mod.rs | 6 + crates/nvisy-server/src/extract/multipart.rs | 80 +++ crates/nvisy-server/src/extract/path.rs | 49 ++ crates/nvisy-server/src/extract/version.rs | 21 +- crates/nvisy-server/src/handler/check.rs | 13 +- .../src/handler/error/http_error.rs | 537 ++++++++++++++++++ crates/nvisy-server/src/handler/error/mod.rs | 9 + crates/nvisy-server/src/handler/execute.rs | 12 +- crates/nvisy-server/src/handler/ingest.rs | 81 +-- crates/nvisy-server/src/handler/mod.rs | 7 +- crates/nvisy-server/src/handler/redact.rs | 23 +- .../src/handler/response/error.rs | 229 +++++--- .../nvisy-server/src/handler/response/mod.rs | 7 +- .../nvisy-server/src/middleware/recovery.rs | 18 +- .../nvisy-server/src/middleware/security.rs | 6 +- 16 files changed, 964 insertions(+), 197 deletions(-) create mode 100644 crates/nvisy-server/src/extract/json.rs create mode 100644 crates/nvisy-server/src/extract/multipart.rs create mode 100644 crates/nvisy-server/src/extract/path.rs create mode 100644 crates/nvisy-server/src/handler/error/http_error.rs create mode 100644 crates/nvisy-server/src/handler/error/mod.rs diff --git a/crates/nvisy-server/src/extract/json.rs b/crates/nvisy-server/src/extract/json.rs new file mode 100644 index 0000000..e4b370b --- /dev/null +++ b/crates/nvisy-server/src/extract/json.rs @@ -0,0 +1,63 @@ +//! Custom `Json` extractor that converts rejections into [`Error`]. +//! +//! Wraps [`axum::Json`] so that malformed JSON bodies produce our +//! standard [`ErrorResponse`](crate::handler::response::ErrorResponse) +//! instead of axum's default plain-text rejection. + +use aide::OperationInput; +use axum::extract::rejection::JsonRejection; +use axum::extract::{FromRequest, Request}; +use axum::response::{IntoResponse, Response}; + +use crate::handler::error::{Error, ErrorKind}; + +/// A JSON extractor that rejects with [`Error`] instead of axum's +/// default [`JsonRejection`]. +/// +/// On the **request** side it deserialises `T` from the body, mapping +/// any rejection to [`ErrorKind::BadRequest`]. +/// +/// On the **response** side it delegates to [`axum::Json`], so +/// handlers can use a single `Json` type for both input and output. +pub struct Json(pub T); + +impl FromRequest for Json +where + S: Send + Sync, + axum::Json: FromRequest, +{ + type Rejection = Error<'static>; + + async fn from_request(req: Request, state: &S) -> Result { + axum::Json::::from_request(req, state) + .await + .map(|axum::Json(v)| Self(v)) + .map_err(|rejection| ErrorKind::BadRequest.with_message(rejection.body_text())) + } +} + +impl IntoResponse for Json { + fn into_response(self) -> Response { + axum::Json(self.0).into_response() + } +} + +impl OperationInput for Json { + fn operation_input( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) { + axum::Json::::operation_input(ctx, operation); + } +} + +impl aide::OperationOutput for Json { + type Inner = T; + + fn operation_response( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) -> Option { + axum::Json::::operation_response(ctx, operation) + } +} diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index 8fbdc5d..4a4646b 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -1,5 +1,11 @@ //! Custom extractors for axum handlers. +mod json; +mod multipart; +mod path; mod version; +pub use json::Json; +pub use multipart::Upload; +pub use path::Path; pub use version::Version; diff --git a/crates/nvisy-server/src/extract/multipart.rs b/crates/nvisy-server/src/extract/multipart.rs new file mode 100644 index 0000000..f087bd1 --- /dev/null +++ b/crates/nvisy-server/src/extract/multipart.rs @@ -0,0 +1,80 @@ +//! Multipart upload extractor. +//! +//! Provides the [`Upload`] struct as an axum `FromRequest` extractor that +//! consumes a `multipart/form-data` body and yields the uploaded file bytes, +//! optional filename, and optional content type. + +use aide::OperationInput; +use axum::extract::{FromRequest, Multipart, Request}; + +use crate::handler::error::{Error, ErrorKind}; + +/// Parsed multipart upload payload. +/// +/// Extracted from a `multipart/form-data` request containing a `file` field +/// and an optional `content_type` text field. +pub struct Upload { + pub bytes: Vec, + pub filename: Option, + pub content_type: Option, +} + +impl FromRequest for Upload { + type Rejection = Error<'static>; + + async fn from_request(req: Request, state: &S) -> Result { + let mut multipart = Multipart::from_request(req, state) + .await + .map_err(|e| ErrorKind::BadRequest.with_message(format!("multipart error: {e}")))?; + + let mut file_bytes: Option> = None; + let mut filename: Option = None; + let mut content_type: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ErrorKind::BadRequest.with_message(format!("multipart error: {e}")))? + { + let field_name = field.name().unwrap_or_default().to_string(); + match field_name.as_str() { + "file" => { + filename = field.file_name().map(String::from); + content_type = field.content_type().map(String::from); + file_bytes = Some( + field + .bytes() + .await + .map_err(|e| { + ErrorKind::BadRequest + .with_message(format!("failed to read file field: {e}")) + })? + .to_vec(), + ); + } + "content_type" => { + let value = field.text().await.map_err(|e| { + ErrorKind::BadRequest + .with_message(format!("failed to read content_type field: {e}")) + })?; + content_type = Some(value); + } + _ => { + tracing::debug!(field = field_name, "ignoring unknown multipart field"); + } + } + } + + let bytes = file_bytes.ok_or_else(|| { + ErrorKind::BadRequest.with_message("missing required 'file' field") + })?; + + Ok(Self { + bytes, + filename, + content_type, + }) + } +} + +impl OperationInput for Upload {} diff --git a/crates/nvisy-server/src/extract/path.rs b/crates/nvisy-server/src/extract/path.rs new file mode 100644 index 0000000..5114016 --- /dev/null +++ b/crates/nvisy-server/src/extract/path.rs @@ -0,0 +1,49 @@ +//! Custom `Path` extractor that converts rejections into [`Error`]. +//! +//! Wraps [`axum::extract::Path`] so that invalid path parameters +//! (e.g. a malformed UUID) produce our standard +//! [`ErrorResponse`](crate::handler::response::ErrorResponse) +//! instead of axum's default plain-text rejection. + +use aide::OperationInput; +use axum::extract::rejection::PathRejection; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; + +use crate::handler::error::{Error, ErrorKind}; + +/// A path extractor that rejects with [`Error`] instead of axum's +/// default [`PathRejection`]. +/// +/// Delegates to [`axum::extract::Path`], mapping any rejection to +/// [`ErrorKind::MissingPathParam`]. +pub struct Path(pub T); + +impl FromRequestParts for Path +where + S: Send + Sync, + axum::extract::Path: FromRequestParts, +{ + type Rejection = Error<'static>; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> Result { + axum::extract::Path::::from_request_parts(parts, state) + .await + .map(|axum::extract::Path(v)| Self(v)) + .map_err(|rejection| { + ErrorKind::MissingPathParam.with_message(rejection.body_text()) + }) + } +} + +impl OperationInput for Path { + fn operation_input( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) { + axum::extract::Path::::operation_input(ctx, operation); + } +} diff --git a/crates/nvisy-server/src/extract/version.rs b/crates/nvisy-server/src/extract/version.rs index 1f87034..b0e4345 100644 --- a/crates/nvisy-server/src/extract/version.rs +++ b/crates/nvisy-server/src/extract/version.rs @@ -9,9 +9,10 @@ use std::str::FromStr; use aide::OperationInput; use axum::extract::FromRequestParts; -use axum::http::StatusCode; use axum::http::request::Parts; +use crate::handler::error::{Error, ErrorKind}; + /// API version extracted from the `Accept-Version` header. /// /// The version follows a simplified semver format: `major.minor.patch`. @@ -92,22 +93,18 @@ impl FromStr for Version { } impl FromRequestParts for Version { - type Rejection = (StatusCode, String); + type Rejection = Error<'static>; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { match parts.headers.get(Self::HEADER) { Some(value) => { let s = value.to_str().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - format!("invalid `{}` header value", Self::HEADER), - ) + ErrorKind::BadRequest + .with_message(format!("invalid `{}` header value", Self::HEADER)) })?; s.parse::().map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("invalid `{}` header: {e}", Self::HEADER), - ) + ErrorKind::BadRequest + .with_message(format!("invalid `{}` header: {e}", Self::HEADER)) }) } None => Ok(Self::LATEST), @@ -211,7 +208,7 @@ mod tests { .0; let result = Version::from_request_parts(&mut parts, &()).await; assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::BAD_REQUEST); + let err = result.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::BadRequest); } } diff --git a/crates/nvisy-server/src/handler/check.rs b/crates/nvisy-server/src/handler/check.rs index ac38993..b0dd47d 100644 --- a/crates/nvisy-server/src/handler/check.rs +++ b/crates/nvisy-server/src/handler/check.rs @@ -11,10 +11,10 @@ use aide::axum::ApiRouter; use aide::axum::routing::get_with; use aide::transform::TransformOperation; use axum::extract::State; -use axum::Json; -use nvisy_core::{Error, ErrorKind}; -use super::response::{Analytics, Health, ServerError}; +use super::error::{ErrorKind, Result}; +use super::response::{Analytics, Health}; +use crate::extract::Json; use crate::service::ServiceState; /// `GET /health`: liveness probe. @@ -33,11 +33,8 @@ fn health_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all)] async fn analytics( State(_state): State, -) -> Result, ServerError> { - Err(ServerError::from(Error::new( - ErrorKind::Runtime, - "analytics endpoint not yet implemented", - ))) +) -> Result> { + Err(ErrorKind::NotImplemented.with_message("analytics endpoint not yet implemented")) } fn analytics_docs(op: TransformOperation) -> TransformOperation { diff --git a/crates/nvisy-server/src/handler/error/http_error.rs b/crates/nvisy-server/src/handler/error/http_error.rs new file mode 100644 index 0000000..05d226d --- /dev/null +++ b/crates/nvisy-server/src/handler/error/http_error.rs @@ -0,0 +1,537 @@ +//! HTTP error handling with builder pattern for dynamic error responses. +//! +//! This module provides comprehensive HTTP error handling with a builder pattern +//! that allows for dynamic error messages and resource-specific context. + +use std::borrow::Cow; +use std::fmt; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +use crate::handler::response::ErrorResponse; + +/// The error type for HTTP handlers in the server. +/// +/// This error type provides a comprehensive way to handle HTTP errors with proper +/// status codes, messages, and optional context information. +#[derive(Clone)] +#[must_use = "errors do nothing unless serialized"] +pub struct Error<'a> { + kind: ErrorKind, + resource: Option>, + context: Option>, + message: Option>, + suggestion: Option>, +} + +impl Error<'static> { + /// Creates a new [`Error`] with the specified kind. + #[inline] + pub fn new(kind: ErrorKind) -> Self { + Self { + kind, + resource: None, + context: None, + message: None, + suggestion: None, + } + } +} + +impl<'a> Error<'a> { + /// Attaches context information to the error. + /// + /// Context provides additional information about what went wrong, + /// which will be included in the error response for debugging. + #[inline] + pub fn with_context(self, context: impl Into>) -> Self { + Self { + context: Some(context.into()), + ..self + } + } + + /// Sets a custom user-friendly message for the error. + #[inline] + pub fn with_message(self, message: impl Into>) -> Self { + Self { + message: Some(message.into()), + ..self + } + } + + /// Sets the resource that caused the error. + #[inline] + pub fn with_resource(self, resource: impl Into>) -> Self { + Self { + resource: Some(resource.into()), + ..self + } + } + + /// Sets a suggestion for how to resolve the error. + #[inline] + pub fn with_suggestion(self, suggestion: impl Into>) -> Self { + Self { + suggestion: Some(suggestion.into()), + ..self + } + } + + /// Returns the error kind. + #[inline] + pub fn kind(&self) -> ErrorKind { + self.kind + } + + /// Returns the context if present. + #[inline] + pub fn context(&self) -> Option<&str> { + self.context.as_deref() + } + + /// Returns the custom message if present. + #[inline] + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } + + /// Returns the resource if present. + #[inline] + pub fn resource(&self) -> Option<&str> { + self.resource.as_deref() + } + + /// Returns the suggestion if present. + #[inline] + pub fn suggestion(&self) -> Option<&str> { + self.suggestion.as_deref() + } + + /// Converts this error into a static version by cloning all borrowed data. + pub fn into_owned(self) -> Error<'static> { + Error { + kind: self.kind, + context: self.context.map(|c| Cow::Owned(c.into_owned())), + message: self.message.map(|m| Cow::Owned(m.into_owned())), + resource: self.resource.map(|r| Cow::Owned(r.into_owned())), + suggestion: self.suggestion.map(|s| Cow::Owned(s.into_owned())), + } + } +} + +impl Default for Error<'static> { + #[inline] + fn default() -> Self { + Self { + kind: ErrorKind::default(), + context: None, + message: None, + resource: None, + suggestion: None, + } + } +} + +impl fmt::Debug for Error<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let response = self.kind.response(); + + let mut debug_struct = f.debug_struct("Error"); + debug_struct + .field("kind", &self.kind) + .field("name", &response.name) + .field("status", &response.status) + .field("message", &response.message) + .field("resource", &response.resource); + + if let Some(ref context) = self.context { + debug_struct.field("context", context); + } + + if let Some(ref message) = self.message { + debug_struct.field("custom_message", message); + } + + if let Some(ref resource) = self.resource { + debug_struct.field("custom_resource", resource); + } + + if let Some(ref suggestion) = self.suggestion { + debug_struct.field("suggestion", suggestion); + } + + debug_struct.finish() + } +} + +impl fmt::Display for Error<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let response = self.kind.response(); + let message = self.message.as_deref().unwrap_or("Unknown error"); + + write!(f, "{} ({}): {}", response.name, response.status, message)?; + + if let Some(ref context) = self.context { + write!(f, " - {}", context)?; + } + + if let Some(ref resource) = self.resource { + write!(f, " [resource: {}]", resource)?; + } + + if let Some(ref suggestion) = self.suggestion { + write!(f, " | suggestion: {}", suggestion)?; + } + + Ok(()) + } +} + +impl std::error::Error for Error<'_> {} + +impl IntoResponse for Error<'_> { + fn into_response(self) -> Response { + let mut response = self.kind.response(); + + if let Some(message) = self.message { + response = response.with_message(message); + } + + if let Some(resource) = self.resource { + response = response.with_resource(resource); + } + + if let Some(context) = self.context { + response = response.with_context(context); + } + + if let Some(suggestion) = self.suggestion { + response = response.with_suggestion(suggestion); + } + + response.into_response() + } +} + +impl From for Error<'static> { + #[inline] + fn from(kind: ErrorKind) -> Self { + Self::new(kind) + } +} + +impl From for Error<'static> { + fn from(err: nvisy_core::Error) -> Self { + let kind = match err.kind { + nvisy_core::ErrorKind::Validation + | nvisy_core::ErrorKind::InvalidInput + | nvisy_core::ErrorKind::Serialization => ErrorKind::BadRequest, + nvisy_core::ErrorKind::Policy => ErrorKind::Forbidden, + nvisy_core::ErrorKind::NotFound => ErrorKind::NotFound, + nvisy_core::ErrorKind::Connection + | nvisy_core::ErrorKind::Timeout + | nvisy_core::ErrorKind::Cancellation + | nvisy_core::ErrorKind::Runtime + | nvisy_core::ErrorKind::Python + | nvisy_core::ErrorKind::InternalError + | nvisy_core::ErrorKind::Other => ErrorKind::InternalServerError, + }; + + let mut error = Self::new(kind).with_message(err.message); + if let Some(component) = err.source_component { + error = error.with_context(component); + } + error + } +} + +impl<'a> aide::OperationOutput for Error<'a> { + type Inner = ErrorResponse<'static>; + + fn operation_response( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) -> Option { + axum::Json::>::operation_response(ctx, operation) + } + + fn inferred_responses( + _ctx: &mut aide::generate::GenContext, + _operation: &mut aide::openapi::Operation, + ) -> Vec<(Option, aide::openapi::Response)> { + Vec::new() + } +} + +/// A specialized [`Result`] type for HTTP operations. +/// +/// This is the standard result type used throughout the nvisy server +/// for operations that can fail with an HTTP error. +/// +/// [`Result`]: std::result::Result +pub type Result> = std::result::Result; + +/// Comprehensive enumeration of all possible HTTP error kinds. +/// +/// Each variant corresponds to a specific HTTP status code and error scenario. +/// The variants are organized by HTTP status code family. +#[must_use = "error kinds do nothing unless used to create errors"] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorKind { + // 4xx Client Errors + /// 400 Bad Request - Missing required path parameter + MissingPathParam, + /// 400 Bad Request - Invalid request data + BadRequest, + /// 401 Unauthorized - Missing authentication token + MissingAuthToken, + /// 401 Unauthorized - Malformed authentication token + MalformedAuthToken, + /// 401 Unauthorized - Invalid credentials + Unauthorized, + /// 403 Forbidden - Access denied + Forbidden, + /// 404 Not Found - Resource not found + NotFound, + /// 409 Conflict - Conflicting resource state + Conflict, + /// 429 Too Many Requests - Rate limit exceeded + TooManyRequests, + + // 5xx Server Errors + /// 500 Internal Server Error - Unexpected server error + #[default] + InternalServerError, + /// 501 Not Implemented - Feature not yet implemented + NotImplemented, +} + +impl ErrorKind { + /// Converts this error kind into a full [`Error`]. + #[inline] + pub fn into_error(self) -> Error<'static> { + Error::new(self) + } + + /// Creates an [`Error`] with the specified context. + #[inline] + pub fn with_context<'a>(self, context: impl Into>) -> Error<'a> { + Error::new(self).with_context(context) + } + + /// Creates an [`Error`] with the specified message. + #[inline] + pub fn with_message<'a>(self, message: impl Into>) -> Error<'a> { + Error::new(self).with_message(message) + } + + /// Creates an [`Error`] with the specified resource. + #[inline] + pub fn with_resource<'a>(self, resource: impl Into>) -> Error<'a> { + Error::new(self).with_resource(resource) + } + + /// Creates an [`Error`] with the specified suggestion. + #[inline] + pub fn with_suggestion<'a>(self, suggestion: impl Into>) -> Error<'a> { + Error::new(self).with_suggestion(suggestion) + } + + /// Returns the HTTP status code for this error kind. + #[inline] + pub fn status_code(self) -> StatusCode { + self.response().status + } + + /// Returns the base [`ErrorResponse`] for this error kind. + #[inline] + pub fn response(self) -> ErrorResponse<'static> { + match self { + Self::MissingPathParam => ErrorResponse::MISSING_PATH_PARAM, + Self::BadRequest => ErrorResponse::BAD_REQUEST, + Self::MissingAuthToken => ErrorResponse::MISSING_AUTH_TOKEN, + Self::MalformedAuthToken => ErrorResponse::MALFORMED_AUTH_TOKEN, + Self::Unauthorized => ErrorResponse::UNAUTHORIZED, + Self::Forbidden => ErrorResponse::FORBIDDEN, + Self::NotFound => ErrorResponse::NOT_FOUND, + Self::Conflict => ErrorResponse::CONFLICT, + Self::TooManyRequests => ErrorResponse::TOO_MANY_REQUESTS, + Self::InternalServerError => ErrorResponse::INTERNAL_SERVER_ERROR, + Self::NotImplemented => ErrorResponse::NOT_IMPLEMENTED, + } + } +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.response().name.as_ref()) + } +} + +impl IntoResponse for ErrorKind { + #[inline] + fn into_response(self) -> Response { + self.response().into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_http_error() { + let error = Error::default(); + assert_eq!(error.kind(), ErrorKind::InternalServerError); + let _ = error.into_response(); + } + + #[test] + fn error_from_kind() { + let error = Error::new(ErrorKind::NotFound); + assert_eq!(error.kind(), ErrorKind::NotFound); + let _ = error.into_response(); + } + + #[test] + fn error_with_context() { + let error = ErrorKind::BadRequest.with_context("Invalid format"); + assert_eq!(error.context(), Some("Invalid format")); + let _ = error.into_response(); + } + + #[test] + fn error_with_message() { + let error = ErrorKind::NotFound.with_message("Custom not found message"); + assert_eq!(error.message(), Some("Custom not found message")); + let _ = error.into_response(); + } + + #[test] + fn error_with_resource() { + let error = ErrorKind::Forbidden.with_resource("document"); + assert_eq!(error.resource(), Some("document")); + let _ = error.into_response(); + } + + #[test] + fn error_builder_chaining() { + let error = ErrorKind::NotFound + .with_message("Document not found") + .with_resource("document") + .with_context("ID: 123") + .with_suggestion("Check if the document ID is correct"); + + assert_eq!(error.kind(), ErrorKind::NotFound); + assert_eq!(error.message(), Some("Document not found")); + assert_eq!(error.resource(), Some("document")); + assert_eq!(error.context(), Some("ID: 123")); + assert_eq!( + error.suggestion(), + Some("Check if the document ID is correct") + ); + } + + #[test] + fn std_fmt_display() { + let error = ErrorKind::NotFound + .with_message("Resource not found") + .with_resource("document") + .with_context("ID: 123"); + + let display = format!("{}", error); + assert!(display.contains("not_found")); + assert!(display.contains("404")); + assert!(display.contains("Resource not found")); + assert!(display.contains("ID: 123")); + assert!(display.contains("document")); + } + + #[test] + fn std_fmt_debug() { + let error = ErrorKind::Forbidden + .with_message("Access denied") + .with_resource("document") + .with_context("User lacks permissions"); + + let debug = format!("{:?}", error); + assert!(debug.contains("Forbidden")); + assert!(debug.contains("Access denied")); + assert!(debug.contains("document")); + } + + #[test] + fn std_error_trait() { + let error = Error::new(ErrorKind::BadRequest); + let _: &dyn std::error::Error = &error; + } + + #[test] + fn error_into_static() { + let error = ErrorKind::NotFound + .with_message("Test message".to_string()) + .with_resource("test_resource".to_string()) + .with_context("Test context".to_string()) + .with_suggestion("Test suggestion".to_string()); + + let static_error = error.into_owned(); + assert_eq!(static_error.message(), Some("Test message")); + assert_eq!(static_error.resource(), Some("test_resource")); + assert_eq!(static_error.context(), Some("Test context")); + assert_eq!(static_error.suggestion(), Some("Test suggestion")); + } + + #[test] + fn all_error_kinds_have_responses() { + let kinds = vec![ + ErrorKind::BadRequest, + ErrorKind::Conflict, + ErrorKind::Forbidden, + ErrorKind::InternalServerError, + ErrorKind::MalformedAuthToken, + ErrorKind::MissingAuthToken, + ErrorKind::MissingPathParam, + ErrorKind::NotFound, + ErrorKind::NotImplemented, + ErrorKind::Unauthorized, + ]; + + for kind in kinds { + let response = kind.response(); + assert!(!response.name.is_empty()); + assert!(response.status.as_u16() >= 400); + let _ = kind.into_response(); + } + } + + #[test] + fn from_nvisy_core_validation() { + let core_err = nvisy_core::Error::new( + nvisy_core::ErrorKind::Validation, + "field is required", + ); + let err = Error::from(core_err); + assert_eq!(err.kind(), ErrorKind::BadRequest); + assert_eq!(err.message(), Some("field is required")); + } + + #[test] + fn from_nvisy_core_not_found() { + let core_err = nvisy_core::Error::new(nvisy_core::ErrorKind::NotFound, "missing") + .with_component("registry"); + let err = Error::from(core_err); + assert_eq!(err.kind(), ErrorKind::NotFound); + assert_eq!(err.message(), Some("missing")); + assert_eq!(err.context(), Some("registry")); + } + + #[test] + fn from_nvisy_core_internal() { + let core_err = + nvisy_core::Error::new(nvisy_core::ErrorKind::Runtime, "unexpected"); + let err = Error::from(core_err); + assert_eq!(err.kind(), ErrorKind::InternalServerError); + } +} diff --git a/crates/nvisy-server/src/handler/error/mod.rs b/crates/nvisy-server/src/handler/error/mod.rs new file mode 100644 index 0000000..464732a --- /dev/null +++ b/crates/nvisy-server/src/handler/error/mod.rs @@ -0,0 +1,9 @@ +//! HTTP error types for API handlers. +//! +//! Provides [`Error`], [`ErrorKind`], and a [`Result`] alias used as +//! the standard error type across all handler, extractor, and +//! middleware code in the server. + +mod http_error; + +pub use http_error::{Error, ErrorKind, Result}; diff --git a/crates/nvisy-server/src/handler/execute.rs b/crates/nvisy-server/src/handler/execute.rs index 63f01e6..66e3a7c 100644 --- a/crates/nvisy-server/src/handler/execute.rs +++ b/crates/nvisy-server/src/handler/execute.rs @@ -14,14 +14,14 @@ use aide::axum::ApiRouter; use aide::axum::routing::post_with; use aide::transform::TransformOperation; use axum::extract::State; -use axum::Json; use base64::Engine as _; use nvisy_core::io::{Content, ContentData}; -use nvisy_core::{Error, ErrorKind}; use nvisy_engine::engine::{Engine as _, EngineInput, Policies}; +use super::error::{ErrorKind, Result}; use super::request::ExecuteRequest; -use super::response::{ExecuteResponse, ServerError}; +use super::response::ExecuteResponse; +use crate::extract::Json; use crate::service::ServiceState; /// `POST /api/v1/execute`: run the full pipeline. @@ -29,13 +29,13 @@ use crate::service::ServiceState; async fn execute( State(state): State, Json(req): Json, -) -> Result, ServerError> { +) -> Result> { let bytes = base64::engine::general_purpose::STANDARD .decode(&req.content) - .map_err(|e| Error::new(ErrorKind::InvalidInput, format!("invalid base64: {e}")))?; + .map_err(|e| ErrorKind::BadRequest.with_message(format!("invalid base64: {e}")))?; let policies: Policies = serde_json::from_value(req.policies) - .map_err(|e| Error::new(ErrorKind::Validation, format!("invalid policies: {e}")))?; + .map_err(|e| ErrorKind::BadRequest.with_message(format!("invalid policies: {e}")))?; let mut content_data = ContentData::from(bytes); if let Some(ref filename) = req.filename diff --git a/crates/nvisy-server/src/handler/ingest.rs b/crates/nvisy-server/src/handler/ingest.rs index 77fae9d..035423f 100644 --- a/crates/nvisy-server/src/handler/ingest.rs +++ b/crates/nvisy-server/src/handler/ingest.rs @@ -1,4 +1,4 @@ -//! Content ingestion handlers — upload, download, and deletion. +//! Content ingestion handlers: upload, download, and deletion. //! //! # Endpoints //! @@ -26,13 +26,13 @@ use aide::axum::ApiRouter; use aide::axum::routing::{delete_with, get_with}; use aide::transform::TransformOperation; -use axum::extract::{Multipart, Path, State}; -use axum::Json; +use axum::extract::State; use nvisy_core::io::{Content, ContentData}; -use nvisy_core::{Error, ErrorKind}; use uuid::Uuid; -use super::response::{DeleteAllResponse, DeleteResponse, DownloadResponse, ServerError, UploadResponse}; +use super::error::{ErrorKind, Result}; +use super::response::{DeleteAllResponse, DeleteResponse, DownloadResponse, UploadResponse}; +use crate::extract::{Json, Path, Upload}; use crate::service::ServiceState; /// `POST /api/v1/ingest`: upload content as multipart form data. @@ -42,56 +42,11 @@ use crate::service::ServiceState; #[tracing::instrument(skip_all)] async fn upload( State(state): State, - mut multipart: Multipart, -) -> Result, ServerError> { - let mut file_bytes: Option> = None; - let mut filename: Option = None; - let mut content_type: Option = None; - - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| Error::new(ErrorKind::InvalidInput, format!("multipart error: {e}")))? - { - let field_name = field.name().unwrap_or_default().to_string(); - match field_name.as_str() { - "file" => { - filename = field.file_name().map(String::from); - content_type = field.content_type().map(String::from); - file_bytes = Some( - field - .bytes() - .await - .map_err(|e| { - Error::new( - ErrorKind::InvalidInput, - format!("failed to read file field: {e}"), - ) - })? - .to_vec(), - ); - } - "content_type" => { - let value = field.text().await.map_err(|e| { - Error::new( - ErrorKind::InvalidInput, - format!("failed to read content_type field: {e}"), - ) - })?; - content_type = Some(value); - } - _ => { - tracing::debug!(field = field_name, "ignoring unknown multipart field"); - } - } - } - - let bytes = file_bytes - .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "missing required 'file' field"))?; - - let size = bytes.len(); - let mut content_data = ContentData::from(bytes); - if let Some(mime) = content_type { + upload: Upload, +) -> Result> { + let size = upload.bytes.len(); + let mut content_data = ContentData::from(upload.bytes); + if let Some(mime) = upload.content_type { content_data.mime = Some(mime); } let content = Content::new(content_data); @@ -101,7 +56,7 @@ async fn upload( tracing::info!( %id, size, - filename = filename.as_deref().unwrap_or(""), + filename = upload.filename.as_deref().unwrap_or(""), "content uploaded", ); @@ -111,16 +66,14 @@ async fn upload( /// `GET /api/v1/ingest/{id}`: download previously uploaded content. /// /// Returns the content as base64-encoded bytes along with its identifier. -/// Currently unimplemented — returns a 500 error. +/// Currently unimplemented: returns a 501 error. #[tracing::instrument(skip_all, fields(%id))] async fn download( State(_state): State, Path(id): Path, -) -> Result, ServerError> { - Err(ServerError::from(Error::new( - ErrorKind::Runtime, - format!("content download not yet implemented (id: {id})"), - ))) +) -> Result> { + Err(ErrorKind::NotImplemented + .with_message(format!("content download not yet implemented (id: {id})"))) } fn download_docs(op: TransformOperation) -> TransformOperation { @@ -138,7 +91,7 @@ fn download_docs(op: TransformOperation) -> TransformOperation { async fn delete( State(state): State, Path(id): Path, -) -> Result, ServerError> { +) -> Result> { state.content_registry().delete(id).await?; tracing::info!(%id, "content deleted"); Ok(Json(DeleteResponse { id })) @@ -158,7 +111,7 @@ fn delete_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all)] async fn delete_all( State(state): State, -) -> Result, ServerError> { +) -> Result> { let deleted = state.content_registry().delete_all().await?; tracing::info!(deleted, "all content deleted"); Ok(Json(DeleteAllResponse { deleted })) diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index 5705bfc..d276618 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -6,7 +6,10 @@ //! router. //! //! Request and response types live in the private [`request`] and [`response`] -//! submodules. Only [`ServerError`] is re-exported for use by middleware. +//! submodules. [`Error`], [`ErrorKind`], and [`Result`] are re-exported for +//! use by middleware and extractors. + +pub mod error; mod check; mod execute; @@ -16,7 +19,7 @@ mod redact; mod request; mod response; -pub use response::ServerError; +pub use error::{Error, ErrorKind, Result}; use aide::axum::ApiRouter; diff --git a/crates/nvisy-server/src/handler/redact.rs b/crates/nvisy-server/src/handler/redact.rs index c876b62..2765d9d 100644 --- a/crates/nvisy-server/src/handler/redact.rs +++ b/crates/nvisy-server/src/handler/redact.rs @@ -8,18 +8,18 @@ //! //! Expects a JSON body with a `content_id` referencing previously ingested //! content, along with policies and an execution graph. Currently -//! unimplemented — returns a 500 error. +//! unimplemented: returns a 501 error. use aide::axum::ApiRouter; use aide::axum::routing::post_with; use aide::transform::TransformOperation; use axum::extract::State; -use axum::Json; -use nvisy_core::{Error, ErrorKind}; use nvisy_engine::engine::Policies; +use super::error::{ErrorKind, Result}; use super::request::RedactionRequest; -use super::response::{RedactionResponse, ServerError}; +use super::response::RedactionResponse; +use crate::extract::Json; use crate::service::ServiceState; /// `POST /api/v1/redaction`: run the redaction pipeline on uploaded content. @@ -27,20 +27,17 @@ use crate::service::ServiceState; async fn redact( State(_state): State, Json(req): Json, -) -> Result, ServerError> { +) -> Result> { let _policies: Policies = serde_json::from_value(req.policies) - .map_err(|e| Error::new(ErrorKind::Validation, format!("invalid policies: {e}")))?; + .map_err(|e| ErrorKind::BadRequest.with_message(format!("invalid policies: {e}")))?; let _graph = req.graph; let _connections = req.connections; - Err(ServerError::from(Error::new( - ErrorKind::Runtime, - format!( - "redaction endpoint not yet implemented (content_id: {}, actor: {})", - req.content_id, - req.actor.as_deref().unwrap_or(""), - ), + Err(ErrorKind::NotImplemented.with_message(format!( + "redaction endpoint not yet implemented (content_id: {}, actor: {})", + req.content_id, + req.actor.as_deref().unwrap_or(""), ))) } diff --git a/crates/nvisy-server/src/handler/response/error.rs b/crates/nvisy-server/src/handler/response/error.rs index fa774e9..644541c 100644 --- a/crates/nvisy-server/src/handler/response/error.rs +++ b/crates/nvisy-server/src/handler/response/error.rs @@ -1,98 +1,177 @@ -//! Unified error handling. +//! Serializable error response body for API endpoints. //! -//! Maps [`ErrorKind`] to HTTP status codes and produces a JSON error body -//! compatible with the OpenAPI specification. Every handler returns -//! `Result, ServerError>` so that errors are serialised uniformly. -//! -//! | [`ErrorKind`] | HTTP Status | -//! |------------------------------------------------|------------------------| -//! | `Validation`, `InvalidInput`, `Serialization` | 400 Bad Request | -//! | `Policy` | 403 Forbidden | -//! | `NotFound` | 404 Not Found | -//! | `Connection` | 502 Bad Gateway | -//! | `Timeout` | 504 Gateway Timeout | -//! | `Cancellation` | 499 Client Closed | -//! | `Runtime`, `Python`, `InternalError`, `Other` | 500 Internal Server | - -use aide::OperationOutput; +//! [`ErrorResponse`] is the JSON body returned by every error path. +//! It carries a stable machine-readable `name`, the HTTP `status` code, +//! and optional human-readable fields (`message`, `resource`, `context`, +//! `suggestion`). + +use std::borrow::Cow; + use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use nvisy_core::{Error, ErrorKind}; use schemars::JsonSchema; use serde::Serialize; -/// JSON error body returned by all endpoints. -#[derive(Debug, Serialize, JsonSchema)] -pub struct ApiError { - pub error: ApiErrorBody, +/// JSON error body returned by all API endpoints. +/// +/// Designed for both human readability and machine parsing. The `name` +/// field is a stable, machine-readable identifier; `status` is the +/// HTTP status code; `message` is a human-readable description. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ErrorResponse<'a> { + /// Machine-readable error name (e.g. `"NOT_FOUND"`, `"BAD_REQUEST"`). + pub name: Cow<'a, str>, + /// HTTP status code (serialized as integer). + #[serde(serialize_with = "serialize_status")] + #[schemars(with = "u16")] + pub status: StatusCode, + /// Human-readable error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option>, + /// The resource the error relates to (e.g. a path or ID). + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option>, + /// Contextual information (e.g. component name, operation). + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option>, + /// A suggested action the client could take. + #[serde(skip_serializing_if = "Option::is_none")] + pub suggestion: Option>, } -/// Inner error payload. -#[derive(Debug, Serialize, JsonSchema)] -pub struct ApiErrorBody { - pub kind: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub component: Option, - pub retryable: bool, +impl ErrorResponse<'static> { + pub const MISSING_PATH_PARAM: Self = Self { + name: Cow::Borrowed("missing_path_param"), + status: StatusCode::BAD_REQUEST, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const BAD_REQUEST: Self = Self { + name: Cow::Borrowed("bad_request"), + status: StatusCode::BAD_REQUEST, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const MISSING_AUTH_TOKEN: Self = Self { + name: Cow::Borrowed("missing_auth_token"), + status: StatusCode::UNAUTHORIZED, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const MALFORMED_AUTH_TOKEN: Self = Self { + name: Cow::Borrowed("malformed_auth_token"), + status: StatusCode::UNAUTHORIZED, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const UNAUTHORIZED: Self = Self { + name: Cow::Borrowed("unauthorized"), + status: StatusCode::UNAUTHORIZED, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const FORBIDDEN: Self = Self { + name: Cow::Borrowed("forbidden"), + status: StatusCode::FORBIDDEN, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const NOT_FOUND: Self = Self { + name: Cow::Borrowed("not_found"), + status: StatusCode::NOT_FOUND, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const CONFLICT: Self = Self { + name: Cow::Borrowed("conflict"), + status: StatusCode::CONFLICT, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const TOO_MANY_REQUESTS: Self = Self { + name: Cow::Borrowed("too_many_requests"), + status: StatusCode::TOO_MANY_REQUESTS, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const INTERNAL_SERVER_ERROR: Self = Self { + name: Cow::Borrowed("internal_server_error"), + status: StatusCode::INTERNAL_SERVER_ERROR, + message: None, + resource: None, + context: None, + suggestion: None, + }; + + pub const NOT_IMPLEMENTED: Self = Self { + name: Cow::Borrowed("not_implemented"), + status: StatusCode::NOT_IMPLEMENTED, + message: None, + resource: None, + context: None, + suggestion: None, + }; } -impl From for ApiError { - fn from(err: Error) -> Self { - Self { - error: ApiErrorBody { - kind: err.kind.to_string(), - message: err.message, - component: err.source_component, - retryable: err.retryable, - }, - } +impl<'a> ErrorResponse<'a> { + /// Sets a human-readable error message. + pub fn with_message(mut self, message: impl Into>) -> Self { + self.message = Some(message.into()); + self } -} -/// Map [`ErrorKind`] to an HTTP status code. -fn status_for(kind: ErrorKind) -> StatusCode { - match kind { - ErrorKind::Validation | ErrorKind::InvalidInput | ErrorKind::Serialization => { - StatusCode::BAD_REQUEST - } - ErrorKind::Policy => StatusCode::FORBIDDEN, - ErrorKind::NotFound => StatusCode::NOT_FOUND, - ErrorKind::Connection => StatusCode::BAD_GATEWAY, - ErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT, - // 499: non-standard "Client Closed Request" - ErrorKind::Cancellation => StatusCode::from_u16(499).unwrap_or(StatusCode::BAD_REQUEST), - ErrorKind::Runtime | ErrorKind::Python | ErrorKind::InternalError | ErrorKind::Other => { - StatusCode::INTERNAL_SERVER_ERROR - } + /// Sets the resource the error relates to. + pub fn with_resource(mut self, resource: impl Into>) -> Self { + self.resource = Some(resource.into()); + self } -} -/// Newtype wrapper so we can implement `IntoResponse` for `nvisy_core::Error`. -pub struct ServerError(pub Error); + /// Sets contextual information about the error. + pub fn with_context(mut self, context: impl Into>) -> Self { + self.context = Some(context.into()); + self + } -impl From for ServerError { - fn from(err: Error) -> Self { - Self(err) + /// Sets a suggestion for how to resolve the error. + pub fn with_suggestion(mut self, suggestion: impl Into>) -> Self { + self.suggestion = Some(suggestion.into()); + self } } -impl IntoResponse for ServerError { +impl IntoResponse for ErrorResponse<'_> { fn into_response(self) -> Response { - let status = status_for(self.0.kind); - tracing::warn!( - kind = %self.0.kind, - status = status.as_u16(), - component = self.0.source_component.as_deref(), - retryable = self.0.retryable, - "{}", - self.0.message, - ); - let body: ApiError = self.0.into(); - (status, axum::Json(body)).into_response() + (self.status, axum::Json(self)).into_response() } } -impl OperationOutput for ServerError { - type Inner = ApiError; +fn serialize_status(status: &StatusCode, s: S) -> Result { + s.serialize_u16(status.as_u16()) } diff --git a/crates/nvisy-server/src/handler/response/mod.rs b/crates/nvisy-server/src/handler/response/mod.rs index 1abe833..fc015c2 100644 --- a/crates/nvisy-server/src/handler/response/mod.rs +++ b/crates/nvisy-server/src/handler/response/mod.rs @@ -2,9 +2,8 @@ //! //! Each struct derives [`Serialize`](serde::Serialize) and //! [`JsonSchema`](schemars::JsonSchema) for automatic OpenAPI schema -//! generation via aide. [`ServerError`] maps -//! [`nvisy_core::ErrorKind`] to HTTP status codes and produces a -//! JSON error body. +//! generation via aide. [`ErrorResponse`] is the serializable JSON +//! body returned by every error path. mod check; pub mod error; @@ -13,7 +12,7 @@ mod ingest; mod redact; pub use check::{Analytics, Health}; -pub use error::ServerError; +pub use error::ErrorResponse; pub use execute::ExecuteResponse; pub use ingest::{DeleteAllResponse, DeleteResponse, DownloadResponse, UploadResponse}; pub use redact::RedactionResponse; diff --git a/crates/nvisy-server/src/middleware/recovery.rs b/crates/nvisy-server/src/middleware/recovery.rs index 4f4d37b..7b83321 100644 --- a/crates/nvisy-server/src/middleware/recovery.rs +++ b/crates/nvisy-server/src/middleware/recovery.rs @@ -26,7 +26,7 @@ use tower::ServiceBuilder; use tower::timeout::TimeoutLayer; use tower_http::catch_panic::CatchPanicLayer; -use crate::handler::ServerError; +use crate::handler::error::{Error, ErrorKind}; /// Tracing target for error recovery. const TRACING_TARGET_ERROR: &str = "nvisy_server::recovery::error"; @@ -101,8 +101,8 @@ fn handle_error(err: tower::BoxError) -> ResponseFut { "request timeout exceeded", ); - let error = nvisy_core::Error::new(nvisy_core::ErrorKind::Timeout, "request timeout"); - return ready(ServerError::from(error).into_response()).boxed(); + let error = Error::new(ErrorKind::InternalServerError).with_message("request timeout"); + return ready(error.into_response()).boxed(); } tracing::error!( @@ -111,11 +111,9 @@ fn handle_error(err: tower::BoxError) -> ResponseFut { "unhandled middleware error", ); - let error = nvisy_core::Error::new( - nvisy_core::ErrorKind::InternalError, - format!("internal error: {err}"), - ); - ready(ServerError::from(error).into_response()).boxed() + let error = + Error::new(ErrorKind::InternalServerError).with_message(format!("internal error: {err}")); + ready(error.into_response()).boxed() } fn catch_panic(err: Panic) -> Response { @@ -131,6 +129,6 @@ fn catch_panic(err: Panic) -> Response { "service panic", ); - let error = nvisy_core::Error::new(nvisy_core::ErrorKind::InternalError, "service panic"); - ServerError::from(error).into_response() + let error = Error::new(ErrorKind::InternalServerError).with_message("service panic"); + error.into_response() } diff --git a/crates/nvisy-server/src/middleware/security.rs b/crates/nvisy-server/src/middleware/security.rs index 0f240f8..e229198 100644 --- a/crates/nvisy-server/src/middleware/security.rs +++ b/crates/nvisy-server/src/middleware/security.rs @@ -4,8 +4,8 @@ //! [`DefaultBodyLimit`] and tower-http [`RequestBodyLimitLayer`]), and //! response compression. The two body limits serve different purposes: //! -//! - [`DefaultBodyLimit`] — governs axum extractors (`Json`, `Form`, etc.). -//! - [`RequestBodyLimitLayer`] — hard cap enforced by tower-http on the raw +//! - [`DefaultBodyLimit`]: governs axum extractors (`Json`, `Form`, etc.). +//! - [`RequestBodyLimitLayer`]: hard cap enforced by tower-http on the raw //! request body, including multipart file uploads. //! //! [`DefaultBodyLimit`]: axum::extract::DefaultBodyLimit @@ -27,7 +27,7 @@ const DEFAULT_FILE_BODY_LIMIT: usize = 50 * 1024 * 1024; /// Configuration for security middleware. /// /// Controls CORS policy and request body size limits. The two limit -/// fields target different layers of the stack — see the module-level +/// fields target different layers of the stack: see the module-level /// documentation for details. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityConfig { From 184ea83aae5699192daea9f4af9bfd6fe5491c38 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 20:18:10 +0100 Subject: [PATCH 07/22] =?UTF-8?q?fix(server):=20review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20JsonSchema=20derives,=20OpenAPI=20docs,=20CORS=20co?= =?UTF-8?q?nfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add JsonSchema derives to Entity, DetectionOutput, Location variants (ontology) and PolicyEvaluation (identify) so ExecuteResponse can use the real types instead of lossy serde_json::Value round-trips. Re-export DetectionOutput from nvisy-engine for downstream access. Server improvements: - Upload endpoint now uses api_route + post_with for OpenAPI visibility - Crate root re-exports routes, ServiceState, Error, ErrorKind, Result - CORS origins configurable via SecurityConfig::cors_allowed_origins - REQUEST_ID_HEADER constant replaces hardcoded "x-request-id" strings - Multipart extractor warns on nameless fields instead of silent default - Expanded MIME map with audio, video, and additional image formats - Doc comments on recovery middleware functions; fix stale handler docs Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-engine/src/engine/mod.rs | 4 +-- .../nvisy-identify/src/policy/evaluation.rs | 3 ++- crates/nvisy-ontology/src/entity/mod.rs | 4 +-- crates/nvisy-ontology/src/location/mod.rs | 13 +++++----- crates/nvisy-server/src/extract/multipart.rs | 5 +++- crates/nvisy-server/src/handler/execute.rs | 25 ++++++++++++++++--- crates/nvisy-server/src/handler/ingest.rs | 14 +++++++++-- crates/nvisy-server/src/handler/mod.rs | 2 +- .../src/handler/response/execute.rs | 22 ++++++++-------- crates/nvisy-server/src/lib.rs | 4 +++ .../src/middleware/observability.rs | 7 ++++-- .../nvisy-server/src/middleware/recovery.rs | 15 +++++++++-- .../nvisy-server/src/middleware/security.rs | 21 ++++++++++++++-- 13 files changed, 105 insertions(+), 34 deletions(-) diff --git a/crates/nvisy-engine/src/engine/mod.rs b/crates/nvisy-engine/src/engine/mod.rs index f0d59bd..ac52be5 100644 --- a/crates/nvisy-engine/src/engine/mod.rs +++ b/crates/nvisy-engine/src/engine/mod.rs @@ -25,9 +25,9 @@ use uuid::Uuid; use nvisy_core::Error; use nvisy_core::fs::ContentHandler; -use nvisy_ontology::entity::DetectionOutput; // Re-exported so downstream crates (e.g. nvisy-server) don't need a direct -// dependency on nvisy-identify. +// dependency on nvisy-ontology or nvisy-identify. +pub use nvisy_ontology::entity::DetectionOutput; pub use nvisy_identify::{ Audit, Policies, PolicyEvaluation, RedactionSummary, }; diff --git a/crates/nvisy-identify/src/policy/evaluation.rs b/crates/nvisy-identify/src/policy/evaluation.rs index c3a2362..3e29781 100644 --- a/crates/nvisy-identify/src/policy/evaluation.rs +++ b/crates/nvisy-identify/src/policy/evaluation.rs @@ -1,12 +1,13 @@ //! Policy evaluation outcome. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; use nvisy_ontology::record::Redaction; /// Full outcome of evaluating a policy against a set of entities. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PolicyEvaluation { /// Identifier of the policy that was evaluated. pub policy_id: Uuid, diff --git a/crates/nvisy-ontology/src/entity/mod.rs b/crates/nvisy-ontology/src/entity/mod.rs index 3661f87..0e14890 100644 --- a/crates/nvisy-ontology/src/entity/mod.rs +++ b/crates/nvisy-ontology/src/entity/mod.rs @@ -66,7 +66,7 @@ pub enum DetectionMethod { } /// A detected sensitive data occurrence within a document. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Entity { /// Content source identity and lineage. #[serde(flatten)] @@ -138,7 +138,7 @@ impl Entity { } /// The output of a detection pass over a single content source. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct DetectionOutput { /// Content source identity and lineage. #[serde(flatten)] diff --git a/crates/nvisy-ontology/src/location/mod.rs b/crates/nvisy-ontology/src/location/mod.rs index abbe460..6a36bef 100644 --- a/crates/nvisy-ontology/src/location/mod.rs +++ b/crates/nvisy-ontology/src/location/mod.rs @@ -4,13 +4,14 @@ mod layout_kind; pub use layout_kind::LayoutKind; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; use nvisy_core::math::{BoundingBox, TimeSpan}; /// Location of an entity within text content. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct TextLocation { /// Byte or character offset where the entity starts. pub start_offset: usize, @@ -38,7 +39,7 @@ impl TextLocation { } /// Location of an entity within an image. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ImageLocation { /// Bounding box of the entity in the image. pub bounding_box: BoundingBox, @@ -51,7 +52,7 @@ pub struct ImageLocation { } /// Location of an entity within tabular data. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TabularLocation { /// Row index (0-based). pub row_index: usize, @@ -66,7 +67,7 @@ pub struct TabularLocation { } /// Location of an entity within an audio stream. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AudioLocation { /// Time interval of the entity. pub time_span: TimeSpan, @@ -79,7 +80,7 @@ pub struct AudioLocation { } /// Location of an entity within a video stream. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct VideoLocation { /// Bounding box of the entity in the frame. pub bounding_box: BoundingBox, @@ -100,7 +101,7 @@ pub struct VideoLocation { /// /// Exactly one variant is set per entity, enforcing the invariant that /// an entity exists in a single modality. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Location { /// Entity found in text content. diff --git a/crates/nvisy-server/src/extract/multipart.rs b/crates/nvisy-server/src/extract/multipart.rs index f087bd1..445dc6c 100644 --- a/crates/nvisy-server/src/extract/multipart.rs +++ b/crates/nvisy-server/src/extract/multipart.rs @@ -36,7 +36,10 @@ impl FromRequest for Upload { .await .map_err(|e| ErrorKind::BadRequest.with_message(format!("multipart error: {e}")))? { - let field_name = field.name().unwrap_or_default().to_string(); + let Some(field_name) = field.name().map(str::to_owned) else { + tracing::warn!("ignoring multipart field with no name"); + continue; + }; match field_name.as_str() { "file" => { filename = field.file_name().map(String::from); diff --git a/crates/nvisy-server/src/handler/execute.rs b/crates/nvisy-server/src/handler/execute.rs index 66e3a7c..f7c6d10 100644 --- a/crates/nvisy-server/src/handler/execute.rs +++ b/crates/nvisy-server/src/handler/execute.rs @@ -79,18 +79,37 @@ fn execute_docs(op: TransformOperation) -> TransformOperation { fn mime_from_filename(filename: &str) -> Option { let ext = filename.rsplit('.').next()?; let mime = match ext.to_ascii_lowercase().as_str() { + // Text "txt" => "text/plain", "csv" => "text/csv", "json" => "application/json", "xml" => "application/xml", "html" | "htm" => "text/html", + // Documents "pdf" => "application/pdf", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "tiff" | "tif" => "image/tiff", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + // Images + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "tiff" | "tif" => "image/tiff", + "bmp" => "image/bmp", + // Audio + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + "flac" => "audio/flac", + "aac" => "audio/aac", + // Video + "mp4" => "video/mp4", + "webm" => "video/webm", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + // Archives "zip" => "application/zip", _ => return None, }; diff --git a/crates/nvisy-server/src/handler/ingest.rs b/crates/nvisy-server/src/handler/ingest.rs index 035423f..b8df3d2 100644 --- a/crates/nvisy-server/src/handler/ingest.rs +++ b/crates/nvisy-server/src/handler/ingest.rs @@ -24,7 +24,7 @@ //! 3. Downstream detection via magic bytes / filename heuristics. use aide::axum::ApiRouter; -use aide::axum::routing::{delete_with, get_with}; +use aide::axum::routing::{delete_with, get_with, post_with}; use aide::transform::TransformOperation; use axum::extract::State; use nvisy_core::io::{Content, ContentData}; @@ -63,6 +63,16 @@ async fn upload( Ok(Json(UploadResponse { id })) } +fn upload_docs(op: TransformOperation) -> TransformOperation { + op.id("uploadContent") + .tag("ingest") + .summary("Upload content as multipart form data") + .description( + "Accepts a multipart/form-data body with a required `file` field \ + and an optional `content_type` text field to override MIME detection.", + ) +} + /// `GET /api/v1/ingest/{id}`: download previously uploaded content. /// /// Returns the content as base64-encoded bytes along with its identifier. @@ -127,7 +137,7 @@ fn delete_all_docs(op: TransformOperation) -> TransformOperation { /// Ingest routes. pub fn routes() -> ApiRouter { ApiRouter::new() - .route("/api/v1/ingest", axum::routing::post(upload)) + .api_route("/api/v1/ingest", post_with(upload, upload_docs)) .api_route( "/api/v1/ingest", delete_with(delete_all, delete_all_docs), diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index d276618..a853830 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -5,7 +5,7 @@ //! The top-level [`routes()`] function merges all fragments into a single //! router. //! -//! Request and response types live in the private [`request`] and [`response`] +//! Request and response types live in the [`request`] and [`response`] //! submodules. [`Error`], [`ErrorKind`], and [`Result`] are re-exported for //! use by middleware and extractors. diff --git a/crates/nvisy-server/src/handler/response/execute.rs b/crates/nvisy-server/src/handler/response/execute.rs index d95e476..42f4f4d 100644 --- a/crates/nvisy-server/src/handler/response/execute.rs +++ b/crates/nvisy-server/src/handler/response/execute.rs @@ -1,6 +1,8 @@ //! Execute response types. -use nvisy_engine::engine::{EngineOutput, RedactionSummary, RunOutput}; +use nvisy_engine::engine::{ + Audit, DetectionOutput, EngineOutput, PolicyEvaluation, RedactionSummary, RunOutput, +}; use schemars::JsonSchema; use serde::Serialize; use uuid::Uuid; @@ -10,14 +12,14 @@ use uuid::Uuid; pub struct ExecuteResponse { /// Unique run identifier. pub run_id: Uuid, - /// Detection output as opaque JSON (DetectionOutput lacks JsonSchema). - pub detection: serde_json::Value, - /// Policy evaluation as opaque JSON (PolicyEvaluation lacks JsonSchema). - pub evaluation: serde_json::Value, + /// Detection output (entities, source, timing). + pub detection: DetectionOutput, + /// Policy evaluation breakdown (redactions, reviews, suppressions). + pub evaluation: PolicyEvaluation, /// Per-source redaction summaries. pub summaries: Vec, - /// Audit trail entries as opaque JSON (Audit uses flatten). - pub audits: serde_json::Value, + /// Immutable audit trail. + pub audits: Vec, /// Per-node DAG execution results. pub run_output: RunOutput, } @@ -26,10 +28,10 @@ impl From for ExecuteResponse { fn from(out: EngineOutput) -> Self { Self { run_id: out.run_id, - detection: serde_json::to_value(&out.detection).unwrap_or_default(), - evaluation: serde_json::to_value(&out.evaluation).unwrap_or_default(), + detection: out.detection, + evaluation: out.evaluation, summaries: out.summaries, - audits: serde_json::to_value(&out.audits).unwrap_or_default(), + audits: out.audits, run_output: out.run_output, } } diff --git a/crates/nvisy-server/src/lib.rs b/crates/nvisy-server/src/lib.rs index 60e60c6..8c92dde 100644 --- a/crates/nvisy-server/src/lib.rs +++ b/crates/nvisy-server/src/lib.rs @@ -6,3 +6,7 @@ pub mod extract; pub mod handler; pub mod middleware; pub mod service; + +pub use handler::error::{Error, ErrorKind, Result}; +pub use handler::routes; +pub use service::ServiceState; diff --git a/crates/nvisy-server/src/middleware/observability.rs b/crates/nvisy-server/src/middleware/observability.rs index 411c1ac..77e08d4 100644 --- a/crates/nvisy-server/src/middleware/observability.rs +++ b/crates/nvisy-server/src/middleware/observability.rs @@ -18,6 +18,9 @@ use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; use tower_http::trace::{self, TraceLayer}; use tracing::Level; +/// Header name used for request correlation. +const REQUEST_ID_HEADER: &str = "x-request-id"; + /// Extension trait for [`Router`] to add observability middleware. /// /// Layers the full observability stack in the correct order: @@ -38,7 +41,7 @@ where { fn with_observability(self) -> Self { self.layer(PropagateRequestIdLayer::new( - header::HeaderName::from_static("x-request-id"), + header::HeaderName::from_static(REQUEST_ID_HEADER), )) .layer(SetSensitiveRequestHeadersLayer::new([ header::AUTHORIZATION, @@ -46,7 +49,7 @@ where ])) .layer(trace_layer()) .layer(SetRequestIdLayer::new( - header::HeaderName::from_static("x-request-id"), + header::HeaderName::from_static(REQUEST_ID_HEADER), MakeRequestUuid, )) } diff --git a/crates/nvisy-server/src/middleware/recovery.rs b/crates/nvisy-server/src/middleware/recovery.rs index 7b83321..6cfcf5e 100644 --- a/crates/nvisy-server/src/middleware/recovery.rs +++ b/crates/nvisy-server/src/middleware/recovery.rs @@ -91,6 +91,10 @@ where } } +/// Converts a Tower service error into an appropriate HTTP error response. +/// +/// Distinguishes timeouts ([`Elapsed`](tower::timeout::error::Elapsed)) +/// from other middleware errors and logs accordingly. fn handle_error(err: tower::BoxError) -> ResponseFut { use tower::timeout::error::Elapsed; @@ -116,6 +120,12 @@ fn handle_error(err: tower::BoxError) -> ResponseFut { ready(error.into_response()).boxed() } +/// Converts a panic payload into a `500 Internal Server Error` response. +/// +/// Returns `Response` directly (not a future) because +/// [`ResponseForPanic`](tower_http::catch_panic::ResponseForPanic) requires +/// a synchronous return, unlike [`handle_error`] which returns a +/// [`BoxFuture`](futures::future::BoxFuture). fn catch_panic(err: Panic) -> Response { let message = err .downcast_ref::() @@ -129,6 +139,7 @@ fn catch_panic(err: Panic) -> Response { "service panic", ); - let error = Error::new(ErrorKind::InternalServerError).with_message("service panic"); - error.into_response() + Error::new(ErrorKind::InternalServerError) + .with_message("service panic") + .into_response() } diff --git a/crates/nvisy-server/src/middleware/security.rs b/crates/nvisy-server/src/middleware/security.rs index e229198..6ca8594 100644 --- a/crates/nvisy-server/src/middleware/security.rs +++ b/crates/nvisy-server/src/middleware/security.rs @@ -13,9 +13,10 @@ use axum::Router; use axum::extract::DefaultBodyLimit; +use axum::http::HeaderValue; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; -use tower_http::cors::CorsLayer; +use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; /// Default request body limit for axum extractors (2 MiB). @@ -36,6 +37,10 @@ pub struct SecurityConfig { /// Maximum body size in bytes for the raw request body (file uploads). pub file_body_limit_bytes: usize, + + /// Allowed CORS origins. An empty list permits all origins (permissive). + #[serde(default)] + pub cors_allowed_origins: Vec, } impl Default for SecurityConfig { @@ -43,6 +48,7 @@ impl Default for SecurityConfig { Self { body_limit_bytes: DEFAULT_BODY_LIMIT, file_body_limit_bytes: DEFAULT_FILE_BODY_LIMIT, + cors_allowed_origins: Vec::new(), } } } @@ -60,9 +66,20 @@ where S: Clone + Send + Sync + 'static, { fn with_security(self, config: &SecurityConfig) -> Self { + let cors = if config.cors_allowed_origins.is_empty() { + CorsLayer::permissive() + } else { + let origins: Vec = config + .cors_allowed_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + CorsLayer::new().allow_origin(AllowOrigin::list(origins)) + }; + self.layer(DefaultBodyLimit::max(config.body_limit_bytes)) .layer(RequestBodyLimitLayer::new(config.file_body_limit_bytes)) .layer(CompressionLayer::new()) - .layer(CorsLayer::permissive()) + .layer(cors) } } From 9f3a001c641f937489617598e0a5346cc4d44f89 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 22:24:02 +0100 Subject: [PATCH 08/22] fix: consolidate JsonSchema derives, add CORS origins CLI arg Merge split `#[derive(schemars::JsonSchema)]` lines into single derive attributes using the short-form `JsonSchema` with proper imports across all crates. Add missing `cors_allowed_origins` field to CLI config. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-cli/src/config/mod.rs | 5 +++++ crates/nvisy-codec/src/transform/audio/output.rs | 4 ++-- crates/nvisy-codec/src/transform/image/output.rs | 4 ++-- crates/nvisy-codec/src/transform/text/output.rs | 4 ++-- crates/nvisy-core/src/fs/document_type.rs | 3 ++- crates/nvisy-core/src/math/mod.rs | 7 +++---- crates/nvisy-core/src/path/source.rs | 3 ++- crates/nvisy-engine/src/compiler/graph.rs | 10 ++++------ crates/nvisy-engine/src/compiler/retry.rs | 7 +++---- crates/nvisy-engine/src/engine/connections.rs | 4 ++-- crates/nvisy-engine/src/engine/executor.rs | 8 ++++---- crates/nvisy-engine/src/engine/runs.rs | 14 ++++++-------- crates/nvisy-identify/src/policy/audit.rs | 5 +++-- crates/nvisy-identify/src/policy/retention.rs | 5 +++-- crates/nvisy-identify/src/policy/summary.rs | 4 ++-- crates/nvisy-ontology/src/record/mod.rs | 3 +-- 16 files changed, 46 insertions(+), 44 deletions(-) diff --git a/crates/nvisy-cli/src/config/mod.rs b/crates/nvisy-cli/src/config/mod.rs index 6aa6dbb..7b2092b 100644 --- a/crates/nvisy-cli/src/config/mod.rs +++ b/crates/nvisy-cli/src/config/mod.rs @@ -55,6 +55,10 @@ pub struct Cli { /// Per-request timeout in seconds. #[arg(long, env = "REQUEST_TIMEOUT_SECS", default_value_t = 300)] pub request_timeout_secs: u64, + + /// Allowed CORS origins (repeat for multiple). Empty means permissive. + #[arg(long, env = "CORS_ALLOWED_ORIGINS", value_delimiter = ',')] + pub cors_allowed_origins: Vec, } impl Cli { @@ -63,6 +67,7 @@ impl Cli { SecurityConfig { body_limit_bytes: self.body_limit_bytes, file_body_limit_bytes: self.file_body_limit_bytes, + cors_allowed_origins: self.cors_allowed_origins.clone(), } } diff --git a/crates/nvisy-codec/src/transform/audio/output.rs b/crates/nvisy-codec/src/transform/audio/output.rs index 304ef97..c9df55c 100644 --- a/crates/nvisy-codec/src/transform/audio/output.rs +++ b/crates/nvisy-codec/src/transform/audio/output.rs @@ -1,10 +1,10 @@ //! Audio redaction output type. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Audio redaction output — records the method used. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] pub enum AudioRedactionOutput { /// Segment replaced with silence. diff --git a/crates/nvisy-codec/src/transform/image/output.rs b/crates/nvisy-codec/src/transform/image/output.rs index 78ec650..a9f5e85 100644 --- a/crates/nvisy-codec/src/transform/image/output.rs +++ b/crates/nvisy-codec/src/transform/image/output.rs @@ -1,10 +1,10 @@ //! Image redaction output type. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Image redaction output — records the method used and its parameters. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] pub enum ImageRedactionOutput { /// Gaussian blur applied to the region. diff --git a/crates/nvisy-codec/src/transform/text/output.rs b/crates/nvisy-codec/src/transform/text/output.rs index fd21dd9..0320aba 100644 --- a/crates/nvisy-codec/src/transform/text/output.rs +++ b/crates/nvisy-codec/src/transform/text/output.rs @@ -1,11 +1,11 @@ //! Text redaction output type. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Text redaction output — the codec only needs to know the replacement string /// or that the span should be removed entirely. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] pub enum TextRedactionOutput { /// Substituted with a replacement string. diff --git a/crates/nvisy-core/src/fs/document_type.rs b/crates/nvisy-core/src/fs/document_type.rs index cf2a955..68da536 100644 --- a/crates/nvisy-core/src/fs/document_type.rs +++ b/crates/nvisy-core/src/fs/document_type.rs @@ -1,10 +1,11 @@ //! Document format classification. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; /// Document format that content can be classified as. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum DocumentType { diff --git a/crates/nvisy-core/src/math/mod.rs b/crates/nvisy-core/src/math/mod.rs index eabc53c..879e387 100644 --- a/crates/nvisy-core/src/math/mod.rs +++ b/crates/nvisy-core/src/math/mod.rs @@ -3,11 +3,11 @@ //! Bounding boxes and time spans used across entity locations, //! rendering, and redaction operations. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A time interval within an audio or video stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TimeSpan { /// Start time in seconds from the beginning of the stream. pub start_secs: f64, @@ -20,8 +20,7 @@ pub struct TimeSpan { /// Coordinates are `f64` to support both pixel and normalized (0.0–1.0) /// values from detection models. Use [`BoundingBoxU32`] (or [`Into`]) /// when integer pixel coordinates are needed for rendering. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct BoundingBox { /// Horizontal offset of the top-left corner (pixels or normalized). pub x: f64, diff --git a/crates/nvisy-core/src/path/source.rs b/crates/nvisy-core/src/path/source.rs index ff8a1cd..30faa4a 100644 --- a/crates/nvisy-core/src/path/source.rs +++ b/crates/nvisy-core/src/path/source.rs @@ -6,6 +6,7 @@ use std::fmt; use jiff::Zoned; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -16,7 +17,7 @@ use uuid::Uuid; /// This allows for efficient tracking and correlation of content throughout /// the processing pipeline. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[derive(Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Serialize, Deserialize, JsonSchema)] pub struct ContentSource { /// `UUIDv7` identifier id: Uuid, diff --git a/crates/nvisy-engine/src/compiler/graph.rs b/crates/nvisy-engine/src/compiler/graph.rs index 24a3fe8..1be10a5 100644 --- a/crates/nvisy-engine/src/compiler/graph.rs +++ b/crates/nvisy-engine/src/compiler/graph.rs @@ -3,6 +3,7 @@ //! A pipeline is represented as a set of [`GraphNode`]s connected by //! [`GraphEdge`]s, collected into a [`Graph`]. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::retry::RetryPolicy; @@ -11,8 +12,7 @@ use super::retry::RetryPolicy; /// /// Nodes are serialized with a `"type"` discriminator so JSON definitions /// can specify `"source"`, `"action"`, or `"target"`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum GraphNode { /// A data source that reads from an external provider via a named stream. @@ -108,8 +108,7 @@ impl GraphNode { } /// A directed edge connecting two nodes by their IDs. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct GraphEdge { /// ID of the upstream (source) node. pub from: String, @@ -120,8 +119,7 @@ pub struct GraphEdge { /// A complete pipeline graph definition containing nodes and edges. /// /// The graph must be a valid DAG (directed acyclic graph) with unique node IDs. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Graph { /// All nodes in the pipeline. pub nodes: Vec, diff --git a/crates/nvisy-engine/src/compiler/retry.rs b/crates/nvisy-engine/src/compiler/retry.rs index 859d6fe..6f59bd6 100644 --- a/crates/nvisy-engine/src/compiler/retry.rs +++ b/crates/nvisy-engine/src/compiler/retry.rs @@ -3,13 +3,13 @@ //! [`RetryPolicy`] configures how many times a failed node should be retried, //! the base delay between attempts, and the [`BackoffStrategy`] to use. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Retry policy attached to a pipeline node. /// /// Defaults to 3 retries with a 1 000 ms fixed delay. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RetryPolicy { /// Maximum number of retry attempts after the initial failure. #[serde(default = "default_max_retries")] @@ -38,8 +38,7 @@ impl Default for RetryPolicy { } /// Strategy for computing the delay between retry attempts. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum BackoffStrategy { /// Constant delay equal to `delay_ms` on every attempt. diff --git a/crates/nvisy-engine/src/engine/connections.rs b/crates/nvisy-engine/src/engine/connections.rs index cab9543..a0dad8f 100644 --- a/crates/nvisy-engine/src/engine/connections.rs +++ b/crates/nvisy-engine/src/engine/connections.rs @@ -5,11 +5,11 @@ //! [`Connections`] is a type alias mapping connection IDs to their definitions. use std::collections::HashMap; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A validated connection to an external service such as S3 or a database. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Connection { /// Provider type identifier (e.g. `"s3"`, `"postgres"`). #[serde(rename = "type")] diff --git a/crates/nvisy-engine/src/engine/executor.rs b/crates/nvisy-engine/src/engine/executor.rs index 3c00b9b..70f51c9 100644 --- a/crates/nvisy-engine/src/engine/executor.rs +++ b/crates/nvisy-engine/src/engine/executor.rs @@ -14,6 +14,8 @@ use std::collections::HashMap; use std::sync::Arc; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; use tokio::task::JoinSet; use uuid::Uuid; @@ -29,8 +31,7 @@ use super::policies::{with_retry, with_timeout}; const CHANNEL_BUFFER_SIZE: usize = 256; /// Outcome of executing a single node in the pipeline. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct NodeOutput { /// ID of the node that produced this result. pub node_id: String, @@ -41,8 +42,7 @@ pub struct NodeOutput { } /// Aggregate outcome of executing an entire pipeline graph. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RunOutput { /// Unique identifier for this execution run. pub run_id: Uuid, diff --git a/crates/nvisy-engine/src/engine/runs.rs b/crates/nvisy-engine/src/engine/runs.rs index 956a85f..17ab84d 100644 --- a/crates/nvisy-engine/src/engine/runs.rs +++ b/crates/nvisy-engine/src/engine/runs.rs @@ -7,14 +7,15 @@ use std::collections::HashMap; use std::sync::Arc; use jiff::Timestamp; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::executor::RunOutput; /// Lifecycle status of a pipeline run. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum RunStatus { /// The run has been created but not yet started. @@ -32,8 +33,7 @@ pub enum RunStatus { } /// Execution progress of a single node within a run. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct NodeProgress { /// ID of the node this progress belongs to. pub node_id: String, @@ -47,8 +47,7 @@ pub struct NodeProgress { } /// Complete mutable state of a pipeline run. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RunState { /// Unique run identifier. pub id: Uuid, @@ -69,8 +68,7 @@ pub struct RunState { } /// Lightweight summary of a run for listing endpoints. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RunSummary { /// Unique run identifier. pub id: Uuid, diff --git a/crates/nvisy-identify/src/policy/audit.rs b/crates/nvisy-identify/src/policy/audit.rs index 0ce4bf9..84e077e 100644 --- a/crates/nvisy-identify/src/policy/audit.rs +++ b/crates/nvisy-identify/src/policy/audit.rs @@ -4,6 +4,7 @@ //! pipeline, carrying structured metadata for compliance. use jiff::Timestamp; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use uuid::Uuid; @@ -11,7 +12,7 @@ use uuid::Uuid; use nvisy_core::path::ContentSource; /// Kind of auditable action recorded in an [`Audit`] entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum AuditAction { @@ -27,7 +28,7 @@ pub enum AuditAction { /// /// Audit entries are emitted by pipeline actions and form a tamper-evident /// log of all detection, redaction, and policy decisions. -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Audit { /// Content source identity and lineage. #[serde(flatten)] diff --git a/crates/nvisy-identify/src/policy/retention.rs b/crates/nvisy-identify/src/policy/retention.rs index d7cc21d..9b3100f 100644 --- a/crates/nvisy-identify/src/policy/retention.rs +++ b/crates/nvisy-identify/src/policy/retention.rs @@ -2,11 +2,12 @@ use std::time::Duration; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; /// What class of data a retention policy applies to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum RetentionScope { @@ -19,7 +20,7 @@ pub enum RetentionScope { } /// A retention policy governing how long data is kept. -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RetentionPolicy { /// What class of data this policy applies to. pub scope: RetentionScope, diff --git a/crates/nvisy-identify/src/policy/summary.rs b/crates/nvisy-identify/src/policy/summary.rs index 2887221..a0e6452 100644 --- a/crates/nvisy-identify/src/policy/summary.rs +++ b/crates/nvisy-identify/src/policy/summary.rs @@ -1,12 +1,12 @@ //! Per-source redaction summary. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use nvisy_core::path::ContentSource; /// Summary of redactions applied to a single content source. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(schemars::JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RedactionSummary { /// The content source these counts apply to. #[serde(flatten)] diff --git a/crates/nvisy-ontology/src/record/mod.rs b/crates/nvisy-ontology/src/record/mod.rs index 6e0c50a..32d2d52 100644 --- a/crates/nvisy-ontology/src/record/mod.rs +++ b/crates/nvisy-ontology/src/record/mod.rs @@ -15,8 +15,7 @@ use crate::specification::RedactionInput; /// A redaction decision recording how a specific entity was (or will be) redacted. /// /// Each `Redaction` is linked to exactly one entity via `entity_id`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Redaction { /// Content source identity and lineage. #[serde(flatten)] From 0d74344473b668e4a69d2ca3eabf719035683db6 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Fri, 27 Feb 2026 22:37:42 +0100 Subject: [PATCH 09/22] fix(engine): use slice refs instead of &mut Vec in executor functions Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-engine/src/engine/executor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/nvisy-engine/src/engine/executor.rs b/crates/nvisy-engine/src/engine/executor.rs index 70f51c9..ae2adbc 100644 --- a/crates/nvisy-engine/src/engine/executor.rs +++ b/crates/nvisy-engine/src/engine/executor.rs @@ -249,7 +249,7 @@ async fn execute_action( action: &str, _params: &serde_json::Value, senders: &[mpsc::Sender], - receivers: &mut Vec>, + receivers: &mut [mpsc::Receiver], ) -> Result { tracing::debug!(action, "action node: processing"); @@ -278,7 +278,7 @@ async fn execute_target( stream: &str, _params: &serde_json::Value, retry: Option<&RetryPolicy>, - receivers: &mut Vec>, + receivers: &mut [mpsc::Receiver], connections: &Connections, ) -> Result { let _conn = resolve_connection(provider, connections)?; From 0175bc606b4d1b6d06bb057beca2065a09b324a6 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 28 Feb 2026 19:19:25 +0100 Subject: [PATCH 10/22] refactor(rig, core): consolidate ErrorKind, split Provider, restructure modules - Reduce nvisy-core ErrorKind from 12 to 9 variants: remove Python, InternalError, InvalidInput, Other; add Internal; rename all callers - Split monolithic Provider enum into domain-specific types: AgentProvider, TranscribeProvider, AudioGenProvider, ImageGenProvider - Rename synthesize/synthesis modules to generate, transcription to transcribe for consistent verb-form naming - Convert flat files to directory modules for future extensibility - Move client-building free functions to methods on AuthenticatedProvider/UnauthenticatedProvider - Extract build_http_client into backend/http_client.rs - Move ContextWindow from backend to agent/base (sole consumer) - Add Debug impls to all provider types (api_key redacted) - Remove all re-exports from lib.rs, use pub mod only Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-asr/src/parse.rs | 8 +- crates/nvisy-core/src/error.rs | 21 +-- crates/nvisy-core/src/fs/content_file.rs | 2 +- crates/nvisy-core/src/fs/content_registry.rs | 12 +- crates/nvisy-core/src/io/content_data.rs | 6 +- crates/nvisy-identify/src/method/cv.rs | 2 +- crates/nvisy-identify/src/method/ner.rs | 6 +- crates/nvisy-paddle/src/parse.rs | 4 +- crates/nvisy-python/src/bridge/error.rs | 2 +- crates/nvisy-python/src/bridge/mod.rs | 10 +- crates/nvisy-rig/Cargo.toml | 7 + crates/nvisy-rig/src/agent/base/agent.rs | 8 +- crates/nvisy-rig/src/agent/base/builder.rs | 51 ++---- .../src/{backend => agent/base}/context.rs | 0 crates/nvisy-rig/src/agent/base/detection.rs | 38 ++++ crates/nvisy-rig/src/agent/base/mod.rs | 10 ++ crates/nvisy-rig/src/agent/base/provider.rs | 78 +++++++++ .../src/{bridge => agent/base}/response.rs | 5 - crates/nvisy-rig/src/agent/cv/mod.rs | 5 +- crates/nvisy-rig/src/agent/cv/prompt.rs | 2 +- crates/nvisy-rig/src/agent/generate/mod.rs | 91 ++++++++++ crates/nvisy-rig/src/agent/generate/output.rs | 24 +++ crates/nvisy-rig/src/agent/generate/prompt.rs | 52 ++++++ crates/nvisy-rig/src/agent/mod.rs | 9 +- crates/nvisy-rig/src/agent/ner/mod.rs | 5 +- crates/nvisy-rig/src/agent/ner/prompt.rs | 80 ++++++++- crates/nvisy-rig/src/agent/ocr/mod.rs | 5 +- crates/nvisy-rig/src/agent/ocr/prompt.rs | 2 +- crates/nvisy-rig/src/audio/base/mod.rs | 11 ++ crates/nvisy-rig/src/audio/base/provider.rs | 135 +++++++++++++++ crates/nvisy-rig/src/audio/generate/mod.rs | 88 ++++++++++ crates/nvisy-rig/src/audio/mod.rs | 13 ++ crates/nvisy-rig/src/audio/transcribe/mod.rs | 124 ++++++++++++++ crates/nvisy-rig/src/backend/http_client.rs | 23 +++ crates/nvisy-rig/src/backend/mod.rs | 42 +---- crates/nvisy-rig/src/backend/provider.rs | 162 ++++++++---------- crates/nvisy-rig/src/bridge/mod.rs | 11 -- crates/nvisy-rig/src/bridge/prompt.rs | 101 ----------- crates/nvisy-rig/src/error.rs | 53 ++++++ crates/nvisy-rig/src/image/base/mod.rs | 6 + crates/nvisy-rig/src/image/base/provider.rs | 60 +++++++ crates/nvisy-rig/src/image/generate/mod.rs | 88 ++++++++++ crates/nvisy-rig/src/image/mod.rs | 11 ++ crates/nvisy-rig/src/lib.rs | 19 +- crates/nvisy-rig/src/prelude.rs | 18 +- .../src/handler/error/http_error.rs | 5 +- 46 files changed, 1139 insertions(+), 376 deletions(-) rename crates/nvisy-rig/src/{backend => agent/base}/context.rs (100%) create mode 100644 crates/nvisy-rig/src/agent/base/detection.rs create mode 100644 crates/nvisy-rig/src/agent/base/provider.rs rename crates/nvisy-rig/src/{bridge => agent/base}/response.rs (98%) create mode 100644 crates/nvisy-rig/src/agent/generate/mod.rs create mode 100644 crates/nvisy-rig/src/agent/generate/output.rs create mode 100644 crates/nvisy-rig/src/agent/generate/prompt.rs create mode 100644 crates/nvisy-rig/src/audio/base/mod.rs create mode 100644 crates/nvisy-rig/src/audio/base/provider.rs create mode 100644 crates/nvisy-rig/src/audio/generate/mod.rs create mode 100644 crates/nvisy-rig/src/audio/mod.rs create mode 100644 crates/nvisy-rig/src/audio/transcribe/mod.rs create mode 100644 crates/nvisy-rig/src/backend/http_client.rs delete mode 100644 crates/nvisy-rig/src/bridge/mod.rs delete mode 100644 crates/nvisy-rig/src/bridge/prompt.rs create mode 100644 crates/nvisy-rig/src/image/base/mod.rs create mode 100644 crates/nvisy-rig/src/image/base/provider.rs create mode 100644 crates/nvisy-rig/src/image/generate/mod.rs create mode 100644 crates/nvisy-rig/src/image/mod.rs diff --git a/crates/nvisy-asr/src/parse.rs b/crates/nvisy-asr/src/parse.rs index b23c8b3..6b5f55d 100644 --- a/crates/nvisy-asr/src/parse.rs +++ b/crates/nvisy-asr/src/parse.rs @@ -16,23 +16,23 @@ pub fn parse_transcribe_entities(raw: &[Value]) -> Result, Error> { for item in raw { let obj = item.as_object().ok_or_else(|| { - Error::python("Expected JSON object in transcription results".to_string()) + Error::runtime("Expected JSON object in transcription results", "python", false) })?; let text = obj .get("text") .and_then(Value::as_str) - .ok_or_else(|| Error::python("Missing 'text' in transcription result".to_string()))?; + .ok_or_else(|| Error::runtime("Missing 'text' in transcription result", "python", false))?; let start_time = obj .get("start_time") .and_then(Value::as_f64) - .ok_or_else(|| Error::python("Missing 'start_time'".to_string()))?; + .ok_or_else(|| Error::runtime("Missing 'start_time'", "python", false))?; let end_time = obj .get("end_time") .and_then(Value::as_f64) - .ok_or_else(|| Error::python("Missing 'end_time'".to_string()))?; + .ok_or_else(|| Error::runtime("Missing 'end_time'", "python", false))?; let confidence = obj .get("confidence") diff --git a/crates/nvisy-core/src/error.rs b/crates/nvisy-core/src/error.rs index 568ef0d..d648bf4 100644 --- a/crates/nvisy-core/src/error.rs +++ b/crates/nvisy-core/src/error.rs @@ -24,18 +24,12 @@ pub enum ErrorKind { Policy, /// An internal runtime error occurred. Runtime, - /// An error originating from the embedded Python bridge. - Python, /// An internal infrastructure error (filesystem, I/O). - InternalError, - /// The requested resource was not found. - NotFound, - /// The input was invalid or out of bounds. - InvalidInput, + Internal, /// A serialization or encoding error. Serialization, - /// An error that does not fit any other category. - Other, + /// The requested resource was not found. + NotFound, } /// Unified error type for the nvisy platform. @@ -130,11 +124,6 @@ impl Error { .with_retryable(retryable) } - /// Shorthand for a Python bridge error. - pub fn python(message: impl Into) -> Self { - Self::new(ErrorKind::Python, message) - } - /// Whether this error is retryable. pub fn is_retryable(&self) -> bool { self.retryable @@ -143,7 +132,7 @@ impl Error { impl From for Error { fn from(err: std::io::Error) -> Self { - Self::new(ErrorKind::InternalError, err.to_string()) + Self::new(ErrorKind::Internal, err.to_string()) .with_source(err) } } @@ -152,7 +141,7 @@ impl From for Error { fn from(err: anyhow::Error) -> Self { // anyhow::Error doesn't implement std::error::Error, so we capture the // full chain as text instead of storing it as a boxed source. - Self::new(ErrorKind::Other, format!("{err:#}")) + Self::new(ErrorKind::Runtime, format!("{err:#}")) } } diff --git a/crates/nvisy-core/src/fs/content_file.rs b/crates/nvisy-core/src/fs/content_file.rs index 0d46e06..ea90704 100644 --- a/crates/nvisy-core/src/fs/content_file.rs +++ b/crates/nvisy-core/src/fs/content_file.rs @@ -211,7 +211,7 @@ impl ContentFile { } if total_read + bytes_read > max_size { - return Err(Error::new(ErrorKind::InvalidInput, format!( + return Err(Error::new(ErrorKind::Validation, format!( "File size exceeds maximum limit of {max_size} bytes" ))); } diff --git a/crates/nvisy-core/src/fs/content_registry.rs b/crates/nvisy-core/src/fs/content_registry.rs index ece4cbb..c5d71b6 100644 --- a/crates/nvisy-core/src/fs/content_registry.rs +++ b/crates/nvisy-core/src/fs/content_registry.rs @@ -39,7 +39,7 @@ impl ContentRegistry { let dir = self.base_dir.join(content_source.to_string()); tokio::fs::create_dir_all(&dir).await.map_err(|err| { - Error::new(ErrorKind::InternalError, format!( + Error::new(ErrorKind::Internal, format!( "Failed to create temporary content directory (path: {})", dir.display() )).with_source(err) })?; @@ -48,7 +48,7 @@ impl ContentRegistry { tokio::fs::write(&data_path, content.as_bytes()) .await .map_err(|err| { - Error::new(ErrorKind::InternalError, format!( + Error::new(ErrorKind::Internal, format!( "Failed to write content data (path: {})", data_path.display() )).with_source(err) })?; @@ -76,7 +76,7 @@ impl ContentRegistry { } tokio::fs::remove_dir_all(&dir).await.map_err(|err| { Error::new( - ErrorKind::InternalError, + ErrorKind::Internal, format!("Failed to delete content directory (path: {})", dir.display()), ) .with_source(err) @@ -90,7 +90,7 @@ impl ContentRegistry { pub async fn delete_all(&self) -> Result { let mut entries = tokio::fs::read_dir(&self.base_dir).await.map_err(|err| { Error::new( - ErrorKind::InternalError, + ErrorKind::Internal, format!( "Failed to read content directory (path: {})", self.base_dir.display() @@ -102,7 +102,7 @@ impl ContentRegistry { let mut count = 0usize; while let Some(entry) = entries.next_entry().await.map_err(|err| { Error::new( - ErrorKind::InternalError, + ErrorKind::Internal, format!( "Failed to read content directory entry (path: {})", self.base_dir.display() @@ -112,7 +112,7 @@ impl ContentRegistry { })? { tokio::fs::remove_dir_all(entry.path()).await.map_err(|err| { Error::new( - ErrorKind::InternalError, + ErrorKind::Internal, format!( "Failed to delete content directory (path: {})", entry.path().display() diff --git a/crates/nvisy-core/src/io/content_data.rs b/crates/nvisy-core/src/io/content_data.rs index e1adb0c..4ef4591 100644 --- a/crates/nvisy-core/src/io/content_data.rs +++ b/crates/nvisy-core/src/io/content_data.rs @@ -357,7 +357,7 @@ impl ContentData { if actual_hash.as_ref() == expected { Ok(()) } else { - Err(Error::new(ErrorKind::InvalidInput, format!( + Err(Error::new(ErrorKind::Validation, format!( "Hash mismatch: expected {}, got {}", hex::encode(expected), hex::encode(actual_hash) @@ -374,14 +374,14 @@ impl ContentData { pub fn slice(&self, start: usize, end: usize) -> Result { let bytes = self.data.as_bytes(); if end > bytes.len() { - return Err(Error::new(ErrorKind::InvalidInput, format!( + return Err(Error::new(ErrorKind::Validation, format!( "Slice end {} exceeds content length {}", end, bytes.len() ))); } if start > end { - return Err(Error::new(ErrorKind::InvalidInput, + return Err(Error::new(ErrorKind::Validation, format!("Slice start {start} is greater than end {end}"))); } Ok(Bytes::copy_from_slice(&bytes[start..end])) diff --git a/crates/nvisy-identify/src/method/cv.rs b/crates/nvisy-identify/src/method/cv.rs index 3d9a697..e1be4da 100644 --- a/crates/nvisy-identify/src/method/cv.rs +++ b/crates/nvisy-identify/src/method/cv.rs @@ -5,7 +5,7 @@ use nvisy_codec::handler::{ImageData, Span}; use nvisy_core::Error; -use nvisy_rig::{CvAgent, CvEntity, DetectionConfig}; +use nvisy_rig::agent::{CvAgent, CvEntity, DetectionConfig}; use crate::{DetectionMethod, Entity, ImageLocation, Location}; use crate::{ParallelContext, DetectionService}; diff --git a/crates/nvisy-identify/src/method/ner.rs b/crates/nvisy-identify/src/method/ner.rs index 8bd7b5d..381c93b 100644 --- a/crates/nvisy-identify/src/method/ner.rs +++ b/crates/nvisy-identify/src/method/ner.rs @@ -11,8 +11,8 @@ use nvisy_codec::handler::{Span, TxtSpan}; use nvisy_ontology::entity::EntityKind; use nvisy_core::Error; use nvisy_ontology::entity::EntityCategory; -use nvisy_rig::{ - BaseAgentConfig, DetectionConfig, KnownNerEntity, NerAgent, NerContext, Provider, +use nvisy_rig::agent::{ + AgentProvider, BaseAgentConfig, DetectionConfig, KnownNerEntity, NerAgent, NerContext, }; use crate::{DetectionMethod, Entity, Location, TextLocation}; @@ -34,7 +34,7 @@ pub struct NerMethodParams { pub confidence_threshold: f64, /// Provider configuration for the NER agent. #[serde(skip)] - pub provider: Option, + pub provider: Option, /// Optional agent config overrides. #[serde(skip)] pub agent_config: Option, diff --git a/crates/nvisy-paddle/src/parse.rs b/crates/nvisy-paddle/src/parse.rs index 1c6d0dc..7bb1308 100644 --- a/crates/nvisy-paddle/src/parse.rs +++ b/crates/nvisy-paddle/src/parse.rs @@ -15,13 +15,13 @@ pub fn parse_ocr_entities(raw: &[Value]) -> Result, Error> { for item in raw { let obj = item.as_object().ok_or_else(|| { - Error::python("Expected JSON object in OCR results".to_string()) + Error::runtime("Expected JSON object in OCR results", "python", false) })?; let text = obj .get("text") .and_then(Value::as_str) - .ok_or_else(|| Error::python("Missing 'text' in OCR result".to_string()))?; + .ok_or_else(|| Error::runtime("Missing 'text' in OCR result", "python", false))?; let x = obj.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = obj.get("y").and_then(Value::as_f64).unwrap_or(0.0); diff --git a/crates/nvisy-python/src/bridge/error.rs b/crates/nvisy-python/src/bridge/error.rs index 3fd4056..f717cd0 100644 --- a/crates/nvisy-python/src/bridge/error.rs +++ b/crates/nvisy-python/src/bridge/error.rs @@ -14,6 +14,6 @@ pub fn from_pyerr(err: PyErr) -> Error { Some(tb) => format!("{}\n{}", err, tb), None => err.to_string(), }; - Error::python(msg) + Error::runtime(msg, "python", false) }) } diff --git a/crates/nvisy-python/src/bridge/mod.rs b/crates/nvisy-python/src/bridge/mod.rs index bd2e468..3bf8f07 100644 --- a/crates/nvisy-python/src/bridge/mod.rs +++ b/crates/nvisy-python/src/bridge/mod.rs @@ -76,15 +76,15 @@ impl PythonBridge { .map_err(from_pyerr)?; pythonize::depythonize::>(&result).map_err(|e| { - Error::python(format!( + Error::runtime(format!( "Failed to deserialize {} result: {}", method, e - )) + ), "python", false) }) }) }) .await - .map_err(|e| Error::python(format!("Task join error: {}", e)))? + .map_err(|e| Error::runtime(format!("Task join error: {}", e), "python", false))? } /// Call an **asynchronous** (coroutine) Python method on the bridge @@ -127,10 +127,10 @@ impl PythonBridge { Python::with_gil(|py| { pythonize::depythonize::>(py_result.bind(py)).map_err(|e| { - Error::python(format!( + Error::runtime(format!( "Failed to deserialize {} result: {}", method, e - )) + ), "python", false) }) }) } diff --git a/crates/nvisy-rig/Cargo.toml b/crates/nvisy-rig/Cargo.toml index 7ad6990..8bf9f73 100644 --- a/crates/nvisy-rig/Cargo.toml +++ b/crates/nvisy-rig/Cargo.toml @@ -15,6 +15,13 @@ repository = { workspace = true } homepage = { workspace = true } documentation = { workspace = true } +[features] +default = [] +## Enable text-to-speech audio synthesis via rig-core's audio generation API. +audio = ["rig-core/audio"] +## Enable image synthesis via rig-core's image generation API. +image = ["rig-core/image"] + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/nvisy-rig/src/agent/base/agent.rs b/crates/nvisy-rig/src/agent/base/agent.rs index 18a951f..a09adb0 100644 --- a/crates/nvisy-rig/src/agent/base/agent.rs +++ b/crates/nvisy-rig/src/agent/base/agent.rs @@ -9,12 +9,10 @@ use serde::de::DeserializeOwned; use serde::Serialize; use uuid::Uuid; -use crate::backend::{ContextWindow, Provider, UsageTracker}; -use crate::bridge::ResponseParser; +use crate::backend::UsageTracker; +use super::{AgentProvider, BaseAgentBuilder, ContextWindow, ResponseParser}; use crate::error::Error; -use super::BaseAgentBuilder; - /// Sampling, retry, context-window, and preamble settings shared by all agents. #[derive(Debug, Clone)] pub struct BaseAgentConfig { @@ -79,7 +77,7 @@ pub(crate) struct BaseAgent { #[allow(dead_code)] impl BaseAgent { - pub fn builder(provider: &Provider, config: BaseAgentConfig) -> BaseAgentBuilder { + pub fn builder(provider: &AgentProvider, config: BaseAgentConfig) -> BaseAgentBuilder { BaseAgentBuilder::new(provider, config) } diff --git a/crates/nvisy-rig/src/agent/base/builder.rs b/crates/nvisy-rig/src/agent/base/builder.rs index 5cae5ba..2fc0254 100644 --- a/crates/nvisy-rig/src/agent/base/builder.rs +++ b/crates/nvisy-rig/src/agent/base/builder.rs @@ -1,15 +1,14 @@ //! Builder for [`BaseAgent`](super::BaseAgent). -use reqwest_middleware::ClientWithMiddleware; use rig::agent::{Agent, AgentBuilder}; use rig::client::CompletionClient; use rig::completion::CompletionModel; -use rig::providers::{anthropic, gemini, ollama, openai}; +use rig::providers::gemini; use rig::tool::{Tool, ToolDyn}; use uuid::Uuid; -use crate::backend::{Provider, UsageTracker, build_http_client}; -use super::{Agents, BaseAgent, BaseAgentConfig}; +use crate::backend::{UsageTracker, build_http_client}; +use super::{AgentProvider, Agents, BaseAgent, BaseAgentConfig}; use crate::error::Error; /// Builder for [`BaseAgent`]. @@ -18,13 +17,13 @@ use crate::error::Error; /// and optional tools, then constructs the concrete rig-core agent on /// [`build`](Self::build). pub(crate) struct BaseAgentBuilder { - provider: Provider, + provider: AgentProvider, config: BaseAgentConfig, tools: Vec>, } impl BaseAgentBuilder { - pub fn new(provider: &Provider, config: BaseAgentConfig) -> Self { + pub fn new(provider: &AgentProvider, config: BaseAgentConfig) -> Self { Self { provider: provider.clone(), config, @@ -50,49 +49,25 @@ impl BaseAgentBuilder { let preamble = config.preamble.as_deref(); let inner = match &provider { - Provider::OpenAi(p) => { - let mut b = openai::Client::::builder() - .api_key(&p.api_key) - .http_client(http_client); - if let Some(url) = &p.base_url { - b = b.base_url(url); - } - let client = b.build().map_err(|e| Error::Client(e.to_string()))?; + AgentProvider::OpenAi(p) => { + let client = p.openai_client(http_client)?; let model = client.completions_api().completion_model(&p.model); Agents::OpenAi(build_rig_agent(model, &config, preamble, tools)) } - Provider::Anthropic(p) => { - let mut b = anthropic::Client::::builder() - .api_key(&p.api_key) - .http_client(http_client); - if let Some(url) = &p.base_url { - b = b.base_url(url); - } - let client = b.build().map_err(|e| Error::Client(e.to_string()))?; + AgentProvider::Anthropic(p) => { + let client = p.anthropic_client(http_client)?; let model = client.completion_model(&p.model); Agents::Anthropic(build_rig_agent(model, &config, preamble, tools)) } - Provider::Gemini(p) => { - let mut b = gemini::Client::::builder() - .api_key(&p.api_key) - .http_client(http_client); - if let Some(url) = &p.base_url { - b = b.base_url(url); - } - let client = b.build().map_err(|e| Error::Client(e.to_string()))?; + AgentProvider::Gemini(p) => { + let client = p.gemini_client(http_client)?; // rig-core 0.31: Gemini's Capabilities doesn't propagate H, // so CompletionClient is unavailable for non-default H. let model = gemini::completion::CompletionModel::new(client, &p.model); Agents::Gemini(build_rig_agent(model, &config, preamble, tools)) } - Provider::Ollama(p) => { - let mut b = ollama::Client::::builder() - .api_key(rig::client::Nothing) - .http_client(http_client); - if let Some(url) = &p.base_url { - b = b.base_url(url); - } - let client = b.build().map_err(|e| Error::Client(e.to_string()))?; + AgentProvider::Ollama(p) => { + let client = p.ollama_client(http_client)?; let model = client.completion_model(&p.model); Agents::Ollama(build_rig_agent(model, &config, preamble, tools)) } diff --git a/crates/nvisy-rig/src/backend/context.rs b/crates/nvisy-rig/src/agent/base/context.rs similarity index 100% rename from crates/nvisy-rig/src/backend/context.rs rename to crates/nvisy-rig/src/agent/base/context.rs diff --git a/crates/nvisy-rig/src/agent/base/detection.rs b/crates/nvisy-rig/src/agent/base/detection.rs new file mode 100644 index 0000000..3a6b3e6 --- /dev/null +++ b/crates/nvisy-rig/src/agent/base/detection.rs @@ -0,0 +1,38 @@ +//! Detection configuration, request, and response types. +//! +//! These were originally in `backend/mod.rs` but belong here because they +//! are agent-specific: every consumer that needs a [`DetectionConfig`] is +//! an agent or an agent prompt builder. + +use serde_json::Value; + +use nvisy_ontology::entity::EntityKind; + +/// Fallback hint used in prompts when no specific entity types are requested. +pub(crate) const ALL_TYPES_HINT: &str = "all entity types"; + +/// Configuration for entity detection: which types to look for and at what +/// confidence threshold. +#[derive(Debug, Clone)] +pub struct DetectionConfig { + /// Entity kinds to detect (empty = all). + pub entity_kinds: Vec, + /// Minimum confidence score to include a detection (0.0..=1.0). + pub confidence_threshold: f64, + /// System prompt override (if set, replaces the agent's default). + pub system_prompt: Option, +} + +/// Request payload for the detection service. +#[derive(Debug, Clone)] +pub struct DetectionRequest { + pub text: String, + pub config: DetectionConfig, +} + +/// Response from the detection service. +#[derive(Debug, Clone)] +pub struct DetectionResponse { + pub entities: Vec, + pub usage: Option, +} diff --git a/crates/nvisy-rig/src/agent/base/mod.rs b/crates/nvisy-rig/src/agent/base/mod.rs index 914639e..25e08a3 100644 --- a/crates/nvisy-rig/src/agent/base/mod.rs +++ b/crates/nvisy-rig/src/agent/base/mod.rs @@ -2,7 +2,17 @@ mod agent; mod builder; +mod context; +mod detection; +mod provider; +mod response; pub use agent::BaseAgentConfig; pub(crate) use agent::{Agents, BaseAgent}; pub(crate) use builder::BaseAgentBuilder; +pub use context::ContextWindow; +pub use provider::AgentProvider; + +pub use detection::{DetectionConfig, DetectionRequest, DetectionResponse}; +pub(crate) use detection::ALL_TYPES_HINT; +pub(crate) use response::ResponseParser; diff --git a/crates/nvisy-rig/src/agent/base/provider.rs b/crates/nvisy-rig/src/agent/base/provider.rs new file mode 100644 index 0000000..9f81e80 --- /dev/null +++ b/crates/nvisy-rig/src/agent/base/provider.rs @@ -0,0 +1,78 @@ +//! Provider configuration for LLM agents. + +use crate::backend::{AuthenticatedProvider, UnauthenticatedProvider}; + +/// Supported LLM providers for agent-based tasks (NER, CV, OCR, text generation). +/// +/// Each variant holds connection parameters and the model name. The actual +/// rig client is constructed lazily when an agent is built. +/// +/// # Example +/// ```rust,ignore +/// let provider = AgentProvider::openai("sk-...", "gpt-4o"); +/// let agent = NerAgent::new(&provider, config); +/// ``` +#[derive(Debug, Clone)] +pub enum AgentProvider { + /// OpenAI (GPT-4o, GPT-4, etc.) + OpenAi(AuthenticatedProvider), + /// Anthropic (Claude) + Anthropic(AuthenticatedProvider), + /// Google Gemini + Gemini(AuthenticatedProvider), + /// Ollama (local models) + Ollama(UnauthenticatedProvider), +} + +impl AgentProvider { + /// Create an OpenAI provider. + pub fn openai(api_key: &str, model: &str) -> Self { + Self::OpenAi(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// Create an Anthropic provider. + pub fn anthropic(api_key: &str, model: &str) -> Self { + Self::Anthropic(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// Create a Google Gemini provider. + pub fn gemini(api_key: &str, model: &str) -> Self { + Self::Gemini(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// Create an Ollama provider using the default local URL. + pub fn ollama(model: &str) -> Self { + Self::Ollama(UnauthenticatedProvider { + model: model.to_owned(), + base_url: None, + }) + } + + /// Create an Ollama provider with a custom base URL. + pub fn ollama_with_url(model: &str, url: &str) -> Self { + Self::Ollama(UnauthenticatedProvider { + model: model.to_owned(), + base_url: Some(url.to_owned()), + }) + } + + /// The model name for this provider. + pub fn model(&self) -> &str { + match self { + Self::OpenAi(p) | Self::Anthropic(p) | Self::Gemini(p) => &p.model, + Self::Ollama(p) => &p.model, + } + } +} diff --git a/crates/nvisy-rig/src/bridge/response.rs b/crates/nvisy-rig/src/agent/base/response.rs similarity index 98% rename from crates/nvisy-rig/src/bridge/response.rs rename to crates/nvisy-rig/src/agent/base/response.rs index 7a28e20..af0d970 100644 --- a/crates/nvisy-rig/src/bridge/response.rs +++ b/crates/nvisy-rig/src/agent/base/response.rs @@ -44,10 +44,6 @@ impl<'a> ResponseParser<'a> { Self { text: text.into() } } - pub fn as_str(&self) -> &str { - &self.text - } - pub fn into_string(self) -> String { self.text.into_owned() } @@ -137,5 +133,4 @@ mod tests { assert_eq!(ResponseParser::from_text("none").parse_json::>().unwrap(), empty); assert_eq!(ResponseParser::from_text("No entities").parse_json::>().unwrap(), empty); } - } diff --git a/crates/nvisy-rig/src/agent/cv/mod.rs b/crates/nvisy-rig/src/agent/cv/mod.rs index 8f42f79..9dee1bb 100644 --- a/crates/nvisy-rig/src/agent/cv/mod.rs +++ b/crates/nvisy-rig/src/agent/cv/mod.rs @@ -17,7 +17,8 @@ use base64::engine::general_purpose::STANDARD; use serde::Serialize; use uuid::Uuid; -use crate::backend::{DetectionConfig, Provider, UsageTracker}; +use crate::backend::UsageTracker; +use super::{AgentProvider, DetectionConfig}; use super::{BaseAgent, BaseAgentConfig}; use crate::error::Error; use prompt::{CV_SYSTEM_PROMPT, CvPromptBuilder}; @@ -67,7 +68,7 @@ pub struct CvAgent { impl CvAgent { /// Create a new CV agent. pub fn new( - provider: &Provider, + provider: &AgentProvider, mut config: BaseAgentConfig, cv: impl CvProvider + 'static, ) -> Result { diff --git a/crates/nvisy-rig/src/agent/cv/prompt.rs b/crates/nvisy-rig/src/agent/cv/prompt.rs index 81c3048..513c31f 100644 --- a/crates/nvisy-rig/src/agent/cv/prompt.rs +++ b/crates/nvisy-rig/src/agent/cv/prompt.rs @@ -3,7 +3,7 @@ //! [`CvPromptBuilder`] constructs the user prompt that instructs the VLM //! to call the CV tool and classify detections into entity categories. -use crate::backend::DetectionConfig; +use crate::agent::DetectionConfig; /// Fallback when no specific entity types are requested. const ALL_TYPES_HINT: &str = "all detectable object types"; diff --git a/crates/nvisy-rig/src/agent/generate/mod.rs b/crates/nvisy-rig/src/agent/generate/mod.rs new file mode 100644 index 0000000..44cfdc3 --- /dev/null +++ b/crates/nvisy-rig/src/agent/generate/mod.rs @@ -0,0 +1,91 @@ +//! Text generation agent for generating synthetic replacement values. +//! +//! [`GenAgent`] wraps a [`BaseAgent`](super::BaseAgent) with +//! generation-specific prompts. It is a pure LLM agent (no tools) that +//! generates realistic fake values to replace detected PII/entities. + +mod output; +mod prompt; + +pub use output::{GenOutput, GeneratedEntity}; + +use nvisy_ontology::entity::EntityKind; +use uuid::Uuid; + +use crate::backend::UsageTracker; +use crate::error::Error; + +use super::{AgentProvider, BaseAgent, BaseAgentConfig}; +use prompt::{GEN_SYSTEM_PROMPT, GenPromptBuilder}; + +/// A request to generate a replacement value for a single entity. +#[derive(Debug, Clone)] +pub struct GenRequest { + /// The type of entity to generate. + pub entity_type: EntityKind, + /// The original (real) value to replace. + pub original_value: String, + /// Optional surrounding text for context. + pub context: Option, + /// Optional locale hint (e.g. `"en-US"`). + pub locale: Option, +} + +/// Agent for generating synthetic replacement values using an LLM. +/// +/// # Workflow +/// +/// 1. Caller passes a batch of [`GenRequest`]s to +/// [`generate`](Self::generate). +/// 2. The agent builds a user prompt via [`GenPromptBuilder`]. +/// 3. Structured output is parsed into `Vec`. +pub struct GenAgent { + base: BaseAgent, +} + +impl GenAgent { + /// Create a new generation agent. + pub fn new(provider: &AgentProvider, mut config: BaseAgentConfig) -> Result { + config + .preamble + .get_or_insert_with(|| GEN_SYSTEM_PROMPT.into()); + let base = BaseAgent::builder(provider, config).build()?; + Ok(Self { base }) + } + + /// Unique identifier for this agent instance (UUIDv7). + pub fn id(&self) -> Uuid { + self.base.id() + } + + /// Access the usage tracker for this agent's LLM calls. + pub fn tracker(&self) -> &UsageTracker { + self.base.tracker() + } + + /// Generate synthetic replacement values for a batch of entities. + #[tracing::instrument( + skip_all, + fields(batch_size = requests.len(), agent = "gen"), + )] + pub async fn generate( + &self, + requests: &[GenRequest], + ) -> Result, Error> { + let prompt = GenPromptBuilder::build(requests); + + tracing::debug!( + prompt_len = prompt.len(), + "built gen prompt" + ); + + let result: GenOutput = self.base.prompt_structured(&prompt).await?; + + tracing::info!( + entity_count = result.entities.len(), + "text generation complete" + ); + + Ok(result.entities) + } +} diff --git a/crates/nvisy-rig/src/agent/generate/output.rs b/crates/nvisy-rig/src/agent/generate/output.rs new file mode 100644 index 0000000..51af9e4 --- /dev/null +++ b/crates/nvisy-rig/src/agent/generate/output.rs @@ -0,0 +1,24 @@ +//! Structured output types for text generation. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use nvisy_ontology::entity::EntityKind; + +/// A single generated entity — the original value replaced with a synthetic one. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct GeneratedEntity { + /// The entity type that was generated. + pub entity_type: EntityKind, + /// The original (real) value. + pub original_value: String, + /// The generated synthetic replacement value. + pub synthetic_value: String, +} + +/// Wrapper for structured output containing a batch of generated entities. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct GenOutput { + /// Generated entities. + pub entities: Vec, +} diff --git a/crates/nvisy-rig/src/agent/generate/prompt.rs b/crates/nvisy-rig/src/agent/generate/prompt.rs new file mode 100644 index 0000000..06e8b8e --- /dev/null +++ b/crates/nvisy-rig/src/agent/generate/prompt.rs @@ -0,0 +1,52 @@ +//! Generation-specific prompt construction. + +use super::GenRequest; + +/// Builds user prompts for text generation from a batch of requests. +pub(crate) struct GenPromptBuilder; + +impl GenPromptBuilder { + /// Build the user prompt from a batch of generation requests. + pub fn build(requests: &[GenRequest]) -> String { + let mut prompt = String::from( + "Generate realistic synthetic replacement values for each entity below. \ + Return a JSON object with an \"entities\" array.\n\n", + ); + + for (i, req) in requests.iter().enumerate() { + prompt.push_str(&format!( + "{}. entity_type={}, original_value=\"{}\"", + i + 1, + req.entity_type, + req.original_value, + )); + if let Some(ref ctx) = req.context { + prompt.push_str(&format!(", context=\"{ctx}\"")); + } + if let Some(ref locale) = req.locale { + prompt.push_str(&format!(", locale=\"{locale}\"")); + } + prompt.push('\n'); + } + + prompt + } +} + +/// Default system prompt for text generation. +pub(super) const GEN_SYSTEM_PROMPT: &str = "\ +You are a data synthesis system that generates realistic, format-matching \ +synthetic values to replace real PII/sensitive data. \ +For each entity provided, generate a plausible fake replacement that: \ +1) Matches the format and structure of the original (e.g. email → email, phone → phone). \ +2) Is contextually appropriate (e.g. a person name from the same cultural context). \ +3) Is clearly different from the original value. \ +4) Maintains consistency — if the same original_value appears multiple times, \ + produce the same synthetic_value each time. \ +Return results as a JSON object with an \"entities\" key containing an array of objects with keys: \ +entity_type, original_value, synthetic_value. \ +Examples: \ +PersonName \"John Smith\" → \"Michael Davis\"; \ +EmailAddress \"john@example.com\" → \"sarah.jones@mail.org\"; \ +PhoneNumber \"+1-555-123-4567\" → \"+1-555-987-6543\"; \ +IpAddress \"192.168.1.100\" → \"10.0.42.7\"."; diff --git a/crates/nvisy-rig/src/agent/mod.rs b/crates/nvisy-rig/src/agent/mod.rs index 2415c84..0b44d92 100644 --- a/crates/nvisy-rig/src/agent/mod.rs +++ b/crates/nvisy-rig/src/agent/mod.rs @@ -1,4 +1,5 @@ -//! Specialized detection agents: NER (text), CV (vision), and OCR (image-to-text). +//! Specialized detection agents: NER (text), CV (vision), OCR (image-to-text), +//! and text generation (synthetic replacement values). //! //! Each agent composes a [`BaseAgent`](base::BaseAgent) with domain-specific //! prompts and optional tools. Public types are re-exported from [`crate`] — @@ -6,12 +7,14 @@ mod base; mod cv; +mod generate; mod ner; mod ocr; -pub use base::BaseAgentConfig; -pub(crate) use base::BaseAgent; +pub use base::{AgentProvider, BaseAgentConfig, ContextWindow, DetectionConfig, DetectionRequest, DetectionResponse}; +pub(crate) use base::{BaseAgent, ALL_TYPES_HINT}; pub use cv::{CvAgent, CvDetection, CvEntities, CvEntity, CvProvider}; +pub use generate::{GenAgent, GenOutput, GenRequest, GeneratedEntity}; pub use ner::{KnownNerEntity, NerAgent, NerContext, NerEntities, NerEntity, ResolvedOffsets}; pub use ocr::{OcrAgent, OcrEntity, OcrOutput, OcrProvider, OcrTextRegion}; diff --git a/crates/nvisy-rig/src/agent/ner/mod.rs b/crates/nvisy-rig/src/agent/ner/mod.rs index bbccfe5..2eb566f 100644 --- a/crates/nvisy-rig/src/agent/ner/mod.rs +++ b/crates/nvisy-rig/src/agent/ner/mod.rs @@ -13,7 +13,8 @@ pub use output::{KnownNerEntity, NerEntities, NerEntity, ResolvedOffsets}; use uuid::Uuid; -use crate::backend::{DetectionConfig, Provider, UsageTracker}; +use crate::backend::UsageTracker; +use super::{AgentProvider, DetectionConfig}; use super::{BaseAgent, BaseAgentConfig}; use crate::error::Error; use prompt::{NER_SYSTEM_PROMPT, NerPromptBuilder}; @@ -33,7 +34,7 @@ pub struct NerAgent { impl NerAgent { /// Create a new NER agent. - pub fn new(provider: &Provider, mut config: BaseAgentConfig) -> Result { + pub fn new(provider: &AgentProvider, mut config: BaseAgentConfig) -> Result { config.preamble.get_or_insert_with(|| NER_SYSTEM_PROMPT.into()); let base = BaseAgent::builder(provider, config).build()?; Ok(Self { base }) diff --git a/crates/nvisy-rig/src/agent/ner/prompt.rs b/crates/nvisy-rig/src/agent/ner/prompt.rs index 27c54f6..eaf3254 100644 --- a/crates/nvisy-rig/src/agent/ner/prompt.rs +++ b/crates/nvisy-rig/src/agent/ner/prompt.rs @@ -1,13 +1,20 @@ //! NER-specific prompt construction. -use crate::backend::DetectionConfig; -use crate::bridge::PromptBuilder; +use crate::agent::{DetectionConfig, ALL_TYPES_HINT}; use super::KnownNerEntity; +/// Instruction prefix for the user prompt. +const DETECT_PREFIX: &str = "Detect entities of types"; + +/// Suffix describing the expected response format. +const RESPONSE_FORMAT: &str = "\ +Return a JSON array of objects with keys: \ +entity_id, category, entity_type, value, confidence, context."; + /// Builds user prompts for NER entity detection. pub(crate) struct NerPromptBuilder<'a> { - inner: PromptBuilder<'a>, + config: &'a DetectionConfig, known_entities: &'a [KnownNerEntity], } @@ -15,14 +22,29 @@ impl<'a> NerPromptBuilder<'a> { /// Create a prompt builder from a [`DetectionConfig`]. pub fn new(config: &'a DetectionConfig, known_entities: &'a [KnownNerEntity]) -> Self { Self { - inner: PromptBuilder::new(config), + config, known_entities, } } /// Build the user prompt for the given text. pub fn build(&self, text: &str) -> String { - let mut prompt = self.inner.build(text); + let types_hint = if self.config.entity_kinds.is_empty() { + ALL_TYPES_HINT.to_string() + } else { + self.config + .entity_kinds + .iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + }; + + let mut prompt = format!( + "{DETECT_PREFIX} [{types_hint}] with minimum confidence \ + {threshold:.2} in the following text. {RESPONSE_FORMAT}\n\n---\n{text}\n---", + threshold = self.config.confidence_threshold, + ); if !self.known_entities.is_empty() { prompt.push_str("\n\nPreviously identified entities (reuse their entity_id for coreferent mentions):\n"); @@ -67,3 +89,51 @@ The \"description\" field should be a brief description of the real-world entity (e.g. \"CEO of Acme Corp\", \"patient's home address\"). Provide it for the first mention \ of each entity or when additional context becomes available. \ If no entities are found, return {\"entities\": []}."; + +#[cfg(test)] +mod tests { + use super::*; + use nvisy_ontology::entity::EntityKind; + + #[test] + fn builds_prompt_with_entity_kinds() { + let config = DetectionConfig { + entity_kinds: vec![EntityKind::PersonName, EntityKind::GovernmentId], + confidence_threshold: 0.7, + system_prompt: None, + }; + let prompt = NerPromptBuilder::new(&config, &[]).build("Hello world"); + assert!(prompt.contains("person_name, government_id")); + assert!(prompt.contains("0.70")); + assert!(prompt.contains("Hello world")); + } + + #[test] + fn builds_prompt_without_entity_kinds() { + let config = DetectionConfig { + entity_kinds: vec![], + confidence_threshold: 0.5, + system_prompt: None, + }; + let prompt = NerPromptBuilder::new(&config, &[]).build("test"); + assert!(prompt.contains("all entity types")); + } + + #[test] + fn builds_prompt_with_known_entities() { + let config = DetectionConfig { + entity_kinds: vec![], + confidence_threshold: 0.8, + system_prompt: None, + }; + let known = vec![KnownNerEntity { + entity_id: "person_1".to_string(), + entity_type: Some(EntityKind::PersonName), + values: vec!["John".to_string()], + descriptions: vec!["CEO of Acme".to_string()], + }]; + let prompt = NerPromptBuilder::new(&config, &known).build("some text"); + assert!(prompt.contains("entity_id=person_1")); + assert!(prompt.contains("CEO of Acme")); + } +} diff --git a/crates/nvisy-rig/src/agent/ocr/mod.rs b/crates/nvisy-rig/src/agent/ocr/mod.rs index 539a034..3c3cf54 100644 --- a/crates/nvisy-rig/src/agent/ocr/mod.rs +++ b/crates/nvisy-rig/src/agent/ocr/mod.rs @@ -17,7 +17,8 @@ use base64::engine::general_purpose::STANDARD; use serde::Serialize; use uuid::Uuid; -use crate::backend::{DetectionConfig, Provider, UsageTracker}; +use crate::backend::UsageTracker; +use super::{AgentProvider, DetectionConfig}; use super::{BaseAgent, BaseAgentConfig}; use crate::error::Error; use prompt::{OCR_SYSTEM_PROMPT, OcrPromptBuilder}; @@ -70,7 +71,7 @@ pub struct OcrAgent { impl OcrAgent { /// Create a new OCR agent. pub fn new( - provider: &Provider, + provider: &AgentProvider, mut config: BaseAgentConfig, ocr: impl OcrProvider + 'static, ) -> Result { diff --git a/crates/nvisy-rig/src/agent/ocr/prompt.rs b/crates/nvisy-rig/src/agent/ocr/prompt.rs index bfb7384..66b2f5e 100644 --- a/crates/nvisy-rig/src/agent/ocr/prompt.rs +++ b/crates/nvisy-rig/src/agent/ocr/prompt.rs @@ -3,7 +3,7 @@ //! [`OcrPromptBuilder`] constructs the user prompt that instructs the VLM //! to call the OCR tool and then detect entities in the extracted text. -use crate::backend::{DetectionConfig, ALL_TYPES_HINT}; +use crate::agent::{DetectionConfig, ALL_TYPES_HINT}; /// Builds user prompts for OCR-based entity extraction. /// diff --git a/crates/nvisy-rig/src/audio/base/mod.rs b/crates/nvisy-rig/src/audio/base/mod.rs new file mode 100644 index 0000000..661baa4 --- /dev/null +++ b/crates/nvisy-rig/src/audio/base/mod.rs @@ -0,0 +1,11 @@ +//! Shared provider dispatch enums for audio models. + +mod provider; + +pub use provider::TranscribeProvider; +pub(crate) use provider::TranscribeModels; + +#[cfg(feature = "audio")] +pub use provider::AudioGenProvider; +#[cfg(feature = "audio")] +pub(crate) use provider::AudioGenModels; diff --git a/crates/nvisy-rig/src/audio/base/provider.rs b/crates/nvisy-rig/src/audio/base/provider.rs new file mode 100644 index 0000000..a444a68 --- /dev/null +++ b/crates/nvisy-rig/src/audio/base/provider.rs @@ -0,0 +1,135 @@ +//! Provider-erased dispatch enums and constructors for audio models. + +use reqwest_middleware::ClientWithMiddleware; +use rig::providers::{gemini, openai}; + +use crate::backend::{AuthenticatedProvider, build_http_client}; +use crate::error::Error; + +/// Supported providers for speech-to-text transcription. +/// +/// Only OpenAI (Whisper) and Gemini support transcription. +#[derive(Debug, Clone)] +pub enum TranscribeProvider { + /// OpenAI (Whisper) + OpenAi(AuthenticatedProvider), + /// Google Gemini + Gemini(AuthenticatedProvider), +} + +impl TranscribeProvider { + /// Create an OpenAI transcription provider. + pub fn openai(api_key: &str, model: &str) -> Self { + Self::OpenAi(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// Create a Gemini transcription provider. + pub fn gemini(api_key: &str, model: &str) -> Self { + Self::Gemini(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// The model name for this provider. + pub fn model(&self) -> &str { + match self { + Self::OpenAi(p) | Self::Gemini(p) => &p.model, + } + } +} + +/// Provider-erased dispatch enum for transcription models. +pub(crate) enum TranscribeModels { + OpenAi(openai::transcription::TranscriptionModel), + Gemini(gemini::transcription::TranscriptionModel), +} + +impl TranscribeModels { + /// Build the appropriate transcription model for the given provider. + pub fn from_provider( + provider: &TranscribeProvider, + model: &str, + max_retries: u32, + ) -> Result { + let http = build_http_client(max_retries); + + match provider { + TranscribeProvider::OpenAi(p) => { + let client = p.openai_client(http)?; + let model = + openai::transcription::TranscriptionModel::new(client, model); + Ok(Self::OpenAi(model)) + } + TranscribeProvider::Gemini(p) => { + let client = p.gemini_client(http)?; + // rig-core 0.31: Gemini's Capabilities doesn't propagate H, + // so TranscriptionClient is unavailable for non-default H. + let model = + gemini::transcription::TranscriptionModel::new(client, model); + Ok(Self::Gemini(model)) + } + } + } +} + +/// Supported providers for audio generation (TTS). +/// +/// Currently only OpenAI supports TTS. +#[cfg(feature = "audio")] +#[derive(Debug, Clone)] +pub enum AudioGenProvider { + /// OpenAI (tts-1, tts-1-hd) + OpenAi(AuthenticatedProvider), +} + +#[cfg(feature = "audio")] +impl AudioGenProvider { + /// Create an OpenAI audio generation provider. + pub fn openai(api_key: &str, model: &str) -> Self { + Self::OpenAi(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// The model name for this provider. + pub fn model(&self) -> &str { + match self { + Self::OpenAi(p) => &p.model, + } + } +} + +/// Provider-erased dispatch enum for audio generation (TTS) models. +#[cfg(feature = "audio")] +pub(crate) enum AudioGenModels { + OpenAi(openai::audio_generation::AudioGenerationModel), +} + +#[cfg(feature = "audio")] +impl AudioGenModels { + /// Build the appropriate audio generation model for the given provider. + pub fn from_provider( + provider: &AudioGenProvider, + model: &str, + max_retries: u32, + ) -> Result { + let http = build_http_client(max_retries); + + match provider { + AudioGenProvider::OpenAi(p) => { + let client = p.openai_client(http)?; + let model = + openai::audio_generation::AudioGenerationModel::new(client, model); + Ok(Self::OpenAi(model)) + } + } + } +} diff --git a/crates/nvisy-rig/src/audio/generate/mod.rs b/crates/nvisy-rig/src/audio/generate/mod.rs new file mode 100644 index 0000000..92d95dc --- /dev/null +++ b/crates/nvisy-rig/src/audio/generate/mod.rs @@ -0,0 +1,88 @@ +//! Text-to-speech audio generation service wrapping rig-core's `AudioGenerationModel`. + +use rig::audio_generation::AudioGenerationModel as _; +use uuid::Uuid; + +use crate::error::Error; + +use super::base::{AudioGenModels, AudioGenProvider}; + +/// Configuration for the audio generation (TTS) service. +#[derive(Debug, Clone)] +pub struct AudioGenConfig { + /// Model name (e.g. `"tts-1"`, `"tts-1-hd"`). + pub model: String, + /// Voice name (e.g. `"alloy"`, `"nova"`, `"shimmer"`). + pub voice: String, + /// Playback speed multiplier (default: 1.0). + pub speed: f32, + /// Maximum retries for transient HTTP errors (default: 3). + pub max_retries: u32, +} + +impl Default for AudioGenConfig { + fn default() -> Self { + Self { + model: "tts-1".to_owned(), + voice: "alloy".to_owned(), + speed: 1.0, + max_retries: 3, + } + } +} + +/// Text-to-speech generation service wrapping rig-core audio generation providers. +/// +/// Currently only supports OpenAI (tts-1, tts-1-hd). +pub struct AudioGenService { + id: Uuid, + inner: AudioGenModels, + config: AudioGenConfig, +} + +impl AudioGenService { + /// Create a new audio generation service for the given provider. + /// + /// # Errors + /// + /// Returns [`Error::Client`] if client construction fails. + pub fn new(provider: &AudioGenProvider, config: AudioGenConfig) -> Result { + let inner = + AudioGenModels::from_provider(provider, &config.model, config.max_retries)?; + + Ok(Self { + id: Uuid::now_v7(), + inner, + config, + }) + } + + /// Unique identifier for this service instance (UUIDv7). + pub fn id(&self) -> Uuid { + self.id + } + + /// Generate speech from text, returning raw audio bytes. + #[tracing::instrument( + skip_all, + fields(service_id = %self.id, text_len = text.len()), + )] + pub async fn generate(&self, text: &str) -> Result, Error> { + let audio = match &self.inner { + AudioGenModels::OpenAi(model) => { + let response = model + .audio_generation_request() + .text(text) + .voice(&self.config.voice) + .speed(self.config.speed) + .send() + .await?; + response.audio + } + }; + + tracing::info!(audio_len = audio.len(), "audio generation complete"); + + Ok(audio) + } +} diff --git a/crates/nvisy-rig/src/audio/mod.rs b/crates/nvisy-rig/src/audio/mod.rs new file mode 100644 index 0000000..9280d43 --- /dev/null +++ b/crates/nvisy-rig/src/audio/mod.rs @@ -0,0 +1,13 @@ +//! Audio services: speech-to-text transcription and text-to-speech generation. + +mod base; +pub mod transcribe; + +pub use base::TranscribeProvider; + +#[cfg(feature = "audio")] +pub use base::AudioGenProvider; + +#[cfg(feature = "audio")] +#[cfg_attr(docsrs, doc(cfg(feature = "audio")))] +pub mod generate; diff --git a/crates/nvisy-rig/src/audio/transcribe/mod.rs b/crates/nvisy-rig/src/audio/transcribe/mod.rs new file mode 100644 index 0000000..a9eeb64 --- /dev/null +++ b/crates/nvisy-rig/src/audio/transcribe/mod.rs @@ -0,0 +1,124 @@ +//! Speech-to-text transcription service wrapping rig-core's `TranscriptionModel`. +//! +//! Not an LLM agent — directly calls the provider's transcription API (OpenAI +//! Whisper, Gemini). Follows the same provider-dispatch enum pattern as +//! [`BaseAgent`](crate::agent::BaseAgent). + +use rig::transcription::TranscriptionModel; +use uuid::Uuid; + +use crate::error::Error; + +use super::base::{TranscribeModels, TranscribeProvider}; + +/// Configuration for the transcription service. +#[derive(Debug, Clone)] +pub struct TranscribeConfig { + /// Model name (e.g. `"whisper-1"`). + pub model: String, + /// BCP-47 language code (e.g. `"en"`, `"de"`). + pub language: Option, + /// Sampling temperature for the transcription model. + pub temperature: Option, + /// Context hint / prompt for the transcription model. + pub prompt: Option, + /// Maximum retries for transient HTTP errors (default: 3). + pub max_retries: u32, +} + +impl Default for TranscribeConfig { + fn default() -> Self { + Self { + model: "whisper-1".to_owned(), + language: None, + temperature: None, + prompt: None, + max_retries: 3, + } + } +} + +/// Transcription result. +#[derive(Debug, Clone)] +pub struct TranscribeOutput { + /// The transcribed text. + pub text: String, +} + +/// Speech-to-text service wrapping rig-core transcription providers. +/// +/// Supports OpenAI (Whisper) and Gemini. +pub struct TranscribeService { + id: Uuid, + inner: TranscribeModels, + config: TranscribeConfig, +} + +impl TranscribeService { + /// Create a new transcription service for the given provider. + /// + /// # Errors + /// + /// Returns [`Error::Client`] if client construction fails. + pub fn new(provider: &TranscribeProvider, config: TranscribeConfig) -> Result { + let inner = + TranscribeModels::from_provider(provider, &config.model, config.max_retries)?; + + Ok(Self { + id: Uuid::now_v7(), + inner, + config, + }) + } + + /// Unique identifier for this service instance (UUIDv7). + pub fn id(&self) -> Uuid { + self.id + } + + /// Transcribe audio data to text. + /// + /// # Arguments + /// + /// * `audio_data` — raw audio bytes (MP3, WAV, etc.). + /// * `filename` — original filename, used for MIME-type detection. + #[tracing::instrument( + skip_all, + fields(service_id = %self.id, data_len = audio_data.len(), filename), + )] + pub async fn transcribe( + &self, + audio_data: &[u8], + filename: &str, + ) -> Result { + macro_rules! build_and_send { + ($model:expr) => {{ + let mut builder = $model + .transcription_request() + .data(audio_data.to_vec()) + .filename(Some(filename.to_owned())); + + if let Some(ref lang) = self.config.language { + builder = builder.language(lang.clone()); + } + if let Some(temp) = self.config.temperature { + builder = builder.temperature(temp); + } + if let Some(ref prompt) = self.config.prompt { + builder = builder.prompt(prompt.clone()); + } + + builder.send().await?.text + }}; + } + + let text = match &self.inner { + TranscribeModels::OpenAi(model) => build_and_send!(model), + TranscribeModels::Gemini(model) => build_and_send!(model), + }; + + tracing::info!(text_len = text.len(), "transcription complete"); + + Ok(TranscribeOutput { text }) + } +} diff --git a/crates/nvisy-rig/src/backend/http_client.rs b/crates/nvisy-rig/src/backend/http_client.rs new file mode 100644 index 0000000..07a947c --- /dev/null +++ b/crates/nvisy-rig/src/backend/http_client.rs @@ -0,0 +1,23 @@ +//! Shared HTTP client with timeout, retry, and tracing middleware. + +use std::time::Duration; + +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; +use reqwest_tracing::TracingMiddleware; + +/// Build a `ClientWithMiddleware` with timeout, retry, and tracing middleware. +pub(crate) fn build_http_client(max_retries: u32) -> ClientWithMiddleware { + let retry_policy = ExponentialBackoff::builder() + .build_with_max_retries(max_retries); + + let client = reqwest_middleware::reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("failed to build reqwest client"); + + ClientBuilder::new(client) + .with(TracingMiddleware::default()) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build() +} diff --git a/crates/nvisy-rig/src/backend/mod.rs b/crates/nvisy-rig/src/backend/mod.rs index 07660c0..7f0dbc8 100644 --- a/crates/nvisy-rig/src/backend/mod.rs +++ b/crates/nvisy-rig/src/backend/mod.rs @@ -1,43 +1,9 @@ -//! LLM backend: provider connections, context windowing, and usage tracking. +//! LLM backend: provider connections and usage tracking. -mod context; +mod http_client; mod metrics; mod provider; -pub use context::ContextWindow; pub use metrics::{UsageStats, UsageTracker}; -pub use provider::{AuthenticatedProvider, Provider, UnauthenticatedProvider}; -pub(crate) use provider::build_http_client; - -use serde_json::Value; - -use nvisy_ontology::entity::EntityKind; - -/// Fallback hint used in prompts when no specific entity types are requested. -pub(crate) const ALL_TYPES_HINT: &str = "all entity types"; - -/// Configuration for entity detection: which types to look for and at what -/// confidence threshold. -#[derive(Debug, Clone)] -pub struct DetectionConfig { - /// Entity kinds to detect (empty = all). - pub entity_kinds: Vec, - /// Minimum confidence score to include a detection (0.0..=1.0). - pub confidence_threshold: f64, - /// System prompt override (if set, replaces the agent's default). - pub system_prompt: Option, -} - -/// Request payload for the detection service. -#[derive(Debug, Clone)] -pub struct DetectionRequest { - pub text: String, - pub config: DetectionConfig, -} - -/// Response from the detection service. -#[derive(Debug, Clone)] -pub struct DetectionResponse { - pub entities: Vec, - pub usage: Option, -} +pub use provider::{AuthenticatedProvider, UnauthenticatedProvider}; +pub(crate) use http_client::build_http_client; diff --git a/crates/nvisy-rig/src/backend/provider.rs b/crates/nvisy-rig/src/backend/provider.rs index be98030..037c99b 100644 --- a/crates/nvisy-rig/src/backend/provider.rs +++ b/crates/nvisy-rig/src/backend/provider.rs @@ -1,15 +1,14 @@ //! LLM provider connection parameters. //! -//! [`Provider`] is a plain enum carrying API keys, model names, and optional -//! base URLs. The actual rig-core client is constructed lazily when a -//! [`BaseAgent`](super::BaseAgent) is built. +//! Provider structs carry API keys, model names, and optional base URLs. +//! The actual rig-core client is constructed lazily when a service is built. -use std::time::Duration; +use std::fmt; -use reqwest_middleware::ClientBuilder; use reqwest_middleware::ClientWithMiddleware; -use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; -use reqwest_tracing::TracingMiddleware; +use rig::providers::{anthropic, gemini, ollama, openai}; + +use crate::error::Error; /// Provider that requires an API key (OpenAI, Anthropic, Gemini). #[derive(Clone)] @@ -19,100 +18,79 @@ pub struct AuthenticatedProvider { pub base_url: Option, } -/// Provider that does not require an API key (Ollama). -#[derive(Clone)] -pub struct UnauthenticatedProvider { - pub model: String, - pub base_url: Option, -} - -/// Supported LLM providers. -/// -/// Each variant holds connection parameters and the model name. The actual -/// rig client is constructed lazily when an agent is built. -/// -/// # Example -/// ```rust,ignore -/// let provider = Provider::openai("sk-...", "gpt-4o"); -/// let agent = NerAgent::new(&provider, config); -/// ``` -#[derive(Clone)] -pub enum Provider { - /// OpenAI (GPT-4o, GPT-4, etc.) - OpenAi(AuthenticatedProvider), - /// Anthropic (Claude) - Anthropic(AuthenticatedProvider), - /// Google Gemini - Gemini(AuthenticatedProvider), - /// Ollama (local models) - Ollama(UnauthenticatedProvider), -} - -impl Provider { - /// Create an OpenAI provider. - pub fn openai(api_key: &str, model: &str) -> Self { - Self::OpenAi(AuthenticatedProvider { - api_key: api_key.to_owned(), - model: model.to_owned(), - base_url: None, - }) - } - - /// Create an Anthropic provider. - pub fn anthropic(api_key: &str, model: &str) -> Self { - Self::Anthropic(AuthenticatedProvider { - api_key: api_key.to_owned(), - model: model.to_owned(), - base_url: None, - }) - } - - /// Create a Google Gemini provider. - pub fn gemini(api_key: &str, model: &str) -> Self { - Self::Gemini(AuthenticatedProvider { - api_key: api_key.to_owned(), - model: model.to_owned(), - base_url: None, - }) +impl fmt::Debug for AuthenticatedProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthenticatedProvider") + .field("api_key", &"***") + .field("model", &self.model) + .field("base_url", &self.base_url) + .finish() } +} - /// Create an Ollama provider using the default local URL. - pub fn ollama(model: &str) -> Self { - Self::Ollama(UnauthenticatedProvider { - model: model.to_owned(), - base_url: None, - }) +impl AuthenticatedProvider { + /// Build an OpenAI rig-core client. + pub(crate) fn openai_client( + &self, + http: ClientWithMiddleware, + ) -> Result, Error> { + let mut b = openai::Client::::builder() + .api_key(&self.api_key) + .http_client(http); + if let Some(url) = &self.base_url { + b = b.base_url(url); + } + b.build().map_err(|e| Error::Client(e.to_string())) } - /// Create an Ollama provider with a custom base URL. - pub fn ollama_with_url(model: &str, url: &str) -> Self { - Self::Ollama(UnauthenticatedProvider { - model: model.to_owned(), - base_url: Some(url.to_owned()), - }) + /// Build a Gemini rig-core client. + pub(crate) fn gemini_client( + &self, + http: ClientWithMiddleware, + ) -> Result, Error> { + let mut b = gemini::Client::::builder() + .api_key(&self.api_key) + .http_client(http); + if let Some(url) = &self.base_url { + b = b.base_url(url); + } + b.build().map_err(|e| Error::Client(e.to_string())) } - /// The model name for this provider. - pub fn model(&self) -> &str { - match self { - Self::OpenAi(p) | Self::Anthropic(p) | Self::Gemini(p) => &p.model, - Self::Ollama(p) => &p.model, + /// Build an Anthropic rig-core client. + pub(crate) fn anthropic_client( + &self, + http: ClientWithMiddleware, + ) -> Result, Error> { + let mut b = anthropic::Client::::builder() + .api_key(&self.api_key) + .http_client(http); + if let Some(url) = &self.base_url { + b = b.base_url(url); } + b.build().map_err(|e| Error::Client(e.to_string())) } } -/// Build a `ClientWithMiddleware` with timeout, retry, and tracing middleware. -pub(crate) fn build_http_client(max_retries: u32) -> ClientWithMiddleware { - let retry_policy = ExponentialBackoff::builder() - .build_with_max_retries(max_retries); - - let client = reqwest_middleware::reqwest::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .expect("failed to build reqwest client"); +/// Provider that does not require an API key (Ollama). +#[derive(Debug, Clone)] +pub struct UnauthenticatedProvider { + pub model: String, + pub base_url: Option, +} - ClientBuilder::new(client) - .with(TracingMiddleware::default()) - .with(RetryTransientMiddleware::new_with_policy(retry_policy)) - .build() +impl UnauthenticatedProvider { + /// Build an Ollama rig-core client. + pub(crate) fn ollama_client( + &self, + http: ClientWithMiddleware, + ) -> Result, Error> { + let mut b = ollama::Client::::builder() + .api_key(rig::client::Nothing) + .http_client(http); + if let Some(url) = &self.base_url { + b = b.base_url(url); + } + b.build().map_err(|e| Error::Client(e.to_string())) + } } diff --git a/crates/nvisy-rig/src/bridge/mod.rs b/crates/nvisy-rig/src/bridge/mod.rs deleted file mode 100644 index 4f2b725..0000000 --- a/crates/nvisy-rig/src/bridge/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Prompt construction and LLM response parsing. -//! -//! [`PromptBuilder`] assembles user prompts with entity-kind filters and -//! confidence thresholds. [`ResponseParser`] extracts and deserializes -//! text from rig-core completion responses. - -mod prompt; -mod response; - -pub use prompt::PromptBuilder; -pub use response::ResponseParser; diff --git a/crates/nvisy-rig/src/bridge/prompt.rs b/crates/nvisy-rig/src/bridge/prompt.rs deleted file mode 100644 index efe095d..0000000 --- a/crates/nvisy-rig/src/bridge/prompt.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! User-prompt construction for LLM entity detection. -//! -//! [`PromptBuilder`] formats the entity-kind list, confidence threshold, -//! and input text into a single prompt string that agent-specific prompt -//! builders can delegate to. - -use std::fmt::Display; - -use nvisy_ontology::entity::EntityKind; - -use crate::backend::{DetectionConfig, ALL_TYPES_HINT}; - -/// Instruction prefix for the user prompt. -const DETECT_PREFIX: &str = "Detect entities of types"; - -/// Suffix describing the expected response format. -const RESPONSE_FORMAT: &str = "\ -Return a JSON array of objects with keys: \ -entity_id, category, entity_type, value, confidence, context."; - -/// Builds user prompts for entity detection requests. -pub struct PromptBuilder<'a> { - entity_kinds: &'a [EntityKind], - confidence_threshold: f64, -} - -impl<'a> PromptBuilder<'a> { - /// Create a prompt builder from a [`DetectionConfig`]. - pub fn new(config: &'a DetectionConfig) -> Self { - Self { - entity_kinds: &config.entity_kinds, - confidence_threshold: config.confidence_threshold, - } - } - - /// Build the user prompt for the given text. - pub fn build(&self, text: &str) -> String { - self.build_for(self.entity_kinds, text) - } - - /// Build a prompt using an arbitrary slice of displayable entity labels. - /// - /// This allows callers to pass any `Vec` where `E: Display` — for - /// example custom string labels or [`EntityKind`] variants. - pub fn build_for(&self, entity_types: &[E], text: &str) -> String { - let types_hint = if entity_types.is_empty() { - ALL_TYPES_HINT.to_string() - } else { - entity_types.iter().map(|e| e.to_string()).collect::>().join(", ") - }; - - format!( - "{DETECT_PREFIX} [{types_hint}] with minimum confidence \ - {threshold:.2} in the following text. {RESPONSE_FORMAT}\n\n---\n{text}\n---", - threshold = self.confidence_threshold, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_prompt_with_entity_kinds() { - let config = DetectionConfig { - entity_kinds: vec![EntityKind::PersonName, EntityKind::GovernmentId], - confidence_threshold: 0.7, - system_prompt: None, - }; - let prompt = PromptBuilder::new(&config).build("Hello world"); - assert!(prompt.contains("person_name, government_id")); - assert!(prompt.contains("0.70")); - assert!(prompt.contains("Hello world")); - } - - #[test] - fn builds_prompt_without_entity_kinds() { - let config = DetectionConfig { - entity_kinds: vec![], - confidence_threshold: 0.5, - system_prompt: None, - }; - let prompt = PromptBuilder::new(&config).build("test"); - assert!(prompt.contains("all entity types")); - } - - #[test] - fn build_for_with_string_labels() { - let config = DetectionConfig { - entity_kinds: vec![], - confidence_threshold: 0.8, - system_prompt: None, - }; - let builder = PromptBuilder::new(&config); - let labels = vec!["PERSON", "SSN"]; - let prompt = builder.build_for(&labels, "some text"); - assert!(prompt.contains("PERSON, SSN")); - assert!(prompt.contains("0.80")); - } -} diff --git a/crates/nvisy-rig/src/error.rs b/crates/nvisy-rig/src/error.rs index 60a689f..59e65ea 100644 --- a/crates/nvisy-rig/src/error.rs +++ b/crates/nvisy-rig/src/error.rs @@ -1,6 +1,7 @@ //! Unified error type covering LLM provider, serialization, and tool failures. use rig::completion::{CompletionError, PromptError, StructuredOutputError}; +use rig::transcription::TranscriptionError; /// Error type for all LLM interactions. /// @@ -56,6 +57,14 @@ pub enum Error { /// Failed to construct a provider client. #[error("Client error: {0}")] Client(String), + + /// A transcription (STT) error from the model provider. + #[error("Transcription error: {0}")] + Transcription(String), + + /// A generation (TTS/image) error from the model provider. + #[error("Generation error: {0}")] + Generation(String), } impl Error { @@ -108,6 +117,47 @@ impl From for Error { } } +impl From for Error { + fn from(err: TranscriptionError) -> Self { + match err { + TranscriptionError::HttpError(e) => Self::Http(e.to_string()), + TranscriptionError::JsonError(e) => Self::Json(e), + TranscriptionError::ProviderError(msg) => Self::Provider(msg), + TranscriptionError::ResponseError(msg) => Self::Response(msg), + TranscriptionError::RequestError(e) => Self::Transcription(e.to_string()), + _ => Self::Transcription(err.to_string()), + } + } +} + +#[cfg(feature = "audio")] +impl From for Error { + fn from(err: rig::audio_generation::AudioGenerationError) -> Self { + use rig::audio_generation::AudioGenerationError; + match err { + AudioGenerationError::HttpError(e) => Self::Http(e.to_string()), + AudioGenerationError::JsonError(e) => Self::Json(e), + AudioGenerationError::ProviderError(msg) => Self::Provider(msg), + AudioGenerationError::ResponseError(msg) => Self::Response(msg), + AudioGenerationError::RequestError(e) => Self::Generation(e.to_string()), + } + } +} + +#[cfg(feature = "image")] +impl From for Error { + fn from(err: rig::image_generation::ImageGenerationError) -> Self { + use rig::image_generation::ImageGenerationError; + match err { + ImageGenerationError::HttpError(e) => Self::Http(e.to_string()), + ImageGenerationError::JsonError(e) => Self::Json(e), + ImageGenerationError::ProviderError(msg) => Self::Provider(msg), + ImageGenerationError::ResponseError(msg) => Self::Response(msg), + ImageGenerationError::RequestError(e) => Self::Generation(e.to_string()), + } + } +} + impl From for nvisy_core::Error { fn from(err: Error) -> Self { // Handle the owned `Core` variant first to avoid borrowing issues. @@ -142,6 +192,9 @@ impl From for nvisy_core::Error { Error::Client(_) => { nvisy_core::Error::connection(err.to_string(), "rig", false) } + Error::Transcription(_) | Error::Generation(_) => { + nvisy_core::Error::runtime(err.to_string(), "rig", false) + } Error::Core(_) => unreachable!(), } } diff --git a/crates/nvisy-rig/src/image/base/mod.rs b/crates/nvisy-rig/src/image/base/mod.rs new file mode 100644 index 0000000..1dff032 --- /dev/null +++ b/crates/nvisy-rig/src/image/base/mod.rs @@ -0,0 +1,6 @@ +//! Shared provider dispatch enum for image generation models. + +mod provider; + +pub use provider::ImageGenProvider; +pub(crate) use provider::ImageGenModels; diff --git a/crates/nvisy-rig/src/image/base/provider.rs b/crates/nvisy-rig/src/image/base/provider.rs new file mode 100644 index 0000000..b835aa0 --- /dev/null +++ b/crates/nvisy-rig/src/image/base/provider.rs @@ -0,0 +1,60 @@ +//! Provider-erased dispatch enum and constructor for image generation models. + +use reqwest_middleware::ClientWithMiddleware; +use rig::image_generation::ImageGenerationModel as _; +use rig::providers::openai; + +use crate::backend::{AuthenticatedProvider, build_http_client}; +use crate::error::Error; + +/// Supported providers for image generation. +/// +/// Currently only OpenAI supports image generation. +#[derive(Debug, Clone)] +pub enum ImageGenProvider { + /// OpenAI (dall-e-3, gpt-image-1, etc.) + OpenAi(AuthenticatedProvider), +} + +impl ImageGenProvider { + /// Create an OpenAI image generation provider. + pub fn openai(api_key: &str, model: &str) -> Self { + Self::OpenAi(AuthenticatedProvider { + api_key: api_key.to_owned(), + model: model.to_owned(), + base_url: None, + }) + } + + /// The model name for this provider. + pub fn model(&self) -> &str { + match self { + Self::OpenAi(p) => &p.model, + } + } +} + +/// Provider-erased dispatch enum for image generation models. +pub(crate) enum ImageGenModels { + OpenAi(openai::image_generation::ImageGenerationModel), +} + +impl ImageGenModels { + /// Build the appropriate image generation model for the given provider. + pub fn from_provider( + provider: &ImageGenProvider, + model: &str, + max_retries: u32, + ) -> Result { + let http = build_http_client(max_retries); + + match provider { + ImageGenProvider::OpenAi(p) => { + let client = p.openai_client(http)?; + let model = + >::make(&client, model); + Ok(Self::OpenAi(model)) + } + } + } +} diff --git a/crates/nvisy-rig/src/image/generate/mod.rs b/crates/nvisy-rig/src/image/generate/mod.rs new file mode 100644 index 0000000..ab06ae6 --- /dev/null +++ b/crates/nvisy-rig/src/image/generate/mod.rs @@ -0,0 +1,88 @@ +//! Image generation service wrapping rig-core's `ImageGenerationModel`. + +use rig::image_generation::ImageGenerationModel as _; +use uuid::Uuid; + +use crate::error::Error; + +use super::base::{ImageGenModels, ImageGenProvider}; + +/// Configuration for the image generation service. +#[derive(Debug, Clone)] +pub struct ImageGenConfig { + /// Model name (e.g. `"dall-e-3"`, `"gpt-image-1"`). + pub model: String, + /// Image width in pixels. + pub width: u32, + /// Image height in pixels. + pub height: u32, + /// Maximum retries for transient HTTP errors (default: 3). + pub max_retries: u32, +} + +impl Default for ImageGenConfig { + fn default() -> Self { + Self { + model: "dall-e-3".to_owned(), + width: 1024, + height: 1024, + max_retries: 3, + } + } +} + +/// Image generation service wrapping rig-core image generation providers. +/// +/// Currently only supports OpenAI (dall-e-3, gpt-image-1). +pub struct ImageGenService { + id: Uuid, + inner: ImageGenModels, + config: ImageGenConfig, +} + +impl ImageGenService { + /// Create a new image generation service for the given provider. + /// + /// # Errors + /// + /// Returns [`Error::Client`] if client construction fails. + pub fn new(provider: &ImageGenProvider, config: ImageGenConfig) -> Result { + let inner = + ImageGenModels::from_provider(provider, &config.model, config.max_retries)?; + + Ok(Self { + id: Uuid::now_v7(), + inner, + config, + }) + } + + /// Unique identifier for this service instance (UUIDv7). + pub fn id(&self) -> Uuid { + self.id + } + + /// Generate an image from a text prompt, returning raw image bytes. + #[tracing::instrument( + skip_all, + fields(service_id = %self.id, prompt_len = prompt.len()), + )] + pub async fn generate(&self, prompt: &str) -> Result, Error> { + let image = match &self.inner { + ImageGenModels::OpenAi(model) => { + let response = model + .image_generation_request() + .prompt(prompt) + .width(self.config.width) + .height(self.config.height) + .send() + .await?; + response.image + } + }; + + tracing::info!(image_len = image.len(), "image generation complete"); + + Ok(image) + } +} diff --git a/crates/nvisy-rig/src/image/mod.rs b/crates/nvisy-rig/src/image/mod.rs new file mode 100644 index 0000000..f24597e --- /dev/null +++ b/crates/nvisy-rig/src/image/mod.rs @@ -0,0 +1,11 @@ +//! Image services: image generation. + +#[cfg(feature = "image")] +mod base; + +#[cfg(feature = "image")] +pub use base::ImageGenProvider; + +#[cfg(feature = "image")] +#[cfg_attr(docsrs, doc(cfg(feature = "image")))] +pub mod generate; diff --git a/crates/nvisy-rig/src/lib.rs b/crates/nvisy-rig/src/lib.rs index edb522d..fd3f4a2 100644 --- a/crates/nvisy-rig/src/lib.rs +++ b/crates/nvisy-rig/src/lib.rs @@ -2,24 +2,11 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] +pub mod agent; +pub mod audio; pub mod backend; -pub mod bridge; pub mod error; -mod agent; +pub mod image; #[doc(hidden)] pub mod prelude; - -pub use agent::BaseAgentConfig; -pub use backend::{ - AuthenticatedProvider, ContextWindow, - DetectionConfig, DetectionRequest, DetectionResponse, - Provider, UnauthenticatedProvider, UsageStats, UsageTracker, -}; -pub use error::Error; - -pub use agent::{ - CvAgent, CvDetection, CvEntities, CvEntity, CvProvider, - KnownNerEntity, NerAgent, NerContext, NerEntities, NerEntity, ResolvedOffsets, - OcrAgent, OcrEntity, OcrOutput, OcrProvider, OcrTextRegion, -}; diff --git a/crates/nvisy-rig/src/prelude.rs b/crates/nvisy-rig/src/prelude.rs index c626bbb..6556a1a 100644 --- a/crates/nvisy-rig/src/prelude.rs +++ b/crates/nvisy-rig/src/prelude.rs @@ -1,14 +1,16 @@ //! Convenience re-exports. +pub use crate::agent::AgentProvider; pub use crate::agent::BaseAgentConfig; +pub use crate::agent::ContextWindow; +pub use crate::agent::{ + CvAgent, CvDetection, CvEntities, CvEntity, CvProvider, KnownNerEntity, NerAgent, NerContext, + NerEntities, NerEntity, OcrAgent, OcrEntity, OcrOutput, OcrProvider, OcrTextRegion, + ResolvedOffsets, +}; +pub use crate::agent::{DetectionConfig, DetectionRequest, DetectionResponse}; +pub use crate::audio::TranscribeProvider; pub use crate::backend::{ - AuthenticatedProvider, ContextWindow, - DetectionConfig, DetectionRequest, DetectionResponse, - Provider, UnauthenticatedProvider, UsageStats, UsageTracker, + AuthenticatedProvider, UnauthenticatedProvider, UsageStats, UsageTracker, }; pub use crate::error::Error; -pub use crate::agent::{ - CvAgent, CvDetection, CvEntities, CvEntity, CvProvider, - KnownNerEntity, NerAgent, NerContext, NerEntities, NerEntity, ResolvedOffsets, - OcrAgent, OcrEntity, OcrOutput, OcrProvider, OcrTextRegion, -}; diff --git a/crates/nvisy-server/src/handler/error/http_error.rs b/crates/nvisy-server/src/handler/error/http_error.rs index 05d226d..7010727 100644 --- a/crates/nvisy-server/src/handler/error/http_error.rs +++ b/crates/nvisy-server/src/handler/error/http_error.rs @@ -226,7 +226,6 @@ impl From for Error<'static> { fn from(err: nvisy_core::Error) -> Self { let kind = match err.kind { nvisy_core::ErrorKind::Validation - | nvisy_core::ErrorKind::InvalidInput | nvisy_core::ErrorKind::Serialization => ErrorKind::BadRequest, nvisy_core::ErrorKind::Policy => ErrorKind::Forbidden, nvisy_core::ErrorKind::NotFound => ErrorKind::NotFound, @@ -234,9 +233,7 @@ impl From for Error<'static> { | nvisy_core::ErrorKind::Timeout | nvisy_core::ErrorKind::Cancellation | nvisy_core::ErrorKind::Runtime - | nvisy_core::ErrorKind::Python - | nvisy_core::ErrorKind::InternalError - | nvisy_core::ErrorKind::Other => ErrorKind::InternalServerError, + | nvisy_core::ErrorKind::Internal => ErrorKind::InternalServerError, }; let mut error = Self::new(kind).with_message(err.message); From 67f8af1a912644793de02b6bd51f63440704cd90 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 28 Feb 2026 19:28:09 +0100 Subject: [PATCH 11/22] docs: use colons over dashes in definitions, remove stale TODO Co-Authored-By: Claude Opus 4.6 --- docs/INGESTION.md | 6 +++--- docs/README.md | 16 ++++++++-------- docs/TODO.md | 11 ----------- 3 files changed, 11 insertions(+), 22 deletions(-) delete mode 100644 docs/TODO.md diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 3d2a208..f9cbd45 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -10,7 +10,7 @@ The quality of the ingestion layer is a critical success factor. Redaction platf The platform must support ingestion across multiple modalities. Formats are organized into tiers reflecting implementation priority and expected coverage at each stage of the product lifecycle. -### Tier 1 — Core (launch requirement) +### Tier 1: Core (launch requirement) These formats represent the most common inputs in regulated enterprise environments and must be supported at general availability: @@ -19,7 +19,7 @@ These formats represent the most common inputs in regulated enterprise environme - **Plain text and markup**: TXT, HTML, and Markdown. - **Structured data**: CSV and JSON. -### Tier 2 — Extended (near-term) +### Tier 2: Extended (near-term) These formats are frequently encountered in enterprise workflows and should be supported shortly after launch: @@ -28,7 +28,7 @@ These formats are frequently encountered in enterprise workflows and should be s - **Video**: Standard container formats (MP4, MOV, AVI) with frame-level extraction. - **Email**: EML and MSG formats, including inline content and attachments (recursively ingested). -### Tier 3 — Specialized (roadmap) +### Tier 3: Specialized (roadmap) These formats address long-tail use cases in specific verticals or operational contexts: diff --git a/docs/README.md b/docs/README.md index dc9db25..747bb3b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,11 +43,11 @@ The platform is designed to serve regulated industries where sensitive data hand | Term | Definition | | --- | --- | -| **PII** | Personally identifiable information — any data that can identify a specific individual | -| **PHI** | Protected health information — health data covered under HIPAA | -| **NER** | Named entity recognition — ML technique for identifying entities (names, locations, organizations) in text | -| **OCR** | Optical character recognition — extraction of text from images and scanned documents | -| **RBAC** | Role-based access control — permissions model based on user roles | -| **SSO** | Single sign-on — authentication mechanism allowing one set of credentials across multiple systems | -| **SCIM** | System for Cross-domain Identity Management — protocol for automating user provisioning | -| **KMS** | Key management service — system for managing cryptographic keys | +| **PII** | Personally identifiable information: any data that can identify a specific individual | +| **PHI** | Protected health information: health data covered under HIPAA | +| **NER** | Named entity recognition: ML technique for identifying entities (names, locations, organizations) in text | +| **OCR** | Optical character recognition: extraction of text from images and scanned documents | +| **RBAC** | Role-based access control: permissions model based on user roles | +| **SSO** | Single sign-on: authentication mechanism allowing one set of credentials across multiple systems | +| **SCIM** | System for Cross-domain Identity Management: protocol for automating user provisioning | +| **KMS** | Key management service: system for managing cryptographic keys | diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 83f408d..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,11 +0,0 @@ -# TODO - -## Engine - -- [ ] Implement `Engine` trait for the DAG runner -- [ ] Wire `EngineInput`/`EngineOutput` through the pipeline - -## Ontology - -- [ ] Add video document types (MP4, WebM, AVI) -- [ ] Add archive document types (ZIP, TAR, GZIP) From 0901a1b31b2c0592681ba07c4ae3fefcdebd6843 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 28 Feb 2026 19:39:57 +0100 Subject: [PATCH 12/22] docs: rewrite root README header and features Co-Authored-By: Claude Opus 4.6 --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ecbad49..bf6a91f 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,20 @@ [![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/runtime/build.yml?branch=main&label=build%20%26%20test&style=flat-square)](https://github.com/nvisycom/runtime/actions/workflows/build.yml) -Open-source multimodal redaction runtime. Detect, redact, and audit PII and -sensitive data across documents, images, audio, and video. +Multimodal redaction runtime for sensitive data. + +Detect and remove sensitive information across documents, images, audio, and +video. Combines deterministic patterns, NER, computer vision, and LLM-driven +classification into auditable, policy-driven pipelines built for regulated +industries such as healthcare, legal, government, and financial services. ## Features -- **Multimodal Codecs**: read, edit, and write PDF, DOCX, images, audio, CSV, JSON, and plain text -- **AI-Powered Detection**: regex, dictionary, checksum, NER, and LLM-driven entity recognition -- **Span-Aware Redaction**: mask, replace, hash, encrypt, blur, block, pixelate, and synthesize -- **Pipeline Engine**: DAG compiler and executor with retry and timeout policies -- **Python Extensions**: PyO3 bridge for AI-powered NER and OCR via embedded CPython +- **Multimodal codecs**: read, edit, and write PDF, DOCX, images, audio, CSV, JSON, and plain text through a unified span-based content model +- **Layered detection**: regex, dictionary, and checksum patterns run first at low cost; NER, OCR, object detection, and LLM classification handle what deterministic methods cannot +- **Context-aware redaction**: mask, replace, hash, encrypt, blur, block, pixelate, and synthesize with policy-driven rules scoped to entity type, document class, and confidence threshold +- **Pipeline engine**: DAG compiler and executor with retry, timeout, and chunked context-window policies +- **Python extensions**: PyO3 bridge for speech-to-text, NER, and OCR via embedded Python ## Quick Start From 5b2189b1c8d5cebdfc1bbf24eb3d0b8893b2a97f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 28 Feb 2026 19:55:21 +0100 Subject: [PATCH 13/22] chore: remove nvisy-asr and nvisy-augment crates Both are superseded by nvisy-rig: audio::transcribe replaces nvisy-asr, agent::GenAgent replaces the synthetic stub, and agent::OcrAgent replaces the OCR action wrapper. Neither crate had any consumers. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 - Cargo.lock | 30 ---- Cargo.toml | 4 - crates/nvisy-asr/Cargo.toml | 34 ----- crates/nvisy-asr/README.md | 25 ---- crates/nvisy-asr/src/backend.rs | 34 ----- crates/nvisy-asr/src/bridge.rs | 28 ---- crates/nvisy-asr/src/lib.rs | 10 -- crates/nvisy-asr/src/parse.rs | 67 --------- crates/nvisy-augment/Cargo.toml | 43 ------ crates/nvisy-augment/README.md | 25 ---- crates/nvisy-augment/src/lib.rs | 17 --- crates/nvisy-augment/src/ocr.rs | 110 --------------- crates/nvisy-augment/src/synthetic.rs | 48 ------- crates/nvisy-augment/src/transcribe.rs | 185 ------------------------- crates/nvisy-identify/Cargo.toml | 1 - docker/Dockerfile | 6 +- 17 files changed, 2 insertions(+), 668 deletions(-) delete mode 100644 crates/nvisy-asr/Cargo.toml delete mode 100644 crates/nvisy-asr/README.md delete mode 100644 crates/nvisy-asr/src/backend.rs delete mode 100644 crates/nvisy-asr/src/bridge.rs delete mode 100644 crates/nvisy-asr/src/lib.rs delete mode 100644 crates/nvisy-asr/src/parse.rs delete mode 100644 crates/nvisy-augment/Cargo.toml delete mode 100644 crates/nvisy-augment/README.md delete mode 100644 crates/nvisy-augment/src/lib.rs delete mode 100644 crates/nvisy-augment/src/ocr.rs delete mode 100644 crates/nvisy-augment/src/synthetic.rs delete mode 100644 crates/nvisy-augment/src/transcribe.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3515684..0d8dd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,14 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Crates -- **nvisy-asr:** ASR/speech-to-text backend trait and provider integration -- **nvisy-augment:** Content augmentation actions (OCR, transcription, synthetic data) - **nvisy-cli:** CLI entry point for the nvisy API server - **nvisy-codec:** File-format codecs — read, edit, and write documents - **nvisy-core:** Domain types, traits, and errors - **nvisy-engine:** DAG compiler and executor for pipeline graphs - **nvisy-identify:** Entity ontology types and detection layers -- **nvisy-ocr:** OCR backend trait and provider integration - **nvisy-ontology:** Domain data types, entity taxonomy, and spatial primitives - **nvisy-pattern:** Built-in regex patterns and dictionaries for PII/PHI detection - **nvisy-python:** PyO3 bridge for AI NER/OCR detection via embedded Python diff --git a/Cargo.lock b/Cargo.lock index 5cf1c1d..f7e25ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2639,35 +2639,6 @@ dependencies = [ "libm", ] -[[package]] -name = "nvisy-asr" -version = "0.1.0" -dependencies = [ - "async-trait", - "nvisy-core", - "nvisy-ontology", - "nvisy-python", - "serde_json", -] - -[[package]] -name = "nvisy-augment" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "nvisy-asr", - "nvisy-codec", - "nvisy-core", - "nvisy-ontology", - "nvisy-paddle", - "nvisy-python", - "nvisy-rig", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "nvisy-cli" version = "0.1.0" @@ -2762,7 +2733,6 @@ version = "0.1.0" dependencies = [ "async-trait", "jiff", - "nvisy-asr", "nvisy-codec", "nvisy-core", "nvisy-ontology", diff --git a/Cargo.toml b/Cargo.toml index 03eb266..e278af1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,6 @@ [workspace] resolver = "2" members = [ - "./crates/nvisy-asr", - "./crates/nvisy-augment", "./crates/nvisy-cli", "./crates/nvisy-codec", "./crates/nvisy-core", @@ -37,8 +35,6 @@ documentation = "https://docs.rs/nvisy-runtime" # See for more details: https://github.com/rust-lang/cargo/issues/11329 # Internal crates -nvisy-asr = { path = "./crates/nvisy-asr", version = "0.1.0" } -nvisy-augment = { path = "./crates/nvisy-augment", version = "0.1.0" } nvisy-codec = { path = "./crates/nvisy-codec", version = "0.1.0" } nvisy-core = { path = "./crates/nvisy-core", version = "0.1.0" } nvisy-engine = { path = "./crates/nvisy-engine", version = "0.1.0" } diff --git a/crates/nvisy-asr/Cargo.toml b/crates/nvisy-asr/Cargo.toml deleted file mode 100644 index b8ff004..0000000 --- a/crates/nvisy-asr/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -# https://doc.rust-lang.org/cargo/reference/manifest.html - -[package] -name = "nvisy-asr" -description = "ASR/speech-to-text backend trait and provider integration for Nvisy" -keywords = ["nvisy", "asr", "speech", "transcription"] -categories = ["multimedia::audio"] - -version = { workspace = true } -rust-version = { workspace = true } -edition = { workspace = true } -license = { workspace = true } -publish = { workspace = true } - -authors = { workspace = true } -repository = { workspace = true } -homepage = { workspace = true } -documentation = { workspace = true } - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - -[dependencies] -# Internal crates -nvisy-core = { workspace = true, features = [] } -nvisy-ontology = { workspace = true, features = [] } -nvisy-python = { workspace = true, features = [] } - -# (De)serialization -serde_json = { workspace = true, features = [] } - -# Async runtime -async-trait = { workspace = true, features = [] } diff --git a/crates/nvisy-asr/README.md b/crates/nvisy-asr/README.md deleted file mode 100644 index d725219..0000000 --- a/crates/nvisy-asr/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# nvisy-asr - -[![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/runtime/build.yml?branch=main&label=build%20%26%20test&style=flat-square)](https://github.com/nvisycom/runtime/actions/workflows/build.yml) - -ASR/speech-to-text backend trait and provider integration for the Nvisy runtime. - -Defines the `TranscribeBackend` trait for automatic speech recognition providers, configuration types, result parsing from raw JSON into entity types, and a `PythonBridge` implementation that delegates to the `nvisy_ai` Python module. - -## Documentation - -See [`docs/`](../../docs/) for architecture, security, and API documentation. - -## Changelog - -See [CHANGELOG.md](../../CHANGELOG.md) for release notes and version history. - -## License - -Apache 2.0 License, see [LICENSE.txt](../../LICENSE.txt) - -## Support - -- **Documentation**: [docs.nvisy.com](https://docs.nvisy.com) -- **Issues**: [GitHub Issues](https://github.com/nvisycom/runtime/issues) -- **Email**: [support@nvisy.com](mailto:support@nvisy.com) diff --git a/crates/nvisy-asr/src/backend.rs b/crates/nvisy-asr/src/backend.rs deleted file mode 100644 index 8ee4dc7..0000000 --- a/crates/nvisy-asr/src/backend.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Transcription backend trait and configuration. - -use serde_json::Value; - -use nvisy_core::Error; - -/// Configuration passed to a [`TranscribeBackend`] implementation. -#[derive(Debug, Clone)] -pub struct TranscribeConfig { - /// BCP-47 language tag for transcription. - pub language: String, - /// Whether to perform speaker diarization. - pub enable_speaker_diarization: bool, - /// Minimum confidence threshold for results. - pub confidence_threshold: f64, -} - -/// Backend trait for transcription providers. -/// -/// Implementations call an external speech-to-text service and return -/// raw JSON results. Entity construction is handled by the consuming crate. -#[async_trait::async_trait] -pub trait TranscribeBackend: Send + Sync + 'static { - /// Transcribe audio bytes, returning raw dicts. - /// - /// Each dict should contain: `text`, `start_time`, `end_time`, `confidence`, - /// and optionally `speaker_id`. - async fn transcribe( - &self, - audio_data: &[u8], - mime_type: &str, - config: &TranscribeConfig, - ) -> Result, Error>; -} diff --git a/crates/nvisy-asr/src/bridge.rs b/crates/nvisy-asr/src/bridge.rs deleted file mode 100644 index 8edb249..0000000 --- a/crates/nvisy-asr/src/bridge.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! [`TranscribeBackend`] implementation for [`PythonBridge`]. - -use serde_json::Value; - -use nvisy_core::Error; -use nvisy_python::bridge::PythonBridge; -use nvisy_python::transcribe::TranscribeParams; - -use crate::backend::{TranscribeBackend, TranscribeConfig}; - -/// Converts [`TranscribeConfig`] to [`TranscribeParams`] and delegates to -/// `nvisy_python::transcribe`. -#[async_trait::async_trait] -impl TranscribeBackend for PythonBridge { - async fn transcribe( - &self, - audio_data: &[u8], - mime_type: &str, - config: &TranscribeConfig, - ) -> Result, Error> { - let params = TranscribeParams { - language: config.language.clone(), - enable_speaker_diarization: config.enable_speaker_diarization, - confidence_threshold: config.confidence_threshold, - }; - nvisy_python::transcribe::transcribe(self, audio_data, mime_type, ¶ms).await - } -} diff --git a/crates/nvisy-asr/src/lib.rs b/crates/nvisy-asr/src/lib.rs deleted file mode 100644 index 7e3e178..0000000 --- a/crates/nvisy-asr/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr(docsrs, feature(doc_cfg))] -#![doc = include_str!("../README.md")] - -mod backend; -mod bridge; -mod parse; - -pub use backend::{TranscribeBackend, TranscribeConfig}; -pub use parse::parse_transcribe_entities; diff --git a/crates/nvisy-asr/src/parse.rs b/crates/nvisy-asr/src/parse.rs deleted file mode 100644 index 6b5f55d..0000000 --- a/crates/nvisy-asr/src/parse.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Transcription result parsing. - -use serde_json::Value; - -use nvisy_core::math::TimeSpan; -use nvisy_core::Error; -use nvisy_ontology::entity::{DetectionMethod, Entity, EntityCategory, EntityKind}; -use nvisy_ontology::location::{AudioLocation, Location}; - -/// Parse raw JSON dicts from a transcription backend into [`Entity`] values. -/// -/// Expected dict keys: `text`, `start_time`, `end_time`, `confidence`, -/// and optionally `speaker_id`. -pub fn parse_transcribe_entities(raw: &[Value]) -> Result, Error> { - let mut entities = Vec::new(); - - for item in raw { - let obj = item.as_object().ok_or_else(|| { - Error::runtime("Expected JSON object in transcription results", "python", false) - })?; - - let text = obj - .get("text") - .and_then(Value::as_str) - .ok_or_else(|| Error::runtime("Missing 'text' in transcription result", "python", false))?; - - let start_time = obj - .get("start_time") - .and_then(Value::as_f64) - .ok_or_else(|| Error::runtime("Missing 'start_time'", "python", false))?; - - let end_time = obj - .get("end_time") - .and_then(Value::as_f64) - .ok_or_else(|| Error::runtime("Missing 'end_time'", "python", false))?; - - let confidence = obj - .get("confidence") - .and_then(Value::as_f64) - .unwrap_or(0.0); - - let speaker_id = obj - .get("speaker_id") - .and_then(Value::as_str) - .map(String::from); - - let entity = Entity::new( - EntityCategory::Pii, - EntityKind::PersonName, - text, - DetectionMethod::SpeechTranscript, - confidence, - ) - .with_location(Location::Audio(AudioLocation { - time_span: TimeSpan { - start_secs: start_time, - end_secs: end_time, - }, - speaker_id, - audio_id: None, - })); - - entities.push(entity); - } - - Ok(entities) -} diff --git a/crates/nvisy-augment/Cargo.toml b/crates/nvisy-augment/Cargo.toml deleted file mode 100644 index 222d382..0000000 --- a/crates/nvisy-augment/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -# https://doc.rust-lang.org/cargo/reference/manifest.html - -[package] -name = "nvisy-augment" -description = "Content augmentation actions (OCR, transcription, synthetic data) for Nvisy" -keywords = ["nvisy", "augment", "ocr", "transcription"] -categories = ["text-processing"] - -version = { workspace = true } -rust-version = { workspace = true } -edition = { workspace = true } -license = { workspace = true } -publish = { workspace = true } - -authors = { workspace = true } -repository = { workspace = true } -homepage = { workspace = true } -documentation = { workspace = true } - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - -[dependencies] -# Internal crates -nvisy-core = { workspace = true, features = [] } -nvisy-ontology = { workspace = true, features = [] } -nvisy-codec = { workspace = true, features = [] } -nvisy-python = { workspace = true, features = [] } -nvisy-rig = { workspace = true, features = [] } -nvisy-paddle = { workspace = true, features = [] } -nvisy-asr = { workspace = true, features = [] } - -# (De)serialization -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true, features = [] } - -# Async runtime -async-trait = { workspace = true, features = [] } - -[dev-dependencies] -bytes = { workspace = true, features = [] } -tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/nvisy-augment/README.md b/crates/nvisy-augment/README.md deleted file mode 100644 index f75fcc3..0000000 --- a/crates/nvisy-augment/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# nvisy-augment - -[![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/runtime/build.yml?branch=main&label=build%20%26%20test&style=flat-square)](https://github.com/nvisycom/runtime/actions/workflows/build.yml) - -Content augmentation actions for the Nvisy runtime. - -Provides OCR text extraction from images (via `nvisy-rig`), audio transcription (via `nvisy-asr`), and synthetic data generation for replacing redacted entities with realistic placeholder values. - -## Documentation - -See [`docs/`](../../docs/) for architecture, security, and API documentation. - -## Changelog - -See [CHANGELOG.md](../../CHANGELOG.md) for release notes and version history. - -## License - -Apache 2.0 License, see [LICENSE.txt](../../LICENSE.txt) - -## Support - -- **Documentation**: [docs.nvisy.com](https://docs.nvisy.com) -- **Issues**: [GitHub Issues](https://github.com/nvisycom/runtime/issues) -- **Email**: [support@nvisy.com](mailto:support@nvisy.com) diff --git a/crates/nvisy-augment/src/lib.rs b/crates/nvisy-augment/src/lib.rs deleted file mode 100644 index 5f6b527..0000000 --- a/crates/nvisy-augment/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr(docsrs, feature(doc_cfg))] -#![doc = include_str!("../README.md")] - -mod ocr; -mod synthetic; -mod transcribe; - -pub use ocr::{ - GenerateOcrAction, GenerateOcrInput, GenerateOcrOutput, GenerateOcrParams, - OcrBackend, OcrConfig, parse_ocr_entities, -}; -pub use synthetic::{GenerateSyntheticAction, GenerateSyntheticInput, GenerateSyntheticParams}; -pub use transcribe::{ - GenerateTranscribeAction, GenerateTranscribeInput, GenerateTranscribeOutput, - GenerateTranscribeParams, TranscribeBackend, TranscribeConfig, parse_transcribe_entities, -}; diff --git a/crates/nvisy-augment/src/ocr.rs b/crates/nvisy-augment/src/ocr.rs deleted file mode 100644 index 92574e4..0000000 --- a/crates/nvisy-augment/src/ocr.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! OCR text extraction action — generates text entities with bounding boxes -//! from image documents. - -use serde::Deserialize; - -use nvisy_codec::document::Document; -use nvisy_codec::handler::{Handler, PngHandler, TxtHandler}; -use nvisy_core::Error; - -use nvisy_ontology::entity::Entity; - -pub use nvisy_paddle::{OcrBackend, OcrConfig, parse_ocr_entities}; - -fn default_language() -> String { - "eng".into() -} - -fn default_engine() -> String { - "tesseract".into() -} - -fn default_confidence() -> f64 { - 0.5 -} - -/// Typed parameters for [`GenerateOcrAction`]. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GenerateOcrParams { - /// OCR language code (ISO 639-3). - #[serde(default = "default_language")] - pub language: String, - /// OCR engine identifier. - #[serde(default = "default_engine")] - pub engine: String, - /// Minimum confidence score for returned entities. - #[serde(default = "default_confidence")] - pub confidence_threshold: f64, -} - -/// Typed input for [`GenerateOcrAction`]. -pub struct GenerateOcrInput { - /// Image documents to extract text from. - pub image_docs: Vec>, -} - -/// Typed output for [`GenerateOcrAction`]. -pub struct GenerateOcrOutput { - /// Detected text entities with bounding-box locations. - pub entities: Vec, - /// Extracted text as new text documents. - pub text_docs: Vec>, -} - -/// OCR generation — delegates to an [`OcrBackend`] at runtime. -pub struct GenerateOcrAction { - backend: B, - params: GenerateOcrParams, -} - -impl GenerateOcrAction { - /// Create a new action with the given backend and params. - pub fn new(backend: B, params: GenerateOcrParams) -> Self { - Self { backend, params } - } - - /// Build the [`OcrConfig`] from action parameters. - fn config(&self) -> OcrConfig { - OcrConfig { - language: self.params.language.clone(), - engine: self.params.engine.clone(), - confidence_threshold: self.params.confidence_threshold, - } - } - - /// Execute OCR on image documents. - pub async fn run(&self, input: GenerateOcrInput) -> Result { - let config = self.config(); - let mut all_entities = Vec::new(); - let mut all_ocr_text = Vec::new(); - - for doc in &input.image_docs { - let png_bytes = doc.handler().encode()?; - let raw = self - .backend - .detect_ocr(&png_bytes, "image/png", &config) - .await?; - let entities = parse_ocr_entities(&raw)?; - for entity in &entities { - all_ocr_text.push(entity.value.clone()); - } - all_entities.extend(entities); - } - - let mut text_docs = Vec::new(); - if !all_ocr_text.is_empty() { - let text = all_ocr_text.join("\n"); - let handler = TxtHandler::new( - text.lines().map(String::from).collect(), - text.ends_with('\n'), - ); - text_docs.push(Document::new(handler)); - } - - Ok(GenerateOcrOutput { - entities: all_entities, - text_docs, - }) - } -} diff --git a/crates/nvisy-augment/src/synthetic.rs b/crates/nvisy-augment/src/synthetic.rs deleted file mode 100644 index 238c6f8..0000000 --- a/crates/nvisy-augment/src/synthetic.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Synthetic data generation action — fills in realistic replacement values -//! for redactions marked with `Synthesize`. - -use serde::Deserialize; - -use nvisy_ontology::entity::Entity; -use nvisy_ontology::record::Redaction; -use nvisy_core::Error; - -fn default_locale() -> String { - "en-US".into() -} - -/// Typed parameters for [`GenerateSyntheticAction`]. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GenerateSyntheticParams { - /// BCP-47 locale for synthetic value generation. - #[serde(default = "default_locale")] - pub locale: String, -} - -/// Typed input for [`GenerateSyntheticAction`]. -pub struct GenerateSyntheticInput { - /// The entities whose redactions need synthetic values. - pub entities: Vec, - /// The redaction instructions (some may have `Synthesize` outputs). - pub redactions: Vec, -} - -/// Synthetic data generation stub — fills `Synthesize` redaction outputs -/// with realistic replacement values at runtime. -pub struct GenerateSyntheticAction; - -impl GenerateSyntheticAction { - pub async fn connect(_params: GenerateSyntheticParams) -> Result { - Ok(Self) - } - - pub async fn execute( - &self, - input: GenerateSyntheticInput, - ) -> Result, Error> { - // Stub: returns redactions unchanged. Real implementation will fill - // Synthesize variants with generated replacement values. - Ok(input.redactions) - } -} diff --git a/crates/nvisy-augment/src/transcribe.rs b/crates/nvisy-augment/src/transcribe.rs deleted file mode 100644 index 8dee0d5..0000000 --- a/crates/nvisy-augment/src/transcribe.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! Speech-to-text transcription action — generates text entities with audio -//! locations and transcript documents from audio input. - -use serde::Deserialize; - -use nvisy_codec::document::Document; -use nvisy_codec::handler::{Handler, WavHandler, TxtHandler}; -use nvisy_core::Error; - -use nvisy_ontology::entity::Entity; - -pub use nvisy_asr::{TranscribeBackend, TranscribeConfig, parse_transcribe_entities}; - -fn default_language() -> String { - "en".into() -} - -fn default_confidence() -> f64 { - 0.5 -} - -/// Typed parameters for [`GenerateTranscribeAction`]. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GenerateTranscribeParams { - /// BCP-47 language tag for transcription. - #[serde(default = "default_language")] - pub language: String, - /// Whether to perform speaker diarization. - #[serde(default)] - pub enable_speaker_diarization: bool, - /// Minimum confidence score for returned entities. - #[serde(default = "default_confidence")] - pub confidence_threshold: f64, -} - -/// Typed input for [`GenerateTranscribeAction`]. -pub struct GenerateTranscribeInput { - /// Audio documents to transcribe. - pub audio_docs: Vec>, -} - -/// Typed output for [`GenerateTranscribeAction`]. -pub struct GenerateTranscribeOutput { - /// Detected entities with audio locations. - pub entities: Vec, - /// Transcripts as new text documents. - pub text_docs: Vec>, -} - -/// Speech-to-text action — delegates to a [`TranscribeBackend`] at runtime. -pub struct GenerateTranscribeAction { - backend: B, - params: GenerateTranscribeParams, -} - -impl GenerateTranscribeAction { - /// Create a new action with the given backend and params. - pub fn new(backend: B, params: GenerateTranscribeParams) -> Self { - Self { backend, params } - } - - /// Build the [`TranscribeConfig`] from action parameters. - fn config(&self) -> TranscribeConfig { - TranscribeConfig { - language: self.params.language.clone(), - enable_speaker_diarization: self.params.enable_speaker_diarization, - confidence_threshold: self.params.confidence_threshold, - } - } - - /// Execute transcription on audio documents. - pub async fn run(&self, input: GenerateTranscribeInput) -> Result { - let config = self.config(); - let mut all_entities = Vec::new(); - let mut all_transcript_text = Vec::new(); - - for doc in &input.audio_docs { - let wav_bytes = doc.handler().encode()?; - let raw = self - .backend - .transcribe(&wav_bytes, "audio/wav", &config) - .await?; - let entities = parse_transcribe_entities(&raw)?; - for entity in &entities { - all_transcript_text.push(entity.value.clone()); - } - all_entities.extend(entities); - } - - let mut text_docs = Vec::new(); - if !all_transcript_text.is_empty() { - let text = all_transcript_text.join(" "); - let handler = TxtHandler::new( - text.lines().map(String::from).collect(), - text.ends_with('\n'), - ); - text_docs.push(Document::new(handler)); - } - - Ok(GenerateTranscribeOutput { - entities: all_entities, - text_docs, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use nvisy_ontology::entity::DetectionMethod; - use serde_json::{json, Value}; - - #[test] - fn parse_transcribe_entities_basic() { - let raw = vec![json!({ - "text": "Hello world", - "start_time": 0.5, - "end_time": 1.2, - "confidence": 0.95, - "speaker_id": "speaker_1" - })]; - let entities = parse_transcribe_entities(&raw).unwrap(); - assert_eq!(entities.len(), 1); - assert_eq!(entities[0].value, "Hello world"); - assert_eq!(entities[0].detection_method, DetectionMethod::SpeechTranscript); - - let loc = entities[0].location.as_ref().unwrap().as_audio().unwrap(); - assert!((loc.time_span.start_secs - 0.5).abs() < f64::EPSILON); - assert!((loc.time_span.end_secs - 1.2).abs() < f64::EPSILON); - assert_eq!(loc.speaker_id.as_deref(), Some("speaker_1")); - } - - struct MockTranscribeBackend; - - #[async_trait::async_trait] - impl TranscribeBackend for MockTranscribeBackend { - async fn transcribe( - &self, - _audio_data: &[u8], - _mime_type: &str, - _config: &TranscribeConfig, - ) -> Result, Error> { - Ok(vec![ - json!({ - "text": "Hello", - "start_time": 0.0, - "end_time": 0.5, - "confidence": 0.9 - }), - json!({ - "text": "world", - "start_time": 0.5, - "end_time": 1.0, - "confidence": 0.85 - }), - ]) - } - } - - #[tokio::test] - async fn run_produces_entities_and_text_docs() { - use bytes::Bytes; - - let action = GenerateTranscribeAction::new( - MockTranscribeBackend, - GenerateTranscribeParams { - language: "en".into(), - enable_speaker_diarization: false, - confidence_threshold: 0.5, - }, - ); - - let wav_handler = WavHandler::new(Bytes::from_static(b"fake-wav")); - let input = GenerateTranscribeInput { - audio_docs: vec![Document::new(wav_handler)], - }; - - let output = action.run(input).await.unwrap(); - assert_eq!(output.entities.len(), 2); - assert_eq!(output.entities[0].value, "Hello"); - assert_eq!(output.entities[1].value, "world"); - assert_eq!(output.text_docs.len(), 1); - } -} diff --git a/crates/nvisy-identify/Cargo.toml b/crates/nvisy-identify/Cargo.toml index e51ce04..0d13970 100644 --- a/crates/nvisy-identify/Cargo.toml +++ b/crates/nvisy-identify/Cargo.toml @@ -32,7 +32,6 @@ nvisy-ontology = { workspace = true, features = [] } nvisy-codec = { workspace = true, features = [] } nvisy-pattern = { workspace = true, features = [] } nvisy-rig = { workspace = true, features = [] } -nvisy-asr = { workspace = true, features = [] } # (De)serialization serde = { workspace = true, features = ["derive"] } diff --git a/docker/Dockerfile b/docker/Dockerfile index 68f8bfa..2e02c84 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,8 +6,6 @@ WORKDIR /app # Copy manifests first to cache dependency builds COPY Cargo.toml Cargo.lock ./ -COPY crates/nvisy-asr/Cargo.toml crates/nvisy-asr/Cargo.toml -COPY crates/nvisy-augment/Cargo.toml crates/nvisy-augment/Cargo.toml COPY crates/nvisy-cli/Cargo.toml crates/nvisy-cli/Cargo.toml COPY crates/nvisy-codec/Cargo.toml crates/nvisy-codec/Cargo.toml COPY crates/nvisy-core/Cargo.toml crates/nvisy-core/Cargo.toml @@ -21,14 +19,14 @@ COPY crates/nvisy-rig/Cargo.toml crates/nvisy-rig/Cargo.toml COPY crates/nvisy-server/Cargo.toml crates/nvisy-server/Cargo.toml # Create empty src files to satisfy cargo's manifest checks -RUN for crate in nvisy-asr nvisy-augment nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig; do \ +RUN for crate in nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig; do \ mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \ done && \ mkdir -p crates/nvisy-cli/src && echo "fn main() {}" > crates/nvisy-cli/src/main.rs && \ mkdir -p crates/nvisy-server/src && echo "fn main() {}" > crates/nvisy-server/src/main.rs # Create stub READMEs for crates that use doc = include_str!("../README.md") -RUN for crate in nvisy-asr nvisy-augment nvisy-cli nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig nvisy-server; do \ +RUN for crate in nvisy-cli nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig nvisy-server; do \ touch crates/$crate/README.md; \ done From 10f6b4bf6b355bb68303ad53cff179bd76dd0809 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 04:11:28 +0100 Subject: [PATCH 14/22] =?UTF-8?q?refactor(ocr,=20core,=20codec):=20rename?= =?UTF-8?q?=20nvisy-paddle=20=E2=86=92=20nvisy-ocr,=20add=20oar-ocr=20back?= =?UTF-8?q?end,=20enrich=20spatial=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename nvisy-paddle to nvisy-ocr to reflect its role as the general OCR layer (not PaddleOCR-only). Add oar-ocr (Rust-native PaddleOCR via ONNX Runtime) as the primary local backend while keeping the Python bridge as a fallback. Enrich the spatial model with Polygon/Vertex types in nvisy-core::math and a TextLevel enum in nvisy-ontology for hierarchical OCR regions. Replace untyped Vec returns in OcrBackend with a typed OcrRegion struct. Update OcrTextRegion and OcrEntity in nvisy-rig to use BoundingBox/Polygon instead of raw [f64; 4] arrays. Add OcrBackendProvider adapter in nvisy-identify bridging OcrBackend → OcrProvider for VLM agent integration. Move PDF-to-image rendering (pdfium-render) to nvisy-codec under the existing pdf feature gate. Split nvisy-core/src/math/mod.rs into bounding_box.rs, polygon.rs, and time_span.rs. Restructure nvisy-ocr/src into folder-modules with only lib.rs and prelude.rs at root. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + Cargo.lock | 811 +++++++++++++++++- Cargo.toml | 12 +- crates/nvisy-codec/Cargo.toml | 5 +- crates/nvisy-codec/src/handler/rich/mod.rs | 4 + .../src/handler/rich/pdf_render.rs | 68 ++ crates/nvisy-core/src/math/bounding_box.rs | 62 ++ crates/nvisy-core/src/math/mod.rs | 78 +- crates/nvisy-core/src/math/polygon.rs | 76 ++ crates/nvisy-core/src/math/time_span.rs | 13 + crates/nvisy-identify/Cargo.toml | 1 + crates/nvisy-identify/src/lib.rs | 4 + crates/nvisy-identify/src/ocr_bridge.rs | 44 + crates/{nvisy-paddle => nvisy-ocr}/Cargo.toml | 14 +- crates/nvisy-ocr/README.md | 3 + crates/nvisy-ocr/src/backend/mod.rs | 48 ++ crates/nvisy-ocr/src/bridge/mod.rs | 60 ++ crates/{nvisy-paddle => nvisy-ocr}/src/lib.rs | 6 +- crates/nvisy-ocr/src/local/mod.rs | 114 +++ crates/nvisy-ocr/src/parse/mod.rs | 27 + crates/nvisy-ocr/src/prelude.rs | 5 + crates/nvisy-ontology/src/location/mod.rs | 2 + .../nvisy-ontology/src/location/text_level.rs | 21 + crates/nvisy-paddle/README.md | 3 - crates/nvisy-paddle/src/backend.rs | 31 - crates/nvisy-paddle/src/bridge.rs | 27 - crates/nvisy-paddle/src/parse.rs | 49 -- crates/nvisy-rig/src/agent/ocr/mod.rs | 11 +- crates/nvisy-rig/src/agent/ocr/output.rs | 5 +- docker/Dockerfile | 18 +- 30 files changed, 1405 insertions(+), 218 deletions(-) create mode 100644 crates/nvisy-codec/src/handler/rich/pdf_render.rs create mode 100644 crates/nvisy-core/src/math/bounding_box.rs create mode 100644 crates/nvisy-core/src/math/polygon.rs create mode 100644 crates/nvisy-core/src/math/time_span.rs create mode 100644 crates/nvisy-identify/src/ocr_bridge.rs rename crates/{nvisy-paddle => nvisy-ocr}/Cargo.toml (68%) create mode 100644 crates/nvisy-ocr/README.md create mode 100644 crates/nvisy-ocr/src/backend/mod.rs create mode 100644 crates/nvisy-ocr/src/bridge/mod.rs rename crates/{nvisy-paddle => nvisy-ocr}/src/lib.rs (60%) create mode 100644 crates/nvisy-ocr/src/local/mod.rs create mode 100644 crates/nvisy-ocr/src/parse/mod.rs create mode 100644 crates/nvisy-ocr/src/prelude.rs create mode 100644 crates/nvisy-ontology/src/location/text_level.rs delete mode 100644 crates/nvisy-paddle/README.md delete mode 100644 crates/nvisy-paddle/src/backend.rs delete mode 100644 crates/nvisy-paddle/src/bridge.rs delete mode 100644 crates/nvisy-paddle/src/parse.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8dd76..8499c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **nvisy-identify:** Entity ontology types and detection layers - **nvisy-ontology:** Domain data types, entity taxonomy, and spatial primitives - **nvisy-pattern:** Built-in regex patterns and dictionaries for PII/PHI detection +- **nvisy-ocr:** OCR backend trait and provider integration (oar-ocr local, Python bridge) - **nvisy-python:** PyO3 bridge for AI NER/OCR detection via embedded Python - **nvisy-rig:** LLM/VLM-driven detection, redaction, and OCR backends - **nvisy-server:** HTTP server exposing the Engine pipeline via REST endpoints diff --git a/Cargo.lock b/Cargo.lock index f7e25ef..4781912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,20 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -81,7 +95,7 @@ version = "0.16.0-alpha.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a02baae51c9c0637df98b62762a431aff6d13d34f05cfc9a30f059dbb8dff72f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -416,12 +430,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit_field" version = "0.10.3" @@ -553,6 +579,15 @@ dependencies = [ "zip 7.2.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -621,7 +656,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -675,6 +712,27 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clipper2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9c96871d5c50dd16c0f9df018f701fc62bd4f1b73cea8efe1985df6ab3d7e6" +dependencies = [ + "clipper2c-sys", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "clipper2c-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fe1f9be1018d6be0fa3c79ab338d295fb0c0c501e4fe81bfdbe3a89a23098" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "cmake" version = "0.1.57" @@ -715,6 +773,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.36" @@ -735,6 +808,39 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -902,8 +1008,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -920,17 +1036,50 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +dependencies = [ + "serde", +] + [[package]] name = "debug_unsafe" version = "0.1.4" @@ -984,6 +1133,16 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -993,6 +1152,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1097,6 +1287,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1142,6 +1338,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "euclid" version = "0.20.14" @@ -1253,6 +1455,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1638,6 +1855,21 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1740,7 +1972,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1991,6 +2223,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -2190,6 +2435,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2288,6 +2543,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" + [[package]] name = "lzma-rust2" version = "0.16.2" @@ -2303,6 +2564,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2353,6 +2630,12 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2431,6 +2714,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -2482,6 +2787,7 @@ dependencies = [ "glam 0.29.3", "glam 0.30.10", "matrixmultiply", + "nalgebra-macros", "num-complex", "num-rational", "num-traits", @@ -2489,6 +2795,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -2498,6 +2815,38 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2669,6 +3018,7 @@ dependencies = [ "lopdf 0.39.0", "nvisy-core", "pdf-extract", + "pdfium-render", "quick-xml 0.37.5", "schemars", "scraper", @@ -2735,6 +3085,7 @@ dependencies = [ "jiff", "nvisy-codec", "nvisy-core", + "nvisy-ocr", "nvisy-ontology", "nvisy-pattern", "nvisy-rig", @@ -2749,28 +3100,32 @@ dependencies = [ ] [[package]] -name = "nvisy-ontology" +name = "nvisy-ocr" version = "0.1.0" dependencies = [ - "derive_more 2.1.1", - "jiff", + "async-trait", + "image", "nvisy-core", - "schemars", + "nvisy-ontology", + "nvisy-python", + "oar-ocr", "serde", "serde_json", - "strum", - "uuid", + "tokio", ] [[package]] -name = "nvisy-paddle" +name = "nvisy-ontology" version = "0.1.0" dependencies = [ - "async-trait", + "derive_more 2.1.1", + "jiff", "nvisy-core", - "nvisy-ontology", - "nvisy-python", + "schemars", + "serde", "serde_json", + "strum", + "uuid", ] [[package]] @@ -2807,7 +3162,7 @@ name = "nvisy-rig" version = "0.1.0" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "nvisy-core", "nvisy-ontology", "reqwest-middleware", @@ -2829,7 +3184,7 @@ version = "0.1.0" dependencies = [ "aide", "axum", - "base64", + "base64 0.22.1", "futures", "nvisy-core", "nvisy-engine", @@ -2843,6 +3198,62 @@ dependencies = [ "uuid", ] +[[package]] +name = "oar-ocr" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5c93ee8b53e960cc49c20771fc770a49c9522f4e2587a22e29ba50f8f5de14" +dependencies = [ + "image", + "imageproc", + "oar-ocr-core", + "oar-ocr-derive", + "rayon", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "oar-ocr-core" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15294521d6bfa77f34139fc5cd141ebbc80839fac917309c8480e75b1785c570" +dependencies = [ + "clipper2", + "html-escape", + "image", + "imageproc", + "itertools", + "nalgebra", + "ndarray", + "oar-ocr-derive", + "once_cell", + "ort", + "rayon", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokenizers", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "oar-ocr-derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d67b339199698232a3431104731a21e72a577f7e9de553441b835a63cd4ed23" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2855,12 +3266,72 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "5.1.0" @@ -2870,6 +3341,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2 0.15.7", + "ureq", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2939,6 +3434,41 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pdfium-render" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6553f6604a52b3203db7b4e9d51eb4dd193cf455af9e56d40cab6575b547b679" +dependencies = [ + "bitflags", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "image", + "itertools", + "js-sys", + "libloading", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3041,6 +3571,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + [[package]] name = "pkg-config" version = "0.3.32" @@ -3538,6 +4074,17 @@ dependencies = [ "rayon-core", ] +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + [[package]] name = "rayon-core" version = "1.13.0" @@ -3612,7 +4159,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -3725,7 +4272,7 @@ checksum = "437fa2a15825caf2505411bbe55b05c8eb122e03934938b38f9ecaa1d6ded7c8" dependencies = [ "as-any", "async-stream", - "base64", + "base64 0.22.1", "bytes", "eventsource-stream", "fastrand", @@ -4134,6 +4681,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4258,12 +4814,35 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sptr" version = "0.3.2" @@ -4276,6 +4855,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -4538,6 +5123,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.49.0" @@ -4588,12 +5207,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -4601,10 +5244,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.5.40", ] +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower" version = "0.5.3" @@ -4815,6 +5473,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -4839,18 +5506,60 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unindent" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf-8", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4869,6 +5578,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4910,6 +5634,21 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5124,6 +5863,22 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5133,6 +5888,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -5423,6 +6184,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5669,7 +6436,7 @@ dependencies = [ "getrandom 0.4.1", "hmac", "indexmap", - "lzma-rust2", + "lzma-rust2 0.16.2", "memchr", "pbkdf2", "ppmd-rust", diff --git a/Cargo.toml b/Cargo.toml index e278af1..d351419 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ "./crates/nvisy-engine", "./crates/nvisy-identify", "./crates/nvisy-ontology", - "./crates/nvisy-paddle", + "./crates/nvisy-ocr", "./crates/nvisy-pattern", "./crates/nvisy-python", "./crates/nvisy-rig", @@ -40,7 +40,7 @@ nvisy-core = { path = "./crates/nvisy-core", version = "0.1.0" } nvisy-engine = { path = "./crates/nvisy-engine", version = "0.1.0" } nvisy-identify = { path = "./crates/nvisy-identify", version = "0.1.0" } nvisy-ontology = { path = "./crates/nvisy-ontology", version = "0.1.0" } -nvisy-paddle = { path = "./crates/nvisy-paddle", version = "0.1.0" } +nvisy-ocr = { path = "./crates/nvisy-ocr", version = "0.1.0" } nvisy-pattern = { path = "./crates/nvisy-pattern", version = "0.1.0" } nvisy-python = { path = "./crates/nvisy-python", version = "0.1.0" } nvisy-rig = { path = "./crates/nvisy-rig", version = "0.1.0" } @@ -98,14 +98,20 @@ petgraph = { version = "0.8", features = [] } # File type detection infer = { version = "0.19", features = [] } -# Document parsing +# PDF processing (parsing, text extraction, page-to-image rendering) pdf-extract = { version = "0.7", features = [] } lopdf = { version = "0.39", features = [] } +pdfium-render = { version = "0.8", features = [] } + +# Document parsing scraper = { version = "0.22", features = [] } calamine = { version = "0.33", features = [] } zip = { version = "8.0", features = [] } quick-xml = { version = "0.37", features = [] } +# OCR engine (Rust-native PaddleOCR via ONNX Runtime) +oar-ocr = { version = "0.6", features = [] } + # Image processing image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } imageproc = { version = "0.26", features = [] } diff --git a/crates/nvisy-codec/Cargo.toml b/crates/nvisy-codec/Cargo.toml index f7fc2d5..bb06dac 100644 --- a/crates/nvisy-codec/Cargo.toml +++ b/crates/nvisy-codec/Cargo.toml @@ -24,8 +24,8 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["pdf", "docx", "html", "xlsx"] -# PDF parsing and text extraction via pdf-extract + lopdf -pdf = ["dep:pdf-extract", "dep:lopdf"] +# PDF parsing, text extraction, and page-to-image rendering +pdf = ["dep:pdf-extract", "dep:lopdf", "dep:pdfium-render"] # Microsoft Word (.docx) parsing via zip + quick-xml docx = ["dep:zip", "dep:quick-xml"] # HTML parsing and text extraction via scraper @@ -68,6 +68,7 @@ imageproc = { workspace = true, features = [] } calamine = { workspace = true, optional = true, features = [] } lopdf = { workspace = true, optional = true, features = [] } pdf-extract = { workspace = true, optional = true, features = [] } +pdfium-render = { workspace = true, optional = true } quick-xml = { workspace = true, optional = true, features = [] } scraper = { workspace = true, optional = true, features = [] } zip = { workspace = true, optional = true, features = [] } diff --git a/crates/nvisy-codec/src/handler/rich/mod.rs b/crates/nvisy-codec/src/handler/rich/mod.rs index 34aba63..15b4d6b 100644 --- a/crates/nvisy-codec/src/handler/rich/mod.rs +++ b/crates/nvisy-codec/src/handler/rich/mod.rs @@ -4,6 +4,8 @@ mod pdf_handler; #[cfg(feature = "pdf")] mod pdf_loader; +#[cfg(feature = "pdf")] +mod pdf_render; #[cfg(feature = "docx")] mod docx_handler; @@ -14,6 +16,8 @@ mod docx_loader; pub use pdf_handler::PdfHandler; #[cfg(feature = "pdf")] pub use pdf_loader::{PdfLoader, PdfParams}; +#[cfg(feature = "pdf")] +pub use pdf_render::PdfRenderer; #[cfg(feature = "docx")] pub use docx_handler::DocxHandler; diff --git a/crates/nvisy-codec/src/handler/rich/pdf_render.rs b/crates/nvisy-codec/src/handler/rich/pdf_render.rs new file mode 100644 index 0000000..351014f --- /dev/null +++ b/crates/nvisy-codec/src/handler/rich/pdf_render.rs @@ -0,0 +1,68 @@ +//! PDF-to-image rendering via PDFium. + +use image::DynamicImage; +use pdfium_render::prelude::*; + +use nvisy_core::Error; + +/// Renders PDF pages to images for OCR processing. +/// +/// Requires the PDFium shared library to be available at runtime +/// (bundled in the Docker image or installed on the host). +pub struct PdfRenderer { + pdfium: Pdfium, +} + +impl PdfRenderer { + /// Create a new renderer by binding to a system-provided PDFium library. + pub fn new() -> Result { + let bindings = Pdfium::bind_to_system_library() + .or_else(|_| Pdfium::bind_to_library("libpdfium")) + .map_err(|e| { + Error::runtime( + format!("failed to load PDFium library: {e}"), + "pdf_render", + false, + ) + })?; + Ok(Self { + pdfium: Pdfium::new(bindings), + }) + } + + /// Render all pages of a PDF to images at the given DPI. + /// + /// Each page is rendered as a separate [`DynamicImage`]. A typical + /// DPI value for OCR is 300. + pub fn render_pages(&self, pdf_bytes: &[u8], dpi: u16) -> Result, Error> { + let document = self + .pdfium + .load_pdf_from_byte_slice(pdf_bytes, None) + .map_err(|e| { + Error::runtime( + format!("failed to load PDF: {e}"), + "pdf_render", + false, + ) + })?; + + // PDF points are 1/72 inch; scale factor = target_dpi / 72. + let scale = f32::from(dpi) / 72.0; + + let config = PdfRenderConfig::new().scale_page_by_factor(scale); + + let mut images = Vec::new(); + for page in document.pages().iter() { + let bitmap = page.render_with_config(&config).map_err(|e| { + Error::runtime( + format!("failed to render PDF page: {e}"), + "pdf_render", + false, + ) + })?; + images.push(bitmap.as_image()); + } + + Ok(images) + } +} diff --git a/crates/nvisy-core/src/math/bounding_box.rs b/crates/nvisy-core/src/math/bounding_box.rs new file mode 100644 index 0000000..3059819 --- /dev/null +++ b/crates/nvisy-core/src/math/bounding_box.rs @@ -0,0 +1,62 @@ +//! Axis-aligned bounding box types. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Axis-aligned bounding box for image-based entity locations. +/// +/// Coordinates are `f64` to support both pixel and normalized (0.0–1.0) +/// values from detection models. Use [`BoundingBoxU32`] (or [`Into`]) +/// when integer pixel coordinates are needed for rendering. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BoundingBox { + /// Horizontal offset of the top-left corner (pixels or normalized). + pub x: f64, + /// Vertical offset of the top-left corner (pixels or normalized). + pub y: f64, + /// Width of the bounding box. + pub width: f64, + /// Height of the bounding box. + pub height: f64, +} + +/// Integer pixel-coordinate bounding box for rendering operations. +/// +/// Converted from [`BoundingBox`] by rounding each field to the nearest +/// integer. Use this at the rendering boundary where pixel-exact +/// coordinates are required. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BoundingBoxU32 { + /// Horizontal offset of the top-left corner in pixels. + pub x: u32, + /// Vertical offset of the top-left corner in pixels. + pub y: u32, + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, +} + +impl BoundingBox { + /// Convert to integer pixel coordinates by rounding each field. + pub fn to_u32(&self) -> BoundingBoxU32 { + BoundingBoxU32 { + x: self.x.round() as u32, + y: self.y.round() as u32, + width: self.width.round() as u32, + height: self.height.round() as u32, + } + } +} + +impl From<&BoundingBox> for BoundingBoxU32 { + fn from(bb: &BoundingBox) -> Self { + bb.to_u32() + } +} + +impl From for BoundingBoxU32 { + fn from(bb: BoundingBox) -> Self { + Self::from(&bb) + } +} diff --git a/crates/nvisy-core/src/math/mod.rs b/crates/nvisy-core/src/math/mod.rs index 879e387..6fb5733 100644 --- a/crates/nvisy-core/src/math/mod.rs +++ b/crates/nvisy-core/src/math/mod.rs @@ -1,74 +1,12 @@ //! Spatial and temporal primitive types. //! -//! Bounding boxes and time spans used across entity locations, -//! rendering, and redaction operations. +//! Bounding boxes, polygons, and time spans used across entity +//! locations, rendering, and redaction operations. -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +mod bounding_box; +mod polygon; +mod time_span; -/// A time interval within an audio or video stream. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct TimeSpan { - /// Start time in seconds from the beginning of the stream. - pub start_secs: f64, - /// End time in seconds from the beginning of the stream. - pub end_secs: f64, -} - -/// Axis-aligned bounding box for image-based entity locations. -/// -/// Coordinates are `f64` to support both pixel and normalized (0.0–1.0) -/// values from detection models. Use [`BoundingBoxU32`] (or [`Into`]) -/// when integer pixel coordinates are needed for rendering. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct BoundingBox { - /// Horizontal offset of the top-left corner (pixels or normalized). - pub x: f64, - /// Vertical offset of the top-left corner (pixels or normalized). - pub y: f64, - /// Width of the bounding box. - pub width: f64, - /// Height of the bounding box. - pub height: f64, -} - -/// Integer pixel-coordinate bounding box for rendering operations. -/// -/// Converted from [`BoundingBox`] by rounding each field to the nearest -/// integer. Use this at the rendering boundary where pixel-exact -/// coordinates are required. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct BoundingBoxU32 { - /// Horizontal offset of the top-left corner in pixels. - pub x: u32, - /// Vertical offset of the top-left corner in pixels. - pub y: u32, - /// Width in pixels. - pub width: u32, - /// Height in pixels. - pub height: u32, -} - -impl BoundingBox { - /// Convert to integer pixel coordinates by rounding each field. - pub fn to_u32(&self) -> BoundingBoxU32 { - BoundingBoxU32 { - x: self.x.round() as u32, - y: self.y.round() as u32, - width: self.width.round() as u32, - height: self.height.round() as u32, - } - } -} - -impl From<&BoundingBox> for BoundingBoxU32 { - fn from(bb: &BoundingBox) -> Self { - bb.to_u32() - } -} - -impl From for BoundingBoxU32 { - fn from(bb: BoundingBox) -> Self { - Self::from(&bb) - } -} +pub use bounding_box::{BoundingBox, BoundingBoxU32}; +pub use polygon::{Polygon, Vertex}; +pub use time_span::TimeSpan; diff --git a/crates/nvisy-core/src/math/polygon.rs b/crates/nvisy-core/src/math/polygon.rs new file mode 100644 index 0000000..4c4605a --- /dev/null +++ b/crates/nvisy-core/src/math/polygon.rs @@ -0,0 +1,76 @@ +//! Polygon type for non-rectangular regions. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::BoundingBox; + +/// A single vertex in a [`Polygon`]. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Vertex { + /// Horizontal coordinate (pixels or normalized). + pub x: f64, + /// Vertical coordinate (pixels or normalized). + pub y: f64, +} + +impl Vertex { + /// Create a new vertex. + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// A closed polygon defined by its vertices. +/// +/// Used for rotated or non-rectangular regions such as skewed text +/// detected by OCR. Vertices are ordered (typically clockwise from +/// top-left) and coordinates are `f64` to support both pixel and +/// normalized values. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Polygon { + /// Ordered vertices defining the polygon outline. + pub vertices: Vec, +} + +impl Polygon { + /// Create an empty polygon. + pub fn new() -> Self { + Self::default() + } + + /// Append a vertex. + pub fn push(&mut self, vertex: Vertex) { + self.vertices.push(vertex); + } + + /// Returns `true` if the polygon has no vertices. + pub fn is_empty(&self) -> bool { + self.vertices.is_empty() + } + + /// Number of vertices. + pub fn len(&self) -> usize { + self.vertices.len() + } + + /// Compute the axis-aligned bounding box that encloses this polygon. + pub fn bounding_box(&self) -> BoundingBox { + let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY); + let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY); + + for v in &self.vertices { + min_x = min_x.min(v.x); + min_y = min_y.min(v.y); + max_x = max_x.max(v.x); + max_y = max_y.max(v.y); + } + + BoundingBox { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } + } +} diff --git a/crates/nvisy-core/src/math/time_span.rs b/crates/nvisy-core/src/math/time_span.rs new file mode 100644 index 0000000..2a036f3 --- /dev/null +++ b/crates/nvisy-core/src/math/time_span.rs @@ -0,0 +1,13 @@ +//! Temporal interval type. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A time interval within an audio or video stream. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TimeSpan { + /// Start time in seconds from the beginning of the stream. + pub start_secs: f64, + /// End time in seconds from the beginning of the stream. + pub end_secs: f64, +} diff --git a/crates/nvisy-identify/Cargo.toml b/crates/nvisy-identify/Cargo.toml index 0d13970..8fb8475 100644 --- a/crates/nvisy-identify/Cargo.toml +++ b/crates/nvisy-identify/Cargo.toml @@ -28,6 +28,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] # Internal crates nvisy-core = { workspace = true, features = [] } +nvisy-ocr = { workspace = true, features = [] } nvisy-ontology = { workspace = true, features = [] } nvisy-codec = { workspace = true, features = [] } nvisy-pattern = { workspace = true, features = [] } diff --git a/crates/nvisy-identify/src/lib.rs b/crates/nvisy-identify/src/lib.rs index f7b4905..6aaaf91 100644 --- a/crates/nvisy-identify/src/lib.rs +++ b/crates/nvisy-identify/src/lib.rs @@ -5,6 +5,7 @@ mod layer; mod method; mod fusion; +mod ocr_bridge; mod policy; pub mod prelude; @@ -29,6 +30,9 @@ pub use fusion::{DetectManualAction, DetectManualParams, Exclusion, ManualOutput pub use fusion::DeduplicateAction; pub use fusion::{EnsembleMerge, FusionStrategy}; +// --- OCR bridge --- +pub use ocr_bridge::OcrBackendProvider; + // --- Policy & governance --- pub use policy::{ Policy, Policies, PolicyRule, RuleKind, RuleCondition, diff --git a/crates/nvisy-identify/src/ocr_bridge.rs b/crates/nvisy-identify/src/ocr_bridge.rs new file mode 100644 index 0000000..2020fdb --- /dev/null +++ b/crates/nvisy-identify/src/ocr_bridge.rs @@ -0,0 +1,44 @@ +//! Adapter bridging [`OcrBackend`] (traditional OCR) → [`OcrProvider`] (VLM agent). + +use std::sync::Arc; + +use async_trait::async_trait; + +use nvisy_ocr::{OcrBackend, OcrConfig}; +use nvisy_rig::agent::{OcrProvider, OcrTextRegion}; +use nvisy_rig::error::Error; + +/// Adapts an [`OcrBackend`] (traditional OCR) into an [`OcrProvider`] +/// for use with the VLM [`OcrAgent`](nvisy_rig::agent::OcrAgent). +pub struct OcrBackendProvider { + backend: Arc, + config: OcrConfig, +} + +impl OcrBackendProvider { + /// Create a new provider wrapping the given backend and config. + pub fn new(backend: Arc, config: OcrConfig) -> Self { + Self { backend, config } + } +} + +#[async_trait] +impl OcrProvider for OcrBackendProvider { + async fn extract_text(&self, image_data: &[u8]) -> Result, Error> { + let regions = self + .backend + .detect_ocr(image_data, "image/png", &self.config) + .await?; + + Ok(regions + .into_iter() + .map(|r| OcrTextRegion { + text: r.text, + confidence: r.confidence, + bbox: Some(r.bbox), + polygon: r.polygon, + level: r.level, + }) + .collect()) + } +} diff --git a/crates/nvisy-paddle/Cargo.toml b/crates/nvisy-ocr/Cargo.toml similarity index 68% rename from crates/nvisy-paddle/Cargo.toml rename to crates/nvisy-ocr/Cargo.toml index a570333..f071eb3 100644 --- a/crates/nvisy-paddle/Cargo.toml +++ b/crates/nvisy-ocr/Cargo.toml @@ -1,9 +1,9 @@ # https://doc.rust-lang.org/cargo/reference/manifest.html [package] -name = "nvisy-paddle" -description = "PaddleOCR backend trait and provider integration for Nvisy" -keywords = ["nvisy", "ocr", "paddle", "text-extraction"] +name = "nvisy-ocr" +description = "OCR backend trait and provider integration for Nvisy" +keywords = ["nvisy", "ocr", "text-extraction"] categories = ["text-processing"] version = { workspace = true } @@ -27,8 +27,16 @@ nvisy-core = { workspace = true, features = [] } nvisy-ontology = { workspace = true, features = [] } nvisy-python = { workspace = true, features = [] } +# OCR engine (Rust-native PaddleOCR via ONNX Runtime) +oar-ocr = { workspace = true } + +# Image decoding +image = { workspace = true } + # (De)serialization +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = [] } # Async runtime async-trait = { workspace = true, features = [] } +tokio = { workspace = true, features = ["rt"] } diff --git a/crates/nvisy-ocr/README.md b/crates/nvisy-ocr/README.md new file mode 100644 index 0000000..9e5095d --- /dev/null +++ b/crates/nvisy-ocr/README.md @@ -0,0 +1,3 @@ +# nvisy-ocr + +OCR backend trait and provider integration for the Nvisy runtime. diff --git a/crates/nvisy-ocr/src/backend/mod.rs b/crates/nvisy-ocr/src/backend/mod.rs new file mode 100644 index 0000000..ecf402d --- /dev/null +++ b/crates/nvisy-ocr/src/backend/mod.rs @@ -0,0 +1,48 @@ +//! OCR backend trait and configuration. + +use serde::Serialize; + +use nvisy_core::Error; +use nvisy_core::math::{BoundingBox, Polygon}; +use nvisy_ontology::location::TextLevel; + +/// Configuration passed to an [`OcrBackend`] implementation. +#[derive(Debug, Clone)] +pub struct OcrConfig { + /// Language hint (e.g. `"eng"` for English). + pub language: String, + /// OCR engine to use (`"tesseract"`, `"google-vision"`, `"aws-textract"`). + pub engine: String, + /// Minimum confidence threshold for OCR results. + pub confidence_threshold: f64, +} + +/// A single text region returned by an OCR backend. +#[derive(Debug, Clone, Serialize)] +pub struct OcrRegion { + /// The extracted text content. + pub text: String, + /// Confidence of the OCR extraction (0.0..=1.0). + pub confidence: f64, + /// Axis-aligned bounding box. + pub bbox: BoundingBox, + /// Polygon vertices for rotated text regions. + pub polygon: Option, + /// Hierarchical level of this text region. + pub level: Option, +} + +/// Backend trait for OCR providers. +/// +/// Implementations call an OCR engine (local or remote) and return +/// typed [`OcrRegion`] results. +#[async_trait::async_trait] +pub trait OcrBackend: Send + Sync + 'static { + /// Run OCR on image bytes, returning detected text regions. + async fn detect_ocr( + &self, + image_data: &[u8], + mime_type: &str, + config: &OcrConfig, + ) -> Result, Error>; +} diff --git a/crates/nvisy-ocr/src/bridge/mod.rs b/crates/nvisy-ocr/src/bridge/mod.rs new file mode 100644 index 0000000..633c1bb --- /dev/null +++ b/crates/nvisy-ocr/src/bridge/mod.rs @@ -0,0 +1,60 @@ +//! [`OcrBackend`] implementation for [`PythonBridge`]. + +use serde_json::Value; + +use nvisy_core::Error; +use nvisy_core::math::BoundingBox; +use nvisy_python::bridge::PythonBridge; +use nvisy_python::ocr::OcrParams; + +use crate::backend::{OcrBackend, OcrConfig, OcrRegion}; + +/// Converts [`OcrConfig`] to [`OcrParams`] and delegates to `nvisy_python::ocr`. +/// +/// Raw JSON dicts from the Python bridge are parsed into typed +/// [`OcrRegion`] values. Expected dict keys: `text`, `x`, `y`, +/// `width`, `height`, `confidence`. +#[async_trait::async_trait] +impl OcrBackend for PythonBridge { + async fn detect_ocr( + &self, + image_data: &[u8], + mime_type: &str, + config: &OcrConfig, + ) -> Result, Error> { + let params = OcrParams { + language: config.language.clone(), + engine: config.engine.clone(), + confidence_threshold: config.confidence_threshold, + }; + let raw = nvisy_python::ocr::detect_ocr(self, image_data, mime_type, ¶ms).await?; + raw.iter().map(parse_region).collect() + } +} + +/// Parse a single raw JSON dict into an [`OcrRegion`]. +fn parse_region(item: &Value) -> Result { + let obj = item + .as_object() + .ok_or_else(|| Error::runtime("expected JSON object in OCR results", "python", false))?; + + let text = obj + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| Error::runtime("missing 'text' in OCR result", "python", false))? + .to_owned(); + + let x = obj.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = obj.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let width = obj.get("width").and_then(Value::as_f64).unwrap_or(0.0); + let height = obj.get("height").and_then(Value::as_f64).unwrap_or(0.0); + let confidence = obj.get("confidence").and_then(Value::as_f64).unwrap_or(0.0); + + Ok(OcrRegion { + text, + confidence, + bbox: BoundingBox { x, y, width, height }, + polygon: None, + level: None, + }) +} diff --git a/crates/nvisy-paddle/src/lib.rs b/crates/nvisy-ocr/src/lib.rs similarity index 60% rename from crates/nvisy-paddle/src/lib.rs rename to crates/nvisy-ocr/src/lib.rs index ae2b5a9..db0349a 100644 --- a/crates/nvisy-paddle/src/lib.rs +++ b/crates/nvisy-ocr/src/lib.rs @@ -4,7 +4,11 @@ mod backend; mod bridge; +mod local; mod parse; -pub use backend::{OcrBackend, OcrConfig}; +pub mod prelude; + +pub use backend::{OcrBackend, OcrConfig, OcrRegion}; +pub use local::LocalOcrBackend; pub use parse::parse_ocr_entities; diff --git a/crates/nvisy-ocr/src/local/mod.rs b/crates/nvisy-ocr/src/local/mod.rs new file mode 100644 index 0000000..1a569bb --- /dev/null +++ b/crates/nvisy-ocr/src/local/mod.rs @@ -0,0 +1,114 @@ +//! [`OcrBackend`] implementation using oar-ocr (Rust-native PaddleOCR via ONNX Runtime). + +use std::io::Cursor; +use std::sync::Arc; + +use image::ImageReader; +use oar_ocr::oarocr::ocr::{OAROCR, OAROCRBuilder}; + +use nvisy_core::Error; +use nvisy_core::math::{Polygon, Vertex}; + +use crate::backend::{OcrBackend, OcrConfig, OcrRegion}; + +/// Local OCR backend using PaddleOCR models via ONNX Runtime (oar-ocr). +/// +/// Model files (detection ONNX, recognition ONNX, character dictionary) +/// must be provided at construction time. The Docker image bundles +/// these; for local development set paths via environment variables or +/// constructor arguments. +pub struct LocalOcrBackend { + engine: Arc, +} + +impl LocalOcrBackend { + /// Build a new local OCR backend from the given model paths. + /// + /// # Errors + /// + /// Returns an error if any model file cannot be loaded or if the + /// ONNX Runtime session fails to initialise. + pub fn new(det_model: &str, rec_model: &str, char_dict: &str) -> Result { + let engine = OAROCRBuilder::new(det_model, rec_model, char_dict) + .build() + .map_err(|e| { + Error::runtime( + format!("failed to build oar-ocr engine: {e}"), + "local_ocr", + false, + ) + })?; + Ok(Self { + engine: Arc::new(engine), + }) + } +} + +#[async_trait::async_trait] +impl OcrBackend for LocalOcrBackend { + async fn detect_ocr( + &self, + image_data: &[u8], + _mime_type: &str, + config: &OcrConfig, + ) -> Result, Error> { + // Decode image bytes into an RgbImage. + let rgb = ImageReader::new(Cursor::new(image_data)) + .with_guessed_format() + .map_err(|e| Error::runtime(format!("image format guess failed: {e}"), "local_ocr", false))? + .decode() + .map_err(|e| Error::runtime(format!("image decode failed: {e}"), "local_ocr", false))? + .to_rgb8(); + + // oar-ocr is synchronous — run on a blocking thread. + let engine = Arc::clone(&self.engine); + let threshold = config.confidence_threshold; + + let regions = tokio::task::spawn_blocking(move || { + let results = engine + .predict(vec![rgb]) + .map_err(|e| Error::runtime(format!("oar-ocr predict failed: {e}"), "local_ocr", false))?; + + let mut out = Vec::new(); + for result in results { + for tr in result.text_regions { + let confidence = tr.confidence.unwrap_or(0.0) as f64; + if confidence < threshold { + continue; + } + + let text = match tr.text { + Some(t) => t.to_string(), + None => continue, + }; + + // Build polygon from detection points. + let polygon = Polygon { + vertices: tr + .bounding_box + .points + .iter() + .map(|p| Vertex::new(p.x as f64, p.y as f64)) + .collect(), + }; + + // Derive axis-aligned bounding box from polygon. + let bbox = polygon.bounding_box(); + + out.push(OcrRegion { + text, + confidence, + bbox, + polygon: Some(polygon), + level: None, + }); + } + } + Ok::<_, Error>(out) + }) + .await + .map_err(|e| Error::runtime(format!("blocking task panicked: {e}"), "local_ocr", false))??; + + Ok(regions) + } +} diff --git a/crates/nvisy-ocr/src/parse/mod.rs b/crates/nvisy-ocr/src/parse/mod.rs new file mode 100644 index 0000000..7dd74d0 --- /dev/null +++ b/crates/nvisy-ocr/src/parse/mod.rs @@ -0,0 +1,27 @@ +//! OCR result parsing. + +use nvisy_ontology::entity::{DetectionMethod, Entity, EntityCategory, EntityKind}; +use nvisy_ontology::location::{ImageLocation, Location}; + +use crate::backend::OcrRegion; + +/// Convert typed [`OcrRegion`] results into [`Entity`] values. +pub fn parse_ocr_entities(regions: &[OcrRegion]) -> Vec { + regions + .iter() + .map(|r| { + Entity::new( + EntityCategory::Pii, + EntityKind::Handwriting, + &r.text, + DetectionMethod::Ocr, + r.confidence, + ) + .with_location(Location::Image(ImageLocation { + bounding_box: r.bbox.clone(), + image_id: None, + page_number: None, + })) + }) + .collect() +} diff --git a/crates/nvisy-ocr/src/prelude.rs b/crates/nvisy-ocr/src/prelude.rs new file mode 100644 index 0000000..a0e17b3 --- /dev/null +++ b/crates/nvisy-ocr/src/prelude.rs @@ -0,0 +1,5 @@ +//! Convenience re-exports. + +pub use crate::backend::{OcrBackend, OcrConfig, OcrRegion}; +pub use crate::local::LocalOcrBackend; +pub use crate::parse::parse_ocr_entities; diff --git a/crates/nvisy-ontology/src/location/mod.rs b/crates/nvisy-ontology/src/location/mod.rs index 6a36bef..4f1c66c 100644 --- a/crates/nvisy-ontology/src/location/mod.rs +++ b/crates/nvisy-ontology/src/location/mod.rs @@ -1,8 +1,10 @@ //! Modality-specific entity location types. mod layout_kind; +mod text_level; pub use layout_kind::LayoutKind; +pub use text_level::TextLevel; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/nvisy-ontology/src/location/text_level.rs b/crates/nvisy-ontology/src/location/text_level.rs new file mode 100644 index 0000000..4f96ed6 --- /dev/null +++ b/crates/nvisy-ontology/src/location/text_level.rs @@ -0,0 +1,21 @@ +//! Hierarchical text-region levels for OCR results. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +/// Hierarchical level of a text region within a document page. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum TextLevel { + /// Full page. + Page, + /// Block-level region (paragraph, table, figure). + Block, + /// Single line of text. + Line, + /// Individual word. + Word, +} diff --git a/crates/nvisy-paddle/README.md b/crates/nvisy-paddle/README.md deleted file mode 100644 index bd19cf5..0000000 --- a/crates/nvisy-paddle/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# nvisy-paddle - -PaddleOCR backend trait and provider integration for the Nvisy runtime. diff --git a/crates/nvisy-paddle/src/backend.rs b/crates/nvisy-paddle/src/backend.rs deleted file mode 100644 index c0c2f32..0000000 --- a/crates/nvisy-paddle/src/backend.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! OCR backend trait and configuration. - -use serde_json::Value; - -use nvisy_core::Error; - -/// Configuration passed to an [`OcrBackend`] implementation. -#[derive(Debug, Clone)] -pub struct OcrConfig { - /// Language hint (e.g. `"eng"` for English). - pub language: String, - /// OCR engine to use (`"tesseract"`, `"google-vision"`, `"aws-textract"`). - pub engine: String, - /// Minimum confidence threshold for OCR results. - pub confidence_threshold: f64, -} - -/// Backend trait for OCR providers. -/// -/// Implementations call an external OCR service and return raw JSON -/// results. Entity construction is handled by the consuming crate. -#[async_trait::async_trait] -pub trait OcrBackend: Send + Sync + 'static { - /// Run OCR on image bytes, returning raw dicts. - async fn detect_ocr( - &self, - image_data: &[u8], - mime_type: &str, - config: &OcrConfig, - ) -> Result, Error>; -} diff --git a/crates/nvisy-paddle/src/bridge.rs b/crates/nvisy-paddle/src/bridge.rs deleted file mode 100644 index 9ea3e5d..0000000 --- a/crates/nvisy-paddle/src/bridge.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! [`OcrBackend`] implementation for [`PythonBridge`]. - -use serde_json::Value; - -use nvisy_core::Error; -use nvisy_python::bridge::PythonBridge; -use nvisy_python::ocr::OcrParams; - -use crate::backend::{OcrBackend, OcrConfig}; - -/// Converts [`OcrConfig`] to [`OcrParams`] and delegates to `nvisy_python::ocr`. -#[async_trait::async_trait] -impl OcrBackend for PythonBridge { - async fn detect_ocr( - &self, - image_data: &[u8], - mime_type: &str, - config: &OcrConfig, - ) -> Result, Error> { - let params = OcrParams { - language: config.language.clone(), - engine: config.engine.clone(), - confidence_threshold: config.confidence_threshold, - }; - nvisy_python::ocr::detect_ocr(self, image_data, mime_type, ¶ms).await - } -} diff --git a/crates/nvisy-paddle/src/parse.rs b/crates/nvisy-paddle/src/parse.rs deleted file mode 100644 index 7bb1308..0000000 --- a/crates/nvisy-paddle/src/parse.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! OCR result parsing. - -use serde_json::Value; - -use nvisy_core::math::BoundingBox; -use nvisy_core::Error; -use nvisy_ontology::entity::{DetectionMethod, Entity, EntityCategory, EntityKind}; -use nvisy_ontology::location::{ImageLocation, Location}; - -/// Parse raw JSON dicts from an OCR backend into [`Entity`] values. -/// -/// Expected dict keys: `text`, `x`, `y`, `width`, `height`, `confidence`. -pub fn parse_ocr_entities(raw: &[Value]) -> Result, Error> { - let mut entities = Vec::new(); - - for item in raw { - let obj = item.as_object().ok_or_else(|| { - Error::runtime("Expected JSON object in OCR results", "python", false) - })?; - - let text = obj - .get("text") - .and_then(Value::as_str) - .ok_or_else(|| Error::runtime("Missing 'text' in OCR result", "python", false))?; - - let x = obj.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = obj.get("y").and_then(Value::as_f64).unwrap_or(0.0); - let width = obj.get("width").and_then(Value::as_f64).unwrap_or(0.0); - let height = obj.get("height").and_then(Value::as_f64).unwrap_or(0.0); - let confidence = obj.get("confidence").and_then(Value::as_f64).unwrap_or(0.0); - - let entity = Entity::new( - EntityCategory::Pii, - EntityKind::Handwriting, - text, - DetectionMethod::Ocr, - confidence, - ) - .with_location(Location::Image(ImageLocation { - bounding_box: BoundingBox { x, y, width, height }, - image_id: None, - page_number: None, - })); - - entities.push(entity); - } - - Ok(entities) -} diff --git a/crates/nvisy-rig/src/agent/ocr/mod.rs b/crates/nvisy-rig/src/agent/ocr/mod.rs index 3c3cf54..a402a1a 100644 --- a/crates/nvisy-rig/src/agent/ocr/mod.rs +++ b/crates/nvisy-rig/src/agent/ocr/mod.rs @@ -17,6 +17,9 @@ use base64::engine::general_purpose::STANDARD; use serde::Serialize; use uuid::Uuid; +use nvisy_core::math::{BoundingBox, Polygon}; +use nvisy_ontology::location::TextLevel; + use crate::backend::UsageTracker; use super::{AgentProvider, DetectionConfig}; use super::{BaseAgent, BaseAgentConfig}; @@ -34,8 +37,12 @@ pub struct OcrTextRegion { pub text: String, /// Confidence of the OCR extraction (0.0..=1.0). pub confidence: f64, - /// Optional bounding box `[x, y, width, height]` in pixels. - pub bbox: Option<[f64; 4]>, + /// Axis-aligned bounding box (always present from traditional OCR). + pub bbox: Option, + /// Polygon vertices for rotated text regions. + pub polygon: Option, + /// Hierarchical level of this text region. + pub level: Option, } /// Trait for OCR capabilities that can be provided to VLM agents. diff --git a/crates/nvisy-rig/src/agent/ocr/output.rs b/crates/nvisy-rig/src/agent/ocr/output.rs index 66baf8b..dd16cd8 100644 --- a/crates/nvisy-rig/src/agent/ocr/output.rs +++ b/crates/nvisy-rig/src/agent/ocr/output.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use nvisy_core::math::BoundingBox; use nvisy_ontology::entity::{EntityCategory, EntityKind}; /// Top-level output from the OCR agent. @@ -25,6 +26,6 @@ pub struct OcrEntity { pub value: String, /// Detection confidence (0.0..=1.0). pub confidence: f64, - /// Optional bounding box `[x, y, width, height]` in pixels. - pub bbox: Option<[f64; 4]>, + /// Axis-aligned bounding box in pixels. + pub bbox: Option, } diff --git a/docker/Dockerfile b/docker/Dockerfile index 2e02c84..8e46882 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,21 +12,21 @@ COPY crates/nvisy-core/Cargo.toml crates/nvisy-core/Cargo.toml COPY crates/nvisy-engine/Cargo.toml crates/nvisy-engine/Cargo.toml COPY crates/nvisy-identify/Cargo.toml crates/nvisy-identify/Cargo.toml COPY crates/nvisy-ontology/Cargo.toml crates/nvisy-ontology/Cargo.toml -COPY crates/nvisy-paddle/Cargo.toml crates/nvisy-paddle/Cargo.toml +COPY crates/nvisy-ocr/Cargo.toml crates/nvisy-ocr/Cargo.toml COPY crates/nvisy-pattern/Cargo.toml crates/nvisy-pattern/Cargo.toml COPY crates/nvisy-python/Cargo.toml crates/nvisy-python/Cargo.toml COPY crates/nvisy-rig/Cargo.toml crates/nvisy-rig/Cargo.toml COPY crates/nvisy-server/Cargo.toml crates/nvisy-server/Cargo.toml # Create empty src files to satisfy cargo's manifest checks -RUN for crate in nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig; do \ +RUN for crate in nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-ocr nvisy-pattern nvisy-python nvisy-rig; do \ mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \ done && \ mkdir -p crates/nvisy-cli/src && echo "fn main() {}" > crates/nvisy-cli/src/main.rs && \ mkdir -p crates/nvisy-server/src && echo "fn main() {}" > crates/nvisy-server/src/main.rs # Create stub READMEs for crates that use doc = include_str!("../README.md") -RUN for crate in nvisy-cli nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-paddle nvisy-pattern nvisy-python nvisy-rig nvisy-server; do \ +RUN for crate in nvisy-cli nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-ocr nvisy-pattern nvisy-python nvisy-rig nvisy-server; do \ touch crates/$crate/README.md; \ done @@ -46,6 +46,18 @@ RUN apt-get update && apt-get install -y \ python3 python3-pip python3-venv ca-certificates \ && rm -rf /var/lib/apt/lists/* +# PDFium shared library for PDF-to-image rendering. +# Download pre-built binary from pdfium-binaries since libpdfium-dev +# is not packaged in Debian bookworm. +RUN apt-get update && apt-get install -y curl && \ + curl -L -o /tmp/pdfium.tgz \ + https://github.com/nickel-org/pdfium-binaries/releases/latest/download/pdfium-linux-x64.tgz && \ + mkdir -p /opt/pdfium && tar -xzf /tmp/pdfium.tgz -C /opt/pdfium && \ + cp /opt/pdfium/lib/libpdfium.so /usr/local/lib/ && \ + ldconfig && \ + rm -rf /tmp/pdfium.tgz /opt/pdfium && \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* + # Install Python packages COPY packages/ /opt/nvisy/packages/ RUN python3 -m pip install --break-system-packages \ From 4c16a6ef75f8c152466457d06c92c9f3b9ae94c9 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 04:18:44 +0100 Subject: [PATCH 15/22] refactor(ocr): move entity conversion to OcrRegion methods, update README Move parse_ocr_entities logic into OcrRegion::into_entity and OcrRegion::as_entity methods and remove the parse module. Group PDF-related dependencies in nvisy-codec Cargo.toml. Rewrite nvisy-ocr README to match other crate READMEs. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-codec/Cargo.toml | 6 +++-- crates/nvisy-ocr/README.md | 29 ++++++++++++++++++++++ crates/nvisy-ocr/src/backend/mod.rs | 37 ++++++++++++++++++++++++++++- crates/nvisy-ocr/src/lib.rs | 2 -- crates/nvisy-ocr/src/parse/mod.rs | 27 --------------------- crates/nvisy-ocr/src/prelude.rs | 1 - 6 files changed, 69 insertions(+), 33 deletions(-) delete mode 100644 crates/nvisy-ocr/src/parse/mod.rs diff --git a/crates/nvisy-codec/Cargo.toml b/crates/nvisy-codec/Cargo.toml index bb06dac..457adbe 100644 --- a/crates/nvisy-codec/Cargo.toml +++ b/crates/nvisy-codec/Cargo.toml @@ -64,11 +64,13 @@ infer = { workspace = true, features = [] } image = { workspace = true, features = [] } imageproc = { workspace = true, features = [] } -# Document parsing (feature-gated) -calamine = { workspace = true, optional = true, features = [] } +# PDF processing (feature-gated) lopdf = { workspace = true, optional = true, features = [] } pdf-extract = { workspace = true, optional = true, features = [] } pdfium-render = { workspace = true, optional = true } + +# Document parsing (feature-gated) +calamine = { workspace = true, optional = true, features = [] } quick-xml = { workspace = true, optional = true, features = [] } scraper = { workspace = true, optional = true, features = [] } zip = { workspace = true, optional = true, features = [] } diff --git a/crates/nvisy-ocr/README.md b/crates/nvisy-ocr/README.md index 9e5095d..0c27934 100644 --- a/crates/nvisy-ocr/README.md +++ b/crates/nvisy-ocr/README.md @@ -1,3 +1,32 @@ # nvisy-ocr +[![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/runtime/build.yml?branch=main&label=build%20%26%20test&style=flat-square)](https://github.com/nvisycom/runtime/actions/workflows/build.yml) + OCR backend trait and provider integration for the Nvisy runtime. + +Defines the [`OcrBackend`] trait for text extraction from images and provides +two implementations: + +- **local:** Rust-native PaddleOCR via ONNX Runtime (oar-ocr) +- **bridge:** Python-based OCR engines via the PyO3 bridge + +Each backend returns typed [`OcrRegion`] results with bounding boxes, optional +polygon vertices for rotated text, and hierarchical text-level annotations. + +## Documentation + +See [`docs/`](../../docs/) for architecture, security, and API documentation. + +## Changelog + +See [CHANGELOG.md](../../CHANGELOG.md) for release notes and version history. + +## License + +Apache 2.0 License, see [LICENSE.txt](../../LICENSE.txt) + +## Support + +- **Documentation**: [docs.nvisy.com](https://docs.nvisy.com) +- **Issues**: [GitHub Issues](https://github.com/nvisycom/runtime/issues) +- **Email**: [support@nvisy.com](mailto:support@nvisy.com) diff --git a/crates/nvisy-ocr/src/backend/mod.rs b/crates/nvisy-ocr/src/backend/mod.rs index ecf402d..547c9c4 100644 --- a/crates/nvisy-ocr/src/backend/mod.rs +++ b/crates/nvisy-ocr/src/backend/mod.rs @@ -4,7 +4,8 @@ use serde::Serialize; use nvisy_core::Error; use nvisy_core::math::{BoundingBox, Polygon}; -use nvisy_ontology::location::TextLevel; +use nvisy_ontology::entity::{DetectionMethod, Entity, EntityCategory, EntityKind}; +use nvisy_ontology::location::{ImageLocation, Location, TextLevel}; /// Configuration passed to an [`OcrBackend`] implementation. #[derive(Debug, Clone)] @@ -32,6 +33,40 @@ pub struct OcrRegion { pub level: Option, } +impl OcrRegion { + /// Convert this region into an [`Entity`], consuming `self`. + pub fn into_entity(self) -> Entity { + Entity::new( + EntityCategory::Pii, + EntityKind::Handwriting, + &self.text, + DetectionMethod::Ocr, + self.confidence, + ) + .with_location(Location::Image(ImageLocation { + bounding_box: self.bbox, + image_id: None, + page_number: None, + })) + } + + /// Create an [`Entity`] from this region by reference. + pub fn as_entity(&self) -> Entity { + Entity::new( + EntityCategory::Pii, + EntityKind::Handwriting, + &self.text, + DetectionMethod::Ocr, + self.confidence, + ) + .with_location(Location::Image(ImageLocation { + bounding_box: self.bbox.clone(), + image_id: None, + page_number: None, + })) + } +} + /// Backend trait for OCR providers. /// /// Implementations call an OCR engine (local or remote) and return diff --git a/crates/nvisy-ocr/src/lib.rs b/crates/nvisy-ocr/src/lib.rs index db0349a..609b11b 100644 --- a/crates/nvisy-ocr/src/lib.rs +++ b/crates/nvisy-ocr/src/lib.rs @@ -5,10 +5,8 @@ mod backend; mod bridge; mod local; -mod parse; pub mod prelude; pub use backend::{OcrBackend, OcrConfig, OcrRegion}; pub use local::LocalOcrBackend; -pub use parse::parse_ocr_entities; diff --git a/crates/nvisy-ocr/src/parse/mod.rs b/crates/nvisy-ocr/src/parse/mod.rs deleted file mode 100644 index 7dd74d0..0000000 --- a/crates/nvisy-ocr/src/parse/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! OCR result parsing. - -use nvisy_ontology::entity::{DetectionMethod, Entity, EntityCategory, EntityKind}; -use nvisy_ontology::location::{ImageLocation, Location}; - -use crate::backend::OcrRegion; - -/// Convert typed [`OcrRegion`] results into [`Entity`] values. -pub fn parse_ocr_entities(regions: &[OcrRegion]) -> Vec { - regions - .iter() - .map(|r| { - Entity::new( - EntityCategory::Pii, - EntityKind::Handwriting, - &r.text, - DetectionMethod::Ocr, - r.confidence, - ) - .with_location(Location::Image(ImageLocation { - bounding_box: r.bbox.clone(), - image_id: None, - page_number: None, - })) - }) - .collect() -} diff --git a/crates/nvisy-ocr/src/prelude.rs b/crates/nvisy-ocr/src/prelude.rs index a0e17b3..51259a4 100644 --- a/crates/nvisy-ocr/src/prelude.rs +++ b/crates/nvisy-ocr/src/prelude.rs @@ -2,4 +2,3 @@ pub use crate::backend::{OcrBackend, OcrConfig, OcrRegion}; pub use crate::local::LocalOcrBackend; -pub use crate::parse::parse_ocr_entities; From f1522e0cfd532b490719e867de44e3b5c4d3ddb7 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 07:43:06 +0100 Subject: [PATCH 16/22] feat(codec): implement PDF loader and handler with per-page text spans Replace the PdfHandler and PdfLoader stubs with full implementations. PdfLoader extracts per-page text via pdf-extract (with encrypted PDF support), and PdfHandler stores pages alongside raw bytes for encoding and rendering. edit_spans applies text replacements to PDF content streams via lopdf::Document::replace_text. Implements TextHandler for redaction pipeline support. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-codec/src/handler/rich/mod.rs | 4 +- .../src/handler/rich/pdf_handler.rs | 277 +++++++++++++++++- .../src/handler/rich/pdf_loader.rs | 140 ++++++++- crates/nvisy-codec/src/prelude.rs | 2 +- 4 files changed, 398 insertions(+), 25 deletions(-) diff --git a/crates/nvisy-codec/src/handler/rich/mod.rs b/crates/nvisy-codec/src/handler/rich/mod.rs index 15b4d6b..944bef3 100644 --- a/crates/nvisy-codec/src/handler/rich/mod.rs +++ b/crates/nvisy-codec/src/handler/rich/mod.rs @@ -13,11 +13,9 @@ mod docx_handler; mod docx_loader; #[cfg(feature = "pdf")] -pub use pdf_handler::PdfHandler; +pub use pdf_handler::{PdfHandler, PdfSpan}; #[cfg(feature = "pdf")] pub use pdf_loader::{PdfLoader, PdfParams}; -#[cfg(feature = "pdf")] -pub use pdf_render::PdfRenderer; #[cfg(feature = "docx")] pub use docx_handler::DocxHandler; diff --git a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs index 4182e5d..93c704b 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs @@ -1,13 +1,94 @@ -//! PDF handler (stub: awaiting migration to Loader/Handler pattern). +//! PDF handler: holds per-page extracted text and raw PDF bytes, +//! providing span-based access via [`Handler`]. +//! +//! # Span model +//! +//! [`Handler::view_spans`] yields one [`Span`] per page. Each span +//! is addressed by a [`PdfSpan`] (0-based page index) and carries the +//! extracted text for that page as a `String`. +//! +//! [`Handler::edit_spans`] replaces the text at the given page indices +//! and applies the changes to the underlying PDF content streams via +//! [`lopdf::Document::replace_text`]. +//! +//! # Encoding +//! +//! [`Handler::encode`] returns the raw PDF bytes. Edits applied via +//! [`edit_spans`](Handler::edit_spans) are already baked into the raw +//! bytes, so `encode` is a simple clone. + +use futures::StreamExt; use nvisy_core::Error; use nvisy_core::fs::DocumentType; +use nvisy_core::math::Dpi; +use crate::handler::image::ImageData; +use crate::handler::{Handler, Span}; use crate::stream::{SpanEditStream, SpanStream}; -use crate::handler::Handler; +use crate::transform::TextHandler; +use super::pdf_render::PdfRenderer; + +/// 0-based page index identifying a span within a PDF document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PdfSpan(pub u32); + +/// PDF document handler. +/// +/// Stores per-page extracted text alongside the raw PDF bytes. +/// Rendering is dispatched to a dedicated single-thread pool via +/// [`PdfRenderer`]. +#[derive(Debug, Clone)] +pub struct PdfHandler { + /// Per-page extracted text (0-indexed). + pages: Vec, + /// Raw PDF bytes for encode and rendering. + raw: Vec, +} + +impl PdfHandler { + /// Create a new handler from per-page text and raw PDF bytes. + pub fn new(pages: Vec, raw: Vec) -> Self { + Self { pages, raw } + } + + /// All per-page text extractions. + pub fn pages(&self) -> &[String] { + &self.pages + } -#[derive(Debug)] -pub struct PdfHandler; + /// Text for a specific page by 0-based index. + pub fn page(&self, index: usize) -> Option<&str> { + self.pages.get(index).map(|s| s.as_str()) + } + + /// Total number of pages. + pub fn page_count(&self) -> usize { + self.pages.len() + } + + /// The raw PDF bytes. + pub fn raw(&self) -> &[u8] { + &self.raw + } + + /// Total number of pages (alias for [`page_count`](Self::page_count)). + pub fn len(&self) -> usize { + self.pages.len() + } + + /// Whether the document has no pages. + pub fn is_empty(&self) -> bool { + self.pages.is_empty() + } + + /// Render all pages of the PDF to images at the given DPI. + /// + /// Delegates to [`PdfRenderer::parallel_render`]. + pub fn render_pages(&self, dpi: Dpi) -> Result, Error> { + PdfRenderer::parallel_render(&self.raw, dpi) + } +} #[async_trait::async_trait] impl Handler for PdfHandler { @@ -15,25 +96,193 @@ impl Handler for PdfHandler { DocumentType::Pdf } - #[tracing::instrument(name = "pdf.encode", skip_all)] + #[tracing::instrument(name = "pdf.encode", skip_all, fields(output_bytes))] fn encode(&self) -> Result, Error> { - Err(Error::validation( - "encode not supported for PDF", - "pdf-handler", - )) + let bytes = self.raw.clone(); + tracing::Span::current().record("output_bytes", bytes.len()); + Ok(bytes) } - type SpanId = (); - type SpanData = (); + type SpanId = PdfSpan; + type SpanData = String; - async fn view_spans(&self) -> SpanStream<'_, (), ()> { - SpanStream::new(futures::stream::empty()) + async fn view_spans(&self) -> SpanStream<'_, PdfSpan, String> { + SpanStream::new(futures::stream::iter(PdfSpanIter { + pages: &self.pages, + index: 0, + })) } async fn edit_spans( &mut self, - _edits: SpanEditStream<'_, (), ()>, + edits: SpanEditStream<'_, PdfSpan, String>, ) -> Result<(), Error> { + let edits: Vec<_> = edits.collect().await; + if edits.is_empty() { + return Ok(()); + } + + // Validate all indices before mutating anything. + for edit in &edits { + let idx = edit.id.0 as usize; + if idx >= self.pages.len() { + return Err(Error::validation( + format!("page index out of bounds: {idx}"), + "pdf-handler", + )); + } + } + + // Load the PDF document for content-stream manipulation. + let mut doc = lopdf::Document::load_mem(&self.raw).map_err(|e| { + Error::runtime( + format!("failed to load PDF for editing: {e}"), + "pdf-handler", + false, + ) + })?; + + for edit in &edits { + let idx = edit.id.0 as usize; + let old_text = &self.pages[idx]; + + // Apply replacement to the PDF content stream. + // lopdf uses 1-based page numbers. + if !old_text.is_empty() && old_text != &edit.data { + let _ = doc.replace_text( + (idx as u32) + 1, + old_text, + &edit.data, + None, + ); + } + + // Update the in-memory text. + self.pages[idx] = edit.data.clone(); + } + + // Serialize the modified document back to raw bytes. + let mut buf = Vec::new(); + doc.save_to(&mut buf).map_err(|e| { + Error::runtime( + format!("failed to save edited PDF: {e}"), + "pdf-handler", + false, + ) + })?; + self.raw = buf; + + Ok(()) + } +} + +impl TextHandler for PdfHandler {} + +/// Iterator over pages of a PDF document. +struct PdfSpanIter<'a> { + pages: &'a [String], + index: usize, +} + +impl<'a> Iterator for PdfSpanIter<'a> { + type Item = Span; + + fn next(&mut self) -> Option { + let text = self.pages.get(self.index)?; + let span = Span::new(PdfSpan(self.index as u32), text.clone()); + self.index += 1; + Some(span) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.pages.len() - self.index; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for PdfSpanIter<'_> {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handler::SpanEdit; + use futures::StreamExt; + use nvisy_core::Error; + + fn handler(pages: &[&str]) -> PdfHandler { + PdfHandler::new( + pages.iter().map(|s| s.to_string()).collect(), + Vec::new(), + ) + } + + #[tokio::test] + async fn view_spans_yields_one_per_page() { + let h = handler(&["page one", "page two", "page three"]); + let spans: Vec<_> = h.view_spans().await.collect().await; + + assert_eq!(spans.len(), 3); + assert_eq!(spans[0].id, PdfSpan(0)); + assert_eq!(spans[0].data, "page one"); + assert_eq!(spans[1].id, PdfSpan(1)); + assert_eq!(spans[1].data, "page two"); + assert_eq!(spans[2].id, PdfSpan(2)); + assert_eq!(spans[2].data, "page three"); + } + + #[tokio::test] + async fn view_spans_empty_document() { + let h = handler(&[]); + let spans: Vec<_> = h.view_spans().await.collect().await; + assert!(spans.is_empty()); + } + + #[test] + fn accessors() { + let h = handler(&["alpha", "beta"]); + assert_eq!(h.page_count(), 2); + assert_eq!(h.len(), 2); + assert!(!h.is_empty()); + assert_eq!(h.page(0), Some("alpha")); + assert_eq!(h.page(1), Some("beta")); + assert_eq!(h.page(2), None); + assert_eq!(h.pages(), &["alpha", "beta"]); + } + + #[test] + fn encode_returns_raw_bytes() -> Result<(), Error> { + let raw = b"fake-pdf-bytes".to_vec(); + let h = PdfHandler::new(vec!["text".into()], raw.clone()); + assert_eq!(h.encode()?, raw); Ok(()) } + + #[tokio::test] + async fn edit_spans_updates_text() -> Result<(), Error> { + // With empty raw bytes, lopdf will fail to parse — but we can + // test the out-of-bounds validation path. + let mut h = handler(&["hello"]); + let err = h + .edit_spans(SpanEditStream::new(futures::stream::iter(vec![ + SpanEdit::new(PdfSpan(5), "nope".into()), + ]))) + .await + .unwrap_err(); + assert!(err.to_string().contains("out of bounds")); + Ok(()) + } + + #[test] + fn document_type_is_pdf() { + let h = handler(&[]); + assert_eq!(h.document_type(), DocumentType::Pdf); + } + + #[test] + fn empty_handler() { + let h = handler(&[]); + assert!(h.is_empty()); + assert_eq!(h.len(), 0); + assert_eq!(h.page_count(), 0); + } } diff --git a/crates/nvisy-codec/src/handler/rich/pdf_loader.rs b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs index 6dc45b2..0d3091c 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_loader.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs @@ -1,4 +1,7 @@ -//! PDF loader (stub: awaiting real implementation). +//! PDF loader: parses raw PDF content into a [`Document`]. +//! +//! Text is extracted per page via [`pdf_extract`]. The raw bytes are +//! preserved for encoding and rendering. use nvisy_core::Error; use nvisy_core::io::ContentData; @@ -8,9 +11,12 @@ use crate::handler::{Loader, PdfHandler}; /// Parameters for [`PdfLoader`]. #[derive(Debug, Default)] -pub struct PdfParams; +pub struct PdfParams { + /// Optional password for encrypted PDFs. + pub password: Option, +} -/// Loader that creates a stub PDF handler. +/// Loader that parses PDF files and extracts per-page text. /// /// Produces a single [`Document`] per input. #[derive(Debug)] @@ -21,15 +27,135 @@ impl Loader for PdfLoader { type Handler = PdfHandler; type Params = PdfParams; - #[tracing::instrument(name = "pdf.decode", skip_all, fields(input_bytes))] + #[tracing::instrument(name = "pdf.decode", skip_all, fields(input_bytes, pages))] async fn decode( &self, content: &ContentData, - _params: &Self::Params, + params: &Self::Params, ) -> Result, Error> { - tracing::Span::current().record("input_bytes", content.to_bytes().len()); - let handler = PdfHandler; + let raw = content.to_bytes(); + tracing::Span::current().record("input_bytes", raw.len()); + + let pages: Vec = match ¶ms.password { + Some(pw) => pdf_extract::extract_text_from_mem_by_pages_encrypted(&raw, pw), + None => pdf_extract::extract_text_from_mem_by_pages(&raw), + } + .map_err(|e| { + Error::runtime( + format!("failed to extract text from PDF: {e}"), + "pdf-loader", + false, + ) + })?; + + tracing::Span::current().record("pages", pages.len()); + + let handler = PdfHandler::new(pages, raw.to_vec()); let doc = Document::new(handler).with_parent(content); Ok(doc) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::StreamExt; + use nvisy_core::path::ContentSource; + use nvisy_core::fs::DocumentType; + + fn content_from_bytes(bytes: &[u8]) -> ContentData { + ContentData::new(ContentSource::new(), Bytes::from(bytes.to_vec())) + } + + /// Build a minimal valid PDF with one blank page using lopdf. + fn minimal_pdf() -> Vec { + use lopdf::{Document, Object, Dictionary, Stream}; + use lopdf::dictionary; + + let mut doc = Document::with_version("1.5"); + + let pages_id = doc.new_object_id(); + let page_id = doc.new_object_id(); + let content_id = doc.add_object(Stream::new(Dictionary::new(), Vec::new())); + + doc.set_object( + page_id, + Object::Dictionary(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Contents" => content_id, + }), + ); + + doc.set_object( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + }), + ); + + let catalog_id = doc.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + doc.trailer.set("Root", catalog_id); + + let mut buf = Vec::new(); + doc.save_to(&mut buf).expect("failed to save minimal PDF"); + buf + } + + #[tokio::test] + async fn load_invalid_pdf_returns_error() { + let content = content_from_bytes(b"not a pdf"); + let err = PdfLoader + .decode(&content, &PdfParams::default()) + .await + .unwrap_err(); + assert!(err.to_string().contains("failed to extract text from PDF")); + } + + #[tokio::test] + async fn load_minimal_pdf() { + let raw = minimal_pdf(); + let content = content_from_bytes(&raw); + let doc = PdfLoader + .decode(&content, &PdfParams::default()) + .await + .unwrap(); + + assert_eq!(doc.document_type(), DocumentType::Pdf); + assert_eq!(doc.page_count(), 1); + // Blank page should yield empty or whitespace-only text. + assert!(doc.page(0).unwrap().trim().is_empty()); + } + + #[tokio::test] + async fn load_preserves_raw_bytes() { + let raw = minimal_pdf(); + let content = content_from_bytes(&raw); + let doc = PdfLoader + .decode(&content, &PdfParams::default()) + .await + .unwrap(); + + assert_eq!(doc.raw(), &raw); + } + + #[tokio::test] + async fn view_spans_matches_pages() { + let raw = minimal_pdf(); + let content = content_from_bytes(&raw); + let doc = PdfLoader + .decode(&content, &PdfParams::default()) + .await + .unwrap(); + + let spans: Vec<_> = doc.view_spans().await.collect().await; + assert_eq!(spans.len(), doc.page_count()); + } +} diff --git a/crates/nvisy-codec/src/prelude.rs b/crates/nvisy-codec/src/prelude.rs index 9136755..602916b 100644 --- a/crates/nvisy-codec/src/prelude.rs +++ b/crates/nvisy-codec/src/prelude.rs @@ -19,7 +19,7 @@ pub use crate::handler::{ #[cfg(feature = "html")] pub use crate::handler::{HtmlData, HtmlHandler, HtmlSpan, HtmlLoader, HtmlParams}; #[cfg(feature = "pdf")] -pub use crate::handler::{PdfHandler, PdfLoader, PdfParams}; +pub use crate::handler::{PdfHandler, PdfSpan, PdfLoader, PdfParams}; #[cfg(feature = "docx")] pub use crate::handler::{DocxHandler, DocxLoader, DocxParams}; #[cfg(feature = "xlsx")] From a30c781a7fbff1555af761256ff7490634ecfb64 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 07:43:33 +0100 Subject: [PATCH 17/22] feat(core, codec): add Dpi type and refactor PDF renderer to use thread pool Add a Dpi newtype to nvisy-core with screen/print/OCR presets and a scale_factor helper. Refactor PdfRenderer to serialise PDFium calls on a dedicated single-thread rayon pool with lazy binding reuse, return ImageData instead of DynamicImage, and accept Dpi instead of raw u16. Add rayon as a workspace dependency feature-gated behind the pdf flag. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 3 + crates/nvisy-codec/Cargo.toml | 5 +- .../src/handler/rich/pdf_render.rs | 59 +++++++++++++++---- crates/nvisy-core/Cargo.toml | 2 +- crates/nvisy-core/src/math/dpi.rs | 47 +++++++++++++++ crates/nvisy-core/src/math/mod.rs | 2 + 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 crates/nvisy-core/src/math/dpi.rs diff --git a/Cargo.lock b/Cargo.lock index 4781912..c69435c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3020,6 +3020,7 @@ dependencies = [ "pdf-extract", "pdfium-render", "quick-xml 0.37.5", + "rayon", "schemars", "scraper", "serde", diff --git a/Cargo.toml b/Cargo.toml index d351419..537669c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,9 @@ hex = { version = "0.4", features = [] } regex = { version = "1.0", features = [] } aho-corasick = { version = "1.0", features = [] } +# Parallelism +rayon = { version = "1.10", features = [] } + # Graph data structures petgraph = { version = "0.8", features = [] } diff --git a/crates/nvisy-codec/Cargo.toml b/crates/nvisy-codec/Cargo.toml index 457adbe..a6c99dd 100644 --- a/crates/nvisy-codec/Cargo.toml +++ b/crates/nvisy-codec/Cargo.toml @@ -25,7 +25,7 @@ rustdoc-args = ["--cfg", "docsrs"] default = ["pdf", "docx", "html", "xlsx"] # PDF parsing, text extraction, and page-to-image rendering -pdf = ["dep:pdf-extract", "dep:lopdf", "dep:pdfium-render"] +pdf = ["dep:pdf-extract", "dep:lopdf", "dep:pdfium-render", "dep:rayon"] # Microsoft Word (.docx) parsing via zip + quick-xml docx = ["dep:zip", "dep:quick-xml"] # HTML parsing and text extraction via scraper @@ -67,7 +67,8 @@ imageproc = { workspace = true, features = [] } # PDF processing (feature-gated) lopdf = { workspace = true, optional = true, features = [] } pdf-extract = { workspace = true, optional = true, features = [] } -pdfium-render = { workspace = true, optional = true } +pdfium-render = { workspace = true, optional = true, features = [] } +rayon = { workspace = true, optional = true, features = [] } # Document parsing (feature-gated) calamine = { workspace = true, optional = true, features = [] } diff --git a/crates/nvisy-codec/src/handler/rich/pdf_render.rs b/crates/nvisy-codec/src/handler/rich/pdf_render.rs index 351014f..74a11d2 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_render.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_render.rs @@ -1,21 +1,46 @@ //! PDF-to-image rendering via PDFium. +//! +//! PDFium is not thread-safe, so all rendering is serialised on a +//! dedicated single-thread [`rayon::ThreadPool`]. The [`PdfRenderer`] +//! binding is created once on first use via a `thread_local!` and +//! reused for all subsequent calls. + +use std::cell::RefCell; +use std::sync::LazyLock; -use image::DynamicImage; use pdfium_render::prelude::*; use nvisy_core::Error; +use nvisy_core::math::Dpi; + +use crate::handler::image::ImageData; + +/// Dedicated single-thread pool for PDFium operations. +static PDF_POOL: LazyLock = LazyLock::new(|| { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .thread_name(|_| "pdfium".into()) + .build() + .expect("failed to create PDFium thread pool") +}); + +thread_local! { + static RENDERER: RefCell> = const { RefCell::new(None) }; +} /// Renders PDF pages to images for OCR processing. /// +/// Binding to the PDFium shared library is expensive, so the renderer +/// is lazily initialised on a dedicated thread and reused across calls. /// Requires the PDFium shared library to be available at runtime /// (bundled in the Docker image or installed on the host). -pub struct PdfRenderer { +pub(super) struct PdfRenderer { pdfium: Pdfium, } impl PdfRenderer { /// Create a new renderer by binding to a system-provided PDFium library. - pub fn new() -> Result { + fn new() -> Result { let bindings = Pdfium::bind_to_system_library() .or_else(|_| Pdfium::bind_to_library("libpdfium")) .map_err(|e| { @@ -32,9 +57,24 @@ impl PdfRenderer { /// Render all pages of a PDF to images at the given DPI. /// - /// Each page is rendered as a separate [`DynamicImage`]. A typical - /// DPI value for OCR is 300. - pub fn render_pages(&self, pdf_bytes: &[u8], dpi: u16) -> Result, Error> { + /// Dispatches work to a dedicated single-thread pool where the + /// PDFium binding is lazily initialised and reused. A typical DPI + /// value for OCR is [`Dpi::OCR`] (300). + pub fn parallel_render(pdf_bytes: &[u8], dpi: Dpi) -> Result, Error> { + let bytes = pdf_bytes.to_vec(); + + PDF_POOL.install(|| { + RENDERER.with_borrow_mut(|slot| { + if slot.is_none() { + *slot = Some(PdfRenderer::new()?); + } + slot.as_ref().unwrap().render(&bytes, dpi) + }) + }) + } + + /// Render all pages using the bound PDFium instance. + fn render(&self, pdf_bytes: &[u8], dpi: Dpi) -> Result, Error> { let document = self .pdfium .load_pdf_from_byte_slice(pdf_bytes, None) @@ -46,10 +86,7 @@ impl PdfRenderer { ) })?; - // PDF points are 1/72 inch; scale factor = target_dpi / 72. - let scale = f32::from(dpi) / 72.0; - - let config = PdfRenderConfig::new().scale_page_by_factor(scale); + let config = PdfRenderConfig::new().scale_page_by_factor(dpi.scale_factor()); let mut images = Vec::new(); for page in document.pages().iter() { @@ -60,7 +97,7 @@ impl PdfRenderer { false, ) })?; - images.push(bitmap.as_image()); + images.push(ImageData::from(bitmap.as_image())); } Ok(images) diff --git a/crates/nvisy-core/Cargo.toml b/crates/nvisy-core/Cargo.toml index 5400aef..0aba663 100644 --- a/crates/nvisy-core/Cargo.toml +++ b/crates/nvisy-core/Cargo.toml @@ -42,7 +42,7 @@ infer = { workspace = true, features = [] } # Error handling thiserror = { workspace = true, features = [] } anyhow = { workspace = true, features = [] } -derive_more = { workspace = true, features = ["display", "deref", "as_ref"] } +derive_more = { workspace = true, features = ["display", "deref", "as_ref", "from", "into"] } # Time jiff = { workspace = true, features = [] } diff --git a/crates/nvisy-core/src/math/dpi.rs b/crates/nvisy-core/src/math/dpi.rs new file mode 100644 index 0000000..d527726 --- /dev/null +++ b/crates/nvisy-core/src/math/dpi.rs @@ -0,0 +1,47 @@ +//! Dots-per-inch resolution type. + +use derive_more::{Display, From, Into}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Dots-per-inch resolution for rasterisation and rendering. +/// +/// Wraps a [`u16`] to prevent accidental misuse of raw integers as DPI +/// values. Common presets are available as associated constants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(From, Into, Display)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[display("{_0} dpi")] +pub struct Dpi(u16); + +impl Dpi { + /// Screen resolution (72 DPI): matches PDF point units. + pub const SCREEN: Self = Self(72); + + /// Standard print resolution: 150 DPI. + pub const PRINT: Self = Self(150); + + /// High-quality OCR resolution: 300 DPI. + pub const OCR: Self = Self(300); + + /// Create a DPI value from a raw `u16`. + pub const fn new(value: u16) -> Self { + Self(value) + } + + /// Return the raw numeric value. + pub const fn value(self) -> u16 { + self.0 + } + + /// Compute the scale factor relative to PDF points (1 pt = 1/72 in). + pub fn scale_factor(self) -> f32 { + self.0 as f32 / Self::SCREEN.0 as f32 + } +} + +impl Default for Dpi { + fn default() -> Self { + Self::OCR + } +} diff --git a/crates/nvisy-core/src/math/mod.rs b/crates/nvisy-core/src/math/mod.rs index 6fb5733..0bd098b 100644 --- a/crates/nvisy-core/src/math/mod.rs +++ b/crates/nvisy-core/src/math/mod.rs @@ -4,9 +4,11 @@ //! locations, rendering, and redaction operations. mod bounding_box; +mod dpi; mod polygon; mod time_span; pub use bounding_box::{BoundingBox, BoundingBoxU32}; +pub use dpi::Dpi; pub use polygon::{Polygon, Vertex}; pub use time_span::TimeSpan; From df94db9d4b63465d3c474e48bd702bf1f3fd3c03 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 09:22:51 +0100 Subject: [PATCH 18/22] feat(codec): add TextData newtype, use Bytes for encode, drop pdf-extract Introduce TextData(HipStr) as the SpanData type for all text-bearing handlers (Txt, Csv, Html, Pdf), replacing raw String at the Handler trait boundary. This mirrors the existing ImageData pattern and gives a consistent, opaque type boundary. Change Handler::encode() return type from Vec to bytes::Bytes across all handlers. Audio and PDF handlers now return zero-copy clones of their inner Bytes instead of allocating. Move PDF text extraction from pdf_loader into PdfHandler::from_raw() and replace the pdf-extract dependency with lopdf-only extraction. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 76 +------------------ Cargo.toml | 3 +- crates/nvisy-codec/Cargo.toml | 8 +- crates/nvisy-codec/src/document/any.rs | 4 +- .../src/handler/audio/audio_handler.rs | 4 +- .../src/handler/audio/mp3_handler.rs | 9 +-- .../src/handler/audio/wav_handler.rs | 9 +-- .../src/handler/image/image_data.rs | 4 +- .../src/handler/image/image_handler.rs | 2 +- .../src/handler/image/image_handler_macro.rs | 4 +- crates/nvisy-codec/src/handler/mod.rs | 4 +- .../src/handler/rich/docx_handler.rs | 2 +- .../src/handler/rich/pdf_handler.rs | 72 +++++++++++++----- .../src/handler/rich/pdf_loader.rs | 24 ++---- .../src/handler/text/csv_handler.rs | 29 +++---- .../src/handler/text/html_handler.rs | 31 ++++---- .../src/handler/text/json_handler.rs | 10 +-- crates/nvisy-codec/src/handler/text/mod.rs | 2 + .../nvisy-codec/src/handler/text/text_data.rs | 46 +++++++++++ .../src/handler/text/txt_handler.rs | 21 ++--- .../src/handler/text/xlsx_handler.rs | 2 +- crates/nvisy-codec/src/prelude.rs | 34 +++------ 22 files changed, 196 insertions(+), 204 deletions(-) create mode 100644 crates/nvisy-codec/src/handler/text/text_data.rs diff --git a/Cargo.lock b/Cargo.lock index c69435c..9e5f0fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,15 +24,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adobe-cmap-parser" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" -dependencies = [ - "pom", -] - [[package]] name = "aes" version = "0.8.4" @@ -1344,15 +1335,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -[[package]] -name = "euclid" -version = "0.20.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" -dependencies = [ - "num-traits", -] - [[package]] name = "eventsource-stream" version = "0.2.3" @@ -2487,24 +2469,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "lopdf" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" -dependencies = [ - "encoding_rs", - "flate2", - "indexmap", - "itoa", - "log", - "md-5", - "nom 7.1.3", - "rangemap", - "time", - "weezl", -] - [[package]] name = "lopdf" version = "0.39.0" @@ -3012,12 +2976,12 @@ dependencies = [ "csv", "derive_more 2.1.1", "futures", + "hipstr", "image", "imageproc", "infer", - "lopdf 0.39.0", + "lopdf", "nvisy-core", - "pdf-extract", "pdfium-render", "quick-xml 0.37.5", "rayon", @@ -3420,21 +3384,6 @@ dependencies = [ "hmac", ] -[[package]] -name = "pdf-extract" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" -dependencies = [ - "adobe-cmap-parser", - "encoding_rs", - "euclid", - "lopdf 0.34.0", - "postscript", - "type1-encoding-parser", - "unicode-normalization", -] - [[package]] name = "pdfium-render" version = "0.8.37" @@ -3597,12 +3546,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "pom" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" - [[package]] name = "portable-atomic" version = "1.13.1" @@ -3618,12 +3561,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postscript" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" - [[package]] name = "potential_utf" version = "0.1.4" @@ -5426,15 +5363,6 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -[[package]] -name = "type1-encoding-parser" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" -dependencies = [ - "pom", -] - [[package]] name = "typed-path" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 537669c..a2148a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ csv = { version = "1.0", features = [] } # Derive macros and error handling thiserror = { version = "2.0", features = [] } anyhow = { version = "1.0", features = [] } -derive_more = { version = "2.0", features = ["display", "from", "into"] } +derive_more = { version = "2.0", features = ["as_ref", "display", "from", "into"] } strum = { version = "0.28", features = ["derive"] } # Primitive datatypes @@ -102,7 +102,6 @@ petgraph = { version = "0.8", features = [] } infer = { version = "0.19", features = [] } # PDF processing (parsing, text extraction, page-to-image rendering) -pdf-extract = { version = "0.7", features = [] } lopdf = { version = "0.39", features = [] } pdfium-render = { version = "0.8", features = [] } diff --git a/crates/nvisy-codec/Cargo.toml b/crates/nvisy-codec/Cargo.toml index a6c99dd..c141e32 100644 --- a/crates/nvisy-codec/Cargo.toml +++ b/crates/nvisy-codec/Cargo.toml @@ -25,7 +25,7 @@ rustdoc-args = ["--cfg", "docsrs"] default = ["pdf", "docx", "html", "xlsx"] # PDF parsing, text extraction, and page-to-image rendering -pdf = ["dep:pdf-extract", "dep:lopdf", "dep:pdfium-render", "dep:rayon"] +pdf = ["dep:lopdf", "dep:pdfium-render", "dep:rayon"] # Microsoft Word (.docx) parsing via zip + quick-xml docx = ["dep:zip", "dep:quick-xml"] # HTML parsing and text extraction via scraper @@ -52,7 +52,10 @@ bytes = { workspace = true, features = [] } uuid = { workspace = true, features = ["v4"] } # Derive macros -derive_more = { workspace = true, features = ["from"] } +derive_more = { workspace = true, features = ["as_ref", "display", "from"] } + +# Immutable string +hipstr = { workspace = true, features = [] } # JSON Schema generation schemars = { workspace = true, features = [] } @@ -66,7 +69,6 @@ imageproc = { workspace = true, features = [] } # PDF processing (feature-gated) lopdf = { workspace = true, optional = true, features = [] } -pdf-extract = { workspace = true, optional = true, features = [] } pdfium-render = { workspace = true, optional = true, features = [] } rayon = { workspace = true, optional = true, features = [] } diff --git a/crates/nvisy-codec/src/document/any.rs b/crates/nvisy-codec/src/document/any.rs index 59cc99b..cfe401e 100644 --- a/crates/nvisy-codec/src/document/any.rs +++ b/crates/nvisy-codec/src/document/any.rs @@ -60,7 +60,7 @@ impl AnyDocument { } /// Encode the inner document back to raw bytes. - pub fn encode(&self) -> Result, Error> { + pub fn encode(&self) -> Result { match self { Self::Txt(d) => d.encode(), Self::Csv(d) => d.encode(), @@ -194,7 +194,7 @@ mod tests { let handler = TxtHandler::new(vec!["hello".into()], true); let doc = AnyDocument::Txt(Document::new(handler)); let bytes = doc.encode().unwrap(); - assert_eq!(bytes, b"hello\n"); + assert_eq!(&bytes[..], b"hello\n"); } #[test] diff --git a/crates/nvisy-codec/src/handler/audio/audio_handler.rs b/crates/nvisy-codec/src/handler/audio/audio_handler.rs index e24f0f3..b885171 100644 --- a/crates/nvisy-codec/src/handler/audio/audio_handler.rs +++ b/crates/nvisy-codec/src/handler/audio/audio_handler.rs @@ -53,7 +53,7 @@ impl Handler for AnyAudio { } } - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { match self { Self::Wav(h) => h.encode(), Self::Mp3(h) => h.encode(), @@ -111,7 +111,7 @@ mod tests { async fn mp3_variant_delegates() { let h = AnyAudio::Mp3(Mp3Handler::new(Bytes::from_static(b"mp3-data"))); assert_eq!(h.document_type(), DocumentType::Mp3); - assert_eq!(h.encode().unwrap(), b"mp3-data"); + assert_eq!(&h.encode().unwrap()[..], b"mp3-data"); } #[test] diff --git a/crates/nvisy-codec/src/handler/audio/mp3_handler.rs b/crates/nvisy-codec/src/handler/audio/mp3_handler.rs index 7387651..0e51fda 100644 --- a/crates/nvisy-codec/src/handler/audio/mp3_handler.rs +++ b/crates/nvisy-codec/src/handler/audio/mp3_handler.rs @@ -39,10 +39,9 @@ impl Handler for Mp3Handler { } #[tracing::instrument(name = "mp3.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { - let bytes = self.bytes.to_vec(); - tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + fn encode(&self) -> Result { + tracing::Span::current().record("output_bytes", self.bytes.len()); + Ok(self.bytes.clone()) } type SpanId = (); @@ -103,7 +102,7 @@ mod tests { fn encode_returns_current_bytes() -> Result<(), Error> { let h = Mp3Handler::new(Bytes::from_static(b"audio-data")); let encoded = h.encode()?; - assert_eq!(encoded, b"audio-data"); + assert_eq!(&encoded[..], b"audio-data"); Ok(()) } } diff --git a/crates/nvisy-codec/src/handler/audio/wav_handler.rs b/crates/nvisy-codec/src/handler/audio/wav_handler.rs index 473b638..c15e5b3 100644 --- a/crates/nvisy-codec/src/handler/audio/wav_handler.rs +++ b/crates/nvisy-codec/src/handler/audio/wav_handler.rs @@ -39,10 +39,9 @@ impl Handler for WavHandler { } #[tracing::instrument(name = "wav.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { - let bytes = self.bytes.to_vec(); - tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + fn encode(&self) -> Result { + tracing::Span::current().record("output_bytes", self.bytes.len()); + Ok(self.bytes.clone()) } type SpanId = (); @@ -103,7 +102,7 @@ mod tests { fn encode_returns_current_bytes() -> Result<(), Error> { let h = WavHandler::new(Bytes::from_static(b"audio-data")); let encoded = h.encode()?; - assert_eq!(encoded, b"audio-data"); + assert_eq!(&encoded[..], b"audio-data"); Ok(()) } } diff --git a/crates/nvisy-codec/src/handler/image/image_data.rs b/crates/nvisy-codec/src/handler/image/image_data.rs index ca68ed5..f9cbd61 100644 --- a/crates/nvisy-codec/src/handler/image/image_data.rs +++ b/crates/nvisy-codec/src/handler/image/image_data.rs @@ -13,12 +13,12 @@ pub struct ImageData(DynamicImage); impl ImageData { /// Encode to PNG bytes. - pub fn encode_png(&self) -> Result, Error> { + pub fn encode_png(&self) -> Result { let mut buf = std::io::Cursor::new(Vec::new()); self.0 .write_to(&mut buf, image::ImageFormat::Png) .map_err(|e| Error::validation(format!("PNG encode failed: {e}"), "image-data"))?; - Ok(buf.into_inner()) + Ok(buf.into_inner().into()) } /// Create a blank RGB image (for tests). diff --git a/crates/nvisy-codec/src/handler/image/image_handler.rs b/crates/nvisy-codec/src/handler/image/image_handler.rs index b63da19..7923580 100644 --- a/crates/nvisy-codec/src/handler/image/image_handler.rs +++ b/crates/nvisy-codec/src/handler/image/image_handler.rs @@ -52,7 +52,7 @@ impl Handler for AnyImage { } } - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { match self { Self::Png(h) => h.encode(), Self::Jpeg(h) => h.encode(), diff --git a/crates/nvisy-codec/src/handler/image/image_handler_macro.rs b/crates/nvisy-codec/src/handler/image/image_handler_macro.rs index 90c23e8..022218e 100644 --- a/crates/nvisy-codec/src/handler/image/image_handler_macro.rs +++ b/crates/nvisy-codec/src/handler/image/image_handler_macro.rs @@ -11,7 +11,7 @@ macro_rules! impl_image_handler { } #[tracing::instrument(name = $encode_name, skip_all, fields(output_bytes))] - fn encode(&self) -> Result, nvisy_core::Error> { + fn encode(&self) -> Result { let mut buf = std::io::Cursor::new(Vec::new()); self.image .write_to(&mut buf, $fmt) @@ -23,7 +23,7 @@ macro_rules! impl_image_handler { })?; let out = buf.into_inner(); tracing::Span::current().record("output_bytes", out.len()); - Ok(out) + Ok(out.into()) } type SpanId = (); diff --git a/crates/nvisy-codec/src/handler/mod.rs b/crates/nvisy-codec/src/handler/mod.rs index 0b50bab..69d75a1 100644 --- a/crates/nvisy-codec/src/handler/mod.rs +++ b/crates/nvisy-codec/src/handler/mod.rs @@ -7,6 +7,8 @@ //! Each handler defines its own span types and exposes them as async //! streams via [`Handler::view_spans`] and [`Handler::edit_spans`]. +use bytes::Bytes; + use nvisy_core::Error; use nvisy_core::io::ContentData; use nvisy_core::fs::DocumentType; @@ -43,7 +45,7 @@ pub trait Handler: Send + Sync + 'static { fn document_type(&self) -> DocumentType; /// Serialize the current handler content back to raw bytes. - fn encode(&self) -> Result, Error>; + fn encode(&self) -> Result; /// Strongly-typed identifier for a span within this handler. type SpanId: Send + Sync + Clone + 'static; diff --git a/crates/nvisy-codec/src/handler/rich/docx_handler.rs b/crates/nvisy-codec/src/handler/rich/docx_handler.rs index 0ba41fe..300929c 100644 --- a/crates/nvisy-codec/src/handler/rich/docx_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/docx_handler.rs @@ -16,7 +16,7 @@ impl Handler for DocxHandler { } #[tracing::instrument(name = "docx.encode", skip_all)] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { Err(Error::validation( "encode not supported for DOCX", "docx-handler", diff --git a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs index 93c704b..09a33ee 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs @@ -17,6 +17,7 @@ //! [`edit_spans`](Handler::edit_spans) are already baked into the raw //! bytes, so `encode` is a simple clone. +use bytes::Bytes; use futures::StreamExt; use nvisy_core::Error; @@ -24,6 +25,7 @@ use nvisy_core::fs::DocumentType; use nvisy_core::math::Dpi; use crate::handler::image::ImageData; +use crate::handler::text::TextData; use crate::handler::{Handler, Span}; use crate::stream::{SpanEditStream, SpanStream}; use crate::transform::TextHandler; @@ -43,13 +45,46 @@ pub struct PdfHandler { /// Per-page extracted text (0-indexed). pages: Vec, /// Raw PDF bytes for encode and rendering. - raw: Vec, + raw: Bytes, } impl PdfHandler { /// Create a new handler from per-page text and raw PDF bytes. - pub fn new(pages: Vec, raw: Vec) -> Self { - Self { pages, raw } + pub fn new(pages: Vec, raw: impl Into) -> Self { + Self { pages, raw: raw.into() } + } + + /// Parse raw PDF bytes, extract per-page text, and return a new handler. + /// + /// Uses [`lopdf::Document::extract_text_chunks`] with error-filtering + /// for resilience (PDF font encoding issues are common). + pub fn from_raw(raw: impl Into, password: Option<&str>) -> Result { + let raw: Bytes = raw.into(); + let mut doc = lopdf::Document::load_mem(&raw).map_err(|e| { + Error::runtime( + format!("failed to extract text from PDF: {e}"), + "pdf-handler", + false, + ) + })?; + if doc.is_encrypted() { + doc.decrypt(password.unwrap_or("")).map_err(|e| { + Error::runtime( + format!("failed to extract text from PDF: {e}"), + "pdf-handler", + false, + ) + })?; + } + let page_count = doc.get_pages().len(); + let mut pages = Vec::with_capacity(page_count); + for page_num in 1..=(page_count as u32) { + // Resilient: skip chunks that fail encoding + let chunks = doc.extract_text_chunks(&[page_num]); + let text: String = chunks.into_iter().filter_map(|r| r.ok()).collect(); + pages.push(text); + } + Ok(Self { pages, raw }) } /// All per-page text extractions. @@ -97,16 +132,15 @@ impl Handler for PdfHandler { } #[tracing::instrument(name = "pdf.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { - let bytes = self.raw.clone(); - tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + fn encode(&self) -> Result { + tracing::Span::current().record("output_bytes", self.raw.len()); + Ok(self.raw.clone()) } type SpanId = PdfSpan; - type SpanData = String; + type SpanData = TextData; - async fn view_spans(&self) -> SpanStream<'_, PdfSpan, String> { + async fn view_spans(&self) -> SpanStream<'_, PdfSpan, TextData> { SpanStream::new(futures::stream::iter(PdfSpanIter { pages: &self.pages, index: 0, @@ -115,7 +149,7 @@ impl Handler for PdfHandler { async fn edit_spans( &mut self, - edits: SpanEditStream<'_, PdfSpan, String>, + edits: SpanEditStream<'_, PdfSpan, TextData>, ) -> Result<(), Error> { let edits: Vec<_> = edits.collect().await; if edits.is_empty() { @@ -148,17 +182,17 @@ impl Handler for PdfHandler { // Apply replacement to the PDF content stream. // lopdf uses 1-based page numbers. - if !old_text.is_empty() && old_text != &edit.data { + if !old_text.is_empty() && old_text.as_str() != edit.data.as_str() { let _ = doc.replace_text( (idx as u32) + 1, old_text, - &edit.data, + edit.data.as_str(), None, ); } // Update the in-memory text. - self.pages[idx] = edit.data.clone(); + self.pages[idx] = edit.data.as_str().to_owned(); } // Serialize the modified document back to raw bytes. @@ -170,7 +204,7 @@ impl Handler for PdfHandler { false, ) })?; - self.raw = buf; + self.raw = Bytes::from(buf); Ok(()) } @@ -185,11 +219,11 @@ struct PdfSpanIter<'a> { } impl<'a> Iterator for PdfSpanIter<'a> { - type Item = Span; + type Item = Span; fn next(&mut self) -> Option { let text = self.pages.get(self.index)?; - let span = Span::new(PdfSpan(self.index as u32), text.clone()); + let span = Span::new(PdfSpan(self.index as u32), TextData::from(text.clone())); self.index += 1; Some(span) } @@ -251,9 +285,9 @@ mod tests { #[test] fn encode_returns_raw_bytes() -> Result<(), Error> { - let raw = b"fake-pdf-bytes".to_vec(); - let h = PdfHandler::new(vec!["text".into()], raw.clone()); - assert_eq!(h.encode()?, raw); + let raw = b"fake-pdf-bytes"; + let h = PdfHandler::new(vec!["text".into()], raw.to_vec()); + assert_eq!(&h.encode()?[..], raw); Ok(()) } diff --git a/crates/nvisy-codec/src/handler/rich/pdf_loader.rs b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs index 0d3091c..a2c765f 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_loader.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_loader.rs @@ -1,9 +1,8 @@ //! PDF loader: parses raw PDF content into a [`Document`]. //! -//! Text is extracted per page via [`pdf_extract`]. The raw bytes are +//! Text is extracted per page via [`lopdf`]. The raw bytes are //! preserved for encoding and rendering. -use nvisy_core::Error; use nvisy_core::io::ContentData; use crate::document::Document; @@ -32,25 +31,14 @@ impl Loader for PdfLoader { &self, content: &ContentData, params: &Self::Params, - ) -> Result, Error> { + ) -> Result, nvisy_core::Error> { let raw = content.to_bytes(); tracing::Span::current().record("input_bytes", raw.len()); - let pages: Vec = match ¶ms.password { - Some(pw) => pdf_extract::extract_text_from_mem_by_pages_encrypted(&raw, pw), - None => pdf_extract::extract_text_from_mem_by_pages(&raw), - } - .map_err(|e| { - Error::runtime( - format!("failed to extract text from PDF: {e}"), - "pdf-loader", - false, - ) - })?; - - tracing::Span::current().record("pages", pages.len()); - - let handler = PdfHandler::new(pages, raw.to_vec()); + let handler = PdfHandler::from_raw(raw, params.password.as_deref())?; + + tracing::Span::current().record("pages", handler.page_count()); + let doc = Document::new(handler).with_parent(content); Ok(doc) } diff --git a/crates/nvisy-codec/src/handler/text/csv_handler.rs b/crates/nvisy-codec/src/handler/text/csv_handler.rs index 5b07098..d45b77c 100644 --- a/crates/nvisy-codec/src/handler/text/csv_handler.rs +++ b/crates/nvisy-codec/src/handler/text/csv_handler.rs @@ -22,6 +22,7 @@ use nvisy_core::fs::DocumentType; use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; +use crate::handler::text::TextData; use crate::transform::TextHandler; /// Cell address within a CSV document. @@ -87,7 +88,7 @@ impl Handler for CsvHandler { } #[tracing::instrument(name = "csv.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { let mut wtr = csv::WriterBuilder::new() .delimiter(self.data.delimiter) .has_headers(false) @@ -116,19 +117,19 @@ impl Handler for CsvHandler { } tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + Ok(bytes.into()) } type SpanId = CsvSpan; - type SpanData = String; + type SpanData = TextData; - async fn view_spans(&self) -> SpanStream<'_, CsvSpan, String> { + async fn view_spans(&self) -> SpanStream<'_, CsvSpan, TextData> { SpanStream::new(futures::stream::iter(CsvSpanIter::new(&self.data))) } async fn edit_spans( &mut self, - edits: SpanEditStream<'_, CsvSpan, String>, + edits: SpanEditStream<'_, CsvSpan, TextData>, ) -> Result<(), Error> { let edits: Vec<_> = edits.collect().await; for edit in edits { @@ -142,7 +143,7 @@ impl Handler for CsvHandler { "csv-handler", ) })?; - *cell = edit.data; + *cell = edit.data.into_inner(); } else { let row = self.data.rows.get_mut(edit.id.row).ok_or_else(|| { Error::validation( @@ -159,7 +160,7 @@ impl Handler for CsvHandler { "csv-handler", ) })?; - *cell = edit.data; + *cell = edit.data.into_inner(); } } Ok(()) @@ -253,7 +254,7 @@ impl<'a> CsvSpanIter<'a> { } impl<'a> Iterator for CsvSpanIter<'a> { - type Item = Span; + type Item = Span; fn next(&mut self) -> Option { loop { @@ -265,7 +266,7 @@ impl<'a> Iterator for CsvSpanIter<'a> { self.col += 1; return Some(Span::new( CsvSpan::header_cell(col, value.clone()), - value.clone(), + TextData::from(value.clone()), )); } self.phase = CsvIterPhase::Data(0); @@ -284,7 +285,7 @@ impl<'a> Iterator for CsvSpanIter<'a> { .unwrap_or_else(|| col.to_string()); return Some(Span::new( CsvSpan::cell(row_idx, col, key), - value.clone(), + TextData::from(value.clone()), )); } self.phase = CsvIterPhase::Data(row_idx + 1); @@ -436,7 +437,7 @@ mod tests { ); let bytes = h.encode()?; assert_eq!( - String::from_utf8(bytes).expect("valid utf-8"), + std::str::from_utf8(&bytes).expect("valid utf-8"), "name,age\nAlice,30\nBob,25\n" ); Ok(()) @@ -449,7 +450,7 @@ mod tests { vec![vec!["Alice", "Has a, comma"]], ); let bytes = h.encode()?; - let text = String::from_utf8(bytes).expect("valid utf-8"); + let text = std::str::from_utf8(&bytes).expect("valid utf-8"); assert!(text.contains("\"Has a, comma\"")); Ok(()) } @@ -459,7 +460,7 @@ mod tests { let mut h = handler_with_headers(vec!["a"], vec![vec!["1"]]); h.data.trailing_newline = false; let bytes = h.encode()?; - assert_eq!(String::from_utf8(bytes).expect("valid utf-8"), "a\n1"); + assert_eq!(std::str::from_utf8(&bytes).expect("valid utf-8"), "a\n1"); Ok(()) } @@ -472,7 +473,7 @@ mod tests { h.data.delimiter = b'\t'; let bytes = h.encode()?; assert_eq!( - String::from_utf8(bytes).expect("valid utf-8"), + std::str::from_utf8(&bytes).expect("valid utf-8"), "a\tb\n1\t2\n" ); Ok(()) diff --git a/crates/nvisy-codec/src/handler/text/html_handler.rs b/crates/nvisy-codec/src/handler/text/html_handler.rs index 00816ad..01fdeff 100644 --- a/crates/nvisy-codec/src/handler/text/html_handler.rs +++ b/crates/nvisy-codec/src/handler/text/html_handler.rs @@ -27,6 +27,7 @@ use nvisy_core::fs::DocumentType; use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; +use crate::handler::text::TextData; use crate::transform::TextHandler; /// 0-based index of a text node within the HTML document. @@ -57,7 +58,7 @@ impl Handler for HtmlHandler { } #[tracing::instrument(name = "html.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { // Re-parse the original source into a mutable DOM. let mut dom = scraper::Html::parse_document(&self.data.raw); @@ -83,13 +84,13 @@ impl Handler for HtmlHandler { // Serialize the mutated DOM back to HTML. let bytes = dom.html().into_bytes(); tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + Ok(bytes.into()) } type SpanId = HtmlSpan; - type SpanData = String; + type SpanData = TextData; - async fn view_spans(&self) -> SpanStream<'_, HtmlSpan, String> { + async fn view_spans(&self) -> SpanStream<'_, HtmlSpan, TextData> { SpanStream::new(futures::stream::iter(HtmlSpanIter { nodes: &self.data.text_nodes, index: 0, @@ -98,7 +99,7 @@ impl Handler for HtmlHandler { async fn edit_spans( &mut self, - edits: SpanEditStream<'_, HtmlSpan, String>, + edits: SpanEditStream<'_, HtmlSpan, TextData>, ) -> Result<(), Error> { let edits: Vec<_> = edits.collect().await; for edit in edits { @@ -108,7 +109,7 @@ impl Handler for HtmlHandler { "html-handler", ) })?; - *node = edit.data; + *node = edit.data.into_inner(); } Ok(()) } @@ -160,11 +161,11 @@ struct HtmlSpanIter<'a> { } impl<'a> Iterator for HtmlSpanIter<'a> { - type Item = Span; + type Item = Span; fn next(&mut self) -> Option { let text = self.nodes.get(self.index)?; - let span = Span::new(HtmlSpan(self.index), text.clone()); + let span = Span::new(HtmlSpan(self.index), TextData::from(text.clone())); self.index += 1; Some(span) } @@ -207,7 +208,7 @@ mod tests { let raw = "

Hello

"; let h = handler_from_html(raw); let bytes = h.encode()?; - assert_eq!(String::from_utf8(bytes).unwrap(), raw); + assert_eq!(std::str::from_utf8(&bytes).unwrap(), raw); Ok(()) } @@ -216,10 +217,10 @@ mod tests { let raw = "

Hello

World

"; let mut h = handler_from_html(raw); h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ - SpanEdit::new(HtmlSpan(0), "[REDACTED]".to_string()), + SpanEdit::new(HtmlSpan(0), "[REDACTED]".into()), ]))) .await?; - let result = String::from_utf8(h.encode()?).unwrap(); + let result = std::str::from_utf8(&h.encode()?).unwrap().to_owned(); assert!(result.contains("[REDACTED]")); assert!(result.contains("World")); assert!(result.contains("

")); @@ -231,7 +232,7 @@ mod tests { let h = handler_from_html("

foo bar
"); let mut h = h; h.data.text_nodes[0] = "baz".to_string(); - let result = String::from_utf8(h.encode()?).unwrap(); + let result = std::str::from_utf8(&h.encode()?).unwrap().to_owned(); assert!(result.contains("baz")); assert!(result.contains(" bar")); Ok(()) @@ -243,10 +244,10 @@ mod tests { let mut h = handler_from_html(raw); // Edit only the first "hello" — the second should remain unchanged. h.edit_spans(SpanEditStream::new(futures::stream::iter(vec![ - SpanEdit::new(HtmlSpan(0), "FIRST".to_string()), + SpanEdit::new(HtmlSpan(0), "FIRST".into()), ]))) .await?; - let result = String::from_utf8(h.encode()?).unwrap(); + let result = std::str::from_utf8(&h.encode()?).unwrap().to_owned(); assert!(result.contains("

FIRST

")); assert!(result.contains("

hello

")); Ok(()) @@ -268,7 +269,7 @@ mod tests { let mut h = handler_from_html("

only

"); let err = h .edit_spans(SpanEditStream::new(futures::stream::iter(vec![ - SpanEdit::new(HtmlSpan(99), "nope".to_string()), + SpanEdit::new(HtmlSpan(99), "nope".into()), ]))) .await .unwrap_err(); diff --git a/crates/nvisy-codec/src/handler/text/json_handler.rs b/crates/nvisy-codec/src/handler/text/json_handler.rs index 01fef97..87f7cbe 100644 --- a/crates/nvisy-codec/src/handler/text/json_handler.rs +++ b/crates/nvisy-codec/src/handler/text/json_handler.rs @@ -126,7 +126,7 @@ impl Handler for JsonHandler { } #[tracing::instrument(name = "json.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { let mut bytes = match self.data.indent { JsonIndent::Compact => serde_json::to_vec(&self.data.value) .map_err(|e| Error::validation(format!("JSON encode error: {e}"), "json-handler"))?, @@ -152,7 +152,7 @@ impl Handler for JsonHandler { bytes.push(b'\n'); } tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + Ok(bytes.into()) } type SpanId = JsonPath; @@ -579,7 +579,7 @@ mod tests { }, }; let bytes = h.encode()?; - assert_eq!(String::from_utf8(bytes).expect("valid utf-8"), r#"{"a":1}"#); + assert_eq!(std::str::from_utf8(&bytes).expect("valid utf-8"), r#"{"a":1}"#); Ok(()) } @@ -592,7 +592,7 @@ mod tests { trailing_newline: true, }, }; - let text = String::from_utf8(h.encode()?).expect("valid utf-8"); + let text = std::str::from_utf8(&h.encode()?).expect("valid utf-8").to_owned(); assert!(text.contains(" \"a\"")); assert!(text.ends_with('\n')); Ok(()) @@ -607,7 +607,7 @@ mod tests { trailing_newline: false, }, }; - let text = String::from_utf8(h.encode()?).expect("valid utf-8"); + let text = std::str::from_utf8(&h.encode()?).expect("valid utf-8").to_owned(); assert!(text.contains("\t\"a\"")); assert!(!text.ends_with('\n')); Ok(()) diff --git a/crates/nvisy-codec/src/handler/text/mod.rs b/crates/nvisy-codec/src/handler/text/mod.rs index ff8c52b..4f3d32e 100644 --- a/crates/nvisy-codec/src/handler/text/mod.rs +++ b/crates/nvisy-codec/src/handler/text/mod.rs @@ -1,5 +1,6 @@ //! Text-based format handlers. +mod text_data; mod txt_handler; mod txt_loader; mod csv_handler; @@ -15,6 +16,7 @@ mod xlsx_handler; #[cfg(feature = "xlsx")] mod xlsx_loader; +pub use text_data::TextData; pub use txt_handler::{TxtHandler, TxtSpan}; pub use txt_loader::{TxtLoader, TxtParams}; pub use csv_handler::{CsvData, CsvHandler, CsvSpan}; diff --git a/crates/nvisy-codec/src/handler/text/text_data.rs b/crates/nvisy-codec/src/handler/text/text_data.rs new file mode 100644 index 0000000..81eb00b --- /dev/null +++ b/crates/nvisy-codec/src/handler/text/text_data.rs @@ -0,0 +1,46 @@ +//! [`TextData`]: opaque wrapper around extracted text content. + +use derive_more::{AsRef, Display, From}; +use hipstr::HipStr; + +/// Opaque wrapper around a text span's content. +/// +/// Mirrors [`ImageData`](crate::handler::ImageData) for text-bearing +/// handlers, providing a consistent type boundary at the [`Handler`] +/// trait level. +/// +/// Internally backed by [`HipStr`] for cheap cloning. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Display, From, AsRef)] +#[as_ref(forward)] +pub struct TextData(HipStr<'static>); + +impl TextData { + /// View the inner string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Consume the wrapper and return the inner `String`. + pub fn into_inner(self) -> String { + self.0.into() + } +} + +impl From for TextData { + fn from(s: String) -> Self { + Self(HipStr::from(s)) + } +} + +impl From<&str> for TextData { + fn from(s: &str) -> Self { + Self(HipStr::from(s)) + } +} + +impl PartialEq<&str> for TextData { + fn eq(&self, other: &&str) -> bool { + self.0.as_str() == *other + } +} diff --git a/crates/nvisy-codec/src/handler/text/txt_handler.rs b/crates/nvisy-codec/src/handler/text/txt_handler.rs index ef4d73a..da76346 100644 --- a/crates/nvisy-codec/src/handler/text/txt_handler.rs +++ b/crates/nvisy-codec/src/handler/text/txt_handler.rs @@ -21,6 +21,7 @@ use nvisy_core::fs::DocumentType; use crate::stream::{SpanEditStream, SpanStream}; use crate::handler::{Handler, Span}; +use crate::handler::text::TextData; use crate::transform::TextHandler; /// 0-based line index identifying a span within a plain-text document. @@ -43,20 +44,20 @@ impl Handler for TxtHandler { } #[tracing::instrument(name = "txt.encode", skip_all, fields(output_bytes))] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { let mut out = self.lines.join("\n"); if self.trailing_newline && !self.lines.is_empty() { out.push('\n'); } let bytes = out.into_bytes(); tracing::Span::current().record("output_bytes", bytes.len()); - Ok(bytes) + Ok(bytes.into()) } type SpanId = TxtSpan; - type SpanData = String; + type SpanData = TextData; - async fn view_spans(&self) -> SpanStream<'_, TxtSpan, String> { + async fn view_spans(&self) -> SpanStream<'_, TxtSpan, TextData> { SpanStream::new(futures::stream::iter(TxtSpanIter { lines: &self.lines, index: 0, @@ -65,7 +66,7 @@ impl Handler for TxtHandler { async fn edit_spans( &mut self, - edits: SpanEditStream<'_, TxtSpan, String>, + edits: SpanEditStream<'_, TxtSpan, TextData>, ) -> Result<(), Error> { let edits: Vec<_> = edits.collect().await; for edit in edits { @@ -75,7 +76,7 @@ impl Handler for TxtHandler { "txt-handler", ) })?; - *line = edit.data; + *line = edit.data.into_inner(); } Ok(()) } @@ -122,11 +123,11 @@ struct TxtSpanIter<'a> { } impl<'a> Iterator for TxtSpanIter<'a> { - type Item = Span; + type Item = Span; fn next(&mut self) -> Option { let line = self.lines.get(self.index)?; - let span = Span::new(TxtSpan(self.index), line.clone()); + let span = Span::new(TxtSpan(self.index), TextData::from(line.clone())); self.index += 1; Some(span) } @@ -204,7 +205,7 @@ mod tests { fn encode_with_trailing_newline() -> Result<(), Error> { let h = handler("hello\nworld\n"); let bytes = h.encode()?; - assert_eq!(bytes, b"hello\nworld\n"); + assert_eq!(&bytes[..], b"hello\nworld\n"); Ok(()) } @@ -212,7 +213,7 @@ mod tests { fn encode_without_trailing_newline() -> Result<(), Error> { let h = handler("no newline"); let bytes = h.encode()?; - assert_eq!(bytes, b"no newline"); + assert_eq!(&bytes[..], b"no newline"); Ok(()) } } diff --git a/crates/nvisy-codec/src/handler/text/xlsx_handler.rs b/crates/nvisy-codec/src/handler/text/xlsx_handler.rs index 550187b..7f74a53 100644 --- a/crates/nvisy-codec/src/handler/text/xlsx_handler.rs +++ b/crates/nvisy-codec/src/handler/text/xlsx_handler.rs @@ -16,7 +16,7 @@ impl Handler for XlsxHandler { } #[tracing::instrument(name = "xlsx.encode", skip_all)] - fn encode(&self) -> Result, Error> { + fn encode(&self) -> Result { Err(Error::validation( "encode not supported for XLSX", "xlsx-handler", diff --git a/crates/nvisy-codec/src/prelude.rs b/crates/nvisy-codec/src/prelude.rs index 602916b..c72d65e 100644 --- a/crates/nvisy-codec/src/prelude.rs +++ b/crates/nvisy-codec/src/prelude.rs @@ -1,33 +1,23 @@ //! Convenience re-exports. +pub use crate::document::{AnyDocument, Document, UniversalLoader}; pub use crate::handler::{ - Handler, Loader, - Span, SpanEdit, - TxtHandler, TxtSpan, - TxtLoader, TxtParams, - CsvData, CsvHandler, CsvSpan, - CsvLoader, CsvParams, - JsonData, JsonHandler, JsonIndent, - JsonParams, JsonLoader, JsonPath, - ImageData, AnyImage, - JpegHandler, JpegLoader, JpegParams, - PngHandler, PngLoader, PngParams, - AnyAudio, - WavHandler, WavLoader, WavParams, - Mp3Handler, Mp3Loader, Mp3Params, + AnyAudio, AnyImage, CsvData, CsvHandler, CsvLoader, CsvParams, CsvSpan, Handler, ImageData, + JpegHandler, JpegLoader, JpegParams, JsonData, JsonHandler, JsonIndent, JsonLoader, JsonParams, + JsonPath, Loader, Mp3Handler, Mp3Loader, Mp3Params, PngHandler, PngLoader, PngParams, Span, + SpanEdit, TextData, TxtHandler, TxtLoader, TxtParams, TxtSpan, WavHandler, WavLoader, + WavParams, }; -#[cfg(feature = "html")] -pub use crate::handler::{HtmlData, HtmlHandler, HtmlSpan, HtmlLoader, HtmlParams}; -#[cfg(feature = "pdf")] -pub use crate::handler::{PdfHandler, PdfSpan, PdfLoader, PdfParams}; #[cfg(feature = "docx")] pub use crate::handler::{DocxHandler, DocxLoader, DocxParams}; +#[cfg(feature = "html")] +pub use crate::handler::{HtmlData, HtmlHandler, HtmlLoader, HtmlParams, HtmlSpan}; +#[cfg(feature = "pdf")] +pub use crate::handler::{PdfHandler, PdfLoader, PdfParams, PdfSpan}; #[cfg(feature = "xlsx")] pub use crate::handler::{XlsxHandler, XlsxLoader, XlsxParams}; -pub use crate::document::{AnyDocument, UniversalLoader, Document}; pub use crate::stream::{SpanEditStream, SpanStream}; pub use crate::transform::{ - AudioHandler, AudioRedaction, AudioRedactionOutput, - ImageHandler, ImageRedaction, ImageRedactionOutput, ImageTransform, - TextHandler, TextRedaction, TextRedactionOutput, + AudioHandler, AudioRedaction, AudioRedactionOutput, ImageHandler, ImageRedaction, + ImageRedactionOutput, ImageTransform, TextHandler, TextRedaction, TextRedactionOutput, }; From f04d1d83b831d98a8a010279892ddf8821cb2ed7 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 10:50:11 +0100 Subject: [PATCH 19/22] refactor(core, docker): improve ContentBytes, math utilities, Dockerfile, and Makefile - ContentBytes: use derive_more for From/AsRef, remove Bytes exposure, keep Deref manual impl - DataReference: strip to lightweight source pointer, remove content field and auto-generated ContentSource - TimeSpan: add constructor, duration, midpoint, contains, overlaps, intersection, union - BoundingBox: add constructor, area, edges, center, contains_point, overlaps, intersection, union, IoU - Dockerfile: bump to rust:1.92, fix PDFium URL to bblanchon repo, extract install-pdfium.sh script, use ENTRYPOINT, clean up formatting - Makefile: add install/install-tools/generate-env targets, remove trivial wrapper targets, fold check/test/build into ci - Move .dockerignore to repo root, add target/ exclusion Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 26 +++ Makefile | 55 ++++--- crates/nvisy-core/Cargo.toml | 19 +-- crates/nvisy-core/src/io/content_data.rs | 83 ++++------ crates/nvisy-core/src/io/data_reference.rs | 109 ++++--------- crates/nvisy-core/src/math/bounding_box.rs | 180 +++++++++++++++++++-- crates/nvisy-core/src/math/dpi.rs | 3 +- crates/nvisy-core/src/math/time_span.rs | 104 +++++++++++- docker/.dockerignore | 8 - docker/Dockerfile | 91 ++++++----- scripts/install-pdfium.sh | 25 +++ 11 files changed, 470 insertions(+), 233 deletions(-) create mode 100644 .dockerignore delete mode 100644 docker/.dockerignore create mode 100755 scripts/install-pdfium.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca51144 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# OS +Thumbs.db +.DS_Store + +# IDE and Editors +.vs/ +.vscode/ +.idea/ +.zed/ + +# VCS & CI +.git +.github + +# Rust +target/ + +# Node +node_modules/ +**/node_modules +**/dist + +# Documentation +docs/ +*.md +!crates/*/README.md diff --git a/Makefile b/Makefile index 9ab97d1..a0a0b4a 100644 --- a/Makefile +++ b/Makefile @@ -11,23 +11,33 @@ define log printf "[%s] [MAKE] [$(MAKECMDGOALS)] $(1)\n" "$$(date '+%Y-%m-%d %H:%M:%S')" endef -.PHONY: dev -dev: ## Starts cargo-watch for the server binary. - @cargo watch -x 'run -p nvisy-server' +.PHONY: install-tools +install-tools: ## Installs CLI tools required for development. + @$(call log,Checking cargo-watch...) + @if ! command -v cargo-watch >/dev/null 2>&1; then \ + $(call log,Installing cargo-watch...); \ + cargo install cargo-watch --locked; \ + $(call log,cargo-watch installed.); \ + else \ + $(call log,cargo-watch already installed.); \ + fi -.PHONY: build -build: ## Builds all crates in release mode. - @$(call log,Building workspace...) - @cargo build --workspace --release - @$(call log,Build complete.) +.PHONY: generate-env +generate-env: ## Copies .env.example to .env. + @$(call log,Copying .env.example to .env...) + @cp ./.env.example ./.env + @$(call log,.env file created successfully.) -.PHONY: check -check: ## Runs cargo check on all crates. - @cargo check --workspace +.PHONY: install +install: install-tools generate-env ## Installs all dependencies and makes scripts executable. + @chmod +x scripts/*.sh + @$(call log,Installing PDFium...) + @./scripts/install-pdfium.sh + @$(call log,Setup complete.) -.PHONY: test -test: ## Runs all tests. - @cargo test --workspace +.PHONY: dev +dev: ## Starts cargo-watch for the server binary. + @cargo watch -x 'run -p nvisy-server' .PHONY: lint lint: ## Runs clippy and format check. @@ -37,20 +47,13 @@ lint: ## Runs clippy and format check. @cargo clippy --workspace -- -D warnings @$(call log,Lint passed.) -.PHONY: fmt -fmt: ## Formats all Rust code. - @cargo fmt --all - .PHONY: ci -ci: lint check test build ## Runs all CI checks locally. +ci: lint ## Runs all CI checks locally. + @cargo check --workspace + @cargo test --workspace + @cargo build --workspace --release @$(call log,All CI checks passed!) -.PHONY: clean -clean: ## Removes build artifacts. - @$(call log,Cleaning build artifacts...) - @cargo clean - @$(call log,Clean complete.) - .PHONY: docker docker: ## Builds the Docker image. @$(call log,Building Docker image...) @@ -60,4 +63,4 @@ docker: ## Builds the Docker image. .PHONY: help help: ## Shows this help message. @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ - awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' diff --git a/crates/nvisy-core/Cargo.toml b/crates/nvisy-core/Cargo.toml index 0aba663..3e6595b 100644 --- a/crates/nvisy-core/Cargo.toml +++ b/crates/nvisy-core/Cargo.toml @@ -35,33 +35,26 @@ tokio = { workspace = true, features = ["sync", "fs", "io-util", "rt"] } # Primitive datatypes uuid = { workspace = true, features = ["serde", "v4", "v7"] } bytes = { workspace = true, features = ["serde"] } +jiff = { workspace = true, features = [] } +semver = { workspace = true, features = [] } +hipstr = { workspace = true, features = [] } # File type detection infer = { workspace = true, features = [] } -# Error handling +# Derive macros and error handling thiserror = { workspace = true, features = [] } anyhow = { workspace = true, features = [] } derive_more = { workspace = true, features = ["display", "deref", "as_ref", "from", "into"] } - -# Time -jiff = { workspace = true, features = [] } - -# Interned strings -hipstr = { workspace = true, features = [] } +strum = { workspace = true, features = [] } # Hashing sha2 = { workspace = true, features = [] } hex = { workspace = true, features = [] } -# Semantic versioning -semver = { workspace = true, features = [] } - -# Enum derives -strum = { workspace = true, features = [] } - # Observability tracing = { workspace = true, features = [] } [dev-dependencies] tempfile = { workspace = true, features = [] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/nvisy-core/src/io/content_data.rs b/crates/nvisy-core/src/io/content_data.rs index 4ef4591..6f53a6c 100644 --- a/crates/nvisy-core/src/io/content_data.rs +++ b/crates/nvisy-core/src/io/content_data.rs @@ -4,10 +4,12 @@ //! along with its metadata and source information. use std::fmt; -use std::ops::Deref; use std::sync::OnceLock; use bytes::Bytes; +use std::ops::Deref; + +use derive_more::{AsRef, From}; use hipstr::HipStr; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -21,17 +23,12 @@ use crate::path::ContentSource; /// for text conversion. It's cheap to clone as `Bytes` uses reference /// counting internally. #[derive(Debug, Clone, PartialEq, Eq, Default)] -#[derive(Serialize, Deserialize)] +#[derive(From, AsRef, Serialize, Deserialize)] +#[as_ref(forward)] #[serde(transparent)] pub struct ContentBytes(Bytes); impl ContentBytes { - /// Creates a new `ContentBytes` from raw bytes. - #[must_use] - pub fn new(bytes: Bytes) -> Self { - Self(bytes) - } - /// Returns the size of the content in bytes. #[must_use] pub fn len(&self) -> usize { @@ -64,24 +61,11 @@ impl ContentBytes { /// /// Returns an error if the content is not valid UTF-8. pub fn as_hipstr(&self) -> Result> { - let s = std::str::from_utf8(&self.0).map_err(|e| { - Error::new(ErrorKind::Serialization, format!("Invalid UTF-8: {e}")) - })?; + let s = std::str::from_utf8(&self.0) + .map_err(|e| Error::new(ErrorKind::Serialization, format!("Invalid UTF-8: {e}")))?; Ok(HipStr::from(s)) } - /// Returns the underlying `Bytes`. - #[must_use] - pub fn to_bytes(&self) -> Bytes { - self.0.clone() - } - - /// Consumes and returns the underlying `Bytes`. - #[must_use] - pub fn into_bytes(self) -> Bytes { - self.0 - } - /// Returns `true` if the content appears to be text. /// /// Uses a simple heuristic: checks if all bytes are ASCII printable @@ -102,12 +86,6 @@ impl Deref for ContentBytes { } } -impl AsRef<[u8]> for ContentBytes { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - impl From<&str> for ContentBytes { fn from(s: &str) -> Self { Self(Bytes::copy_from_slice(s.as_bytes())) @@ -138,12 +116,6 @@ impl From> for ContentBytes { } } -impl From for ContentBytes { - fn from(bytes: Bytes) -> Self { - Self(bytes) - } -} - /// Content data with metadata and computed hashes. /// /// This struct wraps [`ContentBytes`] and stores content data along with @@ -186,7 +158,7 @@ impl ContentData { pub fn new(content_source: ContentSource, data: Bytes) -> Self { Self { content_source, - data: ContentBytes::new(data), + data: ContentBytes::from(data), sha256_cache: OnceLock::new(), mime: None, detected_mime: None, @@ -278,13 +250,13 @@ impl ContentData { /// Converts the content data to `Bytes`. #[must_use] pub fn to_bytes(&self) -> Bytes { - self.data.to_bytes() + self.data.0.clone() } /// Consumes and converts into `Bytes`. #[must_use] pub fn into_bytes(self) -> Bytes { - self.data.into_bytes() + self.data.0 } /// Returns `true` if the content appears to be text. @@ -311,9 +283,8 @@ impl ContentData { /// /// Returns an error if the content data contains invalid UTF-8 sequences. pub fn as_str(&self) -> Result<&str> { - std::str::from_utf8(self.data.as_bytes()).map_err(|e| { - Error::new(ErrorKind::Serialization, format!("Invalid UTF-8: {e}")) - }) + std::str::from_utf8(self.data.as_bytes()) + .map_err(|e| Error::new(ErrorKind::Serialization, format!("Invalid UTF-8: {e}"))) } /// Converts to a `HipStr` if the content is valid UTF-8. @@ -357,11 +328,14 @@ impl ContentData { if actual_hash.as_ref() == expected { Ok(()) } else { - Err(Error::new(ErrorKind::Validation, format!( - "Hash mismatch: expected {}, got {}", - hex::encode(expected), - hex::encode(actual_hash) - ))) + Err(Error::new( + ErrorKind::Validation, + format!( + "Hash mismatch: expected {}, got {}", + hex::encode(expected), + hex::encode(actual_hash) + ), + )) } } @@ -374,15 +348,16 @@ impl ContentData { pub fn slice(&self, start: usize, end: usize) -> Result { let bytes = self.data.as_bytes(); if end > bytes.len() { - return Err(Error::new(ErrorKind::Validation, format!( - "Slice end {} exceeds content length {}", - end, - bytes.len() - ))); + return Err(Error::new( + ErrorKind::Validation, + format!("Slice end {} exceeds content length {}", end, bytes.len()), + )); } if start > end { - return Err(Error::new(ErrorKind::Validation, - format!("Slice start {start} is greater than end {end}"))); + return Err(Error::new( + ErrorKind::Validation, + format!("Slice start {start} is greater than end {end}"), + )); } Ok(Bytes::copy_from_slice(&bytes[start..end])) } @@ -680,7 +655,7 @@ mod tests { #[test] fn test_content_bytes_deref() { let bytes = ContentBytes::from("Hello"); - assert_eq!(&*bytes, b"Hello"); + assert_eq!(&bytes[..], b"Hello"); assert_eq!(bytes.as_ref(), b"Hello"); } } diff --git a/crates/nvisy-core/src/io/data_reference.rs b/crates/nvisy-core/src/io/data_reference.rs index 7397498..bc0ed35 100644 --- a/crates/nvisy-core/src/io/data_reference.rs +++ b/crates/nvisy-core/src/io/data_reference.rs @@ -1,141 +1,96 @@ -//! Data reference definitions -//! -//! This module provides the `DataReference` struct for referencing and -//! tracking content within the Nvisy system. +//! Lightweight source reference for locating data within a document. use serde::{Deserialize, Serialize}; -use crate::io::Content; use crate::path::ContentSource; -/// Reference to data with source tracking and content information +/// A lightweight pointer to a specific location within a content source. /// -/// A `DataReference` provides a lightweight way to reference data content -/// while maintaining information about its source location and optional -/// mapping within that source. +/// `DataReference` does **not** hold the actual data — it only records +/// *where* the data lives (a [`ContentSource`]) and an optional +/// sub-location within that source (the `mapping_id`). /// /// # Examples /// /// ```rust -/// use nvisy_core::io::{DataReference, Content, ContentData}; +/// use nvisy_core::io::DataReference; +/// use nvisy_core::path::ContentSource; /// -/// let content = Content::new(ContentData::from("Hello, world!")); -/// let data_ref = DataReference::new(content) +/// let source = ContentSource::new(); +/// let data_ref = DataReference::new(source) /// .with_mapping_id("line-42"); /// -/// assert!(data_ref.mapping_id().is_some()); -/// assert_eq!(data_ref.mapping_id().unwrap(), "line-42"); +/// assert_eq!(data_ref.mapping_id(), Some("line-42")); /// ``` -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Serialize, Deserialize)] pub struct DataReference { - /// Unique identifier for the source containing this data - /// Using `UUIDv7` for time-ordered, globally unique identification + /// Source document this reference points into. source: ContentSource, - /// Optional identifier that defines the position/location of the data within the source - /// Examples: line numbers, byte offsets, element IDs, `XPath` expressions + /// Optional sub-location within the source. + /// + /// Examples: line numbers, byte offsets, element IDs, XPath expressions. + #[serde(skip_serializing_if = "Option::is_none")] mapping_id: Option, - - /// The actual content data - content: Content, } impl DataReference { - /// Create a new data reference with auto-generated source ID (`UUIDv7`) - pub fn new(content: Content) -> Self { - Self { - source: ContentSource::new(), - mapping_id: None, - content, - } - } - - /// Create a new data reference with specific source - pub fn with_source(source: ContentSource, content: Content) -> Self { + /// Create a new reference to the given source. + pub fn new(source: ContentSource) -> Self { Self { source, mapping_id: None, - content, } } - /// Set the mapping ID for this data reference + /// Set the mapping ID (builder pattern). #[must_use] - pub fn with_mapping_id>(mut self, mapping_id: S) -> Self { + pub fn with_mapping_id(mut self, mapping_id: impl Into) -> Self { self.mapping_id = Some(mapping_id.into()); self } - /// Get the content source + /// The content source this reference points to. pub fn source(&self) -> ContentSource { self.source } - /// Get the mapping ID, if any + /// The sub-location within the source, if any. pub fn mapping_id(&self) -> Option<&str> { self.mapping_id.as_deref() } - - /// Get a reference to the content - pub fn content(&self) -> &Content { - &self.content - } - - /// Check if the content is text-based - pub fn is_likely_text(&self) -> bool { - self.content.is_likely_text() - } - - /// Get the size of the content in bytes - pub fn size(&self) -> usize { - self.content.size() - } } #[cfg(test)] mod tests { use super::*; - use crate::io::ContentData; #[test] - fn test_data_reference_creation() { - let content = Content::new(ContentData::from("Hello, world!")); - let data_ref = DataReference::new(content); + fn creation() { + let source = ContentSource::new(); + let data_ref = DataReference::new(source); - assert!(data_ref.is_likely_text()); + assert_eq!(data_ref.source(), source); assert!(data_ref.mapping_id().is_none()); - assert_eq!(data_ref.size(), 13); - // Verify UUIDv7 is used - assert_eq!(data_ref.source().as_uuid().get_version_num(), 7); } #[test] - fn test_data_reference_with_mapping() { - let content = Content::new(ContentData::from("Test content")); - let data_ref = DataReference::new(content).with_mapping_id("line-42"); + fn with_mapping_id() { + let source = ContentSource::new(); + let data_ref = DataReference::new(source).with_mapping_id("line-42"); assert_eq!(data_ref.mapping_id(), Some("line-42")); } #[test] - fn test_data_reference_with_source() { + fn serialization_roundtrip() { let source = ContentSource::new(); - let content = Content::new(ContentData::from("Test content")); - let data_ref = DataReference::with_source(source, content); - - assert_eq!(data_ref.source(), source); - } - - #[test] - fn test_serialization() { - let content = Content::new(ContentData::from("Test content")); - let data_ref = DataReference::new(content).with_mapping_id("test-mapping"); + let data_ref = DataReference::new(source).with_mapping_id("test-mapping"); let json = serde_json::to_string(&data_ref).unwrap(); let deserialized: DataReference = serde_json::from_str(&json).unwrap(); - assert_eq!(data_ref.source(), deserialized.source()); - assert_eq!(data_ref.mapping_id(), deserialized.mapping_id()); + assert_eq!(data_ref, deserialized); } } diff --git a/crates/nvisy-core/src/math/bounding_box.rs b/crates/nvisy-core/src/math/bounding_box.rs index 3059819..3139160 100644 --- a/crates/nvisy-core/src/math/bounding_box.rs +++ b/crates/nvisy-core/src/math/bounding_box.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// Coordinates are `f64` to support both pixel and normalized (0.0–1.0) /// values from detection models. Use [`BoundingBoxU32`] (or [`Into`]) /// when integer pixel coordinates are needed for rendering. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct BoundingBox { /// Horizontal offset of the top-left corner (pixels or normalized). pub x: f64, @@ -20,6 +20,91 @@ pub struct BoundingBox { pub height: f64, } +impl BoundingBox { + /// Create a new bounding box. + pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + /// Area of the bounding box. + pub fn area(&self) -> f64 { + self.width * self.height + } + + /// Right edge (`x + width`). + pub fn right(&self) -> f64 { + self.x + self.width + } + + /// Bottom edge (`y + height`). + pub fn bottom(&self) -> f64 { + self.y + self.height + } + + /// Center point `(cx, cy)`. + pub fn center(&self) -> (f64, f64) { + (self.x + self.width / 2.0, self.y + self.height / 2.0) + } + + /// Returns `true` if the point `(px, py)` lies inside the box. + pub fn contains_point(&self, px: f64, py: f64) -> bool { + px >= self.x && px <= self.right() && py >= self.y && py <= self.bottom() + } + + /// Returns `true` if this box overlaps with `other`. + pub fn overlaps(&self, other: &BoundingBox) -> bool { + self.x < other.right() + && other.x < self.right() + && self.y < other.bottom() + && other.y < self.bottom() + } + + /// Returns the intersection of two boxes, or `None` if they don't overlap. + pub fn intersection(&self, other: &BoundingBox) -> Option { + let x = self.x.max(other.x); + let y = self.y.max(other.y); + let right = self.right().min(other.right()); + let bottom = self.bottom().min(other.bottom()); + + if x < right && y < bottom { + Some(BoundingBox::new(x, y, right - x, bottom - y)) + } else { + None + } + } + + /// Returns the smallest box that encloses both `self` and `other`. + pub fn union(&self, other: &BoundingBox) -> BoundingBox { + let x = self.x.min(other.x); + let y = self.y.min(other.y); + let right = self.right().max(other.right()); + let bottom = self.bottom().max(other.bottom()); + BoundingBox::new(x, y, right - x, bottom - y) + } + + /// Intersection-over-union (IoU) with `other`. + /// + /// Returns 0.0 if the boxes don't overlap or if both have zero area. + pub fn iou(&self, other: &BoundingBox) -> f64 { + let inter = match self.intersection(other) { + Some(b) => b.area(), + None => return 0.0, + }; + let union = self.area() + other.area() - inter; + if union == 0.0 { 0.0 } else { inter / union } + } + + /// Convert to integer pixel coordinates by rounding each field. + pub fn to_u32(&self) -> BoundingBoxU32 { + BoundingBoxU32 { + x: self.x.round() as u32, + y: self.y.round() as u32, + width: self.width.round() as u32, + height: self.height.round() as u32, + } + } +} + /// Integer pixel-coordinate bounding box for rendering operations. /// /// Converted from [`BoundingBox`] by rounding each field to the nearest @@ -37,18 +122,6 @@ pub struct BoundingBoxU32 { pub height: u32, } -impl BoundingBox { - /// Convert to integer pixel coordinates by rounding each field. - pub fn to_u32(&self) -> BoundingBoxU32 { - BoundingBoxU32 { - x: self.x.round() as u32, - y: self.y.round() as u32, - width: self.width.round() as u32, - height: self.height.round() as u32, - } - } -} - impl From<&BoundingBox> for BoundingBoxU32 { fn from(bb: &BoundingBox) -> Self { bb.to_u32() @@ -60,3 +133,84 @@ impl From for BoundingBoxU32 { Self::from(&bb) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn area() { + let bb = BoundingBox::new(0.0, 0.0, 10.0, 5.0); + assert!((bb.area() - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn edges_and_center() { + let bb = BoundingBox::new(10.0, 20.0, 30.0, 40.0); + assert!((bb.right() - 40.0).abs() < f64::EPSILON); + assert!((bb.bottom() - 60.0).abs() < f64::EPSILON); + let (cx, cy) = bb.center(); + assert!((cx - 25.0).abs() < f64::EPSILON); + assert!((cy - 40.0).abs() < f64::EPSILON); + } + + #[test] + fn contains_point() { + let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0); + assert!(bb.contains_point(5.0, 5.0)); + assert!(bb.contains_point(0.0, 0.0)); + assert!(bb.contains_point(10.0, 10.0)); + assert!(!bb.contains_point(11.0, 5.0)); + } + + #[test] + fn overlaps() { + let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0); + let b = BoundingBox::new(5.0, 5.0, 10.0, 10.0); + let c = BoundingBox::new(10.0, 0.0, 10.0, 10.0); + assert!(a.overlaps(&b)); + assert!(!a.overlaps(&c)); // touching at edge = no overlap + } + + #[test] + fn intersection() { + let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0); + let b = BoundingBox::new(5.0, 5.0, 10.0, 10.0); + let i = a.intersection(&b).unwrap(); + assert!((i.x - 5.0).abs() < f64::EPSILON); + assert!((i.y - 5.0).abs() < f64::EPSILON); + assert!((i.width - 5.0).abs() < f64::EPSILON); + assert!((i.height - 5.0).abs() < f64::EPSILON); + } + + #[test] + fn union_boxes() { + let a = BoundingBox::new(0.0, 0.0, 5.0, 5.0); + let b = BoundingBox::new(3.0, 3.0, 7.0, 7.0); + let u = a.union(&b); + assert!((u.x).abs() < f64::EPSILON); + assert!((u.y).abs() < f64::EPSILON); + assert!((u.width - 10.0).abs() < f64::EPSILON); + assert!((u.height - 10.0).abs() < f64::EPSILON); + } + + #[test] + fn iou() { + let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0); + let b = BoundingBox::new(0.0, 0.0, 10.0, 10.0); + assert!((a.iou(&b) - 1.0).abs() < f64::EPSILON); + + let c = BoundingBox::new(20.0, 20.0, 10.0, 10.0); + assert!(a.iou(&c).abs() < f64::EPSILON); + } + + #[test] + fn to_u32_rounds() { + let bb = BoundingBox::new(1.4, 2.6, 3.5, 4.4); + let u = bb.to_u32(); + assert_eq!(u.x, 1); + assert_eq!(u.y, 3); + assert_eq!(u.width, 4); + assert_eq!(u.height, 4); + } +} diff --git a/crates/nvisy-core/src/math/dpi.rs b/crates/nvisy-core/src/math/dpi.rs index d527726..2c24034 100644 --- a/crates/nvisy-core/src/math/dpi.rs +++ b/crates/nvisy-core/src/math/dpi.rs @@ -9,8 +9,7 @@ use serde::{Deserialize, Serialize}; /// Wraps a [`u16`] to prevent accidental misuse of raw integers as DPI /// values. Common presets are available as associated constants. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(From, Into, Display)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(From, Into, Display, Serialize, Deserialize, JsonSchema)] #[display("{_0} dpi")] pub struct Dpi(u16); diff --git a/crates/nvisy-core/src/math/time_span.rs b/crates/nvisy-core/src/math/time_span.rs index 2a036f3..8b95bfa 100644 --- a/crates/nvisy-core/src/math/time_span.rs +++ b/crates/nvisy-core/src/math/time_span.rs @@ -4,10 +4,112 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A time interval within an audio or video stream. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct TimeSpan { /// Start time in seconds from the beginning of the stream. pub start_secs: f64, /// End time in seconds from the beginning of the stream. pub end_secs: f64, } + +impl TimeSpan { + /// Create a new time span. + pub fn new(start_secs: f64, end_secs: f64) -> Self { + Self { start_secs, end_secs } + } + + /// Duration of the span in seconds. + pub fn duration(&self) -> f64 { + self.end_secs - self.start_secs + } + + /// Midpoint of the span in seconds. + pub fn midpoint(&self) -> f64 { + (self.start_secs + self.end_secs) / 2.0 + } + + /// Returns `true` if `t` falls within `[start, end)`. + pub fn contains(&self, t: f64) -> bool { + t >= self.start_secs && t < self.end_secs + } + + /// Returns `true` if this span overlaps with `other`. + pub fn overlaps(&self, other: &TimeSpan) -> bool { + self.start_secs < other.end_secs && other.start_secs < self.end_secs + } + + /// Returns the intersection of two spans, or `None` if they don't overlap. + pub fn intersection(&self, other: &TimeSpan) -> Option { + let start = self.start_secs.max(other.start_secs); + let end = self.end_secs.min(other.end_secs); + if start < end { + Some(TimeSpan::new(start, end)) + } else { + None + } + } + + /// Returns the smallest span that covers both `self` and `other`. + pub fn union(&self, other: &TimeSpan) -> TimeSpan { + TimeSpan::new( + self.start_secs.min(other.start_secs), + self.end_secs.max(other.end_secs), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duration() { + let span = TimeSpan::new(1.0, 3.5); + assert!((span.duration() - 2.5).abs() < f64::EPSILON); + } + + #[test] + fn midpoint() { + let span = TimeSpan::new(2.0, 4.0); + assert!((span.midpoint() - 3.0).abs() < f64::EPSILON); + } + + #[test] + fn contains() { + let span = TimeSpan::new(1.0, 5.0); + assert!(span.contains(1.0)); + assert!(span.contains(3.0)); + assert!(!span.contains(5.0)); // exclusive end + assert!(!span.contains(0.5)); + } + + #[test] + fn overlaps() { + let a = TimeSpan::new(1.0, 3.0); + let b = TimeSpan::new(2.0, 4.0); + let c = TimeSpan::new(3.0, 5.0); + assert!(a.overlaps(&b)); + assert!(!a.overlaps(&c)); // touching at boundary = no overlap + } + + #[test] + fn intersection() { + let a = TimeSpan::new(1.0, 4.0); + let b = TimeSpan::new(2.0, 5.0); + let i = a.intersection(&b).unwrap(); + assert!((i.start_secs - 2.0).abs() < f64::EPSILON); + assert!((i.end_secs - 4.0).abs() < f64::EPSILON); + + let c = TimeSpan::new(4.0, 6.0); + assert!(a.intersection(&c).is_none()); + } + + #[test] + fn union() { + let a = TimeSpan::new(1.0, 3.0); + let b = TimeSpan::new(2.0, 5.0); + let u = a.union(&b); + assert!((u.start_secs - 1.0).abs() < f64::EPSILON); + assert!((u.end_secs - 5.0).abs() < f64::EPSILON); + } +} diff --git a/docker/.dockerignore b/docker/.dockerignore deleted file mode 100644 index 6746a97..0000000 --- a/docker/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -**/node_modules -**/dist -.git -.github -docs -*.md -!packages/*/package.json diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e46882..8c35f40 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,70 +1,83 @@ -FROM rust:1.85-bookworm AS builder +# syntax=docker/dockerfile:1 +# +# Multi-stage build for the nvisy-server binary. +# +# docker build -f docker/Dockerfile -t nvisy-server . +# +# Build context must be the repository root. -RUN apt-get update && apt-get install -y python3-dev python3-pip && rm -rf /var/lib/apt/lists/* +# Stage 1: Build +FROM rust:1.92-bookworm AS build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3-dev \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app -# Copy manifests first to cache dependency builds +# Forward-compatible PyO3 ABI for any Python ≥ 3.7. +ENV PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 + +# Dependency cache layer +# Copy only manifests + lock so dependency builds are cached until a +# Cargo.toml or Cargo.lock changes. Stub source files satisfy cargo. COPY Cargo.toml Cargo.lock ./ COPY crates/nvisy-cli/Cargo.toml crates/nvisy-cli/Cargo.toml COPY crates/nvisy-codec/Cargo.toml crates/nvisy-codec/Cargo.toml COPY crates/nvisy-core/Cargo.toml crates/nvisy-core/Cargo.toml COPY crates/nvisy-engine/Cargo.toml crates/nvisy-engine/Cargo.toml COPY crates/nvisy-identify/Cargo.toml crates/nvisy-identify/Cargo.toml -COPY crates/nvisy-ontology/Cargo.toml crates/nvisy-ontology/Cargo.toml COPY crates/nvisy-ocr/Cargo.toml crates/nvisy-ocr/Cargo.toml +COPY crates/nvisy-ontology/Cargo.toml crates/nvisy-ontology/Cargo.toml COPY crates/nvisy-pattern/Cargo.toml crates/nvisy-pattern/Cargo.toml COPY crates/nvisy-python/Cargo.toml crates/nvisy-python/Cargo.toml COPY crates/nvisy-rig/Cargo.toml crates/nvisy-rig/Cargo.toml COPY crates/nvisy-server/Cargo.toml crates/nvisy-server/Cargo.toml -# Create empty src files to satisfy cargo's manifest checks -RUN for crate in nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-ocr nvisy-pattern nvisy-python nvisy-rig; do \ - mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \ - done && \ - mkdir -p crates/nvisy-cli/src && echo "fn main() {}" > crates/nvisy-cli/src/main.rs && \ - mkdir -p crates/nvisy-server/src && echo "fn main() {}" > crates/nvisy-server/src/main.rs - -# Create stub READMEs for crates that use doc = include_str!("../README.md") -RUN for crate in nvisy-cli nvisy-codec nvisy-core nvisy-engine nvisy-identify nvisy-ontology nvisy-ocr nvisy-pattern nvisy-python nvisy-rig nvisy-server; do \ - touch crates/$crate/README.md; \ - done - -ENV PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 +# Stub lib.rs / main.rs + empty READMEs (every crate uses +# `include_str!("../README.md")`). +RUN set -e; \ + for c in nvisy-codec nvisy-core nvisy-engine nvisy-identify \ + nvisy-ocr nvisy-ontology nvisy-pattern nvisy-python \ + nvisy-rig nvisy-server; do \ + mkdir -p "crates/$c/src"; \ + echo "" > "crates/$c/src/lib.rs"; \ + touch "crates/$c/README.md"; \ + done; \ + mkdir -p crates/nvisy-cli/src; \ + echo "fn main() {}" > crates/nvisy-cli/src/main.rs; \ + touch crates/nvisy-cli/README.md -# Cache dependency build RUN cargo build --release 2>/dev/null || true -# Copy full source and build +# Full build COPY . . -RUN cargo build --release +RUN cargo build --release --bin nvisy-server -# Runtime stage -FROM debian:bookworm-slim AS runtime +# Stage 2: Runtime +FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y \ - python3 python3-pip python3-venv ca-certificates \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + python3 \ + python3-pip \ + python3-venv \ && rm -rf /var/lib/apt/lists/* -# PDFium shared library for PDF-to-image rendering. -# Download pre-built binary from pdfium-binaries since libpdfium-dev -# is not packaged in Debian bookworm. -RUN apt-get update && apt-get install -y curl && \ - curl -L -o /tmp/pdfium.tgz \ - https://github.com/nickel-org/pdfium-binaries/releases/latest/download/pdfium-linux-x64.tgz && \ - mkdir -p /opt/pdfium && tar -xzf /tmp/pdfium.tgz -C /opt/pdfium && \ - cp /opt/pdfium/lib/libpdfium.so /usr/local/lib/ && \ - ldconfig && \ - rm -rf /tmp/pdfium.tgz /opt/pdfium && \ - apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* +# PDFium (PDF-to-image rendering) +COPY scripts/install-pdfium.sh /tmp/install-pdfium.sh +RUN /tmp/install-pdfium.sh && rm /tmp/install-pdfium.sh -# Install Python packages +# Python packages COPY packages/ /opt/nvisy/packages/ -RUN python3 -m pip install --break-system-packages \ +RUN python3 -m pip install --break-system-packages --no-cache-dir \ /opt/nvisy/packages/nvisy-ai \ /opt/nvisy/packages/nvisy-exif -COPY --from=builder /app/target/release/nvisy-server /usr/local/bin/nvisy-server +# Binary +COPY --from=build /app/target/release/nvisy-server /usr/local/bin/ EXPOSE 8080 -CMD ["nvisy-server"] +ENTRYPOINT ["nvisy-server"] diff --git a/scripts/install-pdfium.sh b/scripts/install-pdfium.sh new file mode 100755 index 0000000..8644c1b --- /dev/null +++ b/scripts/install-pdfium.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Download and install the PDFium shared library. +# +# Pre-built binary from https://github.com/bblanchon/pdfium-binaries. +# Installed to /usr/local/lib so Pdfium::bind_to_system_library() works. +# +# Usage: +# ./scripts/install-pdfium.sh # default: linux-x64 +# ./scripts/install-pdfium.sh linux-arm64 # override platform +# PDFIUM_PLATFORM=linux-arm64 ./scripts/install-pdfium.sh + +set -euo pipefail + +PLATFORM="${1:-${PDFIUM_PLATFORM:-linux-x64}}" +URL="https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-${PLATFORM}.tgz" + +echo "Installing PDFium (${PLATFORM})..." + +curl -fsSL "$URL" | tar xz -C /tmp +mv /tmp/lib/libpdfium.so /usr/local/lib/ +ldconfig +rm -rf /tmp/include /tmp/lib /tmp/*.cmake /tmp/LICENSE /tmp/VERSION /tmp/args.gn + +echo "PDFium installed to /usr/local/lib/libpdfium.so" From 04e255a052f40f375ee71fc9ab19f1a26604b78b Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 11:05:46 +0100 Subject: [PATCH 20/22] refactor(core): remove trivial tests, fix clone on Copy types Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-core/src/fs/content_kind.rs | 11 --- crates/nvisy-core/src/fs/content_metadata.rs | 21 ----- crates/nvisy-core/src/io/content.rs | 26 ------ crates/nvisy-core/src/io/content_data.rs | 95 -------------------- crates/nvisy-core/src/path/source.rs | 20 ----- crates/nvisy-engine/src/apply/image.rs | 2 +- crates/nvisy-ocr/src/backend/mod.rs | 2 +- 7 files changed, 2 insertions(+), 175 deletions(-) diff --git a/crates/nvisy-core/src/fs/content_kind.rs b/crates/nvisy-core/src/fs/content_kind.rs index 8811f40..babadfa 100644 --- a/crates/nvisy-core/src/fs/content_kind.rs +++ b/crates/nvisy-core/src/fs/content_kind.rs @@ -95,12 +95,6 @@ mod tests { assert_eq!(ContentKind::Unknown.to_string(), "unknown"); } - #[test] - fn test_content_kind_as_ref() { - assert_eq!(ContentKind::Text.as_ref(), "text"); - assert_eq!(ContentKind::Document.as_ref(), "document"); - } - #[test] fn test_content_kind_from_str() { use std::str::FromStr; @@ -113,11 +107,6 @@ mod tests { assert!(ContentKind::from_str("invalid").is_err()); } - #[test] - fn test_default() { - assert_eq!(ContentKind::default(), ContentKind::Unknown); - } - #[test] fn test_serialization() { let kind = ContentKind::Spreadsheet; diff --git a/crates/nvisy-core/src/fs/content_metadata.rs b/crates/nvisy-core/src/fs/content_metadata.rs index 11cb976..7a04d2b 100644 --- a/crates/nvisy-core/src/fs/content_metadata.rs +++ b/crates/nvisy-core/src/fs/content_metadata.rs @@ -115,27 +115,6 @@ impl ContentMetadata { mod tests { use super::*; - #[test] - fn test_content_metadata_creation() { - let source = ContentSource::new(); - let metadata = ContentMetadata::new(source); - - assert_eq!(metadata.content_source, source); - assert!(metadata.source_path.is_none()); - assert!(!metadata.has_path()); - } - - #[test] - fn test_content_metadata_with_path() { - let source = ContentSource::new(); - let path = PathBuf::from("/path/to/document.pdf"); - let metadata = ContentMetadata::with_path(source, path.clone()); - - assert_eq!(metadata.content_source, source); - assert_eq!(metadata.source_path, Some(path)); - assert!(metadata.has_path()); - } - #[test] fn test_file_extension_detection() { let source = ContentSource::new(); diff --git a/crates/nvisy-core/src/io/content.rs b/crates/nvisy-core/src/io/content.rs index ed6f3cb..d13f7cf 100644 --- a/crates/nvisy-core/src/io/content.rs +++ b/crates/nvisy-core/src/io/content.rs @@ -164,24 +164,6 @@ mod tests { assert_eq!(content.filename(), Some("test.txt")); } - #[test] - fn test_content_deref() { - let data = ContentData::from("Hello"); - let content = Content::new(data); - - // Test that Deref works - we can call ContentData methods directly - assert_eq!(content.size(), 5); - assert_eq!(content.as_str().unwrap(), "Hello"); - } - - #[test] - fn test_content_from() { - let data = ContentData::from("Test"); - let content: Content = data.into(); - - assert_eq!(content.size(), 4); - } - #[test] fn test_metadata_operations() { let data = ContentData::from("Test"); @@ -223,12 +205,4 @@ mod tests { assert_eq!(content, deserialized); } - #[test] - fn test_content_source() { - let source = ContentSource::new(); - let data = ContentData::from_text(source, "Test"); - let content = Content::new(data); - - assert_eq!(content.content_source(), source); - } } diff --git a/crates/nvisy-core/src/io/content_data.rs b/crates/nvisy-core/src/io/content_data.rs index 6f53a6c..d96bc73 100644 --- a/crates/nvisy-core/src/io/content_data.rs +++ b/crates/nvisy-core/src/io/content_data.rs @@ -473,42 +473,6 @@ mod tests { assert_eq!(content.as_str().unwrap(), "Hello, world!"); } - #[test] - fn test_content_bytes_wrapper() { - let bytes = ContentBytes::from("Hello"); - assert_eq!(bytes.as_str(), Some("Hello")); - assert_eq!(bytes.len(), 5); - assert!(!bytes.is_empty()); - } - - #[test] - fn test_content_bytes_as_hipstr() { - let bytes = ContentBytes::from("Hello, HipStr!"); - let hipstr = bytes.as_hipstr().unwrap(); - assert_eq!(hipstr.as_str(), "Hello, HipStr!"); - - // Test with invalid UTF-8 - let invalid = ContentBytes::from(vec![0xFF, 0xFE]); - assert!(invalid.as_hipstr().is_err()); - } - - #[test] - fn test_content_bytes_binary() { - let binary = ContentBytes::from(vec![0xFF, 0xFE]); - assert_eq!(binary.len(), 2); - assert!(binary.as_str().is_none()); - assert!(!binary.is_likely_text()); - } - - #[test] - fn test_size_methods() { - let content = ContentData::from("Hello"); - assert_eq!(content.size(), 5); - - let pretty_size = content.get_pretty_size(); - assert!(!pretty_size.is_empty()); - } - #[test] fn test_sha256_computation() { let content = ContentData::from("Hello, world!"); @@ -532,17 +496,6 @@ mod tests { assert!(content.verify_sha256(&wrong_hash).is_err()); } - #[test] - fn test_string_conversion() { - let content = ContentData::from("Hello, world!"); - assert_eq!(content.as_string().unwrap(), "Hello, world!"); - assert_eq!(content.as_str().unwrap(), "Hello, world!"); - - let binary_content = ContentData::from(vec![0xFF, 0xFE, 0xFD]); - assert!(binary_content.as_string().is_err()); - assert!(binary_content.as_str().is_err()); - } - #[test] fn test_as_hipstr() { let content = ContentData::from("Hello, HipStr!"); @@ -553,15 +506,6 @@ mod tests { assert!(binary_content.as_hipstr().is_err()); } - #[test] - fn test_is_likely_text() { - let text_content = ContentData::from("Hello, world!"); - assert!(text_content.is_likely_text()); - - let binary_content = ContentData::from(vec![0xFF, 0xFE, 0xFD]); - assert!(!binary_content.is_likely_text()); - } - #[test] fn test_slice() { let content = ContentData::from("Hello, world!"); @@ -612,39 +556,6 @@ mod tests { assert_eq!(original.sha256(), cloned.sha256()); } - #[test] - fn test_cloning_is_cheap() { - let original = ContentData::from("Hello, world!"); - let cloned = original.clone(); - - assert_eq!(original, cloned); - } - - #[test] - fn test_into_bytes() { - let content = ContentData::from("Hello, world!"); - let bytes = content.into_bytes(); - assert_eq!(bytes, Bytes::from("Hello, world!")); - } - - #[test] - fn test_empty_content() { - let content = ContentData::from(""); - assert!(content.is_empty()); - assert_eq!(content.size(), 0); - } - - #[test] - fn test_to_bytes() { - let text_content = ContentData::from_text(ContentSource::new(), "Hello"); - let bytes = text_content.to_bytes(); - assert_eq!(bytes.as_ref(), b"Hello"); - - let binary_content = ContentData::new(ContentSource::new(), Bytes::from("World")); - let bytes = binary_content.to_bytes(); - assert_eq!(bytes.as_ref(), b"World"); - } - #[test] fn test_from_hipstr() { let hipstr = HipStr::from("Hello from HipStr"); @@ -652,10 +563,4 @@ mod tests { assert_eq!(content.as_str().unwrap(), "Hello from HipStr"); } - #[test] - fn test_content_bytes_deref() { - let bytes = ContentBytes::from("Hello"); - assert_eq!(&bytes[..], b"Hello"); - assert_eq!(bytes.as_ref(), b"Hello"); - } } diff --git a/crates/nvisy-core/src/path/source.rs b/crates/nvisy-core/src/path/source.rs index 30faa4a..51f33c3 100644 --- a/crates/nvisy-core/src/path/source.rs +++ b/crates/nvisy-core/src/path/source.rs @@ -287,14 +287,6 @@ mod tests { assert!(source1 < source2); // Test PartialOrd } - #[test] - fn test_display() { - let source = ContentSource::new(); - let display_str = format!("{source}"); - let uuid_str = source.as_uuid().to_string(); - assert_eq!(display_str, uuid_str); - } - #[test] fn test_serde_serialization() { let source = ContentSource::new(); @@ -303,16 +295,4 @@ mod tests { assert_eq!(source, deserialized); } - #[test] - fn test_hash_consistency() { - let source = ContentSource::new(); - let mut set = HashSet::new(); - - set.insert(source); - assert!(set.contains(&source)); - - // Same source should hash the same way - let cloned_source = source; - assert!(set.contains(&cloned_source)); - } } diff --git a/crates/nvisy-engine/src/apply/image.rs b/crates/nvisy-engine/src/apply/image.rs index e39bf87..4c8384f 100644 --- a/crates/nvisy-engine/src/apply/image.rs +++ b/crates/nvisy-engine/src/apply/image.rs @@ -53,7 +53,7 @@ pub(crate) async fn apply_image_doc( }; redactions.push(ImageRedaction { - bounding_box: img_loc.bounding_box.clone(), + bounding_box: img_loc.bounding_box, output, }); } diff --git a/crates/nvisy-ocr/src/backend/mod.rs b/crates/nvisy-ocr/src/backend/mod.rs index 547c9c4..f6c16dd 100644 --- a/crates/nvisy-ocr/src/backend/mod.rs +++ b/crates/nvisy-ocr/src/backend/mod.rs @@ -60,7 +60,7 @@ impl OcrRegion { self.confidence, ) .with_location(Location::Image(ImageLocation { - bounding_box: self.bbox.clone(), + bounding_box: self.bbox, image_id: None, page_number: None, })) From 5c89047a4f1e93ed20169a8790fdec6d5788f6f9 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 19:55:43 +0100 Subject: [PATCH 21/22] refactor(engine): restructure compiler into graph/plan modules, add Compiler struct, move run_graph to Engine trait Flatten GraphNode into struct+GraphNodeKind, replace untyped params/action with strongly-typed ActionKind, add TimeoutPolicy/TimeoutBehavior, move retry into graph module, inline build_plan into Compiler::compile, convert compiled.rs into plan/ folder-module, and promote run_graph to an Engine trait method. Co-Authored-By: Claude Opus 4.6 --- crates/nvisy-engine/Cargo.toml | 10 +- crates/nvisy-engine/src/compiler/graph.rs | 128 ---------------- .../nvisy-engine/src/compiler/graph/action.rs | 27 ++++ crates/nvisy-engine/src/compiler/graph/mod.rs | 135 +++++++++++++++++ .../src/compiler/{ => graph}/retry.rs | 0 .../nvisy-engine/src/compiler/graph/source.rs | 13 ++ .../nvisy-engine/src/compiler/graph/target.rs | 13 ++ .../src/compiler/graph/timeout.rs | 25 ++++ crates/nvisy-engine/src/compiler/mod.rs | 137 ++++++++++++++++-- crates/nvisy-engine/src/compiler/parse.rs | 54 ------- crates/nvisy-engine/src/compiler/plan.rs | 104 ------------- crates/nvisy-engine/src/compiler/plan/mod.rs | 47 ++++++ crates/nvisy-engine/src/engine/default.rs | 29 ++-- crates/nvisy-engine/src/engine/executor.rs | 79 +++++----- crates/nvisy-engine/src/engine/mod.rs | 12 +- crates/nvisy-engine/src/engine/policies.rs | 2 +- crates/nvisy-engine/src/engine/runs.rs | 34 +++-- crates/nvisy-engine/src/prelude.rs | 9 +- 18 files changed, 493 insertions(+), 365 deletions(-) delete mode 100644 crates/nvisy-engine/src/compiler/graph.rs create mode 100644 crates/nvisy-engine/src/compiler/graph/action.rs create mode 100644 crates/nvisy-engine/src/compiler/graph/mod.rs rename crates/nvisy-engine/src/compiler/{ => graph}/retry.rs (100%) create mode 100644 crates/nvisy-engine/src/compiler/graph/source.rs create mode 100644 crates/nvisy-engine/src/compiler/graph/target.rs create mode 100644 crates/nvisy-engine/src/compiler/graph/timeout.rs delete mode 100644 crates/nvisy-engine/src/compiler/parse.rs delete mode 100644 crates/nvisy-engine/src/compiler/plan.rs create mode 100644 crates/nvisy-engine/src/compiler/plan/mod.rs diff --git a/crates/nvisy-engine/Cargo.toml b/crates/nvisy-engine/Cargo.toml index 157eedc..09254bd 100644 --- a/crates/nvisy-engine/Cargo.toml +++ b/crates/nvisy-engine/Cargo.toml @@ -28,23 +28,19 @@ nvisy-ontology = { workspace = true, features = [] } nvisy-identify = { workspace = true, features = [] } nvisy-codec = { workspace = true, features = [] } -# JSON Schema generation -schemars = { workspace = true, features = [] } - -# Semantic versioning -semver = { workspace = true, features = [] } - # (De)serialization serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = [] } +schemars = { workspace = true, features = [] } # Async runtime tokio = { workspace = true, features = ["rt", "sync", "time", "macros"] } tokio-util = { workspace = true, features = [] } # Primitive datatypes -uuid = { workspace = true, features = ["v4"] } +uuid = { workspace = true, features = ["serde", "v4"] } jiff = { workspace = true, features = [] } +semver = { workspace = true, features = [] } # Graph data structures petgraph = { workspace = true, features = [] } diff --git a/crates/nvisy-engine/src/compiler/graph.rs b/crates/nvisy-engine/src/compiler/graph.rs deleted file mode 100644 index 1be10a5..0000000 --- a/crates/nvisy-engine/src/compiler/graph.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Graph data model for pipeline definitions. -//! -//! A pipeline is represented as a set of [`GraphNode`]s connected by -//! [`GraphEdge`]s, collected into a [`Graph`]. - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use super::retry::RetryPolicy; - -/// A node in the pipeline graph, tagged by its role. -/// -/// Nodes are serialized with a `"type"` discriminator so JSON definitions -/// can specify `"source"`, `"action"`, or `"target"`. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum GraphNode { - /// A data source that reads from an external provider via a named stream. - Source { - /// Unique identifier for this node within the graph. - id: String, - /// Provider name used to resolve the connection (e.g. `"s3"`). - provider: String, - /// Stream name on the provider (e.g. `"read"`). - stream: String, - /// Arbitrary provider-specific parameters. - #[serde(default)] - params: serde_json::Value, - /// Optional retry policy applied to this node's execution. - #[serde(skip_serializing_if = "Option::is_none")] - retry: Option, - /// Optional per-node timeout in milliseconds. - #[serde(skip_serializing_if = "Option::is_none")] - timeout_ms: Option, - }, - /// A transformation or detection step applied to data flowing through the pipeline. - Action { - /// Unique identifier for this node within the graph. - id: String, - /// Registered action name (e.g. `"detect_regex"`, `"classify"`). - action: String, - /// Arbitrary action-specific parameters. - #[serde(default)] - params: serde_json::Value, - /// Optional retry policy applied to this node's execution. - #[serde(skip_serializing_if = "Option::is_none")] - retry: Option, - /// Optional per-node timeout in milliseconds. - #[serde(skip_serializing_if = "Option::is_none")] - timeout_ms: Option, - }, - /// A data sink that writes to an external provider via a named stream. - Target { - /// Unique identifier for this node within the graph. - id: String, - /// Provider name used to resolve the connection (e.g. `"s3"`). - provider: String, - /// Stream name on the provider (e.g. `"write"`). - stream: String, - /// Arbitrary provider-specific parameters. - #[serde(default)] - params: serde_json::Value, - /// Optional retry policy applied to this node's execution. - #[serde(skip_serializing_if = "Option::is_none")] - retry: Option, - /// Optional per-node timeout in milliseconds. - #[serde(skip_serializing_if = "Option::is_none")] - timeout_ms: Option, - }, -} - -impl GraphNode { - /// Returns the unique identifier shared by all node variants. - pub fn id(&self) -> &str { - match self { - GraphNode::Source { id, .. } => id, - GraphNode::Action { id, .. } => id, - GraphNode::Target { id, .. } => id, - } - } - - /// Returns the parameters value for this node. - pub fn params(&self) -> &serde_json::Value { - match self { - GraphNode::Source { params, .. } => params, - GraphNode::Action { params, .. } => params, - GraphNode::Target { params, .. } => params, - } - } - - /// Returns the retry policy, if one is configured. - pub fn retry(&self) -> Option<&RetryPolicy> { - match self { - GraphNode::Source { retry, .. } => retry.as_ref(), - GraphNode::Action { retry, .. } => retry.as_ref(), - GraphNode::Target { retry, .. } => retry.as_ref(), - } - } - - /// Returns the per-node timeout in milliseconds, if one is configured. - pub fn timeout_ms(&self) -> Option { - match self { - GraphNode::Source { timeout_ms, .. } => *timeout_ms, - GraphNode::Action { timeout_ms, .. } => *timeout_ms, - GraphNode::Target { timeout_ms, .. } => *timeout_ms, - } - } -} - -/// A directed edge connecting two nodes by their IDs. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct GraphEdge { - /// ID of the upstream (source) node. - pub from: String, - /// ID of the downstream (destination) node. - pub to: String, -} - -/// A complete pipeline graph definition containing nodes and edges. -/// -/// The graph must be a valid DAG (directed acyclic graph) with unique node IDs. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Graph { - /// All nodes in the pipeline. - pub nodes: Vec, - /// Directed edges describing data flow between nodes. - pub edges: Vec, -} diff --git a/crates/nvisy-engine/src/compiler/graph/action.rs b/crates/nvisy-engine/src/compiler/graph/action.rs new file mode 100644 index 0000000..599d64c --- /dev/null +++ b/crates/nvisy-engine/src/compiler/graph/action.rs @@ -0,0 +1,27 @@ +//! Action node definition with strongly-typed action variants. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The set of strongly-typed actions a pipeline node can perform. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ActionKind { + /// Run detection methods on the content. + Detect, + /// Transcribe audio/video content to text. + Transcribe, + /// Translate content between languages. + Translate, + /// Apply redaction instructions to the content. + Redact, + /// Evaluate policies against detected entities. + Evaluate, +} + +/// A transformation or detection step. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ActionNode { + /// The action this node performs. + pub action: ActionKind, +} diff --git a/crates/nvisy-engine/src/compiler/graph/mod.rs b/crates/nvisy-engine/src/compiler/graph/mod.rs new file mode 100644 index 0000000..ce849fa --- /dev/null +++ b/crates/nvisy-engine/src/compiler/graph/mod.rs @@ -0,0 +1,135 @@ +//! Graph data model for pipeline definitions. +//! +//! A pipeline is represented as a set of [`GraphNode`]s connected by +//! [`GraphEdge`]s, collected into a [`Graph`]. Nodes are flattened into +//! a struct carrying shared fields (`id`, `retry`, `timeout`) alongside +//! a `kind` discriminator that determines the node's role. + +mod action; +mod retry; +mod source; +mod target; +mod timeout; + +pub use action::{ActionKind, ActionNode}; +pub use retry::{BackoffStrategy, RetryPolicy}; +pub use source::SourceNode; +pub use target::TargetNode; +pub use timeout::{TimeoutBehavior, TimeoutPolicy}; + +use std::collections::HashSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use nvisy_core::Error; + + +/// A node in the pipeline graph. +/// +/// Shared fields (`id`, `retry`, `timeout`) live directly on the struct +/// while the role-specific payload is carried in [`GraphNodeKind`] via +/// `#[serde(flatten)]`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GraphNode { + /// Unique identifier for this node within the graph. + pub id: Uuid, + /// Optional retry policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry: Option, + /// Optional timeout policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + /// Role-specific payload (source, action, or target). + #[serde(flatten)] + pub kind: GraphNodeKind, +} + +impl GraphNode { + /// Returns the retry policy, if one is configured. + pub fn retry(&self) -> Option<&RetryPolicy> { + self.retry.as_ref() + } + + /// Returns the timeout policy, if one is configured. + pub fn timeout(&self) -> Option<&TimeoutPolicy> { + self.timeout.as_ref() + } +} + +/// Discriminator for the three node roles in a pipeline. +/// +/// Serialized with a `"type"` tag so JSON definitions specify +/// `"source"`, `"action"`, or `"target"`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GraphNodeKind { + /// A data source that reads from an external provider via a named stream. + Source(SourceNode), + /// A transformation or detection step applied to data flowing through the pipeline. + Action(ActionNode), + /// A data sink that writes to an external provider via a named stream. + Target(TargetNode), +} + +/// A directed edge connecting two nodes. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GraphEdge { + /// ID of the upstream node. + pub source: Uuid, + /// ID of the downstream node. + pub target: Uuid, +} + +/// A complete pipeline graph definition containing nodes and edges. +/// +/// The graph must be a valid DAG (directed acyclic graph) with unique node IDs. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Graph { + /// All nodes in the pipeline. + pub nodes: Vec, + /// Directed edges describing data flow between nodes. + pub edges: Vec, +} + +impl Graph { + /// Validate structural invariants. + /// + /// - The graph must contain at least one node. + /// - All node IDs must be unique. + /// - All edge endpoints must reference existing node IDs. + pub fn validate(&self) -> Result<(), Error> { + if self.nodes.is_empty() { + return Err(Error::validation("Graph must have at least one node", "compiler")); + } + + let mut seen = HashSet::new(); + for node in &self.nodes { + if !seen.insert(node.id) { + return Err(Error::validation( + format!("Duplicate node ID: {}", node.id), + "compiler", + )); + } + } + + let node_ids: HashSet = seen; + for edge in &self.edges { + if !node_ids.contains(&edge.source) { + return Err(Error::validation( + format!("Edge references unknown source node: {}", edge.source), + "compiler", + )); + } + if !node_ids.contains(&edge.target) { + return Err(Error::validation( + format!("Edge references unknown target node: {}", edge.target), + "compiler", + )); + } + } + + Ok(()) + } +} diff --git a/crates/nvisy-engine/src/compiler/retry.rs b/crates/nvisy-engine/src/compiler/graph/retry.rs similarity index 100% rename from crates/nvisy-engine/src/compiler/retry.rs rename to crates/nvisy-engine/src/compiler/graph/retry.rs diff --git a/crates/nvisy-engine/src/compiler/graph/source.rs b/crates/nvisy-engine/src/compiler/graph/source.rs new file mode 100644 index 0000000..810c8e0 --- /dev/null +++ b/crates/nvisy-engine/src/compiler/graph/source.rs @@ -0,0 +1,13 @@ +//! Source node definition. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A data source that reads from an external provider. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SourceNode { + /// Provider name used to resolve the connection (e.g. `"s3"`). + pub provider: String, + /// Stream name on the provider (e.g. `"read"`). + pub stream: String, +} diff --git a/crates/nvisy-engine/src/compiler/graph/target.rs b/crates/nvisy-engine/src/compiler/graph/target.rs new file mode 100644 index 0000000..982f87c --- /dev/null +++ b/crates/nvisy-engine/src/compiler/graph/target.rs @@ -0,0 +1,13 @@ +//! Target node definition. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A data sink that writes to an external provider. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TargetNode { + /// Provider name used to resolve the connection (e.g. `"s3"`). + pub provider: String, + /// Stream name on the provider (e.g. `"write"`). + pub stream: String, +} diff --git a/crates/nvisy-engine/src/compiler/graph/timeout.rs b/crates/nvisy-engine/src/compiler/graph/timeout.rs new file mode 100644 index 0000000..87e09c9 --- /dev/null +++ b/crates/nvisy-engine/src/compiler/graph/timeout.rs @@ -0,0 +1,25 @@ +//! Timeout configuration for pipeline graph nodes. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Policy controlling how long a node may run before timing out. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TimeoutPolicy { + /// Maximum wall-clock time in milliseconds before the node is interrupted. + pub duration_ms: u64, + /// What to do when the timeout fires. + #[serde(default)] + pub on_timeout: TimeoutBehavior, +} + +/// Behaviour when a node exceeds its [`TimeoutPolicy`] deadline. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TimeoutBehavior { + /// Return an error and propagate the failure. + #[default] + Fail, + /// Silently discard the result and report zero items processed. + Skip, +} diff --git a/crates/nvisy-engine/src/compiler/mod.rs b/crates/nvisy-engine/src/compiler/mod.rs index 6352439..6da1eb3 100644 --- a/crates/nvisy-engine/src/compiler/mod.rs +++ b/crates/nvisy-engine/src/compiler/mod.rs @@ -1,14 +1,133 @@ -//! Pipeline compilation: parsing, graph construction, and execution planning. +//! Pipeline compilation: graph construction, validation, and execution planning. //! -//! The compiler takes a JSON pipeline definition, validates it, builds a -//! directed graph, and produces a topologically-sorted execution plan. +//! The [`Compiler`] is the entry-point for turning a [`Graph`] into an +//! [`ExecutionPlan`]. It carries optional default retry and timeout policies +//! that are applied to nodes which don't specify their own. pub mod graph; -mod parse; pub mod plan; -pub mod retry; -pub use graph::{Graph, GraphEdge, GraphNode}; -pub use parse::parse_graph; -pub use plan::{build_plan, ExecutionPlan, ResolvedNode}; -pub use retry::{BackoffStrategy, RetryPolicy}; +pub use graph::{ + ActionKind, ActionNode, BackoffStrategy, Graph, GraphEdge, GraphNode, GraphNodeKind, + RetryPolicy, SourceNode, TargetNode, TimeoutBehavior, TimeoutPolicy, +}; +pub use plan::{CompiledGraph, ExecutionPlan, ResolvedNode}; + +use std::collections::HashMap; + +use petgraph::algo::{is_cyclic_directed, toposort}; +use petgraph::graph::{DiGraph, NodeIndex}; +use uuid::Uuid; + +use nvisy_core::Error; + +/// Pipeline compiler with optional default policies. +/// +/// Nodes that don't carry their own retry or timeout policy will inherit +/// the compiler-level defaults (if set) at compile time. +#[derive(Debug, Clone, Default)] +pub struct Compiler { + /// Default retry policy applied to nodes without one. + pub retry: Option, + /// Default timeout policy applied to nodes without one. + pub timeout: Option, +} + +impl Compiler { + /// Create a compiler with no default policies. + pub fn new() -> Self { + Self::default() + } + + /// Set the default retry policy. + pub fn with_retry(mut self, policy: RetryPolicy) -> Self { + self.retry = Some(policy); + self + } + + /// Set the default timeout policy. + pub fn with_timeout(mut self, policy: TimeoutPolicy) -> Self { + self.timeout = Some(policy); + self + } + + /// Compile a [`Graph`] into an [`ExecutionPlan`]. + /// + /// Validates the graph, applies compiler-level default policies to nodes + /// that don't specify their own, builds a `petgraph` representation, + /// checks for cycles, and produces a topologically-sorted plan. + pub fn compile(&self, graph: &Graph) -> Result { + let mut graph = graph.clone(); + + // Apply compiler-level defaults to nodes missing their own policies. + for node in &mut graph.nodes { + if node.retry.is_none() { + node.retry.clone_from(&self.retry); + } + if node.timeout.is_none() { + node.timeout.clone_from(&self.timeout); + } + } + + graph.validate()?; + + // Build petgraph + let mut pg: DiGraph = DiGraph::new(); + let mut index_map: HashMap = HashMap::new(); + + for node in &graph.nodes { + let idx = pg.add_node(node.clone()); + index_map.insert(node.id, idx); + } + + for edge in &graph.edges { + let from = index_map[&edge.source]; + let to = index_map[&edge.target]; + pg.add_edge(from, to, ()); + } + + // Cycle detection + if is_cyclic_directed(&pg) { + return Err(Error::validation("Graph contains a cycle", "compiler")); + } + + // Topological sort + let topo = toposort(&pg, None).map_err(|_| { + Error::validation("Graph contains a cycle", "compiler") + })?; + + let topo_order: Vec = topo.iter().map(|idx| pg[*idx].id).collect(); + + // Build resolved nodes with adjacency info + let mut resolved = Vec::new(); + + for (order, node_id) in topo_order.iter().enumerate() { + let idx = index_map[node_id]; + + let upstream_ids: Vec = pg + .neighbors_directed(idx, petgraph::Direction::Incoming) + .map(|n| pg[n].id) + .collect(); + + let downstream_ids: Vec = pg + .neighbors_directed(idx, petgraph::Direction::Outgoing) + .map(|n| pg[n].id) + .collect(); + + resolved.push(ResolvedNode { + node: pg[idx].clone(), + topo_order: order, + upstream_ids, + downstream_ids, + }); + } + + let compiled = CompiledGraph { graph: pg, index_map }; + + Ok(ExecutionPlan { + nodes: resolved, + topo_order, + compiled, + }) + } +} diff --git a/crates/nvisy-engine/src/compiler/parse.rs b/crates/nvisy-engine/src/compiler/parse.rs deleted file mode 100644 index 0e1c5cd..0000000 --- a/crates/nvisy-engine/src/compiler/parse.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! JSON parsing and validation for pipeline graph definitions. -//! -//! Deserializes a [`serde_json::Value`] into a [`Graph`] and validates -//! structural invariants (non-empty, unique IDs, valid edge references). - -use crate::compiler::graph::Graph; -use nvisy_core::Error; - -/// Parses and validates a [`Graph`] from a JSON value. -/// -/// Performs the following validations: -/// - The graph must contain at least one node. -/// - All node IDs must be unique. -/// - All edge endpoints must reference existing node IDs. -pub fn parse_graph(value: &serde_json::Value) -> Result { - let graph: Graph = serde_json::from_value(value.clone()).map_err(|e| { - Error::validation(format!("Invalid graph definition: {}", e), "compiler") - })?; - - // Validate: must have at least one node - if graph.nodes.is_empty() { - return Err(Error::validation("Graph must have at least one node", "compiler")); - } - - // Validate: no duplicate node IDs - let mut seen = std::collections::HashSet::new(); - for node in &graph.nodes { - if !seen.insert(node.id()) { - return Err(Error::validation( - format!("Duplicate node ID: {}", node.id()), - "compiler", - )); - } - } - - // Validate: all edge endpoints reference existing nodes - let node_ids: std::collections::HashSet<&str> = graph.nodes.iter().map(|n| n.id()).collect(); - for edge in &graph.edges { - if !node_ids.contains(edge.from.as_str()) { - return Err(Error::validation( - format!("Edge references unknown source node: {}", edge.from), - "compiler", - )); - } - if !node_ids.contains(edge.to.as_str()) { - return Err(Error::validation( - format!("Edge references unknown target node: {}", edge.to), - "compiler", - )); - } - } - - Ok(graph) -} diff --git a/crates/nvisy-engine/src/compiler/plan.rs b/crates/nvisy-engine/src/compiler/plan.rs deleted file mode 100644 index 40b2852..0000000 --- a/crates/nvisy-engine/src/compiler/plan.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Execution planning via topological sort. -//! -//! Converts a validated [`Graph`] into an [`ExecutionPlan`] by performing -//! cycle detection and topological sorting using `petgraph`. - -use std::collections::HashMap; -use petgraph::algo::{is_cyclic_directed, toposort}; -use petgraph::graph::{DiGraph, NodeIndex}; -use crate::compiler::graph::{Graph, GraphNode}; -use nvisy_core::Error; - -/// A graph node enriched with topological ordering and adjacency information. -#[derive(Debug, Clone)] -pub struct ResolvedNode { - /// The original graph node definition. - pub node: GraphNode, - /// Zero-based position in the topological ordering. - pub topo_order: usize, - /// IDs of nodes that feed data into this node. - pub upstream_ids: Vec, - /// IDs of nodes that receive data from this node. - pub downstream_ids: Vec, -} - -/// A compiled execution plan ready for the executor. -/// -/// Contains all nodes in topological order along with their adjacency -/// information so the executor can wire channels and schedule tasks. -pub struct ExecutionPlan { - /// Resolved nodes sorted in topological order. - pub nodes: Vec, - /// Node IDs in topological order. - pub topo_order: Vec, -} - -/// Builds an execution plan from a parsed [`Graph`]. -/// -/// Validates that the graph is acyclic, performs a topological sort, and -/// computes upstream/downstream adjacency lists for each node. -/// -/// Returns an error if the graph contains a cycle or references unknown nodes. -pub fn build_plan(graph: &Graph) -> Result { - // Build petgraph - let mut pg: DiGraph<&str, ()> = DiGraph::new(); - let mut index_map: HashMap<&str, NodeIndex> = HashMap::new(); - - for node in &graph.nodes { - let idx = pg.add_node(node.id()); - index_map.insert(node.id(), idx); - } - - for edge in &graph.edges { - let from = index_map.get(edge.from.as_str()).ok_or_else(|| { - Error::validation(format!("Unknown edge source: {}", edge.from), "compiler") - })?; - let to = index_map.get(edge.to.as_str()).ok_or_else(|| { - Error::validation(format!("Unknown edge target: {}", edge.to), "compiler") - })?; - pg.add_edge(*from, *to, ()); - } - - // Cycle detection - if is_cyclic_directed(&pg) { - return Err(Error::validation("Graph contains a cycle", "compiler")); - } - - // Topological sort - let topo = toposort(&pg, None).map_err(|_| { - Error::validation("Graph contains a cycle", "compiler") - })?; - - let topo_order: Vec = topo.iter().map(|idx| pg[*idx].to_string()).collect(); - - // Build resolved nodes with adjacency info - let node_map: HashMap<&str, &GraphNode> = graph.nodes.iter().map(|n| (n.id(), n)).collect(); - let mut resolved = Vec::new(); - - for (order, node_id) in topo_order.iter().enumerate() { - let node = node_map[node_id.as_str()]; - let idx = index_map[node_id.as_str()]; - - let upstream_ids: Vec = pg - .neighbors_directed(idx, petgraph::Direction::Incoming) - .map(|n| pg[n].to_string()) - .collect(); - - let downstream_ids: Vec = pg - .neighbors_directed(idx, petgraph::Direction::Outgoing) - .map(|n| pg[n].to_string()) - .collect(); - - resolved.push(ResolvedNode { - node: node.clone(), - topo_order: order, - upstream_ids, - downstream_ids, - }); - } - - Ok(ExecutionPlan { - nodes: resolved, - topo_order, - }) -} diff --git a/crates/nvisy-engine/src/compiler/plan/mod.rs b/crates/nvisy-engine/src/compiler/plan/mod.rs new file mode 100644 index 0000000..61b5ff0 --- /dev/null +++ b/crates/nvisy-engine/src/compiler/plan/mod.rs @@ -0,0 +1,47 @@ +//! Compiled execution plan types. +//! +//! A [`CompiledGraph`] wraps a `petgraph` representation of the pipeline. +//! An [`ExecutionPlan`] pairs it with topologically-sorted [`ResolvedNode`]s +//! so the executor can wire channels and schedule tasks. + +use std::collections::HashMap; + +use petgraph::graph::{DiGraph, NodeIndex}; +use uuid::Uuid; + +use crate::compiler::graph::GraphNode; + +/// A compiled graph with petgraph representation, ready for execution. +#[derive(Debug, Clone)] +pub struct CompiledGraph { + /// petgraph directed graph: node weight is the `GraphNode`, edge weight is `()`. + pub graph: DiGraph, + /// Lookup from node UUID to petgraph `NodeIndex`. + pub index_map: HashMap, +} + +/// A graph node enriched with topological ordering and adjacency information. +#[derive(Debug, Clone)] +pub struct ResolvedNode { + /// The original graph node definition. + pub node: GraphNode, + /// Zero-based position in the topological ordering. + pub topo_order: usize, + /// IDs of nodes that feed data into this node. + pub upstream_ids: Vec, + /// IDs of nodes that receive data from this node. + pub downstream_ids: Vec, +} + +/// A compiled execution plan ready for the executor. +/// +/// Contains all nodes in topological order along with their adjacency +/// information so the executor can wire channels and schedule tasks. +pub struct ExecutionPlan { + /// Resolved nodes sorted in topological order. + pub nodes: Vec, + /// Node IDs in topological order. + pub topo_order: Vec, + /// The compiled petgraph representation. + pub compiled: CompiledGraph, +} diff --git a/crates/nvisy-engine/src/engine/default.rs b/crates/nvisy-engine/src/engine/default.rs index fa224bb..ebc2b37 100644 --- a/crates/nvisy-engine/src/engine/default.rs +++ b/crates/nvisy-engine/src/engine/default.rs @@ -7,7 +7,8 @@ //! 3. **Redact** — apply redaction instructions to produce output content. //! //! After the content-level pipeline completes, the execution graph is run via -//! [`run_graph`] so that any Source/Action/Target DAG nodes are also executed. +//! [`Engine::run_graph`] so that any Source/Action/Target DAG nodes are also +//! executed. use jiff::Timestamp; use uuid::Uuid; @@ -19,8 +20,10 @@ use nvisy_identify::{ }; use super::{Engine, EngineInput, EngineOutput}; -use super::executor::run_graph; -use crate::compiler::build_plan; +use super::connections::Connections; +use super::executor::{self, RunOutput}; +use crate::compiler::Compiler; +use crate::compiler::plan::ExecutionPlan; /// Default [`Engine`] implementation. /// @@ -35,7 +38,7 @@ impl Engine for DefaultEngine { let mut audits: Vec = Vec::new(); let content_source = input.source.content_source(); - // ── Phase 1: Detection ────────────────────────────────────── + // Phase 1: Detection // // Detection is handled externally (via DetectionService / NER / Pattern / // CV layers) before the engine is called. The engine receives entities as @@ -48,7 +51,7 @@ impl Engine for DefaultEngine { duration_ms: None, }; - // ── Phase 2: Policy Evaluation ────────────────────────────── + // Phase 2: Policy Evaluation // // Evaluate each policy against the detected entities to produce // redaction instructions, review holds, alerts, blocks, etc. @@ -106,7 +109,7 @@ impl Engine for DefaultEngine { } }; - // ── Phase 3: Redaction ────────────────────────────────────── + // Phase 3: Redaction // // The ApplyRedactionAction is called directly by callers that have // parsed documents into the typed codec representation. At this level @@ -120,12 +123,12 @@ impl Engine for DefaultEngine { redactions_skipped: skipped, }]; - // ── Phase 4: DAG Execution ────────────────────────────────── + // Phase 4: DAG Execution // // Compile the graph into a topologically-sorted execution plan and // run Source/Action/Target nodes concurrently. - let plan = build_plan(&input.graph)?; - let run_output = run_graph(&plan, &input.connections).await?; + let plan = Compiler::new().compile(&input.graph)?; + let run_output = self.run_graph(&plan, &input.connections).await?; // Emit a detection audit entry for the overall run. audits.push(Audit { @@ -150,4 +153,12 @@ impl Engine for DefaultEngine { run_output, }) } + + async fn run_graph( + &self, + plan: &ExecutionPlan, + connections: &Connections, + ) -> Result { + executor::run_graph(plan, connections).await + } } diff --git a/crates/nvisy-engine/src/engine/executor.rs b/crates/nvisy-engine/src/engine/executor.rs index ae2adbc..e5efb1e 100644 --- a/crates/nvisy-engine/src/engine/executor.rs +++ b/crates/nvisy-engine/src/engine/executor.rs @@ -22,8 +22,8 @@ use uuid::Uuid; use nvisy_core::io::ContentData; use nvisy_core::{Error, ErrorKind}; use crate::compiler::plan::ExecutionPlan; -use crate::compiler::graph::GraphNode; -use crate::compiler::retry::RetryPolicy; +use crate::compiler::graph::{ActionKind, GraphNode, GraphNodeKind, TimeoutBehavior}; +use crate::compiler::RetryPolicy; use super::connections::{Connection, Connections}; use super::policies::{with_retry, with_timeout}; @@ -34,7 +34,7 @@ const CHANNEL_BUFFER_SIZE: usize = 256; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct NodeOutput { /// ID of the node that produced this result. - pub node_id: String, + pub node_id: Uuid, /// Number of data items processed by this node. pub items_processed: u64, /// Error message if the node failed, or `None` on success. @@ -55,7 +55,7 @@ pub struct RunOutput { /// Executes a compiled [`ExecutionPlan`] by spawning concurrent tasks for each node. /// /// Returns a [`RunOutput`] containing per-node outcomes and an overall success flag. -pub async fn run_graph( +pub(crate) async fn run_graph( plan: &ExecutionPlan, connections: &Connections, ) -> Result { @@ -63,26 +63,26 @@ pub async fn run_graph( let connections = Arc::new(connections.clone()); // Create channels for each edge - let mut senders: HashMap>> = HashMap::new(); - let mut receivers: HashMap>> = HashMap::new(); + let mut senders: HashMap>> = HashMap::new(); + let mut receivers: HashMap>> = HashMap::new(); for node in &plan.nodes { - let node_id = node.node.id(); + let node_id = node.node.id; for downstream_id in &node.downstream_ids { let (tx, rx) = mpsc::channel(CHANNEL_BUFFER_SIZE); - senders.entry(node_id.to_string()).or_default().push(tx); - receivers.entry(downstream_id.clone()).or_default().push(rx); + senders.entry(node_id).or_default().push(tx); + receivers.entry(*downstream_id).or_default().push(rx); } } // Create completion signals per node - let mut signal_senders: HashMap> = HashMap::new(); - let mut signal_receivers: HashMap> = HashMap::new(); + let mut signal_senders: HashMap> = HashMap::new(); + let mut signal_receivers: HashMap> = HashMap::new(); for node in &plan.nodes { let (tx, rx) = watch::channel(false); - signal_senders.insert(node.node.id().to_string(), tx); - signal_receivers.insert(node.node.id().to_string(), rx); + signal_senders.insert(node.node.id, tx); + signal_receivers.insert(node.node.id, rx); } // Spawn tasks @@ -90,7 +90,7 @@ pub async fn run_graph( for resolved in &plan.nodes { let node = resolved.node.clone(); - let node_id = node.id().to_string(); + let node_id = node.id; let upstream_ids = resolved.upstream_ids.clone(); // Collect upstream watch receivers @@ -138,7 +138,7 @@ pub async fn run_graph( match result { Ok(nr) => node_results.push(nr), Err(e) => node_results.push(NodeOutput { - node_id: "unknown".to_string(), + node_id: Uuid::nil(), items_processed: 0, error: Some(format!("Task panicked: {}", e)), }), @@ -155,11 +155,13 @@ pub async fn run_graph( } /// Execute a single node, dispatching to the correct handler based on the -/// [`GraphNode`] variant. +/// [`GraphNodeKind`] variant. /// -/// A per-node timeout is applied when configured. Retry policies are applied -/// within the individual source/target handlers where the retryable I/O -/// actually occurs (channel consumption is not retryable). +/// A per-node timeout is applied when configured. The [`TimeoutBehavior`] +/// determines whether a timeout is treated as an error (`Fail`) or silently +/// yields zero items (`Skip`). Retry policies are applied within the +/// individual source/target handlers where the retryable I/O actually +/// occurs (channel consumption is not retryable). async fn execute_node( node: &GraphNode, senders: Vec>, @@ -167,22 +169,34 @@ async fn execute_node( connections: &Connections, ) -> Result { let run = async { - match node { - GraphNode::Source { provider, stream, params, retry, .. } => { - execute_source(provider, stream, params, retry.as_ref(), &senders, connections).await + match &node.kind { + GraphNodeKind::Source(src) => { + execute_source( + &src.provider, &src.stream, + node.retry(), &senders, connections, + ).await } - GraphNode::Action { action, params, .. } => { - execute_action(action, params, &senders, &mut receivers).await + GraphNodeKind::Action(act) => { + execute_action(&act.action, &senders, &mut receivers).await } - GraphNode::Target { provider, stream, params, retry, .. } => { - execute_target(provider, stream, params, retry.as_ref(), &mut receivers, connections).await + GraphNodeKind::Target(tgt) => { + execute_target( + &tgt.provider, &tgt.stream, + node.retry(), &mut receivers, connections, + ).await } } }; // Apply per-node timeout when configured. - match node.timeout_ms() { - Some(ms) => with_timeout(ms, run).await, + match node.timeout() { + Some(policy) => { + let result = with_timeout(policy.duration_ms, run).await; + match (&result, &policy.on_timeout) { + (Err(e), TimeoutBehavior::Skip) if e.kind == ErrorKind::Timeout => Ok(0), + _ => result, + } + } None => run.await, } } @@ -210,7 +224,6 @@ fn resolve_connection<'a>( async fn execute_source( provider: &str, stream: &str, - _params: &serde_json::Value, retry: Option<&RetryPolicy>, senders: &[mpsc::Sender], connections: &Connections, @@ -242,16 +255,15 @@ async fn execute_source( /// and forward the result downstream. /// /// Concrete action dispatch (detect, classify, redact) is orchestrated by -/// [`DefaultEngine::run`] which drives detection → evaluation → redaction +/// [`DefaultEngine::run`] which drives detection -> evaluation -> redaction /// as sequential phases. The channel-level passthrough here handles any /// action nodes that appear in the DAG but whose logic is managed externally. async fn execute_action( - action: &str, - _params: &serde_json::Value, + action: &ActionKind, senders: &[mpsc::Sender], receivers: &mut [mpsc::Receiver], ) -> Result { - tracing::debug!(action, "action node: processing"); + tracing::debug!(?action, "action node: processing"); // Forward items from all upstream receivers to all downstream senders. let mut count = 0u64; @@ -276,7 +288,6 @@ async fn execute_action( async fn execute_target( provider: &str, stream: &str, - _params: &serde_json::Value, retry: Option<&RetryPolicy>, receivers: &mut [mpsc::Receiver], connections: &Connections, diff --git a/crates/nvisy-engine/src/engine/mod.rs b/crates/nvisy-engine/src/engine/mod.rs index ac52be5..a159b40 100644 --- a/crates/nvisy-engine/src/engine/mod.rs +++ b/crates/nvisy-engine/src/engine/mod.rs @@ -5,7 +5,7 @@ //! output together with a full audit trail and per-phase breakdown. //! //! [`DefaultEngine`] is the standard implementation that orchestrates the -//! detect → evaluate → redact pipeline and drives the DAG execution graph. +//! detect -> evaluate -> redact pipeline and drives the DAG execution graph. pub mod connections; mod default; @@ -16,7 +16,7 @@ pub mod runs; pub use connections::{Connection, Connections}; pub use default::DefaultEngine; -pub use executor::{run_graph, NodeOutput, RunOutput}; +pub use executor::{NodeOutput, RunOutput}; pub use runs::{RunManager, RunState, RunStatus, RunSummary}; use std::future::Future; @@ -33,6 +33,7 @@ pub use nvisy_identify::{ }; use crate::compiler::graph::Graph; +use crate::compiler::plan::ExecutionPlan; /// Everything the caller must provide to run a redaction pipeline. pub struct EngineInput { @@ -80,4 +81,11 @@ pub trait Engine: Send + Sync { &self, input: EngineInput, ) -> impl Future> + Send; + + /// Execute a compiled [`ExecutionPlan`] against the given connections. + fn run_graph( + &self, + plan: &ExecutionPlan, + connections: &Connections, + ) -> impl Future> + Send; } diff --git a/crates/nvisy-engine/src/engine/policies.rs b/crates/nvisy-engine/src/engine/policies.rs index b991b00..68b0319 100644 --- a/crates/nvisy-engine/src/engine/policies.rs +++ b/crates/nvisy-engine/src/engine/policies.rs @@ -8,7 +8,7 @@ use std::time::Duration; use tokio::time; use nvisy_core::Error; -use crate::compiler::retry::{BackoffStrategy, RetryPolicy}; +use crate::compiler::{BackoffStrategy, RetryPolicy}; /// Computes the sleep duration before a retry attempt based on the policy's /// [`BackoffStrategy`] and the zero-based attempt number. diff --git a/crates/nvisy-engine/src/engine/runs.rs b/crates/nvisy-engine/src/engine/runs.rs index 17ab84d..be9440b 100644 --- a/crates/nvisy-engine/src/engine/runs.rs +++ b/crates/nvisy-engine/src/engine/runs.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::executor::RunOutput; +use super::executor::{NodeOutput, RunOutput}; /// Lifecycle status of a pipeline run. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -36,7 +36,7 @@ pub enum RunStatus { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct NodeProgress { /// ID of the node this progress belongs to. - pub node_id: String, + pub node_id: Uuid, /// Current status of this node. pub status: RunStatus, /// Number of data items processed so far. @@ -46,6 +46,21 @@ pub struct NodeProgress { pub error: Option, } +impl From<&NodeOutput> for NodeProgress { + fn from(nr: &NodeOutput) -> Self { + Self { + node_id: nr.node_id, + status: if nr.error.is_none() { + RunStatus::Success + } else { + RunStatus::Failure + }, + items_processed: nr.items_processed, + error: nr.error.clone(), + } + } +} + /// Complete mutable state of a pipeline run. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RunState { @@ -61,7 +76,7 @@ pub struct RunState { #[schemars(with = "Option")] pub completed_at: Option, /// Per-node progress keyed by node ID. - pub node_progress: HashMap, + pub node_progress: HashMap, /// Final result after the run completes. #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, @@ -144,17 +159,8 @@ impl RunManager { for nr in &result.node_results { state.node_progress.insert( - nr.node_id.clone(), - NodeProgress { - node_id: nr.node_id.clone(), - status: if nr.error.is_none() { - RunStatus::Success - } else { - RunStatus::Failure - }, - items_processed: nr.items_processed, - error: nr.error.clone(), - }, + nr.node_id, + NodeProgress::from(nr), ); } diff --git a/crates/nvisy-engine/src/prelude.rs b/crates/nvisy-engine/src/prelude.rs index f716748..403f1ea 100644 --- a/crates/nvisy-engine/src/prelude.rs +++ b/crates/nvisy-engine/src/prelude.rs @@ -1,6 +1,9 @@ //! Convenience re-exports. -pub use crate::compiler::{Graph, GraphEdge, GraphNode}; -pub use crate::compiler::{build_plan, ExecutionPlan, ResolvedNode}; +pub use crate::compiler::{ + ActionKind, ActionNode, BackoffStrategy, CompiledGraph, Compiler, ExecutionPlan, Graph, + GraphEdge, GraphNode, GraphNodeKind, ResolvedNode, RetryPolicy, SourceNode, TargetNode, + TimeoutBehavior, TimeoutPolicy, +}; pub use crate::engine::{DefaultEngine, Engine, EngineInput, EngineOutput}; -pub use crate::engine::{run_graph, RunOutput}; +pub use crate::engine::{RunOutput}; pub use crate::engine::{RunManager, RunState, RunStatus, RunSummary}; From 59ebb367e34dfc0cbcc6f2a605f5fa2167de6f74 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 1 Mar 2026 21:46:39 +0100 Subject: [PATCH 22/22] refactor(engine): restructure policies, add validator, compiled policy types Move retry/timeout policy types from compiler/graph/ to compiler/policy/ module. Replace hand-rolled validate() methods with validator crate derives. Move engine/policies.rs into engine/policy/ folder-module with CompiledRetryPolicy and CompiledTimeoutPolicy structs that convert raw milliseconds into Duration at compile time. Make with_retry and with_timeout methods on compiled policies instead of free functions. Add Copy to BackoffStrategy and TimeoutBehavior enums. Remove Default from RetryPolicy. Add TimeoutPolicy::duration() accessor. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 53 +++++++ Cargo.toml | 3 + crates/nvisy-engine/Cargo.toml | 3 + crates/nvisy-engine/src/compiler/graph/mod.rs | 26 +++- crates/nvisy-engine/src/compiler/mod.rs | 6 +- .../nvisy-engine/src/compiler/policy/mod.rs | 7 + .../src/compiler/{graph => policy}/retry.rs | 21 +-- .../src/compiler/{graph => policy}/timeout.rs | 15 +- crates/nvisy-engine/src/engine/default.rs | 128 +++++++++++++++-- crates/nvisy-engine/src/engine/executor.rs | 132 ++---------------- crates/nvisy-engine/src/engine/mod.rs | 23 ++- crates/nvisy-engine/src/engine/policies.rs | 75 ---------- crates/nvisy-engine/src/engine/policy/mod.rs | 13 ++ .../nvisy-engine/src/engine/policy/retry.rs | 76 ++++++++++ .../nvisy-engine/src/engine/policy/timeout.rs | 47 +++++++ crates/nvisy-engine/src/prelude.rs | 7 +- 16 files changed, 385 insertions(+), 250 deletions(-) create mode 100644 crates/nvisy-engine/src/compiler/policy/mod.rs rename crates/nvisy-engine/src/compiler/{graph => policy}/retry.rs (70%) rename crates/nvisy-engine/src/compiler/{graph => policy}/timeout.rs (63%) delete mode 100644 crates/nvisy-engine/src/engine/policies.rs create mode 100644 crates/nvisy-engine/src/engine/policy/mod.rs create mode 100644 crates/nvisy-engine/src/engine/policy/retry.rs create mode 100644 crates/nvisy-engine/src/engine/policy/timeout.rs diff --git a/Cargo.lock b/Cargo.lock index 9e5f0fd..e5eb6ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3040,6 +3040,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "validator", ] [[package]] @@ -3626,6 +3627,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -5557,6 +5580,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index a2148a7..ec144ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,5 +138,8 @@ tower-http = { version = "0.6", features = [] } # Testing tempfile = { version = "3.0", features = [] } +# Validation +validator = { version = "0.20", features = ["derive"] } + # Randomness rand = { version = "0.10", features = [] } diff --git a/crates/nvisy-engine/Cargo.toml b/crates/nvisy-engine/Cargo.toml index 09254bd..e3f3a22 100644 --- a/crates/nvisy-engine/Cargo.toml +++ b/crates/nvisy-engine/Cargo.toml @@ -49,6 +49,9 @@ petgraph = { workspace = true, features = [] } thiserror = { workspace = true, features = [] } anyhow = { workspace = true, features = [] } +# Validation +validator = { workspace = true, features = [] } + # Randomness rand = { workspace = true, features = [] } diff --git a/crates/nvisy-engine/src/compiler/graph/mod.rs b/crates/nvisy-engine/src/compiler/graph/mod.rs index ce849fa..fcc0994 100644 --- a/crates/nvisy-engine/src/compiler/graph/mod.rs +++ b/crates/nvisy-engine/src/compiler/graph/mod.rs @@ -6,16 +6,12 @@ //! a `kind` discriminator that determines the node's role. mod action; -mod retry; mod source; mod target; -mod timeout; pub use action::{ActionKind, ActionNode}; -pub use retry::{BackoffStrategy, RetryPolicy}; pub use source::SourceNode; pub use target::TargetNode; -pub use timeout::{TimeoutBehavior, TimeoutPolicy}; use std::collections::HashSet; @@ -23,8 +19,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use validator::Validate; + use nvisy_core::Error; +use super::policy::{RetryPolicy, TimeoutPolicy}; /// A node in the pipeline graph. /// @@ -114,6 +113,25 @@ impl Graph { } } + for node in &self.nodes { + if let Some(retry) = &node.retry { + retry.validate().map_err(|e| { + Error::validation( + format!("Node {}: {}", node.id, e), + "compiler", + ) + })?; + } + if let Some(timeout) = &node.timeout { + timeout.validate().map_err(|e| { + Error::validation( + format!("Node {}: {}", node.id, e), + "compiler", + ) + })?; + } + } + let node_ids: HashSet = seen; for edge in &self.edges { if !node_ids.contains(&edge.source) { diff --git a/crates/nvisy-engine/src/compiler/mod.rs b/crates/nvisy-engine/src/compiler/mod.rs index 6da1eb3..0bbc009 100644 --- a/crates/nvisy-engine/src/compiler/mod.rs +++ b/crates/nvisy-engine/src/compiler/mod.rs @@ -6,12 +6,14 @@ pub mod graph; pub mod plan; +pub mod policy; pub use graph::{ - ActionKind, ActionNode, BackoffStrategy, Graph, GraphEdge, GraphNode, GraphNodeKind, - RetryPolicy, SourceNode, TargetNode, TimeoutBehavior, TimeoutPolicy, + ActionKind, ActionNode, Graph, GraphEdge, GraphNode, GraphNodeKind, + SourceNode, TargetNode, }; pub use plan::{CompiledGraph, ExecutionPlan, ResolvedNode}; +pub use policy::{BackoffStrategy, RetryPolicy, TimeoutBehavior, TimeoutPolicy}; use std::collections::HashMap; diff --git a/crates/nvisy-engine/src/compiler/policy/mod.rs b/crates/nvisy-engine/src/compiler/policy/mod.rs new file mode 100644 index 0000000..13adfb4 --- /dev/null +++ b/crates/nvisy-engine/src/compiler/policy/mod.rs @@ -0,0 +1,7 @@ +//! Retry and timeout policies for pipeline nodes. + +mod retry; +mod timeout; + +pub use retry::{BackoffStrategy, RetryPolicy}; +pub use timeout::{TimeoutBehavior, TimeoutPolicy}; diff --git a/crates/nvisy-engine/src/compiler/graph/retry.rs b/crates/nvisy-engine/src/compiler/policy/retry.rs similarity index 70% rename from crates/nvisy-engine/src/compiler/graph/retry.rs rename to crates/nvisy-engine/src/compiler/policy/retry.rs index 6f59bd6..6e59016 100644 --- a/crates/nvisy-engine/src/compiler/graph/retry.rs +++ b/crates/nvisy-engine/src/compiler/policy/retry.rs @@ -5,40 +5,29 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use validator::Validate; /// Retry policy attached to a pipeline node. -/// -/// Defaults to 3 retries with a 1 000 ms fixed delay. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize, JsonSchema)] pub struct RetryPolicy { /// Maximum number of retry attempts after the initial failure. #[serde(default = "default_max_retries")] + #[validate(range(min = 0, max = 5))] pub max_retries: u32, /// Base delay in milliseconds between retry attempts. #[serde(default = "default_delay_ms")] + #[validate(range(min = 1, max = 30_000))] pub delay_ms: u64, /// Strategy used to compute the delay between successive retries. #[serde(default)] pub backoff: BackoffStrategy, } -/// Returns the default maximum retry count (3). fn default_max_retries() -> u32 { 3 } -/// Returns the default base delay in milliseconds (1 000). fn default_delay_ms() -> u64 { 1000 } -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_retries: 3, - delay_ms: 1000, - backoff: BackoffStrategy::default(), - } - } -} - /// Strategy for computing the delay between retry attempts. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum BackoffStrategy { /// Constant delay equal to `delay_ms` on every attempt. diff --git a/crates/nvisy-engine/src/compiler/graph/timeout.rs b/crates/nvisy-engine/src/compiler/policy/timeout.rs similarity index 63% rename from crates/nvisy-engine/src/compiler/graph/timeout.rs rename to crates/nvisy-engine/src/compiler/policy/timeout.rs index 87e09c9..09e4026 100644 --- a/crates/nvisy-engine/src/compiler/graph/timeout.rs +++ b/crates/nvisy-engine/src/compiler/policy/timeout.rs @@ -1,20 +1,31 @@ //! Timeout configuration for pipeline graph nodes. +use std::time::Duration; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use validator::Validate; /// Policy controlling how long a node may run before timing out. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize, JsonSchema)] pub struct TimeoutPolicy { /// Maximum wall-clock time in milliseconds before the node is interrupted. + #[validate(range(min = 1, max = 60_000))] pub duration_ms: u64, /// What to do when the timeout fires. #[serde(default)] pub on_timeout: TimeoutBehavior, } +impl TimeoutPolicy { + /// Returns the timeout duration. + pub fn duration(&self) -> Duration { + Duration::from_millis(self.duration_ms) + } +} + /// Behaviour when a node exceeds its [`TimeoutPolicy`] deadline. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TimeoutBehavior { /// Return an error and propagate the failure. diff --git a/crates/nvisy-engine/src/engine/default.rs b/crates/nvisy-engine/src/engine/default.rs index ebc2b37..0a52974 100644 --- a/crates/nvisy-engine/src/engine/default.rs +++ b/crates/nvisy-engine/src/engine/default.rs @@ -6,14 +6,19 @@ //! 2. **Evaluate** — map detected entities to redaction instructions via policies. //! 3. **Redact** — apply redaction instructions to produce output content. //! -//! After the content-level pipeline completes, the execution graph is run via -//! [`Engine::run_graph`] so that any Source/Action/Target DAG nodes are also -//! executed. +//! After the content-level pipeline completes, the execution graph is run +//! so that any Source/Action/Target DAG nodes are also executed. + +use std::collections::HashMap; +use std::sync::Arc; use jiff::Timestamp; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinSet; use uuid::Uuid; use nvisy_core::Error; +use nvisy_core::io::ContentData; use nvisy_identify::{ Audit, AuditAction, EvaluatePolicyAction, EvaluatePolicyParams, PolicyEvaluation, RedactionSummary, @@ -21,10 +26,13 @@ use nvisy_identify::{ use super::{Engine, EngineInput, EngineOutput}; use super::connections::Connections; -use super::executor::{self, RunOutput}; +use super::executor::{NodeOutput, RunOutput, execute_node}; use crate::compiler::Compiler; use crate::compiler::plan::ExecutionPlan; +/// Default buffer size for bounded inter-node MPSC channels. +const CHANNEL_BUFFER_SIZE: usize = 256; + /// Default [`Engine`] implementation. /// /// Stateless — all configuration comes from the [`EngineInput`] provided at @@ -32,6 +40,108 @@ use crate::compiler::plan::ExecutionPlan; #[derive(Debug, Clone, Copy)] pub struct DefaultEngine; +impl DefaultEngine { + /// Execute a compiled [`ExecutionPlan`] by spawning concurrent tasks for + /// each node. + async fn run_graph( + plan: &ExecutionPlan, + connections: &Connections, + ) -> Result { + let run_id = Uuid::new_v4(); + let connections = Arc::new(connections.clone()); + + // Create channels for each edge + let mut senders: HashMap>> = HashMap::new(); + let mut receivers: HashMap>> = HashMap::new(); + + for node in &plan.nodes { + let node_id = node.node.id; + for downstream_id in &node.downstream_ids { + let (tx, rx) = mpsc::channel(CHANNEL_BUFFER_SIZE); + senders.entry(node_id).or_default().push(tx); + receivers.entry(*downstream_id).or_default().push(rx); + } + } + + // Create completion signals per node + let mut signal_senders: HashMap> = HashMap::new(); + let mut signal_receivers: HashMap> = HashMap::new(); + + for node in &plan.nodes { + let (tx, rx) = watch::channel(false); + signal_senders.insert(node.node.id, tx); + signal_receivers.insert(node.node.id, rx); + } + + // Spawn tasks + let mut join_set: JoinSet = JoinSet::new(); + + for resolved in &plan.nodes { + let node = resolved.node.clone(); + let node_id = node.id; + let upstream_ids = resolved.upstream_ids.clone(); + + let upstream_watches: Vec> = upstream_ids + .iter() + .filter_map(|id| signal_receivers.get(id).cloned()) + .collect(); + + let completion_tx = signal_senders.remove(&node_id); + let node_senders = senders.remove(&node_id).unwrap_or_default(); + let node_receivers = receivers.remove(&node_id).unwrap_or_default(); + let conns = Arc::clone(&connections); + + join_set.spawn(async move { + // Wait for upstream nodes to complete + for mut rx in upstream_watches { + let _ = rx.wait_for(|&done| done).await; + } + + let result = execute_node(&node, node_senders, node_receivers, &conns).await; + + // Signal completion + if let Some(tx) = completion_tx { + let _ = tx.send(true); + } + + match result { + Ok(count) => NodeOutput { + node_id, + items_processed: count, + error: None, + }, + Err(e) => NodeOutput { + node_id, + items_processed: 0, + error: Some(e.to_string()), + }, + } + }); + } + + // Collect results + let mut node_results = Vec::new(); + while let Some(result) = join_set.join_next().await { + match result { + Ok(nr) => node_results.push(nr), + Err(e) => node_results.push(NodeOutput { + node_id: Uuid::nil(), + items_processed: 0, + error: Some(format!("Task panicked: {}", e)), + }), + } + } + + let success = node_results.iter().all(|r| r.error.is_none()); + + Ok(RunOutput { + run_id, + node_results, + success, + }) + } +} + impl Engine for DefaultEngine { async fn run(&self, input: EngineInput) -> Result { let run_id = Uuid::new_v4(); @@ -128,7 +238,7 @@ impl Engine for DefaultEngine { // Compile the graph into a topologically-sorted execution plan and // run Source/Action/Target nodes concurrently. let plan = Compiler::new().compile(&input.graph)?; - let run_output = self.run_graph(&plan, &input.connections).await?; + let run_output = Self::run_graph(&plan, &input.connections).await?; // Emit a detection audit entry for the overall run. audits.push(Audit { @@ -153,12 +263,4 @@ impl Engine for DefaultEngine { run_output, }) } - - async fn run_graph( - &self, - plan: &ExecutionPlan, - connections: &Connections, - ) -> Result { - executor::run_graph(plan, connections).await - } } diff --git a/crates/nvisy-engine/src/engine/executor.rs b/crates/nvisy-engine/src/engine/executor.rs index e5efb1e..54d694b 100644 --- a/crates/nvisy-engine/src/engine/executor.rs +++ b/crates/nvisy-engine/src/engine/executor.rs @@ -1,8 +1,4 @@ -//! Graph runner that executes a compiled [`ExecutionPlan`]. -//! -//! Each node is spawned as a concurrent Tokio task. Data flows between nodes -//! via bounded MPSC channels, and upstream completion is signalled via watch -//! channels so downstream tasks wait before starting. +//! Node-level execution dispatchers. //! //! [`execute_node`] dispatches to variant-specific handlers: //! @@ -12,23 +8,16 @@ //! | `Action` | Receives upstream data, applies a transformation, and forwards results. | //! | `Target` | Receives upstream data and writes it to an external connection. | -use std::collections::HashMap; -use std::sync::Arc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, watch}; -use tokio::task::JoinSet; +use tokio::sync::mpsc; use uuid::Uuid; use nvisy_core::io::ContentData; use nvisy_core::{Error, ErrorKind}; -use crate::compiler::plan::ExecutionPlan; -use crate::compiler::graph::{ActionKind, GraphNode, GraphNodeKind, TimeoutBehavior}; -use crate::compiler::RetryPolicy; +use crate::compiler::graph::{ActionKind, GraphNode, GraphNodeKind}; +use crate::compiler::{RetryPolicy, TimeoutBehavior}; use super::connections::{Connection, Connections}; -use super::policies::{with_retry, with_timeout}; - -/// Default buffer size for bounded inter-node MPSC channels. -const CHANNEL_BUFFER_SIZE: usize = 256; +use super::policy::{CompiledRetryPolicy, CompiledTimeoutPolicy}; /// Outcome of executing a single node in the pipeline. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -52,108 +41,6 @@ pub struct RunOutput { pub success: bool, } -/// Executes a compiled [`ExecutionPlan`] by spawning concurrent tasks for each node. -/// -/// Returns a [`RunOutput`] containing per-node outcomes and an overall success flag. -pub(crate) async fn run_graph( - plan: &ExecutionPlan, - connections: &Connections, -) -> Result { - let run_id = Uuid::new_v4(); - let connections = Arc::new(connections.clone()); - - // Create channels for each edge - let mut senders: HashMap>> = HashMap::new(); - let mut receivers: HashMap>> = HashMap::new(); - - for node in &plan.nodes { - let node_id = node.node.id; - for downstream_id in &node.downstream_ids { - let (tx, rx) = mpsc::channel(CHANNEL_BUFFER_SIZE); - senders.entry(node_id).or_default().push(tx); - receivers.entry(*downstream_id).or_default().push(rx); - } - } - - // Create completion signals per node - let mut signal_senders: HashMap> = HashMap::new(); - let mut signal_receivers: HashMap> = HashMap::new(); - - for node in &plan.nodes { - let (tx, rx) = watch::channel(false); - signal_senders.insert(node.node.id, tx); - signal_receivers.insert(node.node.id, rx); - } - - // Spawn tasks - let mut join_set: JoinSet = JoinSet::new(); - - for resolved in &plan.nodes { - let node = resolved.node.clone(); - let node_id = node.id; - let upstream_ids = resolved.upstream_ids.clone(); - - // Collect upstream watch receivers - let upstream_watches: Vec> = upstream_ids - .iter() - .filter_map(|id| signal_receivers.get(id).cloned()) - .collect(); - - let completion_tx = signal_senders.remove(&node_id); - let node_senders = senders.remove(&node_id).unwrap_or_default(); - let node_receivers = receivers.remove(&node_id).unwrap_or_default(); - let conns = Arc::clone(&connections); - - join_set.spawn(async move { - // Wait for upstream nodes to complete - for mut rx in upstream_watches { - let _ = rx.wait_for(|&done| done).await; - } - - let result = execute_node(&node, node_senders, node_receivers, &conns).await; - - // Signal completion - if let Some(tx) = completion_tx { - let _ = tx.send(true); - } - - match result { - Ok(count) => NodeOutput { - node_id, - items_processed: count, - error: None, - }, - Err(e) => NodeOutput { - node_id, - items_processed: 0, - error: Some(e.to_string()), - }, - } - }); - } - - // Collect results - let mut node_results = Vec::new(); - while let Some(result) = join_set.join_next().await { - match result { - Ok(nr) => node_results.push(nr), - Err(e) => node_results.push(NodeOutput { - node_id: Uuid::nil(), - items_processed: 0, - error: Some(format!("Task panicked: {}", e)), - }), - } - } - - let success = node_results.iter().all(|r| r.error.is_none()); - - Ok(RunOutput { - run_id, - node_results, - success, - }) -} - /// Execute a single node, dispatching to the correct handler based on the /// [`GraphNodeKind`] variant. /// @@ -162,7 +49,7 @@ pub(crate) async fn run_graph( /// yields zero items (`Skip`). Retry policies are applied within the /// individual source/target handlers where the retryable I/O actually /// occurs (channel consumption is not retryable). -async fn execute_node( +pub(crate) async fn execute_node( node: &GraphNode, senders: Vec>, mut receivers: Vec>, @@ -191,7 +78,8 @@ async fn execute_node( // Apply per-node timeout when configured. match node.timeout() { Some(policy) => { - let result = with_timeout(policy.duration_ms, run).await; + let compiled = CompiledTimeoutPolicy::from(policy); + let result = compiled.with_timeout(run).await; match (&result, &policy.on_timeout) { (Err(e), TimeoutBehavior::Skip) if e.kind == ErrorKind::Timeout => Ok(0), _ => result, @@ -240,7 +128,9 @@ async fn execute_source( }; let count = match retry { - Some(policy) => with_retry(policy, read_from_provider).await?, + Some(policy) => { + CompiledRetryPolicy::from(policy).with_retry(read_from_provider).await? + } None => read_from_provider().await?, }; diff --git a/crates/nvisy-engine/src/engine/mod.rs b/crates/nvisy-engine/src/engine/mod.rs index a159b40..f19a085 100644 --- a/crates/nvisy-engine/src/engine/mod.rs +++ b/crates/nvisy-engine/src/engine/mod.rs @@ -7,17 +7,20 @@ //! [`DefaultEngine`] is the standard implementation that orchestrates the //! detect -> evaluate -> redact pipeline and drives the DAG execution graph. -pub mod connections; +mod connections; mod default; -pub mod executor; -pub mod ontology; -pub mod policies; -pub mod runs; +mod executor; +mod ontology; +mod runs; + +pub mod policy; pub use connections::{Connection, Connections}; pub use default::DefaultEngine; pub use executor::{NodeOutput, RunOutput}; -pub use runs::{RunManager, RunState, RunStatus, RunSummary}; +pub use ontology::{Explainable, Explanation}; +pub use policy::{CompiledRetryPolicy, CompiledTimeoutPolicy}; +pub use runs::{NodeProgress, RunManager, RunState, RunStatus, RunSummary}; use std::future::Future; @@ -33,7 +36,6 @@ pub use nvisy_identify::{ }; use crate::compiler::graph::Graph; -use crate::compiler::plan::ExecutionPlan; /// Everything the caller must provide to run a redaction pipeline. pub struct EngineInput { @@ -81,11 +83,4 @@ pub trait Engine: Send + Sync { &self, input: EngineInput, ) -> impl Future> + Send; - - /// Execute a compiled [`ExecutionPlan`] against the given connections. - fn run_graph( - &self, - plan: &ExecutionPlan, - connections: &Connections, - ) -> impl Future> + Send; } diff --git a/crates/nvisy-engine/src/engine/policies.rs b/crates/nvisy-engine/src/engine/policies.rs deleted file mode 100644 index 68b0319..0000000 --- a/crates/nvisy-engine/src/engine/policies.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Retry and timeout policies for pipeline execution. -//! -//! Provides [`compute_delay`] for backoff calculation, [`with_retry`] for -//! automatic retry of fallible futures, and [`with_timeout`] for deadline -//! enforcement. - -use std::time::Duration; -use tokio::time; -use nvisy_core::Error; - -use crate::compiler::{BackoffStrategy, RetryPolicy}; - -/// Computes the sleep duration before a retry attempt based on the policy's -/// [`BackoffStrategy`] and the zero-based attempt number. -pub fn compute_delay(policy: &RetryPolicy, attempt: u32) -> Duration { - let base = Duration::from_millis(policy.delay_ms); - match policy.backoff { - BackoffStrategy::Fixed => base, - BackoffStrategy::Exponential => base * 2u32.saturating_pow(attempt), - BackoffStrategy::Jitter => { - let exp = base * 2u32.saturating_pow(attempt); - let jitter_range = exp.as_millis() as u64 + 1; - let jitter = Duration::from_millis(rand::random_range(0..jitter_range)); - exp + jitter - } - } -} - -/// Executes a fallible async closure with automatic retry according to the -/// given [`RetryPolicy`]. -/// -/// The closure is invoked up to `max_retries + 1` times. Non-retryable errors -/// (as determined by [`Error::is_retryable`]) are returned immediately. -pub async fn with_retry( - policy: &RetryPolicy, - mut f: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - let mut last_err = None; - for attempt in 0..=policy.max_retries { - match f().await { - Ok(v) => return Ok(v), - Err(e) => { - if !e.is_retryable() || attempt == policy.max_retries { - return Err(e); - } - last_err = Some(e); - let delay = compute_delay(policy, attempt); - time::sleep(delay).await; - } - } - } - Err(last_err.unwrap_or_else(|| Error::runtime("Retry exhausted", "policies", false))) -} - -/// Wraps a future with a deadline, returning an [`Error::timeout`] if it -/// does not complete within `timeout_ms` milliseconds. -pub async fn with_timeout( - timeout_ms: u64, - f: F, -) -> Result -where - F: std::future::Future>, -{ - match time::timeout(Duration::from_millis(timeout_ms), f).await { - Ok(result) => result, - Err(_) => Err(Error::timeout(format!( - "Operation timed out after {}ms", - timeout_ms - ))), - } -} diff --git a/crates/nvisy-engine/src/engine/policy/mod.rs b/crates/nvisy-engine/src/engine/policy/mod.rs new file mode 100644 index 0000000..5fd868a --- /dev/null +++ b/crates/nvisy-engine/src/engine/policy/mod.rs @@ -0,0 +1,13 @@ +//! Compiled runtime policies and execution helpers. +//! +//! The compiler-level [`RetryPolicy`](crate::compiler::RetryPolicy) and +//! [`TimeoutPolicy`](crate::compiler::TimeoutPolicy) are user-facing +//! configuration types. This module provides their compiled runtime +//! counterparts ([`CompiledRetryPolicy`], [`CompiledTimeoutPolicy`]) +//! with `with_retry` and `with_timeout` methods. + +mod retry; +mod timeout; + +pub use retry::CompiledRetryPolicy; +pub use timeout::CompiledTimeoutPolicy; diff --git a/crates/nvisy-engine/src/engine/policy/retry.rs b/crates/nvisy-engine/src/engine/policy/retry.rs new file mode 100644 index 0000000..d61e4b6 --- /dev/null +++ b/crates/nvisy-engine/src/engine/policy/retry.rs @@ -0,0 +1,76 @@ +//! Compiled retry policy and execution helper. + +use std::time::Duration; + +use tokio::time; + +use nvisy_core::Error; + +use crate::compiler::{BackoffStrategy, RetryPolicy}; + +/// Pre-compiled retry policy ready for runtime use. +/// +/// Converts the user-facing [`RetryPolicy`] (raw milliseconds) into a +/// runtime representation with a [`Duration`] base delay. +#[derive(Debug, Clone)] +pub struct CompiledRetryPolicy { + /// Maximum number of retry attempts after the initial failure. + pub max_retries: u32, + /// Base delay between retry attempts. + pub base_delay: Duration, + /// Strategy used to compute the delay between successive retries. + pub backoff: BackoffStrategy, +} + +impl From<&RetryPolicy> for CompiledRetryPolicy { + fn from(policy: &RetryPolicy) -> Self { + Self { + max_retries: policy.max_retries, + base_delay: Duration::from_millis(policy.delay_ms), + backoff: policy.backoff, + } + } +} + +impl CompiledRetryPolicy { + /// Computes the sleep duration for a given zero-based attempt number. + pub fn compute_delay(&self, attempt: u32) -> Duration { + match self.backoff { + BackoffStrategy::Fixed => self.base_delay, + BackoffStrategy::Exponential => self.base_delay * 2u32.saturating_pow(attempt), + BackoffStrategy::Jitter => { + let exp = self.base_delay * 2u32.saturating_pow(attempt); + let jitter_range = exp.as_millis() as u64 + 1; + let jitter = Duration::from_millis(rand::random_range(0..jitter_range)); + exp + jitter + } + } + } + + /// Executes a fallible async closure with automatic retry. + /// + /// The closure is invoked up to `max_retries + 1` times. Non-retryable + /// errors (as determined by [`Error::is_retryable`]) are returned + /// immediately. + pub async fn with_retry(&self, mut f: F) -> Result + where + F: FnMut() -> Fut, + Fut: std::future::Future>, + { + let mut last_err = None; + for attempt in 0..=self.max_retries { + match f().await { + Ok(v) => return Ok(v), + Err(e) => { + if !e.is_retryable() || attempt == self.max_retries { + return Err(e); + } + last_err = Some(e); + let delay = self.compute_delay(attempt); + time::sleep(delay).await; + } + } + } + Err(last_err.unwrap_or_else(|| Error::runtime("Retry exhausted", "policy", false))) + } +} diff --git a/crates/nvisy-engine/src/engine/policy/timeout.rs b/crates/nvisy-engine/src/engine/policy/timeout.rs new file mode 100644 index 0000000..afb8fe3 --- /dev/null +++ b/crates/nvisy-engine/src/engine/policy/timeout.rs @@ -0,0 +1,47 @@ +//! Compiled timeout policy and execution helper. + +use std::time::Duration; + +use tokio::time; + +use nvisy_core::Error; + +use crate::compiler::{TimeoutBehavior, TimeoutPolicy}; + +/// Pre-compiled timeout policy ready for runtime use. +/// +/// Converts the user-facing [`TimeoutPolicy`] (raw milliseconds) into a +/// runtime representation with a [`Duration`] deadline. +#[derive(Debug, Clone)] +pub struct CompiledTimeoutPolicy { + /// Maximum wall-clock time before the node is interrupted. + pub duration: Duration, + /// What to do when the timeout fires. + pub on_timeout: TimeoutBehavior, +} + +impl From<&TimeoutPolicy> for CompiledTimeoutPolicy { + fn from(policy: &TimeoutPolicy) -> Self { + Self { + duration: Duration::from_millis(policy.duration_ms), + on_timeout: policy.on_timeout, + } + } +} + +impl CompiledTimeoutPolicy { + /// Wraps a future with a deadline, returning an [`Error::timeout`] if it + /// does not complete within the configured duration. + pub async fn with_timeout(&self, f: F) -> Result + where + F: std::future::Future>, + { + match time::timeout(self.duration, f).await { + Ok(result) => result, + Err(_) => Err(Error::timeout(format!( + "Operation timed out after {}ms", + self.duration.as_millis(), + ))), + } + } +} diff --git a/crates/nvisy-engine/src/prelude.rs b/crates/nvisy-engine/src/prelude.rs index 403f1ea..f24cd4f 100644 --- a/crates/nvisy-engine/src/prelude.rs +++ b/crates/nvisy-engine/src/prelude.rs @@ -4,6 +4,7 @@ pub use crate::compiler::{ GraphEdge, GraphNode, GraphNodeKind, ResolvedNode, RetryPolicy, SourceNode, TargetNode, TimeoutBehavior, TimeoutPolicy, }; -pub use crate::engine::{DefaultEngine, Engine, EngineInput, EngineOutput}; -pub use crate::engine::{RunOutput}; -pub use crate::engine::{RunManager, RunState, RunStatus, RunSummary}; +pub use crate::engine::{ + CompiledRetryPolicy, CompiledTimeoutPolicy, DefaultEngine, Engine, EngineInput, EngineOutput, + RunManager, RunOutput, RunState, RunStatus, RunSummary, +};