diff --git a/.vale/styles/config/vocabularies/Bobbin/accept.txt b/.vale/styles/config/vocabularies/Bobbin/accept.txt index bd2cb3c..2db9ad2 100644 --- a/.vale/styles/config/vocabularies/Bobbin/accept.txt +++ b/.vale/styles/config/vocabularies/Bobbin/accept.txt @@ -81,11 +81,13 @@ MCP mdbook middleware monorepo +[Mm]ultimodal Namespace OAuth Onboarding param params +PDFs pensieve Pensieve polars @@ -104,6 +106,7 @@ ripgrep rmcp rrf_k rst +runbooks rustup save_session search_beads diff --git a/CHANGELOG.md b/CHANGELOG.md index af794d8..4713f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Multimodal ingest — PDF text (bo-j5r0)** — opt-in `[index] multimodal` + flag. When enabled, `bobbin index` also walks `**/*.pdf`, extracts text via a + pure-Rust extractor (`pdf-extract`; no Python/native toolchain), and chunks it + like a plain-text document (`language = "pdf"`) so runbooks, design docs, and + specs become searchable. Off by default — no change to the default + dep/behavior profile. Image captioning (vision LLM) is tracked as a follow-up. + ## [0.4.0] - 2026-06-27 Search quality, knowledge-graph ranking, workflow telemetry, and a bead diff --git a/Cargo.toml b/Cargo.toml index ff7b18c..1f0fb97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,7 @@ axum = "0.8" tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } urlencoding = "2" +pdf-extract = "0.12" [features] knowledge = ["dep:quipu"] diff --git a/docs/book/src/config/index.md b/docs/book/src/config/index.md index 332765e..63bece8 100644 --- a/docs/book/src/config/index.md +++ b/docs/book/src/config/index.md @@ -40,6 +40,10 @@ exclude = [ ] use_gitignore = true + +# Multimodal ingest (opt-in). When true, the indexer also walks PDFs, +# extracts their text, and chunks it like a plain-text document. +multimodal = false ``` ## Options @@ -49,9 +53,25 @@ use_gitignore = true | `include` | string[] | See above | Glob patterns for files to include | | `exclude` | string[] | See above | Additional exclusion patterns (on top of `.gitignore`) | | `use_gitignore` | bool | `true` | Whether to respect `.gitignore` files | +| `multimodal` | bool | `false` | Enable multimodal ingest (PDF text extraction). See below. | ## Notes - **Include patterns** determine which file extensions are parsed and indexed. Add patterns to index additional file types. - **Exclude patterns** are applied in addition to `.gitignore`. Use them to skip generated code, vendor directories, or other non-useful content. - When `use_gitignore` is `true`, files matched by `.gitignore` are automatically excluded even if they match an include pattern. + +## Multimodal ingest + +By default bobbin indexes code, markdown, and beads. Set `multimodal = true` to +also ingest **PDFs** (runbooks, design docs, specs): + +- The indexer automatically walks `**/*.pdf` — you do **not** need to add it to + `include`. Toggling the flag is the only knob. +- Text is extracted with a pure-Rust extractor (no Python, no native toolchain) + and chunked like a plain-text document. Chunks are tagged with + `language = "pdf"`, so you can filter on them in search. +- Image-only or encrypted PDFs may yield little or no text; those files are + skipped the same way an empty file is. +- Image captioning (vision LLM) is **not** yet supported and is tracked as a + follow-up. diff --git a/src/cli/index.rs b/src/cli/index.rs index 80d6851..3d9e561 100644 --- a/src/cli/index.rs +++ b/src/cli/index.rs @@ -286,8 +286,7 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> { .to_string(); if !args.force { - let content = std::fs::read_to_string(file_path) - .with_context(|| format!("Failed to read {}", file_path.display()))?; + let content = read_indexable_content(file_path, &config)?; let hash = compute_hash(&content); if let Some(stored_hash) = metadata_store.get_file_hash(&rel_path)? { @@ -391,7 +390,7 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> { .to_string(); let t_read = Instant::now(); - let content = match std::fs::read_to_string(file_path) { + let content = match read_indexable_content(file_path, &config) { Ok(c) => c, Err(e) => { errors.push((rel_path.clone(), e.to_string())); @@ -1362,13 +1361,35 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> { Ok(()) } +/// Read a file's text content for indexing. +/// +/// For multimodal-enabled file types (currently PDFs) the text is extracted via +/// [`crate::index::multimodal`]; everything else is read as UTF-8. The +/// multimodal branch only activates when `index.multimodal` is set, so default +/// indexing behavior is unchanged. +fn read_indexable_content(path: &Path, config: &Config) -> Result { + if config.index.multimodal && crate::index::multimodal::is_multimodal_file(path) { + crate::index::multimodal::extract_text(path) + } else { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display())) + } +} + /// Collect all files to index based on configuration patterns fn collect_files(repo_root: &Path, config: &Config) -> Result> { let mut files = Vec::new(); - let include_patterns: Vec = config - .index - .include + let mut include_globs: Vec = config.index.include.clone(); + if config.index.multimodal { + // Opt-in: also walk multimodal file types (PDFs) so the extractor can + // turn them into searchable text. Kept separate from the default + // include list so toggling the flag is the only knob users need. + for ext in crate::index::multimodal::MULTIMODAL_EXTENSIONS { + include_globs.push(format!("**/*.{ext}")); + } + } + let include_patterns: Vec = include_globs .iter() .filter_map(|p| glob::Pattern::new(p).ok()) .collect(); @@ -1771,6 +1792,30 @@ mod tests { assert!(txt_files.is_empty()); } + #[test] + fn test_collect_files_multimodal_flag_gates_pdf() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + std::fs::write(root.join("main.rs"), "fn main() {}").unwrap(); + std::fs::write(root.join("runbook.pdf"), b"%PDF-1.4 dummy").unwrap(); + + // Off by default: PDFs are not walked. + let mut config = Config::default(); + assert!(!config.index.multimodal); + let files = collect_files(root, &config).unwrap(); + assert!(!files + .iter() + .any(|p| p.extension().map(|e| e == "pdf").unwrap_or(false))); + + // Opt-in: the flag makes the walker pick up PDFs. + config.index.multimodal = true; + let files = collect_files(root, &config).unwrap(); + assert!(files + .iter() + .any(|p| p.file_name().map(|n| n == "runbook.pdf").unwrap_or(false))); + } + #[test] fn test_collect_files_excludes_patterns() { let dir = tempdir().unwrap(); diff --git a/src/config.rs b/src/config.rs index 6985c23..2f3b805 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,6 +68,11 @@ pub struct IndexConfig { /// Overlapping lines between consecutive line-based chunks. Default: 10. /// Must be smaller than `chunk_size`. pub chunk_overlap: usize, + /// Enable multimodal ingest (currently PDF text extraction). When true, the + /// indexer also walks `**/*.pdf`, extracts their text via a pure-Rust + /// extractor, and chunks it like a plain-text document (language = "pdf"). + /// Default: false — opt-in so the default behavior/dep profile is unchanged. + pub multimodal: bool, } impl Default for IndexConfig { @@ -110,6 +115,7 @@ impl Default for IndexConfig { use_gitignore: true, chunk_size: 50, chunk_overlap: 10, + multimodal: false, } } } diff --git a/src/index/mod.rs b/src/index/mod.rs index f9b0847..41fa8a4 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -4,6 +4,7 @@ pub mod coverage; pub mod cross_repo; pub mod embedder; pub mod git; +pub mod multimodal; pub mod parser; pub mod resolver; diff --git a/src/index/multimodal.rs b/src/index/multimodal.rs new file mode 100644 index 0000000..e03f515 --- /dev/null +++ b/src/index/multimodal.rs @@ -0,0 +1,93 @@ +//! Multimodal ingest: extract searchable text from non-source file types. +//! +//! Today this covers PDFs (runbooks, design docs) via the pure-Rust +//! `pdf-extract` crate — no Python, no native toolchain. The extracted text is +//! handed back to the normal chunking pipeline (language = "pdf") so PDFs become +//! searchable and graph-extractable alongside code and markdown. +//! +//! Image captioning (vision LLM) is intentionally out of scope here: bobbin has +//! no chat/vision provider path yet, so it is tracked as a follow-up bead. This +//! module is the seam where that second extractor will slot in. + +use std::path::Path; + +use anyhow::{Context, Result}; + +/// File extensions handled by the multimodal extractor. +/// +/// Centralized so the indexer's include-pattern injection, the per-file routing +/// decision, and any future additions all agree on one list. +pub const MULTIMODAL_EXTENSIONS: &[&str] = &["pdf"]; + +/// Returns true if `path` has an extension the multimodal extractor handles. +/// Comparison is case-insensitive (`.PDF` counts). +pub fn is_multimodal_file(path: &Path) -> bool { + match path.extension().and_then(|e| e.to_str()) { + Some(ext) => { + let ext = ext.to_ascii_lowercase(); + MULTIMODAL_EXTENSIONS.contains(&ext.as_str()) + } + None => false, + } +} + +/// Extract plain text from a multimodal file for indexing. +/// +/// Currently only PDFs are supported; callers gate this behind +/// [`is_multimodal_file`] and the `index.multimodal` config flag, so an +/// unsupported extension here is a programming error, not user input. +pub fn extract_text(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + + match ext.as_str() { + "pdf" => extract_pdf_text(path), + other => anyhow::bail!("multimodal extractor: unsupported extension {other:?}"), + } +} + +/// Extract text from a PDF using the pure-Rust `pdf-extract` crate. +/// +/// Returns the concatenated text content. Encrypted or image-only PDFs may +/// yield little or no text; callers treat empty output the same as an empty +/// file (skipped), so this only errors on genuine parse failures. +fn extract_pdf_text(path: &Path) -> Result { + pdf_extract::extract_text(path) + .with_context(|| format!("failed to extract text from PDF {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn detects_pdf_extension_case_insensitively() { + assert!(is_multimodal_file(&PathBuf::from("docs/runbook.pdf"))); + assert!(is_multimodal_file(&PathBuf::from("docs/RUNBOOK.PDF"))); + assert!(!is_multimodal_file(&PathBuf::from("src/main.rs"))); + assert!(!is_multimodal_file(&PathBuf::from("README.md"))); + assert!(!is_multimodal_file(&PathBuf::from("no_extension"))); + } + + #[test] + fn extract_text_rejects_unsupported_extension() { + let err = extract_text(&PathBuf::from("photo.png")).unwrap_err(); + assert!(err.to_string().contains("unsupported extension")); + } + + #[test] + fn extracts_text_from_sample_pdf() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.pdf"); + let text = extract_text(&fixture).expect("extract sample PDF"); + // Whitespace/layout is extractor-dependent; assert the words survive. + let normalized: String = text.split_whitespace().collect::>().join(" "); + assert!( + normalized.contains("Hello Bobbin multimodal PDF ingest"), + "extracted text was: {text:?}", + ); + } +} diff --git a/src/index/parser.rs b/src/index/parser.rs index 3fd5579..7478bf4 100644 --- a/src/index/parser.rs +++ b/src/index/parser.rs @@ -862,6 +862,9 @@ fn detect_language(path: &Path) -> Option { "c" | "h" => Some("c".to_string()), "cpp" | "cc" | "hpp" => Some("cpp".to_string()), "md" => Some("markdown".to_string()), + // PDF text (extracted upstream by index::multimodal). Falls through to + // line-based chunking in parse_file, tagged with language = "pdf". + "pdf" => Some("pdf".to_string()), _ => None, } } diff --git a/tests/fixtures/sample.pdf b/tests/fixtures/sample.pdf new file mode 100644 index 0000000..2f8f776 Binary files /dev/null and b/tests/fixtures/sample.pdf differ