From 3f3a441ce5cc59a3acec1b62ac307ab09c60791d Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Mon, 13 Apr 2026 06:02:28 +0200 Subject: [PATCH 1/7] refactor(ontology): rename math to primitive, add LanguageTag, rework Transcription, derive_more cleanup - Rename `math` module to `primitive` across workspace - Add `oxilangtag` dependency, create typed `LanguageTag` newtype for BCP-47 tags - Use `LanguageTag` in `Entity::language` and `Transcription::language` - Rework `Transcription`: remove `text` field, add `TranscriptSegment` with `time_span`, `speaker_id`, `confidence` for diarization support - Add `Transcription::text()` method to join segments - Replace manual impls with derive_more across ontology types: - `Annotations`: Deref, DerefMut, From, IntoIterator - `ContentSource`: Display - `ContentArtifacts`: From - `Contexts`: Deref, DerefMut, From - `ContextEntryData`: From - `Policies`: Deref, DerefMut - `RedactionMap`: Deref, DerefMut - `GraphNodeKind`: Display, From Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 10 ++ Cargo.toml | 3 +- .../src/handler/audio/audio_handler_macro.rs | 2 +- .../src/handler/image/image_handler_macro.rs | 2 +- .../src/handler/rich/pdf_handler.rs | 4 +- .../src/handler/rich/pdf_render.rs | 2 +- .../src/transform/audio/instruction.rs | 2 +- .../src/transform/image/instruction.rs | 2 +- crates/nvisy-codec/src/transform/image/ops.rs | 2 +- .../src/operation/deduplication/mod.rs | 4 +- .../src/operation/deduplication/span_size.rs | 2 +- .../src/operation/envelope/document.rs | 2 +- .../src/operation/extraction/speech.rs | 12 +- crates/nvisy-ontology/Cargo.toml | 3 +- crates/nvisy-ontology/src/artifacts/audio.rs | 112 +++++++++++++++++- crates/nvisy-ontology/src/artifacts/image.rs | 2 +- crates/nvisy-ontology/src/artifacts/mod.rs | 5 +- .../src/context/biometric/face.rs | 2 +- .../src/context/biometric/voice.rs | 2 +- .../src/context/document/signature.rs | 2 +- .../src/context/document/template.rs | 2 +- crates/nvisy-ontology/src/context/entry.rs | 3 +- .../src/context/geospatial/coordinates.rs | 2 +- .../src/context/geospatial/region.rs | 2 +- crates/nvisy-ontology/src/context/mod.rs | 31 ++--- .../src/context/reference/image.rs | 2 +- .../nvisy-ontology/src/entity/annotation.rs | 29 ++--- .../src/entity/location/audio.rs | 2 +- .../src/entity/location/image.rs | 2 +- .../nvisy-ontology/src/entity/location/mod.rs | 2 +- crates/nvisy-ontology/src/entity/mod.rs | 4 +- crates/nvisy-ontology/src/entity/source.rs | 12 +- crates/nvisy-ontology/src/lib.rs | 2 +- crates/nvisy-ontology/src/policy/mod.rs | 11 +- .../src/policy/strategy/image.rs | 2 +- .../src/{math => primitive}/bounding_box.rs | 0 .../{math => primitive}/bounding_box_pixel.rs | 0 .../src/{math => primitive}/color.rs | 0 .../src/{math => primitive}/dpi.rs | 0 .../src/primitive/language_tag.rs | 75 ++++++++++++ .../src/{math => primitive}/mod.rs | 8 +- .../src/{math => primitive}/polygon.rs | 0 .../src/{math => primitive}/time_span.rs | 0 .../src/provenance/redaction_map.rs | 21 +--- crates/nvisy-ontology/src/workflow/mod.rs | 39 +++--- crates/nvisy-provider/src/agent/ocr/input.rs | 2 +- crates/nvisy-provider/src/agent/ocr/output.rs | 2 +- crates/nvisy-provider/src/agent/ocr/prompt.rs | 2 +- .../src/ocr/provider/aws_textract/backend.rs | 2 +- .../src/ocr/provider/azure_docai/backend.rs | 2 +- .../src/ocr/provider/datalab_surya/backend.rs | 2 +- .../src/ocr/provider/google_vision/backend.rs | 2 +- .../ocr/provider/paddle_paddlex/backend.rs | 2 +- 53 files changed, 302 insertions(+), 142 deletions(-) rename crates/nvisy-ontology/src/{math => primitive}/bounding_box.rs (100%) rename crates/nvisy-ontology/src/{math => primitive}/bounding_box_pixel.rs (100%) rename crates/nvisy-ontology/src/{math => primitive}/color.rs (100%) rename crates/nvisy-ontology/src/{math => primitive}/dpi.rs (100%) create mode 100644 crates/nvisy-ontology/src/primitive/language_tag.rs rename crates/nvisy-ontology/src/{math => primitive}/mod.rs (59%) rename crates/nvisy-ontology/src/{math => primitive}/polygon.rs (100%) rename crates/nvisy-ontology/src/{math => primitive}/time_span.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index f1677324..bfc162ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3091,6 +3091,7 @@ dependencies = [ "derive_builder", "derive_more", "jiff", + "oxilangtag", "schemars 1.2.1", "semver", "serde", @@ -3226,6 +3227,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "oxilangtag" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f3f87617a86af77fa3691e6350483e7154c2ead9f1261b75130e21ca0f8acb" +dependencies = [ + "serde", +] + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/Cargo.toml b/Cargo.toml index 1bc978b8..0bda97b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ toml = { version = "1.1", features = [] } # Derive macros and error handling thiserror = { version = "2.0", features = [] } anyhow = { version = "1.0", features = [] } -derive_more = { version = "2.0", features = ["as_ref", "deref", "deref_mut", "display", "from", "into", "is_variant", "try_into"] } +derive_more = { version = "2.0", features = ["as_ref", "deref", "deref_mut", "display", "from", "from_str", "into", "into_iterator", "is_variant", "try_into"] } derive_builder = { version = "0.20", features = [] } strum = { version = "0.28", features = ["derive"] } @@ -83,6 +83,7 @@ bytes = { version = "1.0", features = ["serde"] } hipstr = { version = "0.8", features = [] } jiff = { version = "0.2", features = ["serde"] } semver = { version = "1.0", features = ["serde"] } +oxilangtag = { version = "0.1", features = ["serde"] } # Encoding and hashing base64 = { version = "0.22", features = [] } diff --git a/crates/nvisy-codec/src/handler/audio/audio_handler_macro.rs b/crates/nvisy-codec/src/handler/audio/audio_handler_macro.rs index 3d7e04aa..16d2cf3a 100644 --- a/crates/nvisy-codec/src/handler/audio/audio_handler_macro.rs +++ b/crates/nvisy-codec/src/handler/audio/audio_handler_macro.rs @@ -39,7 +39,7 @@ macro_rules! impl_audio_handler { // a placeholder. The actual time span is set by the // STT extraction operation after transcription. let location = nvisy_ontology::entity::AudioLocation { - time_span: nvisy_ontology::math::TimeSpan { + time_span: nvisy_ontology::primitive::TimeSpan { start_us: 0, end_us: 0, }, 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 7751811c..e055c1eb 100644 --- a/crates/nvisy-codec/src/handler/image/image_handler_macro.rs +++ b/crates/nvisy-codec/src/handler/image/image_handler_macro.rs @@ -37,7 +37,7 @@ macro_rules! impl_image_handler { > { let (w, h) = (self.image.width(), self.image.height()); let location = nvisy_ontology::entity::ImageLocation { - bounding_box: nvisy_ontology::math::BoundingBox { + bounding_box: nvisy_ontology::primitive::BoundingBox { x: 0.0, y: 0.0, width: w as f64, diff --git a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs index e9f82050..9c4a940a 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_handler.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_handler.rs @@ -19,7 +19,7 @@ use nvisy_core::Error; use nvisy_core::content::{ContentData, ContentSource}; use nvisy_core::media::DocumentType; use nvisy_ontology::entity::{ImageLocation, TextLocation}; -use nvisy_ontology::math::Dpi; +use nvisy_ontology::primitive::Dpi; use super::pdf_render::PdfRenderer; use crate::document::{Span, SpanStream}; @@ -270,7 +270,7 @@ impl ImageHandler for RichTextHandler { // the page requires PDF content stream parsing. For now, // use a full-page placeholder that identifies the page. let location = ImageLocation { - bounding_box: nvisy_ontology::math::BoundingBox::default(), + bounding_box: nvisy_ontology::primitive::BoundingBox::default(), image_id: None, page_number: Some((i + 1) as u32), }; diff --git a/crates/nvisy-codec/src/handler/rich/pdf_render.rs b/crates/nvisy-codec/src/handler/rich/pdf_render.rs index d240fd96..3b33d6eb 100644 --- a/crates/nvisy-codec/src/handler/rich/pdf_render.rs +++ b/crates/nvisy-codec/src/handler/rich/pdf_render.rs @@ -9,7 +9,7 @@ use std::cell::RefCell; use std::sync::LazyLock; use nvisy_core::Error; -use nvisy_ontology::math::Dpi; +use nvisy_ontology::primitive::Dpi; use pdfium_render::prelude::*; use crate::handler::image::ImageData; diff --git a/crates/nvisy-codec/src/transform/audio/instruction.rs b/crates/nvisy-codec/src/transform/audio/instruction.rs index c97da1d2..c14e334d 100644 --- a/crates/nvisy-codec/src/transform/audio/instruction.rs +++ b/crates/nvisy-codec/src/transform/audio/instruction.rs @@ -1,6 +1,6 @@ //! Audio redaction instruction types. -use nvisy_ontology::math::TimeSpan; +use nvisy_ontology::primitive::TimeSpan; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/nvisy-codec/src/transform/image/instruction.rs b/crates/nvisy-codec/src/transform/image/instruction.rs index 8aacc15d..b15ba5f0 100644 --- a/crates/nvisy-codec/src/transform/image/instruction.rs +++ b/crates/nvisy-codec/src/transform/image/instruction.rs @@ -1,6 +1,6 @@ //! Image redaction instruction types. -use nvisy_ontology::math::{BoundingBox, Color}; +use nvisy_ontology::primitive::{BoundingBox, Color}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/nvisy-codec/src/transform/image/ops.rs b/crates/nvisy-codec/src/transform/image/ops.rs index af7c416f..7c5b1a07 100644 --- a/crates/nvisy-codec/src/transform/image/ops.rs +++ b/crates/nvisy-codec/src/transform/image/ops.rs @@ -6,7 +6,7 @@ use image::DynamicImage; use image::imageops::FilterType; use imageproc::filter::gaussian_blur_f32; -use nvisy_ontology::math::{BoundingBoxPixel, Color}; +use nvisy_ontology::primitive::{BoundingBoxPixel, Color}; /// Mutating image-transform operations on individual bounding-box regions. pub trait ImageOps { diff --git a/crates/nvisy-engine/src/operation/deduplication/mod.rs b/crates/nvisy-engine/src/operation/deduplication/mod.rs index 9960b109..4df237f1 100644 --- a/crates/nvisy-engine/src/operation/deduplication/mod.rs +++ b/crates/nvisy-engine/src/operation/deduplication/mod.rs @@ -401,13 +401,13 @@ mod tests { .with_confidence(0.7) .test_build(); e1.language = None; - e2.language = Some("en".into()); + e2.language = Some("en".parse().unwrap()); let entities: Entities = vec![e1, e2].into(); let result = MaxConfidence .fuse(entities, GroupingCriteria::default(), &doc) .await; - assert_eq!(result[0].language.as_deref(), Some("en")); + assert_eq!(result[0].language.as_ref().map(|t| t.as_str()), Some("en")); } #[tokio::test] diff --git a/crates/nvisy-engine/src/operation/deduplication/span_size.rs b/crates/nvisy-engine/src/operation/deduplication/span_size.rs index 6ce2e42f..897c921b 100644 --- a/crates/nvisy-engine/src/operation/deduplication/span_size.rs +++ b/crates/nvisy-engine/src/operation/deduplication/span_size.rs @@ -59,7 +59,7 @@ impl SpanSize for Location { #[cfg(test)] mod tests { use nvisy_ontology::entity::{AudioLocation, ImageLocation, TabularLocation, TextLocation}; - use nvisy_ontology::math::{BoundingBox, TimeSpan}; + use nvisy_ontology::primitive::{BoundingBox, TimeSpan}; use super::*; diff --git a/crates/nvisy-engine/src/operation/envelope/document.rs b/crates/nvisy-engine/src/operation/envelope/document.rs index 5235878d..06db7b69 100644 --- a/crates/nvisy-engine/src/operation/envelope/document.rs +++ b/crates/nvisy-engine/src/operation/envelope/document.rs @@ -146,7 +146,7 @@ impl Document { .artifacts .as_audio() .and_then(|a| a.transcription.as_ref()) - .map(|t| t.text.clone()), + .map(|t| t.text()), // Image OCR results are multi-region; location-specific // lookup is not yet implemented. _ => None, diff --git a/crates/nvisy-engine/src/operation/extraction/speech.rs b/crates/nvisy-engine/src/operation/extraction/speech.rs index 2f8bad07..c067269b 100644 --- a/crates/nvisy-engine/src/operation/extraction/speech.rs +++ b/crates/nvisy-engine/src/operation/extraction/speech.rs @@ -5,7 +5,8 @@ use nvisy_codec::ContentHandle; use nvisy_codec::handler::{BoxedTextHandler, Handler, TxtHandler}; use nvisy_core::{Error, ErrorKind, Result}; -use nvisy_ontology::artifacts::Transcription; +use nvisy_ontology::artifacts::{TranscriptSegment, Transcription}; +use nvisy_ontology::primitive::TimeSpan; use nvisy_ontology::workflow::AudialExtraction as AudialExtractionCfg; use nvisy_provider::audio::stt::{SttConfig, SttService}; use nvisy_provider::http::HttpClient; @@ -73,9 +74,16 @@ impl Operation for AudialExtractionOp { tracing::debug!(target: TARGET, "transcription returned empty text"); } else { // Store transcription in artifacts. + // TODO: populate real segment timestamps once the STT provider + // returns verbose_json with timestamp_granularities. if let Some(audio) = envelope.document.artifacts.as_audio_mut() { audio.transcription = Some(Transcription { - text: stt_result.text.clone(), + segments: vec![TranscriptSegment { + text: stt_result.text.clone(), + time_span: TimeSpan::new(0, 0), + speaker_id: None, + confidence: None, + }], language: None, }); } diff --git a/crates/nvisy-ontology/Cargo.toml b/crates/nvisy-ontology/Cargo.toml index f4e4de8e..59a6205a 100644 --- a/crates/nvisy-ontology/Cargo.toml +++ b/crates/nvisy-ontology/Cargo.toml @@ -35,7 +35,7 @@ schemars = { workspace = true, features = [] } # Derive macros and error handling thiserror = { workspace = true, features = [] } derive_builder = { workspace = true, features = [] } -derive_more = { workspace = true, features = ["deref", "deref_mut", "display", "from", "into", "into_iterator"] } +derive_more = { workspace = true, features = ["deref", "deref_mut", "display", "from", "from_str", "into", "into_iterator"] } strum = { workspace = true, features = [] } validator = { workspace = true, features = [] } @@ -43,3 +43,4 @@ validator = { workspace = true, features = [] } uuid = { workspace = true, features = [] } jiff = { workspace = true, features = [] } semver = { workspace = true, features = [] } +oxilangtag = { workspace = true, features = [] } diff --git a/crates/nvisy-ontology/src/artifacts/audio.rs b/crates/nvisy-ontology/src/artifacts/audio.rs index a0dbdc11..84029cd9 100644 --- a/crates/nvisy-ontology/src/artifacts/audio.rs +++ b/crates/nvisy-ontology/src/artifacts/audio.rs @@ -3,16 +3,57 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::primitive::{LanguageTag, TimeSpan}; + +/// A single timestamped segment within a transcription. +#[derive(Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptSegment { + /// The transcribed text for this segment. + pub text: String, + /// Time interval of this segment within the audio stream. + pub time_span: TimeSpan, + /// Speaker identifier from diarization, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub speaker_id: Option, + /// Confidence score for this segment in the range `[0.0, 1.0]`. + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, +} + /// Full transcript produced by speech-to-text extraction. #[derive(Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct Transcription { - /// The transcribed text. - pub text: String, + /// Timestamped segments composing the full transcript. + pub segments: Vec, /// BCP-47 language tag of the detected language, if available. #[serde(skip_serializing_if = "Option::is_none")] - pub language: Option, + #[schemars(with = "Option")] + pub language: Option, +} + +impl Transcription { + /// Returns the full transcript text by joining all segments. + pub fn text(&self) -> String { + self.segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + } + + /// Returns the total time span covering all segments, or `None` if empty. + pub fn time_span(&self) -> Option { + let first = self.segments.first()?; + let last = self.segments.last()?; + Some(TimeSpan::new( + first.time_span.start_us, + last.time_span.end_us, + )) + } } /// Artifacts produced during processing of audio content. @@ -24,3 +65,68 @@ pub struct AudioArtifacts { #[serde(skip_serializing_if = "Option::is_none")] pub transcription: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_transcription() -> Transcription { + Transcription { + segments: vec![ + TranscriptSegment { + text: "Hello".to_owned(), + time_span: TimeSpan::from_secs(0.0, 1.0), + speaker_id: Some("speaker_1".to_owned()), + confidence: Some(0.95), + }, + TranscriptSegment { + text: "world".to_owned(), + time_span: TimeSpan::from_secs(1.0, 2.0), + speaker_id: Some("speaker_2".to_owned()), + confidence: Some(0.90), + }, + ], + language: Some("en".parse().unwrap()), + } + } + + #[test] + fn text_joins_segments() { + let t = sample_transcription(); + assert_eq!(t.text(), "Hello world"); + } + + #[test] + fn text_empty_segments() { + let t = Transcription { + segments: vec![], + language: None, + }; + assert_eq!(t.text(), ""); + } + + #[test] + fn time_span_covers_all_segments() { + let t = sample_transcription(); + let span = t.time_span().unwrap(); + assert_eq!(span.start_us, 0); + assert_eq!(span.end_us, 2_000_000); + } + + #[test] + fn time_span_empty() { + let t = Transcription { + segments: vec![], + language: None, + }; + assert!(t.time_span().is_none()); + } + + #[test] + fn serde_roundtrip() { + let t = sample_transcription(); + let json = serde_json::to_string(&t).unwrap(); + let back: Transcription = serde_json::from_str(&json).unwrap(); + assert_eq!(t, back); + } +} diff --git a/crates/nvisy-ontology/src/artifacts/image.rs b/crates/nvisy-ontology/src/artifacts/image.rs index 99631bfd..4c7fa294 100644 --- a/crates/nvisy-ontology/src/artifacts/image.rs +++ b/crates/nvisy-ontology/src/artifacts/image.rs @@ -3,7 +3,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::math::{BoundingBox, Polygon}; +use crate::primitive::{BoundingBox, Polygon}; /// A single word detected by OCR. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/artifacts/mod.rs b/crates/nvisy-ontology/src/artifacts/mod.rs index 3661ff59..90c99ea3 100644 --- a/crates/nvisy-ontology/src/artifacts/mod.rs +++ b/crates/nvisy-ontology/src/artifacts/mod.rs @@ -10,10 +10,11 @@ mod rich; mod tabular; mod text; +use derive_more::From; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -pub use self::audio::{AudioArtifacts, Transcription}; +pub use self::audio::{AudioArtifacts, TranscriptSegment, Transcription}; pub use self::image::{Block, BlockKind, ImageArtifacts, Line, Page, Word}; pub use self::rich::RichArtifacts; pub use self::tabular::TabularArtifacts; @@ -24,7 +25,7 @@ pub use self::text::TextArtifacts; /// Each variant matches a content modality and holds only the artifact /// types that make sense for that modality (e.g. transcription is only /// available on audio, OCR only on image/rich). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, From, Serialize, Deserialize, JsonSchema)] #[serde(tag = "modality", rename_all = "snake_case")] pub enum ContentArtifacts { /// Artifacts from text content processing. diff --git a/crates/nvisy-ontology/src/context/biometric/face.rs b/crates/nvisy-ontology/src/context/biometric/face.rs index 90481289..670c6a89 100644 --- a/crates/nvisy-ontology/src/context/biometric/face.rs +++ b/crates/nvisy-ontology/src/context/biometric/face.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::entity::ContentSource; -use crate::math::BoundingBox; +use crate::primitive::BoundingBox; /// Reference face data for identity matching. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/biometric/voice.rs b/crates/nvisy-ontology/src/context/biometric/voice.rs index cc8122a4..d4879124 100644 --- a/crates/nvisy-ontology/src/context/biometric/voice.rs +++ b/crates/nvisy-ontology/src/context/biometric/voice.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::entity::ContentSource; -use crate::math::TimeSpan; +use crate::primitive::TimeSpan; /// Reference voice data for speaker identification. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/document/signature.rs b/crates/nvisy-ontology/src/context/document/signature.rs index 7f3192d6..126448b0 100644 --- a/crates/nvisy-ontology/src/context/document/signature.rs +++ b/crates/nvisy-ontology/src/context/document/signature.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::entity::ContentSource; -use crate::math::BoundingBox; +use crate::primitive::BoundingBox; /// Reference handwritten signature for verification. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/document/template.rs b/crates/nvisy-ontology/src/context/document/template.rs index 18d88504..a661f189 100644 --- a/crates/nvisy-ontology/src/context/document/template.rs +++ b/crates/nvisy-ontology/src/context/document/template.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::entity::ContentSource; -use crate::math::BoundingBox; +use crate::primitive::BoundingBox; /// Reference document template for layout/type classification. /// diff --git a/crates/nvisy-ontology/src/context/entry.rs b/crates/nvisy-ontology/src/context/entry.rs index d01725d8..e6bf91cd 100644 --- a/crates/nvisy-ontology/src/context/entry.rs +++ b/crates/nvisy-ontology/src/context/entry.rs @@ -1,5 +1,6 @@ //! Context entry types for reference data. +use derive_more::From; use jiff::Timestamp; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -16,7 +17,7 @@ use super::temporal::TemporalVariant; /// /// Each domain contains a nested enum of specific variants, /// keeping modality and semantic purpose cleanly separated. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, From, Serialize, Deserialize, JsonSchema)] #[serde(tag = "domain", content = "data", rename_all = "snake_case")] #[non_exhaustive] pub enum ContextEntryData { diff --git a/crates/nvisy-ontology/src/context/geospatial/coordinates.rs b/crates/nvisy-ontology/src/context/geospatial/coordinates.rs index 0b2d5e51..dac84e1c 100644 --- a/crates/nvisy-ontology/src/context/geospatial/coordinates.rs +++ b/crates/nvisy-ontology/src/context/geospatial/coordinates.rs @@ -3,7 +3,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::math::Vertex; +use crate::primitive::Vertex; /// A geographic coordinate (latitude/longitude). #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/geospatial/region.rs b/crates/nvisy-ontology/src/context/geospatial/region.rs index d23432f5..a8a820b2 100644 --- a/crates/nvisy-ontology/src/context/geospatial/region.rs +++ b/crates/nvisy-ontology/src/context/geospatial/region.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::coordinates::GeoCoordinate; -use crate::math::Polygon; +use crate::primitive::Polygon; /// A geographic bounding box defined by its south-west and north-east corners. #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/mod.rs b/crates/nvisy-ontology/src/context/mod.rs index 750b2dcb..be34de4e 100644 --- a/crates/nvisy-ontology/src/context/mod.rs +++ b/crates/nvisy-ontology/src/context/mod.rs @@ -13,6 +13,7 @@ pub mod reference; pub mod temporal; use derive_builder::Builder; +use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; @@ -23,39 +24,21 @@ pub use self::entry::{ContextEntry, ContextEntryData}; /// Lightweight set of context references carried by each document envelope. /// /// Each UUID points to a [`Context`] in the engine's context cache. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, Deref, DerefMut, From)] +#[derive(Serialize, Deserialize, JsonSchema)] pub struct Contexts(Vec); impl Contexts { pub fn new() -> Self { - Self(Vec::new()) - } - - pub fn from_ids(ids: Vec) -> Self { - Self(ids) + Self::default() } + /// Add a context ID, deduplicating. pub fn push(&mut self, id: Uuid) { if !self.0.contains(&id) { self.0.push(id); } } - - pub fn ids(&self) -> &[Uuid] { - &self.0 - } - - pub fn len(&self) -> usize { - self.0.len() - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - pub fn contains(&self, id: &Uuid) -> bool { - self.0.contains(id) - } } #[cfg(test)] @@ -82,9 +65,9 @@ mod tests { #[test] fn contains_and_ids() { let id = Uuid::now_v7(); - let ctx = Contexts::from_ids(vec![id]); + let ctx = Contexts::from(vec![id]); assert!(ctx.contains(&id)); - assert_eq!(ctx.ids(), &[id]); + assert_eq!(&*ctx, &[id]); } #[test] diff --git a/crates/nvisy-ontology/src/context/reference/image.rs b/crates/nvisy-ontology/src/context/reference/image.rs index b86b42ba..6b90d32b 100644 --- a/crates/nvisy-ontology/src/context/reference/image.rs +++ b/crates/nvisy-ontology/src/context/reference/image.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::entity::ContentSource; -use crate::math::BoundingBox; +use crate::primitive::BoundingBox; /// Reference image for object/scene matching. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/entity/annotation.rs b/crates/nvisy-ontology/src/entity/annotation.rs index 54866784..307c1d5b 100644 --- a/crates/nvisy-ontology/src/entity/annotation.rs +++ b/crates/nvisy-ontology/src/entity/annotation.rs @@ -1,5 +1,6 @@ //! Annotation types for pre-identified regions and classification labels. +use derive_more::{Deref, DerefMut, From, IntoIterator}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -59,7 +60,9 @@ pub struct Annotation { } /// A collection of [`Annotation`]s. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Deref, DerefMut, From, IntoIterator)] +#[derive(Serialize, Deserialize, JsonSchema)] pub struct Annotations(Vec); impl Annotations { @@ -68,26 +71,14 @@ impl Annotations { Self::default() } - /// Number of annotations. - pub fn len(&self) -> usize { - self.0.len() - } - /// Returns `true` if the collection is empty. + /// + /// Provided as an inherent method so it can be used with + /// `#[serde(skip_serializing_if)]`. pub fn is_empty(&self) -> bool { self.0.is_empty() } - /// Iterate over all annotations. - pub fn iter(&self) -> std::slice::Iter<'_, Annotation> { - self.0.iter() - } - - /// Add an annotation. - pub fn push(&mut self, annotation: Annotation) { - self.0.push(annotation); - } - /// All document-level label names. pub fn document_labels(&self) -> Vec<&str> { self.0 @@ -168,12 +159,6 @@ impl Annotations { } } -impl From> for Annotations { - fn from(v: Vec) -> Self { - Self(v) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/nvisy-ontology/src/entity/location/audio.rs b/crates/nvisy-ontology/src/entity/location/audio.rs index fc69b19f..49005bf9 100644 --- a/crates/nvisy-ontology/src/entity/location/audio.rs +++ b/crates/nvisy-ontology/src/entity/location/audio.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::Overlap; -use crate::math::TimeSpan; +use crate::primitive::TimeSpan; /// Location of an entity within an audio stream. #[derive(Debug, Clone, PartialEq, Builder)] diff --git a/crates/nvisy-ontology/src/entity/location/image.rs b/crates/nvisy-ontology/src/entity/location/image.rs index 7bab91dc..72312506 100644 --- a/crates/nvisy-ontology/src/entity/location/image.rs +++ b/crates/nvisy-ontology/src/entity/location/image.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::Overlap; -use crate::math::BoundingBox; +use crate::primitive::BoundingBox; /// Location of an entity within an image. #[derive(Debug, Clone, PartialEq, Builder)] diff --git a/crates/nvisy-ontology/src/entity/location/mod.rs b/crates/nvisy-ontology/src/entity/location/mod.rs index 9f615d97..fc4408b8 100644 --- a/crates/nvisy-ontology/src/entity/location/mod.rs +++ b/crates/nvisy-ontology/src/entity/location/mod.rs @@ -120,7 +120,7 @@ impl Overlap for Location { #[cfg(test)] mod tests { use super::*; - use crate::math::{BoundingBox, TimeSpan}; + use crate::primitive::{BoundingBox, TimeSpan}; fn text(start: usize, end: usize) -> Location { Location::Text( diff --git a/crates/nvisy-ontology/src/entity/mod.rs b/crates/nvisy-ontology/src/entity/mod.rs index 1a8ffd61..47c0a014 100644 --- a/crates/nvisy-ontology/src/entity/mod.rs +++ b/crates/nvisy-ontology/src/entity/mod.rs @@ -31,6 +31,7 @@ pub use self::method::{ }; pub use self::sensitivity::EntitySensitivity; pub use self::source::ContentSource; +use crate::primitive::LanguageTag; /// A detected sensitive data occurrence within a document. #[derive(Debug, Clone, PartialEq, Builder)] @@ -66,7 +67,8 @@ pub struct Entity { /// BCP-47 language tag of the detected content. #[builder(default, setter(into = false))] #[serde(skip_serializing_if = "Option::is_none")] - pub language: Option, + #[schemars(with = "Option")] + pub language: Option, /// Sensitivity classification of this entity. #[builder(default, setter(into = false))] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/nvisy-ontology/src/entity/source.rs b/crates/nvisy-ontology/src/entity/source.rs index 544f78fb..9a354221 100644 --- a/crates/nvisy-ontology/src/entity/source.rs +++ b/crates/nvisy-ontology/src/entity/source.rs @@ -1,7 +1,6 @@ //! Content source identity and lineage. -use std::fmt; - +use derive_more::Display; use jiff::Zoned; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -12,7 +11,8 @@ use uuid::Uuid; /// Uses `UUIDv7` for time-ordered, globally unique identification of data /// sources. Tracks parent lineage for provenance. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, Deserialize, JsonSchema, Display)] +#[display("{id}")] pub struct ContentSource { id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] @@ -110,12 +110,6 @@ impl Default for ContentSource { } } -impl fmt::Display for ContentSource { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.id) - } -} - impl From for ContentSource { fn from(id: Uuid) -> Self { Self::from_uuid(id) diff --git a/crates/nvisy-ontology/src/lib.rs b/crates/nvisy-ontology/src/lib.rs index 37edef57..70a68efe 100644 --- a/crates/nvisy-ontology/src/lib.rs +++ b/crates/nvisy-ontology/src/lib.rs @@ -5,7 +5,7 @@ pub mod artifacts; pub mod context; pub mod entity; -pub mod math; +pub mod primitive; mod error; pub use self::error::{Error, Result}; diff --git a/crates/nvisy-ontology/src/policy/mod.rs b/crates/nvisy-ontology/src/policy/mod.rs index d2317f47..85462cd4 100644 --- a/crates/nvisy-ontology/src/policy/mod.rs +++ b/crates/nvisy-ontology/src/policy/mod.rs @@ -10,6 +10,7 @@ mod selector; mod strategy; use derive_builder::Builder; +use derive_more::{Deref, DerefMut}; use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; @@ -65,18 +66,16 @@ impl Policy { } /// A collection of policies to apply during a pipeline run. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, Deref, DerefMut)] +#[derive(Serialize, Deserialize, JsonSchema)] pub struct Policies { /// The policies to evaluate, in order. + #[deref] + #[deref_mut] pub policies: Vec, } impl Policies { - /// Append a policy. - pub fn push(&mut self, policy: Policy) { - self.policies.push(policy); - } - /// All strategy policies across all policies, sorted by priority. /// /// Returns tuples of `(policy_id, strategy)` so callers can trace diff --git a/crates/nvisy-ontology/src/policy/strategy/image.rs b/crates/nvisy-ontology/src/policy/strategy/image.rs index 2201f4d9..4ec26745 100644 --- a/crates/nvisy-ontology/src/policy/strategy/image.rs +++ b/crates/nvisy-ontology/src/policy/strategy/image.rs @@ -3,7 +3,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::math::Color; +use crate::primitive::Color; const DEFAULT_BLUR_SIGMA: f32 = 15.0; const DEFAULT_PIXELATE_BLOCK_SIZE: u32 = 10; diff --git a/crates/nvisy-ontology/src/math/bounding_box.rs b/crates/nvisy-ontology/src/primitive/bounding_box.rs similarity index 100% rename from crates/nvisy-ontology/src/math/bounding_box.rs rename to crates/nvisy-ontology/src/primitive/bounding_box.rs diff --git a/crates/nvisy-ontology/src/math/bounding_box_pixel.rs b/crates/nvisy-ontology/src/primitive/bounding_box_pixel.rs similarity index 100% rename from crates/nvisy-ontology/src/math/bounding_box_pixel.rs rename to crates/nvisy-ontology/src/primitive/bounding_box_pixel.rs diff --git a/crates/nvisy-ontology/src/math/color.rs b/crates/nvisy-ontology/src/primitive/color.rs similarity index 100% rename from crates/nvisy-ontology/src/math/color.rs rename to crates/nvisy-ontology/src/primitive/color.rs diff --git a/crates/nvisy-ontology/src/math/dpi.rs b/crates/nvisy-ontology/src/primitive/dpi.rs similarity index 100% rename from crates/nvisy-ontology/src/math/dpi.rs rename to crates/nvisy-ontology/src/primitive/dpi.rs diff --git a/crates/nvisy-ontology/src/primitive/language_tag.rs b/crates/nvisy-ontology/src/primitive/language_tag.rs new file mode 100644 index 00000000..50a64778 --- /dev/null +++ b/crates/nvisy-ontology/src/primitive/language_tag.rs @@ -0,0 +1,75 @@ +//! BCP-47 language tag type. + +use derive_more::{Display, FromStr}; +use serde::{Deserialize, Serialize}; + +/// A validated [BCP-47](https://www.rfc-editor.org/info/bcp47) language tag. +/// +/// Wraps [`oxilangtag::LanguageTag`] with serde support. Use +/// `#[schemars(with = "String")]` on fields of this type for JSON Schema +/// generation. +/// +/// # Examples +/// +/// ``` +/// use nvisy_ontology::primitive::LanguageTag; +/// +/// let tag: LanguageTag = "en-US".parse().unwrap(); +/// assert_eq!(tag.as_str(), "en-US"); +/// assert_eq!(tag.primary_language(), "en"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, FromStr)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct LanguageTag(oxilangtag::LanguageTag); + +impl LanguageTag { + /// Returns the tag as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Returns the primary language subtag (e.g. `"en"` from `"en-US"`). + pub fn primary_language(&self) -> &str { + self.0.primary_language() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_simple() { + let tag: LanguageTag = "en".parse().unwrap(); + assert_eq!(tag.as_str(), "en"); + assert_eq!(tag.primary_language(), "en"); + } + + #[test] + fn parse_with_region() { + let tag: LanguageTag = "en-US".parse().unwrap(); + assert_eq!(tag.as_str(), "en-US"); + assert_eq!(tag.primary_language(), "en"); + } + + #[test] + fn parse_invalid() { + assert!("not a valid tag!!!".parse::().is_err()); + } + + #[test] + fn serde_roundtrip() { + let tag: LanguageTag = "uk-UA".parse().unwrap(); + let json = serde_json::to_string(&tag).unwrap(); + assert_eq!(json, "\"uk-UA\""); + let back: LanguageTag = serde_json::from_str(&json).unwrap(); + assert_eq!(tag, back); + } + + #[test] + fn display() { + let tag: LanguageTag = "de-AT".parse().unwrap(); + assert_eq!(format!("{tag}"), "de-AT"); + } +} diff --git a/crates/nvisy-ontology/src/math/mod.rs b/crates/nvisy-ontology/src/primitive/mod.rs similarity index 59% rename from crates/nvisy-ontology/src/math/mod.rs rename to crates/nvisy-ontology/src/primitive/mod.rs index ef185d36..65726669 100644 --- a/crates/nvisy-ontology/src/math/mod.rs +++ b/crates/nvisy-ontology/src/primitive/mod.rs @@ -1,12 +1,13 @@ -//! Spatial and temporal primitive types. +//! Primitive types used across the ontology. //! -//! Bounding boxes, polygons, time spans, and rendering primitives used -//! across entity locations, detection, and redaction operations. +//! Spatial primitives (bounding boxes, polygons), temporal intervals, +//! language tags, and rendering types. mod bounding_box; mod bounding_box_pixel; mod color; mod dpi; +mod language_tag; mod polygon; mod time_span; @@ -14,5 +15,6 @@ pub use self::bounding_box::BoundingBox; pub use self::bounding_box_pixel::BoundingBoxPixel; pub use self::color::Color; pub use self::dpi::Dpi; +pub use self::language_tag::LanguageTag; pub use self::polygon::{Polygon, Vertex}; pub use self::time_span::TimeSpan; diff --git a/crates/nvisy-ontology/src/math/polygon.rs b/crates/nvisy-ontology/src/primitive/polygon.rs similarity index 100% rename from crates/nvisy-ontology/src/math/polygon.rs rename to crates/nvisy-ontology/src/primitive/polygon.rs diff --git a/crates/nvisy-ontology/src/math/time_span.rs b/crates/nvisy-ontology/src/primitive/time_span.rs similarity index 100% rename from crates/nvisy-ontology/src/math/time_span.rs rename to crates/nvisy-ontology/src/primitive/time_span.rs diff --git a/crates/nvisy-ontology/src/provenance/redaction_map.rs b/crates/nvisy-ontology/src/provenance/redaction_map.rs index f6024b48..0aa038b5 100644 --- a/crates/nvisy-ontology/src/provenance/redaction_map.rs +++ b/crates/nvisy-ontology/src/provenance/redaction_map.rs @@ -7,6 +7,7 @@ //! //! [`Audit`]: super::Audit +use derive_more::{Deref, DerefMut}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -37,10 +38,13 @@ pub struct RedactionMapping { /// under access control. /// /// [`Audit`]: super::Audit -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, Deref, DerefMut)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RedactionMap { /// Per-entity redaction mappings. + #[deref] + #[deref_mut] pub entries: Vec, } @@ -50,21 +54,6 @@ impl RedactionMap { Self::default() } - /// Add a mapping entry. - pub fn push(&mut self, mapping: RedactionMapping) { - self.entries.push(mapping); - } - - /// Number of entries in the map. - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Whether the map is empty. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - /// Look up the original value for a given entity. pub fn original(&self, entity_id: Uuid) -> Option<&str> { self.entries diff --git a/crates/nvisy-ontology/src/workflow/mod.rs b/crates/nvisy-ontology/src/workflow/mod.rs index d019b178..325a6664 100644 --- a/crates/nvisy-ontology/src/workflow/mod.rs +++ b/crates/nvisy-ontology/src/workflow/mod.rs @@ -13,6 +13,7 @@ mod policy; mod refinement; mod validate; +use derive_more::{Display, From}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -39,52 +40,54 @@ use crate::Error; /// Variants carry a dedicated configuration struct. /// /// [`Operation`]: crate::operation::Operation -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + PartialEq, + Display, + From, + Serialize, + Deserialize, + JsonSchema +)] #[serde(tag = "action", rename_all = "snake_case")] #[non_exhaustive] pub enum GraphNodeKind { /// Loads reference-data contexts required by downstream actions. + #[display("load_context")] LoadContext(LoadContext), /// Persists contexts produced during the pipeline run. + #[display("save_context")] SaveContext(SaveContext), /// Generates a new context from detection results and content data. + #[display("generate_context")] GenerateContext(GenerateContext), /// Extracts structured text from content (visual, audial, text). + #[display("extraction")] Extraction(Extraction), /// Detects entities via NER and/or pattern matching. + #[display("detection")] Detection(Detection), /// Merges and scores entities from multiple detection sources. + #[display("deduplication")] Deduplication(Deduplication), /// Applies redaction instructions to produce output content. + #[display("redaction")] Redaction(Redaction), /// Verifies that redacted content does not leak original values. + #[display("validation")] Validation(Validation), /// Imports content into the pipeline for processing. + #[display("import")] ImportFile(ImportFile), /// Exports processed content to a target destination. + #[display("export")] ExportFile(ExportFile), } -impl std::fmt::Display for GraphNodeKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::LoadContext(_) => f.write_str("load_context"), - Self::SaveContext(_) => f.write_str("save_context"), - Self::GenerateContext(_) => f.write_str("generate_context"), - Self::Extraction(_) => f.write_str("extraction"), - Self::Detection(_) => f.write_str("detection"), - Self::Deduplication(_) => f.write_str("deduplication"), - Self::Redaction(_) => f.write_str("redaction"), - Self::Validation(_) => f.write_str("validation"), - Self::ImportFile(_) => f.write_str("import"), - Self::ExportFile(_) => f.write_str("export"), - } - } -} - impl GraphNodeKind { /// Returns the pipeline phase for this node kind. /// diff --git a/crates/nvisy-provider/src/agent/ocr/input.rs b/crates/nvisy-provider/src/agent/ocr/input.rs index c0714afe..c50efb3d 100644 --- a/crates/nvisy-provider/src/agent/ocr/input.rs +++ b/crates/nvisy-provider/src/agent/ocr/input.rs @@ -1,7 +1,7 @@ //! Input types for OCR verification. use nvisy_ontology::entity::{Entity, EntityCategory, EntityKind, Location}; -use nvisy_ontology::math::BoundingBox; +use nvisy_ontology::primitive::BoundingBox; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/nvisy-provider/src/agent/ocr/output.rs b/crates/nvisy-provider/src/agent/ocr/output.rs index f6d1b4f8..c84a43e4 100644 --- a/crates/nvisy-provider/src/agent/ocr/output.rs +++ b/crates/nvisy-provider/src/agent/ocr/output.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use nvisy_ontology::entity::{Entity, EntityCategory, EntityKind, ImageLocation, RefinementMethod}; -use nvisy_ontology::math::BoundingBox; +use nvisy_ontology::primitive::BoundingBox; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/nvisy-provider/src/agent/ocr/prompt.rs b/crates/nvisy-provider/src/agent/ocr/prompt.rs index ada59303..17944681 100644 --- a/crates/nvisy-provider/src/agent/ocr/prompt.rs +++ b/crates/nvisy-provider/src/agent/ocr/prompt.rs @@ -69,7 +69,7 @@ whichever fields changed: category, entity_type, value, bbox."; #[cfg(test)] mod tests { use nvisy_ontology::entity::{EntityCategory, EntityKind}; - use nvisy_ontology::math::BoundingBox; + use nvisy_ontology::primitive::BoundingBox; use super::*; diff --git a/crates/nvisy-provider/src/ocr/provider/aws_textract/backend.rs b/crates/nvisy-provider/src/ocr/provider/aws_textract/backend.rs index 24767bbe..1f6e1f31 100644 --- a/crates/nvisy-provider/src/ocr/provider/aws_textract/backend.rs +++ b/crates/nvisy-provider/src/ocr/provider/aws_textract/backend.rs @@ -8,7 +8,7 @@ use std::fmt; use hmac::{Hmac, KeyInit, Mac}; use nvisy_core::{Error, Result}; use nvisy_ontology::artifacts::{Block, BlockKind, Line, Page, Word}; -use nvisy_ontology::math::{BoundingBox, Polygon, Vertex}; +use nvisy_ontology::primitive::{BoundingBox, Polygon, Vertex}; use serde::Deserialize; use sha2::{Digest, Sha256}; diff --git a/crates/nvisy-provider/src/ocr/provider/azure_docai/backend.rs b/crates/nvisy-provider/src/ocr/provider/azure_docai/backend.rs index 5c4e1080..c11950ca 100644 --- a/crates/nvisy-provider/src/ocr/provider/azure_docai/backend.rs +++ b/crates/nvisy-provider/src/ocr/provider/azure_docai/backend.rs @@ -6,7 +6,7 @@ use std::fmt; use nvisy_core::{Error, Result}; use nvisy_ontology::artifacts::{Block, BlockKind, Line, Page, Word}; -use nvisy_ontology::math::{BoundingBox, Polygon, Vertex}; +use nvisy_ontology::primitive::{BoundingBox, Polygon, Vertex}; use serde::Deserialize; use tokio::time::{Duration, sleep}; diff --git a/crates/nvisy-provider/src/ocr/provider/datalab_surya/backend.rs b/crates/nvisy-provider/src/ocr/provider/datalab_surya/backend.rs index cc7908c5..100a85be 100644 --- a/crates/nvisy-provider/src/ocr/provider/datalab_surya/backend.rs +++ b/crates/nvisy-provider/src/ocr/provider/datalab_surya/backend.rs @@ -4,7 +4,7 @@ use nvisy_core::{Error, Result}; use nvisy_ontology::artifacts::{Block, BlockKind, Line, Page, Word}; -use nvisy_ontology::math::{BoundingBox, Polygon, Vertex}; +use nvisy_ontology::primitive::{BoundingBox, Polygon, Vertex}; use reqwest_middleware::reqwest::multipart::Form; use serde::Deserialize; diff --git a/crates/nvisy-provider/src/ocr/provider/google_vision/backend.rs b/crates/nvisy-provider/src/ocr/provider/google_vision/backend.rs index 6822192a..475c2fc9 100644 --- a/crates/nvisy-provider/src/ocr/provider/google_vision/backend.rs +++ b/crates/nvisy-provider/src/ocr/provider/google_vision/backend.rs @@ -6,7 +6,7 @@ use std::fmt; use nvisy_core::{Error, Result}; use nvisy_ontology::artifacts::{Block, BlockKind, Line, Page, Word}; -use nvisy_ontology::math::{BoundingBox, Polygon, Vertex}; +use nvisy_ontology::primitive::{BoundingBox, Polygon, Vertex}; use serde::Deserialize; use super::GoogleVisionParams; diff --git a/crates/nvisy-provider/src/ocr/provider/paddle_paddlex/backend.rs b/crates/nvisy-provider/src/ocr/provider/paddle_paddlex/backend.rs index 2941d05e..e14b3d58 100644 --- a/crates/nvisy-provider/src/ocr/provider/paddle_paddlex/backend.rs +++ b/crates/nvisy-provider/src/ocr/provider/paddle_paddlex/backend.rs @@ -4,7 +4,7 @@ use nvisy_core::{Error, Result}; use nvisy_ontology::artifacts::{Block, BlockKind, Line, Page, Word}; -use nvisy_ontology::math::{BoundingBox, Polygon, Vertex}; +use nvisy_ontology::primitive::{BoundingBox, Polygon, Vertex}; use reqwest_middleware::reqwest::multipart::Form; use serde::Deserialize; From 2b10476abeb4008027deba89d5bbbbd70246295f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Mon, 13 Apr 2026 17:13:00 +0200 Subject: [PATCH 2/7] refactor(ontology, engine): populate artifact types, wire up OCR storage - TextArtifacts: add language and char_count fields - TabularArtifacts: add row_count, column_count, sparse headers (ColumnHeader) - RichArtifacts: add tabular field alongside text and image - ContentArtifacts: add as_text/as_text_mut and as_tabular/as_tabular_mut accessors - Vision extraction: store OCR results in ImageArtifacts::ocr_pages instead of discarding Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/operation/extraction/vision.rs | 9 ++++- crates/nvisy-ontology/src/artifacts/mod.rs | 38 ++++++++++++++++++- crates/nvisy-ontology/src/artifacts/rich.rs | 9 +++-- .../nvisy-ontology/src/artifacts/tabular.rs | 26 ++++++++++++- crates/nvisy-ontology/src/artifacts/text.rs | 12 +++++- 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/crates/nvisy-engine/src/operation/extraction/vision.rs b/crates/nvisy-engine/src/operation/extraction/vision.rs index 005669c4..cc7a415f 100644 --- a/crates/nvisy-engine/src/operation/extraction/vision.rs +++ b/crates/nvisy-engine/src/operation/extraction/vision.rs @@ -143,7 +143,14 @@ impl Operation for VisualExtractionOp { "running OCR extraction", ); - let _ocr_output = self.extract(&image_spans).await?; + let ocr_output = self.extract(&image_spans).await?; + + // Store OCR results in image artifacts. + if let Some(image_artifacts) = envelope.document.artifacts.as_image_mut() { + for output in &ocr_output { + image_artifacts.ocr_pages.extend(output.pages.clone()); + } + } if self.agent.has_verifier() && !envelope.audit.entities.is_empty() { let verify_spans = envelope.document.collect_image_spans().await; diff --git a/crates/nvisy-ontology/src/artifacts/mod.rs b/crates/nvisy-ontology/src/artifacts/mod.rs index 90c99ea3..fba52aec 100644 --- a/crates/nvisy-ontology/src/artifacts/mod.rs +++ b/crates/nvisy-ontology/src/artifacts/mod.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; pub use self::audio::{AudioArtifacts, TranscriptSegment, Transcription}; pub use self::image::{Block, BlockKind, ImageArtifacts, Line, Page, Word}; pub use self::rich::RichArtifacts; -pub use self::tabular::TabularArtifacts; +pub use self::tabular::{ColumnHeader, TabularArtifacts}; pub use self::text::TextArtifacts; /// Modality-specific processing artifacts. @@ -99,4 +99,40 @@ impl ContentArtifacts { _ => None, } } + + /// Access text artifacts, if this is a text or rich variant. + pub fn as_text(&self) -> Option<&TextArtifacts> { + match self { + Self::Text(a) => Some(a), + Self::Rich(a) => Some(&a.text), + _ => None, + } + } + + /// Access text artifacts mutably, if this is a text or rich variant. + pub fn as_text_mut(&mut self) -> Option<&mut TextArtifacts> { + match self { + Self::Text(a) => Some(a), + Self::Rich(a) => Some(&mut a.text), + _ => None, + } + } + + /// Access tabular artifacts, if this is a tabular or rich variant. + pub fn as_tabular(&self) -> Option<&TabularArtifacts> { + match self { + Self::Tabular(a) => Some(a), + Self::Rich(a) => Some(&a.tabular), + _ => None, + } + } + + /// Access tabular artifacts mutably, if this is a tabular or rich variant. + pub fn as_tabular_mut(&mut self) -> Option<&mut TabularArtifacts> { + match self { + Self::Tabular(a) => Some(a), + Self::Rich(a) => Some(&mut a.tabular), + _ => None, + } + } } diff --git a/crates/nvisy-ontology/src/artifacts/rich.rs b/crates/nvisy-ontology/src/artifacts/rich.rs index aab9b62f..fc960ff5 100644 --- a/crates/nvisy-ontology/src/artifacts/rich.rs +++ b/crates/nvisy-ontology/src/artifacts/rich.rs @@ -1,15 +1,16 @@ -//! Rich-document artifacts (text + image combined). +//! Rich-document artifacts (text + image + tabular combined). use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::image::ImageArtifacts; +use super::tabular::TabularArtifacts; use super::text::TextArtifacts; /// Artifacts produced during processing of rich documents (PDF, DOCX). /// -/// Rich documents contain both text and image content, so their -/// artifacts compose both modality-specific artifact types. +/// Rich documents can contain text, images, and tables, so their +/// artifacts compose all three modality-specific artifact types. #[derive(Debug, Clone, Default, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -18,4 +19,6 @@ pub struct RichArtifacts { pub text: TextArtifacts, /// Image-modality artifacts (embedded images, OCR results). pub image: ImageArtifacts, + /// Tabular-modality artifacts (embedded tables). + pub tabular: TabularArtifacts, } diff --git a/crates/nvisy-ontology/src/artifacts/tabular.rs b/crates/nvisy-ontology/src/artifacts/tabular.rs index 7ebc654c..b7065206 100644 --- a/crates/nvisy-ontology/src/artifacts/tabular.rs +++ b/crates/nvisy-ontology/src/artifacts/tabular.rs @@ -3,8 +3,32 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// A column header in a tabular document. +/// +/// Only columns with detected headers get an entry, so this +/// representation naturally supports gaps (headerless columns). +#[derive(Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ColumnHeader { + /// 0-based column index. + pub column_index: u32, + /// Header text. + pub text: String, +} + /// Artifacts produced during processing of tabular content. #[derive(Debug, Clone, Default, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct TabularArtifacts {} +pub struct TabularArtifacts { + /// Number of data rows (excluding headers). + #[serde(skip_serializing_if = "Option::is_none")] + pub row_count: Option, + /// Number of columns. + #[serde(skip_serializing_if = "Option::is_none")] + pub column_count: Option, + /// Detected column headers, sparse — only columns with headers are present. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec, +} diff --git a/crates/nvisy-ontology/src/artifacts/text.rs b/crates/nvisy-ontology/src/artifacts/text.rs index 80b8f64c..60458d7b 100644 --- a/crates/nvisy-ontology/src/artifacts/text.rs +++ b/crates/nvisy-ontology/src/artifacts/text.rs @@ -3,8 +3,18 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::primitive::LanguageTag; + /// Artifacts produced during processing of text content. #[derive(Debug, Clone, Default, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct TextArtifacts {} +pub struct TextArtifacts { + /// BCP-47 language tag of the detected language, if available. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub language: Option, + /// Total character count of the text content. + #[serde(skip_serializing_if = "Option::is_none")] + pub char_count: Option, +} From 7a564962e1667fa13f0ee71b57b47e26af4fcadb Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Mon, 13 Apr 2026 17:28:05 +0200 Subject: [PATCH 3/7] refactor(ontology): fix context type inconsistencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix UUID version: ContextEntry now uses now_v7 (was new_v4) - EmbeddingData: Vec → Vec, remove redundant dimensions field, rename model → algorithm for consistency with FaceData/VoiceData - PatternExpression: rename serde tag "kind" → "syntax" to avoid collision with AnalyticVariant's "kind" tag when flattened - SignatureData: add missing algorithm field - AddressData: rename region → state to avoid GeospatialVariant confusion - GeoShape::Circle: centre → center (American English consistency) - GeoShape::Polygon: polygon → boundary (avoid redundant naming) - ReferenceVariant::Object → Image (match wrapped ImageData type) - CredentialData: skip_serializing on value to prevent plaintext leaks, add CredentialKind enum replacing untyped credential_type string - TextData: add language field (Option) - TemporalVariant: add TimeSpan variant with TimeSpanData Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/context/analytic/embedding.rs | 16 ++++++---- .../src/context/analytic/pattern.rs | 2 +- .../src/context/document/signature.rs | 10 +++++++ crates/nvisy-ontology/src/context/entry.rs | 2 +- .../src/context/geospatial/address.rs | 4 +-- .../src/context/geospatial/region.rs | 20 ++++++------- .../src/context/reference/credential.rs | 29 ++++++++++++++++-- .../src/context/reference/mod.rs | 4 +-- .../src/context/reference/text.rs | 6 ++++ .../src/context/temporal/mod.rs | 4 +++ .../src/context/temporal/time.rs | 30 +++++++++++++++++++ 11 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 crates/nvisy-ontology/src/context/temporal/time.rs diff --git a/crates/nvisy-ontology/src/context/analytic/embedding.rs b/crates/nvisy-ontology/src/context/analytic/embedding.rs index 8d3a2765..16e3066b 100644 --- a/crates/nvisy-ontology/src/context/analytic/embedding.rs +++ b/crates/nvisy-ontology/src/context/analytic/embedding.rs @@ -24,14 +24,18 @@ pub enum DistanceMetric { #[serde(rename_all = "camelCase")] pub struct EmbeddingData { /// The embedding vector values. - pub vector: Vec, - /// Dimensionality of the vector. + pub vector: Vec, + /// Identifier of the model/algorithm that produced this embedding. #[serde(skip_serializing_if = "Option::is_none")] - pub dimensions: Option, - /// Identifier of the model that produced this embedding. - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, + pub algorithm: Option, /// Distance metric to use for comparison. #[serde(skip_serializing_if = "Option::is_none")] pub distance_metric: Option, } + +impl EmbeddingData { + /// Dimensionality of the embedding vector. + pub fn dimensions(&self) -> usize { + self.vector.len() + } +} diff --git a/crates/nvisy-ontology/src/context/analytic/pattern.rs b/crates/nvisy-ontology/src/context/analytic/pattern.rs index 90bf583e..188b6ac3 100644 --- a/crates/nvisy-ontology/src/context/analytic/pattern.rs +++ b/crates/nvisy-ontology/src/context/analytic/pattern.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// A pattern expression with its type. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(tag = "syntax", rename_all = "snake_case")] #[non_exhaustive] pub enum PatternExpression { /// Regular expression pattern. diff --git a/crates/nvisy-ontology/src/context/document/signature.rs b/crates/nvisy-ontology/src/context/document/signature.rs index 126448b0..30c9118e 100644 --- a/crates/nvisy-ontology/src/context/document/signature.rs +++ b/crates/nvisy-ontology/src/context/document/signature.rs @@ -21,6 +21,9 @@ pub struct SignatureData { /// Identity of the signer this signature belongs to. #[serde(skip_serializing_if = "Option::is_none")] pub signer_id: Option, + /// Algorithm used for signature verification. + #[serde(skip_serializing_if = "Option::is_none")] + pub algorithm: Option, } impl SignatureData { @@ -31,6 +34,7 @@ impl SignatureData { region: None, format: None, signer_id: None, + algorithm: None, } } @@ -39,4 +43,10 @@ impl SignatureData { self.signer_id = Some(signer_id.into()); self } + + /// Set the verification algorithm. + pub fn with_algorithm(mut self, algorithm: impl Into) -> Self { + self.algorithm = Some(algorithm.into()); + self + } } diff --git a/crates/nvisy-ontology/src/context/entry.rs b/crates/nvisy-ontology/src/context/entry.rs index e6bf91cd..50d74231 100644 --- a/crates/nvisy-ontology/src/context/entry.rs +++ b/crates/nvisy-ontology/src/context/entry.rs @@ -60,7 +60,7 @@ impl ContextEntry { /// Create a new context entry with a generated UUID and current timestamp. pub fn new(data: ContextEntryData) -> Self { Self { - id: Uuid::new_v4(), + id: Uuid::now_v7(), label: None, created_at: Timestamp::now(), expires_at: None, diff --git a/crates/nvisy-ontology/src/context/geospatial/address.rs b/crates/nvisy-ontology/src/context/geospatial/address.rs index fe156d39..5b92413a 100644 --- a/crates/nvisy-ontology/src/context/geospatial/address.rs +++ b/crates/nvisy-ontology/src/context/geospatial/address.rs @@ -15,9 +15,9 @@ pub struct AddressData { /// City name. #[serde(skip_serializing_if = "Option::is_none")] pub city: Option, - /// State, province, or region. + /// State, province, or administrative area. #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option, + pub state: Option, /// Postal / ZIP code. #[serde(skip_serializing_if = "Option::is_none")] pub postal_code: Option, diff --git a/crates/nvisy-ontology/src/context/geospatial/region.rs b/crates/nvisy-ontology/src/context/geospatial/region.rs index a8a820b2..2067a262 100644 --- a/crates/nvisy-ontology/src/context/geospatial/region.rs +++ b/crates/nvisy-ontology/src/context/geospatial/region.rs @@ -41,17 +41,17 @@ impl GeoBounds { pub enum GeoShape { /// Axis-aligned bounding rectangle. Bounds(GeoBounds), - /// Circular region defined by centre and radius. + /// Circular region defined by center and radius. Circle { - /// Centre of the circle. - centre: GeoCoordinate, - /// Radius in metres. + /// Center of the circle. + center: GeoCoordinate, + /// Radius in meters. radius_m: f64, }, /// Arbitrary polygon defined by vertices. Polygon { - /// Vertices in order (x = lng, y = lat). - polygon: Polygon, + /// Boundary vertices in order (x = lng, y = lat). + boundary: Polygon, }, } @@ -77,17 +77,17 @@ impl RegionData { } /// Create a circular region. - pub fn from_circle(centre: GeoCoordinate, radius_m: f64) -> Self { + pub fn from_circle(center: GeoCoordinate, radius_m: f64) -> Self { Self { - region: GeoShape::Circle { centre, radius_m }, + region: GeoShape::Circle { center, radius_m }, name: None, } } /// Create a polygon region. - pub fn from_polygon(polygon: Polygon) -> Self { + pub fn from_polygon(boundary: Polygon) -> Self { Self { - region: GeoShape::Polygon { polygon }, + region: GeoShape::Polygon { boundary }, name: None, } } diff --git a/crates/nvisy-ontology/src/context/reference/credential.rs b/crates/nvisy-ontology/src/context/reference/credential.rs index a31d4bb5..5711487a 100644 --- a/crates/nvisy-ontology/src/context/reference/credential.rs +++ b/crates/nvisy-ontology/src/context/reference/credential.rs @@ -3,15 +3,40 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Classification of credential secrets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum CredentialKind { + /// API key. + ApiKey, + /// OAuth access or refresh token. + OauthToken, + /// Password or passphrase. + Password, + /// Private key (SSH, TLS, etc.). + PrivateKey, + /// Other credential type. + Other, +} + /// A reference credential for detecting leaked secrets. +/// +/// The `value` field is intentionally excluded from serialization to +/// prevent plaintext secrets from appearing in logs or API responses. +/// It is only accepted during deserialization (ingest). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CredentialData { /// The credential value (API key, token, etc.). + /// + /// Excluded from serialization output to prevent leaking secrets. + #[serde(skip_serializing)] pub value: String, - /// Type of credential (e.g. `"api_key"`, `"oauth_token"`, `"password"`). + /// Classification of this credential. #[serde(skip_serializing_if = "Option::is_none")] - pub credential_type: Option, + pub credential_kind: Option, /// Service or provider this credential belongs to. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, diff --git a/crates/nvisy-ontology/src/context/reference/mod.rs b/crates/nvisy-ontology/src/context/reference/mod.rs index d587b68e..cf265600 100644 --- a/crates/nvisy-ontology/src/context/reference/mod.rs +++ b/crates/nvisy-ontology/src/context/reference/mod.rs @@ -8,7 +8,7 @@ mod text; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -pub use self::credential::CredentialData; +pub use self::credential::{CredentialData, CredentialKind}; pub use self::image::ImageData; pub use self::tag::TagData; pub use self::text::{TextData, TextEntry}; @@ -25,5 +25,5 @@ pub enum ReferenceVariant { /// API keys, tokens, or known secret patterns. Credential(CredentialData), /// Reference image for object/scene matching. - Object(ImageData), + Image(ImageData), } diff --git a/crates/nvisy-ontology/src/context/reference/text.rs b/crates/nvisy-ontology/src/context/reference/text.rs index 7c739d4d..b476c638 100644 --- a/crates/nvisy-ontology/src/context/reference/text.rs +++ b/crates/nvisy-ontology/src/context/reference/text.rs @@ -3,6 +3,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::primitive::LanguageTag; + /// A labeled text value for reference matching. /// /// The `key` is a human/LLM-readable label describing what this value @@ -26,4 +28,8 @@ pub struct TextEntry { pub struct TextData { /// Key-value pairs for matching. pub entries: Vec, + /// BCP-47 language tag for locale-sensitive matching. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub language: Option, } diff --git a/crates/nvisy-ontology/src/context/temporal/mod.rs b/crates/nvisy-ontology/src/context/temporal/mod.rs index 95613630..de8b2540 100644 --- a/crates/nvisy-ontology/src/context/temporal/mod.rs +++ b/crates/nvisy-ontology/src/context/temporal/mod.rs @@ -1,11 +1,13 @@ //! Date and time-based reference data. mod date; +mod time; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use self::date::DateData; +pub use self::time::TimeSpanData; /// Temporal matching variants. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -14,4 +16,6 @@ pub use self::date::DateData; pub enum TemporalVariant { /// Date or date-range to match. Date(DateData), + /// Time span reference for matching audio/video segments. + TimeSpan(TimeSpanData), } diff --git a/crates/nvisy-ontology/src/context/temporal/time.rs b/crates/nvisy-ontology/src/context/temporal/time.rs new file mode 100644 index 00000000..ca643563 --- /dev/null +++ b/crates/nvisy-ontology/src/context/temporal/time.rs @@ -0,0 +1,30 @@ +//! Time span reference data. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::primitive::TimeSpan; + +/// A time span reference for matching audio/video segments. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TimeSpanData { + /// The time interval to match. + pub span: TimeSpan, + /// Optional human-readable label (e.g. `"intro"`, `"closing remarks"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl TimeSpanData { + /// Create a time span reference. + pub fn new(span: TimeSpan) -> Self { + Self { span, label: None } + } + + /// Set a label. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } +} From 0c32cbc93a72ab8b7256f975c6a59023fe27bf76 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Mon, 13 Apr 2026 18:22:53 +0200 Subject: [PATCH 4/7] refactor(ontology): pattern types, ImageData format, temporal variants - PatternExpression: extract RegexPattern and GlobPattern as dedicated types - ImageData: remove untyped format field - TemporalVariant: add TimeOfDay(TimeOfDayData) and DateTime(DateTimeData) variants using jiff::civil::Time and jiff::civil::DateTime Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/context/analytic/mod.rs | 2 +- .../src/context/analytic/pattern.rs | 31 +++++++++----- .../src/context/reference/image.rs | 3 -- .../src/context/temporal/datetime.rs | 40 +++++++++++++++++++ .../src/context/temporal/mod.rs | 12 +++++- .../src/context/temporal/time_of_day.rs | 39 ++++++++++++++++++ 6 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 crates/nvisy-ontology/src/context/temporal/datetime.rs create mode 100644 crates/nvisy-ontology/src/context/temporal/time_of_day.rs diff --git a/crates/nvisy-ontology/src/context/analytic/mod.rs b/crates/nvisy-ontology/src/context/analytic/mod.rs index 567454fa..2487456a 100644 --- a/crates/nvisy-ontology/src/context/analytic/mod.rs +++ b/crates/nvisy-ontology/src/context/analytic/mod.rs @@ -7,7 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use self::embedding::{DistanceMetric, EmbeddingData}; -pub use self::pattern::PatternData; +pub use self::pattern::{GlobPattern, PatternData, PatternExpression, RegexPattern}; /// Analytic computation variants. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/nvisy-ontology/src/context/analytic/pattern.rs b/crates/nvisy-ontology/src/context/analytic/pattern.rs index 188b6ac3..d5248bc0 100644 --- a/crates/nvisy-ontology/src/context/analytic/pattern.rs +++ b/crates/nvisy-ontology/src/context/analytic/pattern.rs @@ -1,23 +1,34 @@ //! Pattern reference data for regex/glob matching. +use derive_more::From; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// A pattern expression with its type. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +/// A regular expression pattern. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegexPattern { + /// The regex expression string. + pub expression: String, +} + +/// A shell-style glob pattern. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GlobPattern { + /// The glob expression string. + pub expression: String, +} + +/// A pattern expression with its syntax type. +#[derive(Debug, Clone, PartialEq, Eq, From, Serialize, Deserialize, JsonSchema)] #[serde(tag = "syntax", rename_all = "snake_case")] #[non_exhaustive] pub enum PatternExpression { /// Regular expression pattern. - Regex { - /// The regex expression. - expression: String, - }, + Regex(RegexPattern), /// Shell-style glob pattern. - Glob { - /// The glob expression. - expression: String, - }, + Glob(GlobPattern), } /// A named pattern for detection matching. diff --git a/crates/nvisy-ontology/src/context/reference/image.rs b/crates/nvisy-ontology/src/context/reference/image.rs index 6b90d32b..64b80457 100644 --- a/crates/nvisy-ontology/src/context/reference/image.rs +++ b/crates/nvisy-ontology/src/context/reference/image.rs @@ -15,7 +15,4 @@ pub struct ImageData { /// Optional sub-region within the image. #[serde(skip_serializing_if = "Option::is_none")] pub region: Option, - /// Image format hint (e.g. `"jpeg"`, `"png"`, `"webp"`). - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option, } diff --git a/crates/nvisy-ontology/src/context/temporal/datetime.rs b/crates/nvisy-ontology/src/context/temporal/datetime.rs new file mode 100644 index 00000000..203a9960 --- /dev/null +++ b/crates/nvisy-ontology/src/context/temporal/datetime.rs @@ -0,0 +1,40 @@ +//! Date-time reference data. + +use jiff::civil::DateTime; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A date-time or date-time range reference for temporal matching. +/// +/// Uses naive (timezone-unaware) date-times from [`jiff::civil::DateTime`]. +/// For timezone-aware timestamps, use the entry-level `created_at` / +/// `expires_at` fields on [`ContextEntry`](crate::context::ContextEntry). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DateTimeData { + /// Start (or only) date-time. + #[schemars(with = "String")] + pub start: DateTime, + /// End date-time for a range. When `None` this represents a single instant. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub end: Option, +} + +impl DateTimeData { + /// Create a single date-time reference. + pub fn single(dt: DateTime) -> Self { + Self { + start: dt, + end: None, + } + } + + /// Create a date-time range reference. + pub fn range(start: DateTime, end: DateTime) -> Self { + Self { + start, + end: Some(end), + } + } +} diff --git a/crates/nvisy-ontology/src/context/temporal/mod.rs b/crates/nvisy-ontology/src/context/temporal/mod.rs index de8b2540..d28abed2 100644 --- a/crates/nvisy-ontology/src/context/temporal/mod.rs +++ b/crates/nvisy-ontology/src/context/temporal/mod.rs @@ -1,21 +1,29 @@ -//! Date and time-based reference data. +//! Date, time, and datetime reference data. mod date; +mod datetime; mod time; +mod time_of_day; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use self::date::DateData; +pub use self::datetime::DateTimeData; pub use self::time::TimeSpanData; +pub use self::time_of_day::TimeOfDayData; /// Temporal matching variants. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] #[non_exhaustive] pub enum TemporalVariant { - /// Date or date-range to match. + /// Date or date-range to match (e.g. birthdays, expiry dates). Date(DateData), + /// Time-of-day or time range (e.g. business hours). + TimeOfDay(TimeOfDayData), + /// Date-time or date-time range (e.g. appointment timestamps). + DateTime(DateTimeData), /// Time span reference for matching audio/video segments. TimeSpan(TimeSpanData), } diff --git a/crates/nvisy-ontology/src/context/temporal/time_of_day.rs b/crates/nvisy-ontology/src/context/temporal/time_of_day.rs new file mode 100644 index 00000000..505e8c8c --- /dev/null +++ b/crates/nvisy-ontology/src/context/temporal/time_of_day.rs @@ -0,0 +1,39 @@ +//! Time-of-day reference data. + +use jiff::civil::Time; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A time-of-day or time range reference for temporal matching. +/// +/// Uses naive (timezone-unaware) times from [`jiff::civil::Time`]. +/// Useful for matching recurring time patterns (e.g. business hours). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TimeOfDayData { + /// Start (or only) time. + #[schemars(with = "String")] + pub start: Time, + /// End time for a range. When `None` this represents a single time. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub end: Option