relay_knowledge/domain/graph/multimodal/
mod.rs1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum EvidenceModality {
9 TextSpan,
10 ImageAsset,
11 OcrText,
12 Caption,
13 ImageEmbedding,
14 Table,
15 LayoutRegion,
16}
17
18impl EvidenceModality {
19 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::TextSpan => "text_span",
23 Self::ImageAsset => "image_asset",
24 Self::OcrText => "ocr_text",
25 Self::Caption => "caption",
26 Self::ImageEmbedding => "image_embedding",
27 Self::Table => "table",
28 Self::LayoutRegion => "layout_region",
29 }
30 }
31
32 pub const fn requires_parent(self) -> bool {
34 matches!(
35 self,
36 Self::OcrText | Self::Caption | Self::ImageEmbedding | Self::LayoutRegion
37 )
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct LayoutRegion {
44 pub page_number: u32,
45 pub x: u32,
46 pub y: u32,
47 pub width: u32,
48 pub height: u32,
49}
50
51impl LayoutRegion {
52 pub fn new(
54 page_number: u32,
55 x: u32,
56 y: u32,
57 width: u32,
58 height: u32,
59 ) -> Result<Self, DomainError> {
60 if page_number == 0 {
61 return Err(DomainError::invalid(
62 "layout_region",
63 "page number must be one-based",
64 ));
65 }
66 if width == 0 || height == 0 {
67 return Err(DomainError::invalid(
68 "layout_region",
69 "width and height must be greater than zero",
70 ));
71 }
72
73 Ok(Self {
74 page_number,
75 x,
76 y,
77 width,
78 height,
79 })
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ExtractionStatus {
87 Succeeded,
88 Degraded,
89 Failed,
90}
91
92impl ExtractionStatus {
93 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::Succeeded => "succeeded",
97 Self::Degraded => "degraded",
98 Self::Failed => "failed",
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ExtractionDiagnostic {
106 pub status: ExtractionStatus,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub message: Option<String>,
109}
110
111impl ExtractionDiagnostic {
112 pub fn new(status: ExtractionStatus, message: Option<String>) -> Result<Self, DomainError> {
114 let message = message
115 .map(|value| required_text("extraction_diagnostic", value))
116 .transpose()?;
117 if status != ExtractionStatus::Succeeded && message.is_none() {
118 return Err(DomainError::invalid(
119 "extraction_diagnostic",
120 "degraded or failed extraction requires a diagnostic message",
121 ));
122 }
123
124 Ok(Self { status, message })
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct EvidenceExtractionMetadata {
131 pub modality: EvidenceModality,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub source_uri: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub source_hash: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub media_hash: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub extractor: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub extractor_version: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub observed_at: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub parent_evidence_id: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub layout_region: Option<LayoutRegion>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub embedding_model: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub embedding_dimension: Option<u16>,
152 pub diagnostic: ExtractionDiagnostic,
153}
154
155impl EvidenceExtractionMetadata {
156 pub fn text_span() -> Self {
158 Self {
159 modality: EvidenceModality::TextSpan,
160 source_uri: None,
161 source_hash: None,
162 media_hash: None,
163 extractor: None,
164 extractor_version: None,
165 observed_at: None,
166 parent_evidence_id: None,
167 layout_region: None,
168 embedding_model: None,
169 embedding_dimension: None,
170 diagnostic: ExtractionDiagnostic {
171 status: ExtractionStatus::Succeeded,
172 message: None,
173 },
174 }
175 }
176
177 pub fn validate(mut self) -> Result<Self, DomainError> {
179 self.source_uri = normalize_optional_text("source_uri", self.source_uri)?;
180 self.source_hash = normalize_optional_text("source_hash", self.source_hash)?;
181 self.media_hash = normalize_optional_text("media_hash", self.media_hash)?;
182 self.extractor = normalize_optional_text("extractor", self.extractor)?;
183 self.extractor_version =
184 normalize_optional_text("extractor_version", self.extractor_version)?;
185 self.observed_at = normalize_optional_text("observed_at", self.observed_at)?;
186 self.parent_evidence_id =
187 normalize_optional_text("parent_evidence_id", self.parent_evidence_id)?;
188 self.embedding_model = normalize_optional_text("embedding_model", self.embedding_model)?;
189 self.diagnostic =
190 ExtractionDiagnostic::new(self.diagnostic.status, self.diagnostic.message)?;
191 if let Some(region) = self.layout_region {
192 self.layout_region = Some(LayoutRegion::new(
193 region.page_number,
194 region.x,
195 region.y,
196 region.width,
197 region.height,
198 )?);
199 }
200
201 if self.modality.requires_parent() && self.parent_evidence_id.is_none() {
202 return Err(DomainError::invalid(
203 "parent_evidence_id",
204 "derived multimodal evidence must reference a parent evidence item",
205 ));
206 }
207 if self.modality == EvidenceModality::ImageEmbedding
208 && (self.embedding_model.is_none()
209 || self.embedding_dimension.is_none()
210 || self.embedding_dimension == Some(0))
211 {
212 return Err(DomainError::invalid(
213 "embedding_model",
214 "image embedding evidence requires model and positive dimension metadata",
215 ));
216 }
217 if self.modality == EvidenceModality::LayoutRegion && self.layout_region.is_none() {
218 return Err(DomainError::invalid(
219 "layout_region",
220 "layout region evidence requires coordinates",
221 ));
222 }
223 if self.modality == EvidenceModality::ImageAsset
224 && self.media_hash.is_none()
225 && self.source_hash.is_none()
226 {
227 return Err(DomainError::invalid(
228 "media_hash",
229 "image evidence requires a media hash or source hash",
230 ));
231 }
232
233 Ok(self)
234 }
235}
236
237fn normalize_optional_text(
238 field: &'static str,
239 value: Option<String>,
240) -> Result<Option<String>, DomainError> {
241 value.map(|inner| required_text(field, inner)).transpose()
242}
243
244#[cfg(test)]
245#[path = "mod_tests.rs"]
246mod tests;