Skip to main content

seyk_core/
extractor.rs

1//! On-demand readable evidence, independent of models and caches.
2
3use crate::{
4    Error, Modality, ResourceId, ResourceLocator, ResourceMetadata, ResourceProvider, Result,
5};
6use serde::{Deserialize, Serialize};
7
8/// Readable content from a selected location in a verified resource snapshot.
9///
10/// Unstable: may change before 1.0 (a range/streaming method is planned for audio and video).
11#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ExtractedContent {
13    /// How readable text was derived; absent for exact raw text slices.
14    #[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    /// Includes the BLAKE3 hash of the entire source resource, not just `text`.
20    pub metadata: ResourceMetadata,
21}
22
23/// Provenance of derived readable evidence, not a claim about transcription accuracy.
24#[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/// Bounds identify model input, not word alignment or transcription accuracy.
33#[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    /// None means automatic language detection was requested.
42    pub language_requested: Option<String>,
43    /// None for no_speech; never invent a language for silence.
44    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
59/// Convert verified resource bytes into readable evidence.
60///
61/// Use [`extract_resource`] or [`crate::SearchEngine::extract`] for provider-backed
62/// extraction. Direct calls to `extract` must supply bytes already verified
63/// against `metadata.content_hash`. The central gateway verifies before calling
64/// any implementation. Future range/streaming extractors need a separate method.
65///
66/// Unstable: may change before 1.0 (a range/streaming method is planned for audio and video).
67pub 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/// Exact UTF-8 text ranges, preserving whitespace, Markdown and source code.
84#[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
117/// Read and verify a full resource before dispatching to an extractor.
118///
119/// This model-free entry point uses only the supplied provider. It does not
120/// establish authorization or authenticate a caller-supplied content hash.
121///
122/// Unstable: may change before 1.0 (a range/streaming method is planned for audio and video).
123pub 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        // The byte-based extractor contract must never load a long audio file.
133        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
164/// A single provider read and its digest; previews retain this only per search.
165pub(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}