Skip to main content

lean_ctx/core/extractors/
mod.rs

1//! Format extractors & format-aware chunkers (`extractors-v1`, EPIC 12.13).
2//!
3//! The front-door that turns a non-code document/data file into clean LLM text
4//! plus structure-aware chunks. It complements [`super::ingestion`] (which
5//! decides *whether* to index) by deciding *how* to read a given format:
6//!
7//! | Format | Extractor | Chunking |
8//! |--------|-----------|----------|
9//! | JSON   | [`json`] | per array element / object entry |
10//! | CSV/TSV| [`csv`]  | header-prefixed row groups |
11//! | EML    | [`eml`]  | header summary + body paragraphs |
12//! | HTML   | [`super::web::html_to_text`] | paragraphs of rendered Markdown |
13//! | PDF    | [`super::web::pdf`] | paragraphs of extracted text |
14//! | text   | (verbatim) | paragraphs |
15//!
16//! The text-based formats also register as named [`Chunker`]s in the
17//! [`extension_registry`](super::extension_registry) so they are discoverable
18//! via `/v1/capabilities` and exercised by the conformance suite. Every
19//! extractor degrades gracefully — arbitrary input never panics and non-empty
20//! input always yields at least one non-empty chunk.
21
22// Chunker::name returns &str; literals here would otherwise trip the lint.
23#![allow(clippy::unnecessary_literal_bound)]
24
25pub mod csv;
26pub mod eml;
27pub mod json;
28
29use std::path::Path;
30use std::sync::Arc;
31
32use super::extension_registry::{Chunker, ExtensionRegistry};
33
34/// The result of extracting one document: a stable kind tag, clean text, and
35/// structure-aware chunks.
36#[derive(Debug, Clone)]
37pub struct Extracted {
38    pub kind: &'static str,
39    pub text: String,
40    pub chunks: Vec<String>,
41}
42
43/// Extract clean text + chunks from raw `bytes`, dispatching on `path`'s
44/// extension. Binary formats (PDF) read from bytes; text formats decode UTF-8
45/// lossily so malformed encodings still produce content.
46#[must_use]
47pub fn extract(path: &Path, bytes: &[u8]) -> Extracted {
48    let ext = path
49        .extension()
50        .and_then(|e| e.to_str())
51        .unwrap_or_default()
52        .to_ascii_lowercase();
53
54    match ext.as_str() {
55        "json" | "jsonl" | "ndjson" => {
56            let s = String::from_utf8_lossy(bytes);
57            Extracted {
58                kind: "json",
59                text: json::to_text(&s),
60                chunks: json::chunks(&s),
61            }
62        }
63        "csv" | "tsv" => {
64            let s = String::from_utf8_lossy(bytes);
65            let delim = if ext == "tsv" { '\t' } else { ',' };
66            Extracted {
67                kind: "csv",
68                text: csv::to_text(&s, delim),
69                chunks: csv::chunks(&s, delim),
70            }
71        }
72        "eml" => {
73            let s = String::from_utf8_lossy(bytes);
74            Extracted {
75                kind: "eml",
76                text: eml::to_text(&s),
77                chunks: eml::chunks(&s),
78            }
79        }
80        "html" | "htm" | "xhtml" => {
81            let s = String::from_utf8_lossy(bytes);
82            let doc = super::web::html_to_text::parse(&s);
83            Extracted {
84                kind: "html",
85                chunks: paragraph_chunks(&doc.markdown),
86                text: doc.markdown,
87            }
88        }
89        "pdf" => match super::web::pdf::extract_text(bytes) {
90            Ok(text) => Extracted {
91                kind: "pdf",
92                chunks: paragraph_chunks(&text),
93                text,
94            },
95            Err(e) => Extracted {
96                kind: "pdf",
97                text: String::new(),
98                chunks: vec![format!("[pdf extraction failed: {e}]")],
99            },
100        },
101        _ => {
102            let s = String::from_utf8_lossy(bytes).to_string();
103            Extracted {
104                kind: "text",
105                chunks: paragraph_chunks(&s),
106                text: s,
107            }
108        }
109    }
110}
111
112/// Whether `path` is a binary document format that must be read through
113/// [`extract`] from raw bytes because it is not valid UTF-8 text. Text and
114/// structured formats (json/csv/eml/html/markdown/…) index fine as raw UTF-8;
115/// only true binary documents — currently PDF — need byte-level extraction
116/// before they can enter the text index. Grows as binary extractors (DOCX,
117/// XLSX, …) are added. Single source of truth for the indexer's read path.
118#[must_use]
119pub fn is_binary_document(path: &Path) -> bool {
120    matches!(
121        path.extension()
122            .and_then(|e| e.to_str())
123            .map(str::to_ascii_lowercase)
124            .as_deref(),
125        Some("pdf")
126    )
127}
128
129/// Split `text` into paragraph chunks on blank-line boundaries, trimming and
130/// dropping empties. The shared fallback chunker for prose-like formats.
131#[must_use]
132pub fn paragraph_chunks(text: &str) -> Vec<String> {
133    text.split("\n\n")
134        .map(str::trim)
135        .filter(|s| !s.is_empty())
136        .map(String::from)
137        .collect()
138}
139
140/// Register the text-based format chunkers into `reg`. Called from
141/// [`ExtensionRegistry::with_builtins`] so the formats are first-class,
142/// discoverable, and conformance-checked.
143pub fn register_into(reg: &mut ExtensionRegistry) {
144    reg.register_chunker(Arc::new(FormatChunker {
145        name: "csv",
146        f: |s| csv::chunks(s, ','),
147    }));
148    reg.register_chunker(Arc::new(FormatChunker {
149        name: "json",
150        f: json::chunks,
151    }));
152    reg.register_chunker(Arc::new(FormatChunker {
153        name: "eml",
154        f: eml::chunks,
155    }));
156    reg.register_chunker(Arc::new(FormatChunker {
157        name: "html",
158        f: |s| paragraph_chunks(&super::web::html_to_text::parse(s).markdown),
159    }));
160}
161
162/// Adapter exposing a format chunk function as a named registry [`Chunker`].
163struct FormatChunker {
164    name: &'static str,
165    f: fn(&str) -> Vec<String>,
166}
167
168impl Chunker for FormatChunker {
169    fn name(&self) -> &str {
170        self.name
171    }
172    fn chunk(&self, input: &str) -> Vec<String> {
173        (self.f)(input)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn dispatches_json_by_extension() {
183        let e = extract(Path::new("data.json"), br#"[{"a":1}]"#);
184        assert_eq!(e.kind, "json");
185        assert_eq!(e.chunks.len(), 1);
186    }
187
188    #[test]
189    fn dispatches_csv_and_tsv() {
190        let csv = extract(Path::new("t.csv"), b"a,b\n1,2");
191        assert_eq!(csv.kind, "csv");
192        assert!(csv.text.contains("a: 1 | b: 2"));
193        let tsv = extract(Path::new("t.tsv"), b"a\tb\n1\t2");
194        assert!(tsv.text.contains("a: 1 | b: 2"));
195    }
196
197    #[test]
198    fn dispatches_html_to_markdown() {
199        let e = extract(Path::new("p.html"), b"<h1>Title</h1><p>Body</p>");
200        assert_eq!(e.kind, "html");
201        assert!(e.text.contains("Title"));
202    }
203
204    #[test]
205    fn unknown_extension_is_text_paragraphs() {
206        let e = extract(Path::new("notes.txt"), b"one\n\ntwo");
207        assert_eq!(e.kind, "text");
208        assert_eq!(e.chunks, vec!["one", "two"]);
209    }
210
211    #[test]
212    fn binary_document_predicate_matches_pdf_only() {
213        assert!(is_binary_document(Path::new("a.pdf")));
214        assert!(is_binary_document(Path::new("A.PDF")));
215        for f in ["p.html", "d.json", "t.csv", "m.eml", "n.txt", "s.rs"] {
216            assert!(!is_binary_document(Path::new(f)), "{f}");
217        }
218    }
219
220    #[test]
221    fn format_chunkers_register_and_run() {
222        let mut reg = ExtensionRegistry::new();
223        register_into(&mut reg);
224        for name in ["csv", "json", "eml", "html"] {
225            let c = reg
226                .chunker(name)
227                .unwrap_or_else(|| panic!("{name} missing"));
228            assert!(c.chunk("").is_empty(), "{name} empty input must be empty");
229            assert!(
230                !c.chunk("hello world").is_empty(),
231                "{name} non-empty input must chunk"
232            );
233        }
234    }
235}