mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
feat: accept image summary requests
This commit is contained in:
parent
a107b849b7
commit
2c79c83a9e
1 changed files with 159 additions and 7 deletions
|
|
@ -523,6 +523,8 @@ struct SummaryRequestBody {
|
||||||
provider: String,
|
provider: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
include_images: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/archives/:id/entries/:uid/summary`
|
/// `GET /api/archives/:id/entries/:uid/summary`
|
||||||
|
|
@ -553,7 +555,8 @@ async fn entry_summary_handler(
|
||||||
|
|
||||||
/// `POST /api/archives/:id/entries/:uid/summary`
|
/// `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*
|
/// Provider configuration and content extraction are both resolved *before*
|
||||||
/// spawning, so a missing env var or an unsummarizable artifact comes back as a
|
/// 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.
|
// variable name, which is the whole point of returning it as a 400.
|
||||||
let provider_cfg = summarizer::provider_from_env(&body.provider)
|
let provider_cfg = summarizer::provider_from_env(&body.provider)
|
||||||
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
.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 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 conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
let entry_id = database::entry_id_for_uid(&conn, &entry_uid)?
|
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
|
// 2. Extract the same content the summarizer will feed the model, so the
|
||||||
// digest below is the identical cache key summarize_entry will compute.
|
// digest below is the identical cache key summarize_entry will compute.
|
||||||
let input = summarizer::build_summary_input(
|
let input = summarizer::build_summary_input(&archive_paths, &entry_uid, summary_options)
|
||||||
&archive_paths,
|
|
||||||
&entry_uid,
|
|
||||||
summarizer::SummaryBuildOptions::default(),
|
|
||||||
)
|
|
||||||
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
||||||
|
|
||||||
// 3. Cache hit: identical entry + provider + model + prompt + input.
|
// 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 archive_path = mounted.archive_path.clone();
|
||||||
let entry_uid_bg = entry_uid.clone();
|
let entry_uid_bg = entry_uid.clone();
|
||||||
let summary_uid_bg = summary_uid.clone();
|
let summary_uid_bg = summary_uid.clone();
|
||||||
|
let summary_options_bg = summary_options;
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
// summarize_entry owns the pending → running → completed/failed
|
// summarize_entry owns the pending → running → completed/failed
|
||||||
// transitions for this same row (the cache key is identical, so the
|
// 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(
|
summarizer::summarize_entry(
|
||||||
&archive_paths,
|
&archive_paths,
|
||||||
&entry_uid_bg,
|
&entry_uid_bg,
|
||||||
summarizer::SummaryBuildOptions::default(),
|
summary_options_bg,
|
||||||
provider.as_ref(),
|
provider.as_ref(),
|
||||||
summarizer::PROMPT_VERSION,
|
summarizer::PROMPT_VERSION,
|
||||||
)
|
)
|
||||||
|
|
@ -3007,6 +3015,46 @@ mod tests {
|
||||||
.unwrap()
|
.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 {
|
async fn body_json(response: axum::response::Response) -> serde_json::Value {
|
||||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
|
|
@ -3018,6 +3066,110 @@ mod tests {
|
||||||
Body::from(serde_json::to_vec(payload).unwrap())
|
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]
|
#[tokio::test]
|
||||||
async fn archives_endpoint_lists_mounted_archives() {
|
async fn archives_endpoint_lists_mounted_archives() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue