1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 21:03:17 +02:00

feat: accept image summary requests

This commit is contained in:
archivr-qa 2026-08-23 21:32:26 +02:00
parent a107b849b7
commit 2c79c83a9e
No known key found for this signature in database

View file

@ -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"<article>summary fixture</article>",
);
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<String> = conn
.prepare("SELECT input_sha256 FROM entry_summaries WHERE entry_id = ?1")
.unwrap()
.query_map([entry.id], |row| row.get(0))
.unwrap()
.collect::<Result<_, _>>()
.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();