diff --git a/crates/archivr-core/src/summarizer.rs b/crates/archivr-core/src/summarizer.rs index 6b1741c..f30971e 100644 --- a/crates/archivr-core/src/summarizer.rs +++ b/crates/archivr-core/src/summarizer.rs @@ -32,6 +32,13 @@ use crate::{archive::ArchivePaths, database, hash}; /// silently mixing outputs from two different prompts. pub const PROMPT_VERSION: &str = "v1-2026-08-22"; +/// Maximum number of explicitly opted-in local images supplied to a summary. +pub const MAX_SUMMARY_IMAGES: usize = 4; +/// Maximum byte size for one explicitly opted-in image. +pub const MAX_SUMMARY_IMAGE_BYTES: u64 = 5 * 1024 * 1024; +/// Maximum combined byte size for explicitly opted-in images. +pub const MAX_SUMMARY_IMAGE_TOTAL_BYTES: u64 = 12 * 1024 * 1024; + /// Upper bound on characters fed to a model. Archived pages run to hundreds of /// kilobytes; past this point we are paying for tokens that do not change a /// five-sentence summary. Truncation happens *before* hashing so the cache key @@ -61,6 +68,24 @@ short or empty to summarize, still return the object and say so in \"summary\"." // ── Request / output types ───────────────────────────────────────────────── +/// Controls optional material included while building a summary request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SummaryBuildOptions { + pub include_images: bool, +} + +/// An archived image that was explicitly selected for a summary request. +/// +/// `archive_file` is an absolute local path, kept inside the archive store and +/// never exposed through an API response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryImage { + pub sha256: String, + pub mime_type: String, + pub byte_size: u64, + pub archive_file: PathBuf, +} + /// Everything the prompt builder needs about one entry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SummaryRequest { @@ -69,6 +94,7 @@ pub struct SummaryRequest { pub source_kind: String, pub entity_kind: String, pub content: String, + pub images: Vec, } /// What a provider produced. `model` is echoed back because HTTP providers may @@ -804,7 +830,104 @@ fn load_summary_artifacts( Ok(rows) } -pub fn build_summary_input(paths: &ArchivePaths, entry_uid: &str) -> Result { +fn matching_image_mime(extension: &str, mime_type: &str) -> bool { + matches!( + (extension, mime_type), + ("jpg" | "jpeg", "image/jpeg") + | ("png", "image/png") + | ("webp", "image/webp") + | ("gif", "image/gif") + | ("avif", "image/avif") + ) +} + +/// Loads media artifacts in their insertion order and keeps only bounded, +/// supported image files that resolve inside the archive store. +fn load_summary_image_candidates( + conn: &rusqlite::Connection, + store_path: &Path, + entry_id: i64, +) -> Result> { + let canonical_store = store_path + .canonicalize() + .with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?; + let mut stmt = conn.prepare( + "SELECT b.sha256, b.mime_type, b.extension, b.byte_size, ea.relpath + FROM entry_artifacts ea + JOIN blobs b ON b.id = ea.blob_id + WHERE ea.entry_id = ?1 AND ea.artifact_role = 'media' + ORDER BY ea.id ASC", + )?; + let rows = stmt + .query_map([entry_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::>>()?; + + let mut images = Vec::new(); + let mut total_bytes = 0_u64; + for (sha256, mime_type, extension, byte_size, relpath) in rows { + let Some(mime_type) = mime_type else { + continue; + }; + let Some(extension) = extension else { + continue; + }; + let extension = extension.to_ascii_lowercase(); + let mime_type = mime_type.to_ascii_lowercase(); + let Ok(byte_size) = u64::try_from(byte_size) else { + continue; + }; + if !matching_image_mime(&extension, &mime_type) + || byte_size > MAX_SUMMARY_IMAGE_BYTES + || images.len() >= MAX_SUMMARY_IMAGES + || total_bytes.saturating_add(byte_size) > MAX_SUMMARY_IMAGE_TOTAL_BYTES + { + continue; + } + + let archive_file = match store_path.join(relpath).canonicalize() { + Ok(path) if path.starts_with(&canonical_store) => path, + _ => continue, + }; + total_bytes += byte_size; + images.push(SummaryImage { + sha256, + mime_type, + byte_size, + archive_file, + }); + } + Ok(images) +} + +fn summary_input_digest(content: &str, include_images: bool, images: &[SummaryImage]) -> String { + let mut preimage = Vec::with_capacity(content.len() + 32 + images.len() * 128); + preimage.extend_from_slice(content.as_bytes()); + preimage.extend_from_slice(b"\0images="); + preimage.extend_from_slice(if include_images { b"1" } else { b"0" }); + for image in images { + preimage.push(0); + preimage.extend_from_slice(image.sha256.as_bytes()); + preimage.push(0); + preimage.extend_from_slice(image.mime_type.as_bytes()); + preimage.push(0); + preimage.extend_from_slice(image.byte_size.to_string().as_bytes()); + } + hash::hash_bytes(&preimage) +} + +pub fn build_summary_input( + paths: &ArchivePaths, + entry_uid: &str, + options: SummaryBuildOptions, +) -> Result { let conn = database::open_or_initialize(&paths.archive_path)?; let (entry_id, title, source_kind, entity_kind) = conn .query_row( @@ -882,7 +1005,12 @@ pub fn build_summary_input(paths: &ArchivePaths, entry_uid: &str) -> Result Result String { pub fn summarize_entry( archive_paths: &ArchivePaths, entry_uid: &str, + options: SummaryBuildOptions, provider: &dyn SummaryProvider, prompt_version: &str, ) -> Result { @@ -949,7 +1079,7 @@ pub fn summarize_entry( let entry_id = database::entry_id_for_uid(&conn, entry_uid)? .ok_or_else(|| anyhow!("entry not found: {entry_uid}"))?; - let input = build_summary_input(archive_paths, entry_uid)?; + let input = build_summary_input(archive_paths, entry_uid, options)?; let summary_uid = database::upsert_pending_entry_summary( &conn, entry_id, @@ -1023,6 +1153,7 @@ mod tests { source_kind: "web".into(), entity_kind: "page".into(), content: "Body text.".into(), + images: Vec::new(), } } @@ -1395,10 +1526,194 @@ mod tests { .unwrap(); } - let input = build_summary_input(&paths, &entry.entry_uid).unwrap(); + let input = build_summary_input(&paths, &entry.entry_uid, SummaryBuildOptions::default()).unwrap(); assert!(input.request.content.contains("First article body.\n\n---\n\nArticle 2\n\nSecond article body.")); } + fn summary_image_fixture() -> (tempfile::TempDir, ArchivePaths, database::ArchivedEntry) { + let temp = tempfile::tempdir().unwrap(); + let paths = crate::archive::initialize_archive( + temp.path(), + &temp.path().join("store"), + "Test archive", + false, + ) + .unwrap(); + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let source_id = database::upsert_source_identity( + &conn, + "web", + "page", + Some("summary-image-test"), + Some("https://example.test/summary-image-test"), + "https://example.test/summary-image-test", + ) + .unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: source_id, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Image test".to_string()), + visibility: "private".to_string(), + representation_kind: "webpage".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + + let text_relpath = "raw/summary-image-test.txt"; + std::fs::write(paths.store_path.join(text_relpath), "Summary source text.").unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "primary_media".to_string(), + storage_area: "raw".to_string(), + relpath: text_relpath.to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + (temp, paths, entry) + } + + fn add_summary_image_artifact( + paths: &ArchivePaths, + entry_id: i64, + ordinal: usize, + role: &str, + extension: &str, + mime_type: &str, + byte_size: u64, + ) { + let conn = database::open_or_initialize(&paths.archive_path).unwrap(); + let relpath = format!("raw/summary-image-{ordinal}.{extension}"); + std::fs::write(paths.store_path.join(&relpath), "image fixture").unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: format!("summary-image-{ordinal:02}"), + byte_size: byte_size.try_into().unwrap(), + mime_type: Some(mime_type.to_string()), + extension: Some(extension.to_string()), + raw_relpath: relpath.clone(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id, + artifact_role: role.to_string(), + storage_area: "raw".to_string(), + relpath, + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + } + + #[test] + fn summary_image_selection_filters_candidates_and_stops_at_image_count() { + let (_temp, paths, entry) = summary_image_fixture(); + add_summary_image_artifact(&paths, entry.id, 0, "avatar", "jpg", "image/jpeg", 1); + add_summary_image_artifact(&paths, entry.id, 1, "video", "mp4", "video/mp4", 1); + add_summary_image_artifact(&paths, entry.id, 2, "audio", "mp3", "audio/mpeg", 1); + add_summary_image_artifact(&paths, entry.id, 3, "media", "svg", "image/svg+xml", 1); + add_summary_image_artifact(&paths, entry.id, 4, "media", "png", "image/jpeg", 1); + add_summary_image_artifact( + &paths, + entry.id, + 5, + "media", + "jpg", + "image/jpeg", + MAX_SUMMARY_IMAGE_BYTES + 1, + ); + add_summary_image_artifact(&paths, entry.id, 6, "media", "jpg", "image/jpeg", 1); + add_summary_image_artifact(&paths, entry.id, 7, "media", "png", "image/png", 2); + add_summary_image_artifact(&paths, entry.id, 8, "media", "webp", "image/webp", 3); + add_summary_image_artifact(&paths, entry.id, 9, "media", "gif", "image/gif", 4); + add_summary_image_artifact(&paths, entry.id, 10, "media", "avif", "image/avif", 5); + + let text_only = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { include_images: false }, + ) + .unwrap(); + let visual = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { include_images: true }, + ) + .unwrap(); + + assert!(text_only.request.images.is_empty()); + assert_ne!(text_only.input_sha256, visual.input_sha256); + assert_eq!(visual.request.images.len(), MAX_SUMMARY_IMAGES); + assert_eq!( + visual + .request + .images + .iter() + .map(|image| image.sha256.as_str()) + .collect::>(), + vec![ + "summary-image-06", + "summary-image-07", + "summary-image-08", + "summary-image-09", + ] + ); + assert!(visual + .request + .images + .iter() + .all(|image| image.byte_size <= MAX_SUMMARY_IMAGE_BYTES)); + } + + #[test] + fn summary_image_selection_enforces_aggregate_limit_and_keeps_scanning() { + let (_temp, paths, entry) = summary_image_fixture(); + add_summary_image_artifact(&paths, entry.id, 0, "media", "jpg", "image/jpeg", 4 * 1024 * 1024); + add_summary_image_artifact(&paths, entry.id, 1, "media", "png", "image/png", 4 * 1024 * 1024); + add_summary_image_artifact(&paths, entry.id, 2, "media", "webp", "image/webp", 4 * 1024 * 1024); + add_summary_image_artifact(&paths, entry.id, 3, "media", "gif", "image/gif", 1); + + let visual = build_summary_input( + &paths, + &entry.entry_uid, + SummaryBuildOptions { include_images: true }, + ) + .unwrap(); + + assert_eq!(visual.request.images.len(), 3); + assert_eq!( + visual.request.images.iter().map(|image| image.byte_size).sum::(), + MAX_SUMMARY_IMAGE_TOTAL_BYTES + ); + assert!(visual + .request + .images + .iter() + .all(|image| image.sha256 != "summary-image-03")); + } + // ── Output normalization ─────────────────────────────────────────────── #[test] diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 1a15a78..8268e99 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -585,7 +585,11 @@ 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) + let input = summarizer::build_summary_input( + &archive_paths, + &entry_uid, + summarizer::SummaryBuildOptions::default(), + ) .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; // 3. Cache hit: identical entry + provider + model + prompt + input. @@ -630,7 +634,13 @@ async fn request_entry_summary_handler( // upsert above and the one inside it resolve to one row). We only have // to catch the case where it fails before it can record anything. if let Err(e) = - summarizer::summarize_entry(&archive_paths, &entry_uid_bg, provider.as_ref(), summarizer::PROMPT_VERSION) + summarizer::summarize_entry( + &archive_paths, + &entry_uid_bg, + summarizer::SummaryBuildOptions::default(), + provider.as_ref(), + summarizer::PROMPT_VERSION, + ) { eprintln!("warn: summary {summary_uid_bg}: {e:#}"); if let Ok(conn) = database::open_or_initialize(&archive_path) {