diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 8268e99..b0f8331 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -523,6 +523,8 @@ struct SummaryRequestBody { provider: String, #[serde(default)] force: bool, + #[serde(default)] + include_images: bool, } /// `GET /api/archives/:id/entries/:uid/summary` @@ -553,7 +555,8 @@ async fn entry_summary_handler( /// `POST /api/archives/:id/entries/:uid/summary` /// -/// Manual-only summary generation. Body: `{ "provider": "...", "force": false }`. +/// Manual-only summary generation. Body: `{ "provider": "...", "force": false, +/// "include_images": false }`. /// /// Provider configuration and content extraction are both resolved *before* /// spawning, so a missing env var or an unsummarizable artifact comes back as a @@ -577,7 +580,15 @@ async fn request_entry_summary_handler( // variable name, which is the whole point of returning it as a 400. let provider_cfg = summarizer::provider_from_env(&body.provider) .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; + if body.include_images && matches!(provider_cfg, summarizer::ProviderConfig::ClaudeCli(_)) { + return Err(ApiError::bad_request( + "Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI", + )); + } let provider = summarizer::provider_from_config(provider_cfg); + let summary_options = summarizer::SummaryBuildOptions { + include_images: body.include_images, + }; let conn = database::open_or_initialize(&mounted.archive_path)?; let entry_id = database::entry_id_for_uid(&conn, &entry_uid)? @@ -585,11 +596,7 @@ async fn request_entry_summary_handler( // 2. Extract the same content the summarizer will feed the model, so the // digest below is the identical cache key summarize_entry will compute. - let input = summarizer::build_summary_input( - &archive_paths, - &entry_uid, - summarizer::SummaryBuildOptions::default(), - ) + let input = summarizer::build_summary_input(&archive_paths, &entry_uid, summary_options) .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; // 3. Cache hit: identical entry + provider + model + prompt + input. @@ -628,6 +635,7 @@ async fn request_entry_summary_handler( let archive_path = mounted.archive_path.clone(); let entry_uid_bg = entry_uid.clone(); let summary_uid_bg = summary_uid.clone(); + let summary_options_bg = summary_options; tokio::task::spawn_blocking(move || { // summarize_entry owns the pending → running → completed/failed // transitions for this same row (the cache key is identical, so the @@ -637,7 +645,7 @@ async fn request_entry_summary_handler( summarizer::summarize_entry( &archive_paths, &entry_uid_bg, - summarizer::SummaryBuildOptions::default(), + summary_options_bg, provider.as_ref(), summarizer::PROMPT_VERSION, ) @@ -3007,6 +3015,46 @@ mod tests { .unwrap() } + fn add_summary_test_artifact( + archive_path: &std::path::Path, + entry_id: i64, + relpath: &str, + role: &str, + mime_type: &str, + contents: &[u8], + ) { + let paths = archive::read_archive_paths(archive_path).unwrap(); + let full_path = paths.store_path.join(relpath); + std::fs::create_dir_all(full_path.parent().unwrap()).unwrap(); + std::fs::write(&full_path, contents).unwrap(); + + let conn = database::open_or_initialize(archive_path).unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: format!("test-summary-{}", relpath.replace('/', "-")), + byte_size: contents.len().try_into().unwrap(), + mime_type: Some(mime_type.to_string()), + extension: relpath.rsplit('.').next().map(str::to_string), + raw_relpath: relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: role.to_string(), + storage_area: "raw".to_string(), + relpath: relpath.to_string(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + async fn body_json(response: axum::response::Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await @@ -3018,6 +3066,110 @@ mod tests { Body::from(serde_json::to_vec(payload).unwrap()) } + #[tokio::test] + async fn summary_include_images_rejects_claude_cli_without_creating_row() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let session_cookie = make_test_session(&auth_path); + + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "provider": "claude_cli", + "include_images": true, + }))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(response).await["error"], + "Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI" + ); + let conn = database::open_or_initialize(&archive_path).unwrap(); + assert!(database::latest_entry_summary(&conn, entry.id).unwrap().is_none()); + } + + #[tokio::test] + async fn summary_include_images_uses_distinct_cache_identity() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path, auth_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/summary-test.html", + "primary_media", + "text/html", + b"
summary fixture
", + ); + add_summary_test_artifact( + &archive_path, + entry.id, + "raw/summary-test.jpg", + "media", + "image/jpeg", + b"image fixture", + ); + let session_cookie = make_test_session(&auth_path); + let previous_codex_cli = std::env::var_os("ARCHIVR_CODEX_CLI"); + unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", "/usr/bin/false") }; + + let text_response = app(registry.clone(), auth_path.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ "provider": "codex_cli" }))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(text_response.status(), StatusCode::ACCEPTED); + + let image_response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{}/summary", entry.entry_uid)) + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(json_body(&serde_json::json!({ + "provider": "codex_cli", + "include_images": true, + }))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(image_response.status(), StatusCode::ACCEPTED); + match previous_codex_cli { + Some(value) => unsafe { std::env::set_var("ARCHIVR_CODEX_CLI", value) }, + None => unsafe { std::env::remove_var("ARCHIVR_CODEX_CLI") }, + } + + let conn = database::open_or_initialize(&archive_path).unwrap(); + let input_hashes: Vec = conn + .prepare("SELECT input_sha256 FROM entry_summaries WHERE entry_id = ?1") + .unwrap() + .query_map([entry.id], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(input_hashes.len(), 2); + assert_ne!(input_hashes[0], input_hashes[1]); + } + #[tokio::test] async fn archives_endpoint_lists_mounted_archives() { let dir = tempfile::tempdir().unwrap();