diff --git a/Cargo.lock b/Cargo.lock index c89c0ce..174bcb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -278,10 +298,28 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", "rayon-core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -304,6 +342,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -343,6 +387,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.63" @@ -366,6 +419,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -386,6 +445,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -484,6 +553,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -558,6 +636,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -616,6 +704,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "5.0.1" @@ -654,6 +752,15 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.16.0" @@ -705,6 +812,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "exr" version = "1.74.0" @@ -881,6 +997,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1392,6 +1518,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -1567,6 +1703,34 @@ dependencies = [ "imgref", ] +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "nom_locate", + "rand 0.9.4", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + [[package]] name = "lru" version = "0.12.5" @@ -1595,6 +1759,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.1" @@ -1696,6 +1870,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -1876,6 +2061,23 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1907,6 +2109,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2100,6 +2314,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "ratatui" version = "0.29.0" @@ -2529,6 +2749,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2638,6 +2869,7 @@ dependencies = [ "image_hasher", "infer", "kamadak-exif", + "pdf-extract", "ratatui", "ratatui-image", "rayon", @@ -2674,6 +2906,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2852,6 +3095,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -3098,18 +3356,60 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3208,6 +3508,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 51af24d..f564fcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ base64 = "0.22" hex = "0.4" atty = "0.2" dotenv = "0.15.0" +pdf-extract = "0.10.0" [dev-dependencies] tempfile = "3" diff --git a/src/ai/claude.rs b/src/ai/claude.rs index 038a302..42582d6 100644 --- a/src/ai/claude.rs +++ b/src/ai/claude.rs @@ -379,6 +379,38 @@ impl AiProvider for ClaudeProvider { Ok(description) } + async fn describe_text( + &self, + excerpt: &str, + context: &DescribeContext, + ) -> Result { + let user_text = + super::describe_text_user_prompt(context, excerpt); + let system_text = format!( + "{task}\n\n{instructions}", + task = super::describe_system_prompt(), + instructions = super::describe_response_instructions(), + ); + + let request = cached_api_request( + self.model.clone(), + 1024, + Some(vec![cached_system_block(system_text)]), + vec![Message { + role: "user", + content: vec![ContentBlock::Text { + text: user_text, + cache_control: None, + }], + }], + ); + + let text = self.send_request(request).await?; + let description: ContentDescription = serde_json::from_str(&text) + .context("Failed to parse description JSON from Claude")?; + Ok(description) + } + async fn propose_groups( &self, files: &[FileSummary], diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 6681ad0..ecff90a 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -23,6 +23,19 @@ pub trait AiProvider: Send + Sync { context: &DescribeContext, ) -> impl std::future::Future> + Send; + /// Describe a file from an excerpt of its text content. + fn describe_text( + &self, + excerpt: &str, + context: &DescribeContext, + ) -> impl std::future::Future> + Send + { + let _ = (excerpt, context); + async { + anyhow::bail!("text analysis not supported by this provider") + } + } + fn propose_groups( &self, files: &[FileSummary], diff --git a/src/ai/prompts.rs b/src/ai/prompts.rs index 55634ae..7746154 100644 --- a/src/ai/prompts.rs +++ b/src/ai/prompts.rs @@ -4,9 +4,9 @@ use crate::model::FileSummary; use super::DescribeContext; -const DESCRIBE_RESPONSE_INSTRUCTIONS: &str = "Focus on the SUBJECT and THEME of the content, not the file format.\nA photo, video, PDF, and spreadsheet about the same topic should get similar tags.\n\nRespond in JSON:\n{\n \"summary\": \"1-2 sentence description of the subject/theme of this content\",\n \"tags\": [\"topic1\", \"topic2\", \"topic3\"],\n \"suggested_category\": \"travel|nature|food|work|family|pets|sports|entertainment|art|science|tech|finance|health|education|events|vehicles|architecture|other\",\n \"confidence\": 0.0-1.0\n}"; +const DESCRIBE_RESPONSE_INSTRUCTIONS: &str = "Focus on the SUBJECT and THEME of the content, not the file format.\nA photo, video, PDF, and spreadsheet about the same topic should get similar tags.\n\nBe SPECIFIC enough that similar files can be told apart later:\n- Photos: say WHO is in the frame (how many people, adults/children, selfie vs posed vs candid), any pets and their species, the setting, and the activity or event. Two photos of the same person must get DIFFERENT descriptions when the companions, pets, location, or activity differ.\n- Documents: identify the document TYPE (contract, court filing, invoice, letter, medical record, ...), the parties or organizations involved, and any case numbers, matter names, account numbers, or dates. Two legal documents from different cases must be distinguishable from their summaries alone.\n- Screenshots: name the app or site shown and what is happening in it.\n\nRespond in JSON:\n{\n \"summary\": \"1-2 sentence description specific to THIS file's subject\",\n \"tags\": [\"5-8 tags, most specific first (e.g. 'couple-photo', 'smith-v-jones', 'golden-retriever'), ending with general ones (e.g. 'pets', 'legal')\"],\n \"suggested_category\": \"travel|nature|food|work|family|pets|sports|entertainment|art|science|tech|finance|health|education|events|vehicles|architecture|legal|other\",\n \"confidence\": 0.0-1.0\n}"; -const GROUP_RESPONSE_INSTRUCTIONS: &str = "Respond in JSON:\n{\n \"groups\": [\n {\n \"label\": \"...\",\n \"rationale\": \"...\",\n \"members\": [\n { \"index\": 0, \"dest_name\": \"image1.jpg\" },\n { \"index\": 3, \"dest_name\": \"porn/nude.jpg\" }\n ]\n }\n ]\n}\n\nRules:\n- A file can only be in one group\n- Groups should have at least 2 members\n- Prefer fewer, larger groups over many tiny ones\n- Files that don't fit any group can be omitted\n- NEVER group by file type — group by subject, theme, or context\n- A .jpg, .mp4, .pdf, and .csv can all belong in the same group if they share a topic\n- For each member, dest_name is the filename or sub-path to use inside the group folder\n- Preserve source subfolder prefixes in dest_name ONLY when they add meaningful context\n- Drop misleading or redundant subfolder prefixes (e.g. a cat photo in \"porn/\" → just the filename)\n- dest_name must always end with the original file's name and extension\n"; +const GROUP_RESPONSE_INSTRUCTIONS: &str = "Respond in JSON:\n{\n \"groups\": [\n {\n \"label\": \"...\",\n \"rationale\": \"...\",\n \"members\": [\n { \"index\": 0, \"dest_name\": \"image1.jpg\" },\n { \"index\": 3, \"dest_name\": \"porn/nude.jpg\" }\n ]\n }\n ]\n}\n\nRules:\n- A file can only be in one group\n- Groups should have at least 2 members\n- Prefer SPECIFIC groups over broad catch-alls. \"Alex & Katy\", \"Selfies\", and \"Dog Photos\" are better than one \"Personal Photos\" bucket. \"Smith v. Jones Lawsuit\" and \"Apartment Lease\" are better than one \"Legal Documents\" bucket.\n- Split a broad theme whenever the summaries/tags distinguish sub-subjects: different people pictured, different pets, different cases or matters, different trips or events\n- Use dest_name sub-paths to organize within a group (e.g. \"2023-trip/beach.jpg\") when members share a group but differ in sub-subject\n- Files that don't fit any group can be omitted\n- NEVER group by file type — group by subject, theme, or context\n- A .jpg, .mp4, .pdf, and .csv can all belong in the same group if they share a topic\n- For each member, dest_name is the filename or sub-path to use inside the group folder\n- Preserve source subfolder prefixes in dest_name ONLY when they add meaningful context\n- Drop misleading or redundant subfolder prefixes (e.g. a cat photo in \"porn/\" → just the filename)\n- dest_name must always end with the original file's name and extension\n"; pub fn describe_system_prompt() -> &'static str { "You are helping organize a messy folder. \ @@ -51,6 +51,17 @@ pub fn group_user_prompt(files: &[FileSummary]) -> String { prompt } +pub fn describe_text_user_prompt( + context: &DescribeContext, + excerpt: &str, +) -> String { + let mut prompt = describe_user_prompt(context); + prompt.push_str("\nContent excerpt (may be truncated):\n---\n"); + prompt.push_str(excerpt); + prompt.push_str("\n---\n"); + prompt +} + pub fn build_describe_prompt(context: &DescribeContext) -> String { format!( "{describe_system}\n\n{user}\n{instructions}", @@ -294,4 +305,39 @@ mod tests { assert!(prompt.contains("\"members\"")); assert!(prompt.contains("\"index\"")); } + + #[test] + fn describe_text_prompt_includes_excerpt_and_filename() { + let ctx = DescribeContext { + filename: "lease.pdf".to_string(), + file_type_label: "PDF document".to_string(), + file_size: 4096, + metadata_hint: None, + }; + + let prompt = describe_text_user_prompt( + &ctx, + "LEASE AGREEMENT between Alice and Bob", + ); + + assert!(prompt.contains("lease.pdf")); + assert!(prompt.contains("LEASE AGREEMENT between Alice and Bob")); + } + + #[test] + fn describe_instructions_demand_specific_distinctions() { + let instructions = describe_response_instructions(); + + assert!(instructions.contains("selfie")); + assert!(instructions.contains("case numbers")); + assert!(instructions.contains("5-8 tags")); + } + + #[test] + fn group_prompt_prefers_specific_groups() { + let prompt = group_system_prompt(); + + assert!(prompt.contains("SPECIFIC groups")); + assert!(!prompt.contains("Prefer fewer, larger groups")); + } } diff --git a/src/analyze/mod.rs b/src/analyze/mod.rs index 7e39754..644f213 100644 --- a/src/analyze/mod.rs +++ b/src/analyze/mod.rs @@ -26,9 +26,13 @@ fn default_cache_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".cache/spindle")) } +/// Bump when the describe prompts change materially — old cached +/// descriptions are too shallow for the new grouping to work with. +const ANALYSIS_CACHE_VERSION: u32 = 2; + fn cache_path(cache_dir: &Path, blake3_hash: &[u8; 32]) -> PathBuf { let hex = hex::encode(blake3_hash); - cache_dir.join(format!("{hex}.json")) + cache_dir.join(format!("{hex}.v{ANALYSIS_CACHE_VERSION}.json")) } pub async fn read_cache( @@ -82,6 +86,11 @@ pub async fn analyze_file( analyze_video(provider, file, &filename).await? } else if file.scanned.file_type.is_image() { analyze_image(provider, file, &filename).await? + } else if matches!( + file.scanned.file_type, + crate::model::FileType::Document(_) + ) { + analyze_document(provider, file, &filename).await? } else { describe_by_filename(file, &filename) }; @@ -119,6 +128,116 @@ fn describe_by_filename( } } +/// Max bytes of extracted text sent to the API per document +/// (~2k tokens). +const MAX_TEXT_EXCERPT_BYTES: usize = 8 * 1024; + +/// Analyze a document by extracting its text content. Falls back to +/// a filename-only description when no text can be extracted +/// (scanned PDFs, binary formats like doc/docx). +async fn analyze_document( + provider: &impl AiProvider, + file: &FingerprintedFile, + filename: &str, +) -> Result { + let excerpt = extract_document_text(file).await; + + let excerpt = match excerpt { + Some(text) if !text.trim().is_empty() => text, + _ => return Ok(describe_by_filename(file, filename)), + }; + + let context = DescribeContext { + filename: filename.to_string(), + file_type_label: document_type_label(file), + file_size: file.scanned.size, + metadata_hint: None, + }; + + provider.describe_text(&excerpt, &context).await +} + +fn document_type_label(file: &FingerprintedFile) -> String { + let ext = file + .scanned + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("unknown"); + format!("{} document", ext.to_uppercase()) +} + +/// Extract a text excerpt from a document file. Returns None for +/// formats we can't extract (doc, docx, rtf) or unreadable files. +async fn extract_document_text( + file: &FingerprintedFile, +) -> Option { + use crate::model::DocumentFormat as Df; + use crate::model::FileType; + + let format = match file.scanned.file_type { + FileType::Document(f) => f, + _ => return None, + }; + + match format { + Df::Pdf => extract_pdf_text(&file.scanned.path).await, + Df::Txt + | Df::Md + | Df::Csv + | Df::Json + | Df::Xml + | Df::Html + | Df::Yaml + | Df::Toml => read_text_excerpt(&file.scanned.path).await, + Df::Doc | Df::Docx | Df::Rtf => None, + } +} + +async fn read_text_excerpt(path: &std::path::Path) -> Option { + use tokio::io::AsyncReadExt; + + let mut f = tokio::fs::File::open(path).await.ok()?; + let mut buf = vec![0u8; MAX_TEXT_EXCERPT_BYTES]; + let mut filled = 0; + while filled < buf.len() { + match f.read(&mut buf[filled..]).await { + Ok(0) => break, + Ok(n) => filled += n, + Err(_) => return None, + } + } + buf.truncate(filled); + Some(String::from_utf8_lossy(&buf).into_owned()) +} + +async fn extract_pdf_text(path: &std::path::Path) -> Option { + let path = path.to_path_buf(); + // pdf-extract is sync and can panic on malformed PDFs — isolate + // it on a blocking thread and treat panics as "no text". + let result = tokio::task::spawn_blocking(move || { + std::panic::catch_unwind(|| pdf_extract::extract_text(&path)) + }) + .await + .ok()?; + + match result { + Ok(Ok(text)) => Some(truncate_to_excerpt(text)), + _ => None, + } +} + +fn truncate_to_excerpt(text: String) -> String { + if text.len() <= MAX_TEXT_EXCERPT_BYTES { + return text; + } + let mut end = MAX_TEXT_EXCERPT_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + text[..end].to_string() +} + async fn analyze_image( provider: &impl AiProvider, file: &FingerprintedFile, @@ -317,8 +436,9 @@ mod tests { assert_eq!( path, PathBuf::from(format!( - "/cache/{}.json", - hex::encode([0xAB; 32]) + "/cache/{}.v{}.json", + hex::encode([0xAB; 32]), + ANALYSIS_CACHE_VERSION )) ); } @@ -602,4 +722,159 @@ mod tests { read_cache(cache_dir.path(), &file.blake3_hash).await; assert!(cached.is_some()); } + + fn make_document_file( + dir: &Path, + name: &str, + content: &[u8], + format: crate::model::DocumentFormat, + ) -> FingerprintedFile { + let mut file = make_test_file(dir, name, content); + file.scanned.file_type = FileType::Document(format); + file + } + + /// Provider that records the excerpt passed to describe_text. + struct TextCapturingProvider { + captured: std::sync::Mutex>, + } + + impl AiProvider for TextCapturingProvider { + async fn describe_image( + &self, + _: &[u8], + _: &str, + _: &DescribeContext, + ) -> Result { + panic!("describe_image should not be called for documents"); + } + + async fn describe_text( + &self, + excerpt: &str, + _: &DescribeContext, + ) -> Result { + *self.captured.lock().unwrap() = Some(excerpt.to_string()); + Ok(ContentDescription { + summary: "Lease agreement for 123 Main St".to_string(), + tags: vec!["lease".to_string(), "legal".to_string()], + suggested_category: "legal".to_string(), + confidence: 0.9, + }) + } + + async fn propose_groups( + &self, + _: &[crate::model::FileSummary], + ) -> Result> { + panic!("unused"); + } + } + + #[tokio::test] + async fn analyze_file_sends_text_content_for_documents() { + let cache_dir = TempDir::new().unwrap(); + let file_dir = TempDir::new().unwrap(); + let file = make_document_file( + file_dir.path(), + "lease.txt", + b"RESIDENTIAL LEASE AGREEMENT between Alice and Bob", + crate::model::DocumentFormat::Txt, + ); + + let opts = AnalyzeOptions { + cache_dir: cache_dir.path().to_path_buf(), + max_concurrent: 1, + }; + + let provider = TextCapturingProvider { + captured: std::sync::Mutex::new(None), + }; + + let result = analyze_file(&provider, &file, &opts).await.unwrap(); + + let captured = provider.captured.lock().unwrap(); + assert!(captured + .as_deref() + .unwrap() + .contains("RESIDENTIAL LEASE AGREEMENT")); + assert_eq!(result.suggested_category, "legal"); + } + + #[tokio::test] + async fn analyze_file_falls_back_to_filename_for_docx() { + let cache_dir = TempDir::new().unwrap(); + let file_dir = TempDir::new().unwrap(); + let file = make_document_file( + file_dir.path(), + "report.docx", + b"PK\x03\x04 binary docx bytes", + crate::model::DocumentFormat::Docx, + ); + + let opts = AnalyzeOptions { + cache_dir: cache_dir.path().to_path_buf(), + max_concurrent: 1, + }; + + let provider = TextCapturingProvider { + captured: std::sync::Mutex::new(None), + }; + + let result = analyze_file(&provider, &file, &opts).await.unwrap(); + + assert!(provider.captured.lock().unwrap().is_none()); + assert_eq!(result.suggested_category, "document"); + assert!(result.summary.contains("report.docx")); + } + + #[tokio::test] + async fn analyze_file_falls_back_when_document_is_empty() { + let cache_dir = TempDir::new().unwrap(); + let file_dir = TempDir::new().unwrap(); + let file = make_document_file( + file_dir.path(), + "empty.txt", + b" \n\t ", + crate::model::DocumentFormat::Txt, + ); + + let opts = AnalyzeOptions { + cache_dir: cache_dir.path().to_path_buf(), + max_concurrent: 1, + }; + + let provider = TextCapturingProvider { + captured: std::sync::Mutex::new(None), + }; + + let result = analyze_file(&provider, &file, &opts).await.unwrap(); + + assert!(provider.captured.lock().unwrap().is_none()); + assert_eq!(result.suggested_category, "document"); + } + + #[tokio::test] + async fn read_text_excerpt_caps_at_limit() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("big.txt"); + std::fs::write(&path, "x".repeat(MAX_TEXT_EXCERPT_BYTES * 3)) + .unwrap(); + + let excerpt = read_text_excerpt(&path).await.unwrap(); + + assert_eq!(excerpt.len(), MAX_TEXT_EXCERPT_BYTES); + } + + #[test] + fn truncate_to_excerpt_respects_char_boundaries() { + let mut text = "a".repeat(MAX_TEXT_EXCERPT_BYTES - 1); + text.push('é'); + text.push_str("trailing"); + + let truncated = truncate_to_excerpt(text); + + assert!(truncated.len() <= MAX_TEXT_EXCERPT_BYTES); + assert!(truncated.is_char_boundary(truncated.len())); + } } diff --git a/src/config.rs b/src/config.rs index 66657f0..861b595 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,7 +180,7 @@ fn default_output_dir() -> PathBuf { } fn default_model() -> String { - "claude-sonnet-4-20250514".to_string() + "claude-opus-4-8".to_string() } fn default_max_concurrent() -> usize { @@ -300,7 +300,7 @@ mod tests { let config = Config::load(&cli).unwrap(); - assert_eq!(config.ai.model, "claude-sonnet-4-20250514"); + assert_eq!(config.ai.model, "claude-opus-4-8"); assert_eq!(config.ai.max_concurrent_requests, 5); assert_eq!(config.duplicates.near_duplicate_threshold, 8); } diff --git a/src/pipeline.rs b/src/pipeline.rs index 4aaa65f..162cbe4 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::Result; const BYTES_PER_MB: u64 = 1_000_000; use tokio::sync::mpsc; @@ -53,6 +53,9 @@ pub enum PipelineEvent { failed: usize, failed_files: Vec<(String, String)>, }, + GroupingFailed { + error: String, + }, GroupingComplete { group_count: usize, }, @@ -303,10 +306,25 @@ async fn run_ai_pipeline( }) .await; - provider - .propose_groups(&summaries) - .await - .context("Failed to propose semantic groups") + match provider.propose_groups(&summaries).await { + Ok(groups) => Ok(groups), + Err(err) => { + let _ = tx + .send(PipelineEvent::GroupingFailed { + error: format!("{err:#}"), + }) + .await; + Ok(vec![ProposedGroup { + label: "All Files".to_string(), + rationale: format!( + "Semantic grouping failed ({err:#}), \ + falling back to single group" + ), + member_indices: (0..summaries.len()).collect(), + member_destinations: vec![], + }]) + } + } } #[cfg(test)] diff --git a/src/progress.rs b/src/progress.rs index f776097..884974a 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -263,6 +263,10 @@ impl PipelineProgress { println!(" \u{2717} {} \u{2014} {}", name, err); } } + PipelineEvent::GroupingFailed { error } => { + println!(" \u{26a0} Semantic grouping failed: {}", error,); + println!(" Falling back to single group"); + } PipelineEvent::GroupingComplete { group_count } => { tracing::info!(group_count, "Semantic grouping complete"); } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index cc72d87..f40e350 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -285,7 +285,7 @@ async fn analyze_caches_and_reuses_results() { let cache_file = cache_dir .path() - .join(format!("{}.json", hex::encode(file.blake3_hash))); + .join(format!("{}.v2.json", hex::encode(file.blake3_hash))); assert!(cache_file.exists()); let result2 =