1use crate::{
4 Error, Modality, ResourceId, ResourceLocator, ResourceMetadata, ResourceProvider, Result,
5};
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ExtractedContent {
13 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub provenance: Option<ExtractionProvenance>,
16 pub text: String,
17 pub source: ResourceId,
18 pub locator: ResourceLocator,
19 pub metadata: ResourceMetadata,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ExtractionProvenance {
26 pub method: String,
27 pub implementation: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub speech: Option<SpeechProvenance>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct SpeechProvenance {
35 pub fidelity: String,
36 pub requested_locator: ResourceLocator,
37 pub sample_start: u64,
38 pub sample_end: u64,
39 pub sample_rate: u32,
40 pub manifest_sha256: String,
41 pub language_requested: Option<String>,
43 pub language_detected: Option<String>,
45 pub no_speech: bool,
46}
47
48impl std::fmt::Debug for ExtractedContent {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("ExtractedContent")
51 .field("text", &"[redacted]")
52 .field("source", &self.source)
53 .field("locator", &self.locator)
54 .field("provenance", &self.provenance)
55 .finish_non_exhaustive()
56 }
57}
58
59pub trait Extractor: Send + Sync {
68 fn supports(
69 &self,
70 source: &ResourceId,
71 locator: &ResourceLocator,
72 metadata: &ResourceMetadata,
73 ) -> bool;
74 fn extract(
75 &self,
76 bytes: &[u8],
77 source: &ResourceId,
78 locator: &ResourceLocator,
79 metadata: &ResourceMetadata,
80 ) -> Result<ExtractedContent>;
81}
82
83#[derive(Debug, Default)]
85pub struct TextExtractor;
86
87impl Extractor for TextExtractor {
88 fn supports(
89 &self,
90 _source: &ResourceId,
91 locator: &ResourceLocator,
92 metadata: &ResourceMetadata,
93 ) -> bool {
94 metadata.modality == Modality::Text && matches!(locator, ResourceLocator::Text { .. })
95 }
96
97 fn extract(
98 &self,
99 bytes: &[u8],
100 source: &ResourceId,
101 locator: &ResourceLocator,
102 metadata: &ResourceMetadata,
103 ) -> Result<ExtractedContent> {
104 if !self.supports(source, locator, metadata) {
105 return Err(unsupported(source, locator, metadata));
106 }
107 Ok(ExtractedContent {
108 provenance: None,
109 text: text_range(bytes, source, locator)?.to_owned(),
110 source: source.clone(),
111 locator: locator.clone(),
112 metadata: metadata.clone(),
113 })
114 }
115}
116
117pub fn extract_resource(
124 provider: &dyn ResourceProvider,
125 extractor: &dyn Extractor,
126 source: &ResourceId,
127 locator: &ResourceLocator,
128 metadata: &ResourceMetadata,
129) -> Result<ExtractedContent> {
130 #[cfg(feature = "audio")]
131 if metadata.modality == Modality::Audio {
132 crate::audio::extract_samples(provider, source, locator, metadata, 7200)?;
134 return Err(unsupported(source, locator, metadata));
135 }
136 if !extractor.supports(source, locator, metadata) {
137 return Err(unsupported(source, locator, metadata));
138 }
139 let read = ResourceBytes::read(provider, source)?;
140 extractor.extract(read.verify(source, metadata)?, source, locator, metadata)
141}
142
143pub(crate) fn unsupported(
144 source: &ResourceId,
145 locator: &ResourceLocator,
146 metadata: &ResourceMetadata,
147) -> Error {
148 if metadata.modality == Modality::Document
149 && metadata.media_type.as_deref() == Some("application/pdf")
150 && let ResourceLocator::Page { page } = locator
151 {
152 return Error::PdfOcrUnavailable {
153 resource: source.clone(),
154 page: *page,
155 };
156 }
157 Error::UnsupportedExtraction {
158 resource: source.clone(),
159 modality: metadata.modality,
160 locator: locator.clone(),
161 }
162}
163
164pub(crate) struct ResourceBytes {
166 bytes: Vec<u8>,
167 digest: String,
168}
169
170impl ResourceBytes {
171 pub(crate) fn read(provider: &dyn ResourceProvider, source: &ResourceId) -> Result<Self> {
172 let bytes = provider.read(source)?;
173 let digest = blake3::hash(&bytes).to_hex().to_string();
174 Ok(Self { bytes, digest })
175 }
176
177 pub(crate) fn verify(&self, source: &ResourceId, metadata: &ResourceMetadata) -> Result<&[u8]> {
178 if self.digest != metadata.content_hash {
179 return Err(Error::StaleResource {
180 resource: source.clone(),
181 expected: metadata.content_hash.clone(),
182 actual: self.digest.clone(),
183 });
184 }
185 Ok(&self.bytes)
186 }
187}
188
189pub(crate) fn text_range<'a>(
190 bytes: &'a [u8],
191 source: &ResourceId,
192 locator: &ResourceLocator,
193) -> Result<&'a str> {
194 let invalid = |reason| Error::InvalidLocator {
195 resource: source.clone(),
196 locator: locator.clone(),
197 reason,
198 };
199 let ResourceLocator::Text {
200 byte_start,
201 byte_end,
202 ..
203 } = locator
204 else {
205 return Err(invalid("expected a text byte range"));
206 };
207 let start =
208 usize::try_from(*byte_start).map_err(|_| invalid("byte offset exceeds platform limits"))?;
209 let end =
210 usize::try_from(*byte_end).map_err(|_| invalid("byte offset exceeds platform limits"))?;
211 let text = std::str::from_utf8(bytes).map_err(|error| Error::InvalidText {
212 resource: source.clone(),
213 source: error,
214 })?;
215 text.get(start..end).ok_or_else(|| {
216 invalid("range must be ordered, in bounds, and on UTF-8 character boundaries")
217 })
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn locator(start: u64, end: u64) -> ResourceLocator {
225 ResourceLocator::Text {
226 byte_start: start,
227 byte_end: end,
228 line_start: None,
229 line_end: None,
230 }
231 }
232
233 #[test]
234 fn scanned_page_extraction_has_a_typed_model_free_error() {
235 let source: ResourceId = "mem://scanned.pdf".parse().unwrap();
236 let metadata = ResourceMetadata {
237 media_type: Some("application/pdf".into()),
238 modality: Modality::Document,
239 size_bytes: 0,
240 modified_ns: None,
241 content_hash: String::new(),
242 };
243 let error = TextExtractor
244 .extract(&[], &source, &ResourceLocator::Page { page: 2 }, &metadata)
245 .unwrap_err();
246 assert!(matches!(error, Error::PdfOcrUnavailable { page: 2, .. }));
247 assert!(
248 error
249 .to_string()
250 .contains("readable text for scanned pages needs OCR, which is not available yet")
251 );
252 let mut other = metadata;
253 other.media_type = Some("application/other".into());
254 assert!(matches!(
255 unsupported(&source, &ResourceLocator::Page { page: 2 }, &other),
256 Error::UnsupportedExtraction { .. }
257 ));
258 }
259
260 #[test]
261 fn text_is_exact_and_ranges_are_checked() {
262 let source = "mem://notes/example".parse().unwrap();
263 for text in [
264 "# Title\r\n\n**文書** [link](url)\n",
265 " café\t🙂\r\n",
266 "fn main() {\n // café\n}\n",
267 ] {
268 let bytes = text.as_bytes();
269 let metadata = ResourceMetadata {
270 media_type: None,
271 modality: Modality::Text,
272 size_bytes: bytes.len() as u64,
273 modified_ns: None,
274 content_hash: blake3::hash(bytes).to_hex().to_string(),
275 };
276 let range = locator(0, bytes.len() as u64);
277 let content = TextExtractor
278 .extract(bytes, &source, &range, &metadata)
279 .unwrap();
280 assert_eq!(content.text.as_bytes(), bytes);
281 assert_eq!(content.source, source);
282 assert_eq!(content.locator, range);
283 assert_eq!(content.metadata, metadata);
284 assert_eq!(
285 text_range(
286 bytes,
287 &source,
288 &locator(bytes.len() as u64, bytes.len() as u64)
289 )
290 .unwrap(),
291 ""
292 );
293 for range in [locator(2, 1), locator(0, u64::MAX)] {
294 assert!(matches!(
295 text_range(bytes, &source, &range),
296 Err(Error::InvalidLocator { .. })
297 ));
298 }
299 }
300 assert_eq!(
301 text_range("a🙂b".as_bytes(), &source, &locator(1, 5)).unwrap(),
302 "🙂"
303 );
304 for range in [locator(2, 5), locator(1, 4), locator(2, 2)] {
305 assert!(matches!(
306 text_range("a🙂b".as_bytes(), &source, &range),
307 Err(Error::InvalidLocator { .. })
308 ));
309 }
310 assert!(matches!(
311 text_range(&[255], &source, &locator(0, 1)),
312 Err(Error::InvalidText { .. })
313 ));
314 }
315 struct Bytes(Vec<u8>);
316 impl ResourceProvider for Bytes {
317 fn list(&self, _: &crate::ResourceScope) -> Result<Vec<crate::ProvidedResource>> {
318 panic!("must not enumerate")
319 }
320 fn read(&self, _: &ResourceId) -> Result<Vec<u8>> {
321 Ok(self.0.clone())
322 }
323 fn version(&self, _: &ResourceId) -> Result<crate::ResourceVersion> {
324 panic!("must hash actual bytes")
325 }
326 }
327 struct NeverCalled;
328 impl Extractor for NeverCalled {
329 fn supports(&self, _: &ResourceId, _: &ResourceLocator, _: &ResourceMetadata) -> bool {
330 true
331 }
332 fn extract(
333 &self,
334 _: &[u8],
335 _: &ResourceId,
336 _: &ResourceLocator,
337 _: &ResourceMetadata,
338 ) -> Result<ExtractedContent> {
339 panic!("unverified bytes reached extractor")
340 }
341 }
342 #[test]
343 fn gateway_checks_hash_before_dispatch_and_rejects_unsupported() {
344 let source = "mem://notes/entry".parse().unwrap();
345 let provider = Bytes(b"hello".to_vec());
346 let mut metadata = ResourceMetadata {
347 media_type: None,
348 modality: Modality::Text,
349 size_bytes: 5,
350 modified_ns: None,
351 content_hash: blake3::hash(b"hello").to_hex().to_string(),
352 };
353 assert_eq!(
354 extract_resource(
355 &provider,
356 &TextExtractor,
357 &source,
358 &locator(1, 4),
359 &metadata
360 )
361 .unwrap()
362 .text,
363 "ell"
364 );
365 metadata.content_hash = blake3::hash(b"other").to_hex().to_string();
366 assert!(matches!(
367 extract_resource(&provider, &NeverCalled, &source, &locator(0, 5), &metadata),
368 Err(Error::StaleResource { .. })
369 ));
370 for range in [
371 ResourceLocator::WholeResource,
372 ResourceLocator::Page { page: 1 },
373 ResourceLocator::Time {
374 start_ms: 0,
375 end_ms: 1,
376 },
377 ResourceLocator::ImageRegion {
378 x: 0,
379 y: 0,
380 width: 1,
381 height: 1,
382 },
383 ] {
384 assert!(matches!(
385 extract_resource(&provider, &TextExtractor, &source, &range, &metadata),
386 Err(Error::UnsupportedExtraction { .. })
387 ));
388 }
389 for modality in [Modality::Image, Modality::Audio, Modality::Video] {
390 metadata.modality = modality;
391 assert!(matches!(
392 extract_resource(
393 &provider,
394 &TextExtractor,
395 &source,
396 &locator(0, 5),
397 &metadata
398 ),
399 Err(Error::UnsupportedExtraction { .. })
400 ));
401 }
402 }
403}