Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 307 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions src/ai/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,38 @@ impl AiProvider for ClaudeProvider {
Ok(description)
}

async fn describe_text(
&self,
excerpt: &str,
context: &DescribeContext,
) -> Result<ContentDescription> {
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],
Expand Down
13 changes: 13 additions & 0 deletions src/ai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ pub trait AiProvider: Send + Sync {
context: &DescribeContext,
) -> impl std::future::Future<Output = Result<ContentDescription>> + Send;

/// Describe a file from an excerpt of its text content.
fn describe_text(
&self,
excerpt: &str,
context: &DescribeContext,
) -> impl std::future::Future<Output = Result<ContentDescription>> + Send
{
let _ = (excerpt, context);
async {
anyhow::bail!("text analysis not supported by this provider")
}
}

fn propose_groups(
&self,
files: &[FileSummary],
Expand Down
50 changes: 48 additions & 2 deletions src/ai/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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. \
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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"));
}
}
Loading
Loading