From 0dfec4c8c5d5d38b1225433ba77397163604b088 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 2 Jan 2026 19:29:42 -0500 Subject: [PATCH 01/25] Fix memory filename date extraction --- src/memories.rs | 98 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/src/memories.rs b/src/memories.rs index 2e46328..ea31f65 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -204,16 +204,21 @@ pub fn extract_date_from_filename(filename: &str) -> Option= 13 - && let Some(date_time) = timestamp_str[0..13] + // Skip autogenerated filenames that start with "10000" (e.g., 1000004178.jpg) + // These are not timestamps but auto-generated file IDs + if timestamp_str.starts_with("10000") { + return None; + } + + // Try milliseconds first (13 digits exactly) + if len == 13 + && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(DateTime::from_timestamp_millis) @@ -222,8 +227,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option= 10 + // For 14-16 digits, treat first 10 digits as seconds to avoid far future dates + // Examples: att_1422489664680106 (16 digits), att_142248967186928 (15 digits) + if len >= 14 + && len <= 16 && let Some(date_time) = timestamp_str[0..10] .parse::() .ok() @@ -232,6 +239,28 @@ pub fn extract_date_from_filename(filename: &str) -> Option() + .ok() + .and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0)) + .map(|naive_dt| naive_dt.fixed_offset()) + { + return Some(date_time); + } + + // 11-12 digits: try as milliseconds (might be partial millisecond timestamp) + if (len == 11 || len == 12) + && let Some(date_time) = timestamp_str + .parse::() + .ok() + .and_then(DateTime::from_timestamp_millis) + .map(|naive_dt| naive_dt.fixed_offset()) + { + return Some(date_time); + } } None @@ -752,6 +781,55 @@ mod tests { assert_eq!(date_time.second(), 0); } + #[test] + fn test_extract_date_from_filename_attachment_15_digits() { + // att_142248967186928.jpeg - 15 digits, should parse first 10 as seconds + // 1422489671 = 2015-01-28 23:07:51 UTC (converts to local timezone) + let filename = "att_142248967186928.jpeg"; + let date_time = extract_date_from_filename(filename).unwrap(); + + // Verify year and month are correct (2015-01) + assert_eq!(date_time.year(), 2015); + assert_eq!(date_time.month(), 1); + // Day may be 28 or 29 depending on timezone + assert!(date_time.day() >= 28 && date_time.day() <= 29); + + // Verify timestamp is within expected range (should be around 1422489671) + let timestamp = date_time.timestamp(); + assert!(timestamp >= 1422480000 && timestamp <= 1422576000); // Jan 28-29, 2015 + } + + #[test] + fn test_extract_date_from_filename_attachment_16_digits() { + // att_1422489664680106.jpeg - 16 digits, should parse first 10 as seconds + // 1422489664 = 2015-01-28 23:07:44 UTC (converts to local timezone) + let filename = "att_1422489664680106.jpeg"; + let date_time = extract_date_from_filename(filename).unwrap(); + + // Verify year and month are correct (2015-01) + assert_eq!(date_time.year(), 2015); + assert_eq!(date_time.month(), 1); + // Day may be 28 or 29 depending on timezone + assert!(date_time.day() >= 28 && date_time.day() <= 29); + + // Verify timestamp is within expected range (should be around 1422489664) + let timestamp = date_time.timestamp(); + assert!(timestamp >= 1422480000 && timestamp <= 1422576000); // Jan 28-29, 2015 + } + + #[test] + fn test_extract_date_from_filename_autogenerated_should_not_match() { + // Autogenerated filenames like 1000004178.jpg should NOT be parsed as timestamps + // These start with "10000" which would be Sept 2001 if parsed literally + let filename = "1000004178.jpg"; + let date_time = extract_date_from_filename(filename); + + assert!( + date_time.is_none(), + "Autogenerated filenames starting with 10000 should not be parsed as dates" + ); + } + #[test] fn test_memory_date_priority_filename() { let temp_dir = tempdir().unwrap(); -- 2.49.1 From 1171f19845011291f9de073e896d8360ec9c4670 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 3 Jan 2026 10:30:37 -0500 Subject: [PATCH 02/25] Create Insight Generation Feature Added integration with Messages API and Ollama --- .gitignore | 1 + Cargo.lock | 290 +++++++++++++++++- Cargo.toml | 2 + .../down.sql | 3 + .../2025-12-31-000000_add_ai_insights/up.sql | 11 + src/ai/handlers.rs | 154 ++++++++++ src/ai/insight_generator.rs | 239 +++++++++++++++ src/ai/mod.rs | 11 + src/ai/ollama.rs | 173 +++++++++++ src/ai/sms_client.rs | 220 +++++++++++++ src/database/insights_dao.rs | 133 ++++++++ src/database/mod.rs | 3 + src/database/models.rs | 22 +- src/database/schema.rs | 20 +- src/files.rs | 32 +- src/lib.rs | 1 + src/main.rs | 13 +- src/state.rs | 71 ++++- 18 files changed, 1365 insertions(+), 34 deletions(-) create mode 100644 migrations/2025-12-31-000000_add_ai_insights/down.sql create mode 100644 migrations/2025-12-31-000000_add_ai_insights/up.sql create mode 100644 src/ai/handlers.rs create mode 100644 src/ai/insight_generator.rs create mode 100644 src/ai/mod.rs create mode 100644 src/ai/ollama.rs create mode 100644 src/ai/sms_client.rs create mode 100644 src/database/insights_dao.rs diff --git a/.gitignore b/.gitignore index ae33b4c..1437451 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ database/target .idea/dataSources.local.xml # Editor-based HTTP Client requests .idea/httpRequests/ +/.claude/settings.local.json diff --git a/Cargo.lock b/Cargo.lock index b235d7c..3d8173f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,9 +646,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.35" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -694,7 +694,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -783,6 +783,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1137,9 +1147,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.0" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "flate2" @@ -1163,6 +1173,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1469,6 +1494,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1482,6 +1523,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.16" @@ -1501,9 +1558,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.0", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1698,10 +1757,12 @@ dependencies = [ "rand 0.8.5", "rayon", "regex", + "reqwest", "serde", "serde_json", "tempfile", "tokio", + "urlencoding", "walkdir", ] @@ -2070,6 +2131,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2181,6 +2259,50 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -2744,23 +2866,31 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2 0.4.12", "http 1.3.1", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tower", "tower-http", "tower-service", @@ -2818,6 +2948,39 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2839,12 +3002,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.26" @@ -3077,6 +3272,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3208,6 +3424,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -3363,9 +3599,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -3477,6 +3713,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3688,7 +3930,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -3721,13 +3963,30 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -3736,7 +3995,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -3766,6 +4025,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3788,7 +4056,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/Cargo.toml b/Cargo.toml index f31b722..35043cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,3 +49,5 @@ opentelemetry-appender-log = "0.31.0" tempfile = "3.20.0" regex = "1.11.1" exif = { package = "kamadak-exif", version = "0.6.1" } +reqwest = { version = "0.12", features = ["json"] } +urlencoding = "2.1" diff --git a/migrations/2025-12-31-000000_add_ai_insights/down.sql b/migrations/2025-12-31-000000_add_ai_insights/down.sql new file mode 100644 index 0000000..6064840 --- /dev/null +++ b/migrations/2025-12-31-000000_add_ai_insights/down.sql @@ -0,0 +1,3 @@ +-- Rollback AI insights table +DROP INDEX IF EXISTS idx_photo_insights_path; +DROP TABLE IF EXISTS photo_insights; diff --git a/migrations/2025-12-31-000000_add_ai_insights/up.sql b/migrations/2025-12-31-000000_add_ai_insights/up.sql new file mode 100644 index 0000000..81d4849 --- /dev/null +++ b/migrations/2025-12-31-000000_add_ai_insights/up.sql @@ -0,0 +1,11 @@ +-- AI-generated insights for individual photos +CREATE TABLE IF NOT EXISTS photo_insights ( + id INTEGER PRIMARY KEY NOT NULL, + file_path TEXT NOT NULL UNIQUE, -- Full path to the photo + title TEXT NOT NULL, -- "At the beach with Sarah" + summary TEXT NOT NULL, -- 2-3 sentence description + generated_at BIGINT NOT NULL, + model_version TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_photo_insights_path ON photo_insights(file_path); diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs new file mode 100644 index 0000000..3b74a49 --- /dev/null +++ b/src/ai/handlers.rs @@ -0,0 +1,154 @@ +use actix_web::{HttpResponse, Responder, delete, get, post, web}; +use serde::{Deserialize, Serialize}; + +use crate::ai::InsightGenerator; +use crate::data::Claims; +use crate::database::InsightDao; + +#[derive(Debug, Deserialize)] +pub struct GeneratePhotoInsightRequest { + pub file_path: String, +} + +#[derive(Debug, Deserialize)] +pub struct GetPhotoInsightQuery { + pub path: String, +} + +#[derive(Debug, Serialize)] +pub struct PhotoInsightResponse { + pub id: i32, + pub file_path: String, + pub title: String, + pub summary: String, + pub generated_at: i64, + pub model_version: String, +} + +/// POST /insights/generate - Generate insight for a specific photo +#[post("/insights/generate")] +pub async fn generate_insight_handler( + _claims: Claims, + request: web::Json, + insight_generator: web::Data, +) -> impl Responder { + log::info!( + "Manual insight generation triggered for photo: {}", + request.file_path + ); + + // Generate insight + match insight_generator + .generate_insight_for_photo(&request.file_path) + .await + { + Ok(()) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Insight generated successfully" + })), + Err(e) => { + log::error!("Failed to generate insight: {:?}", e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to generate insight: {:?}", e) + })) + } + } +} + +/// GET /insights?path=/path/to/photo.jpg - Fetch insight for specific photo +#[get("/insights")] +pub async fn get_insight_handler( + _claims: Claims, + query: web::Query, + insight_dao: web::Data>>, +) -> impl Responder { + log::debug!("Fetching insight for {}", query.path); + + let otel_context = opentelemetry::Context::new(); + let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); + + match dao.get_insight(&otel_context, &query.path) { + Ok(Some(insight)) => { + let response = PhotoInsightResponse { + id: insight.id, + file_path: insight.file_path, + title: insight.title, + summary: insight.summary, + generated_at: insight.generated_at, + model_version: insight.model_version, + }; + HttpResponse::Ok().json(response) + } + Ok(None) => HttpResponse::NotFound().json(serde_json::json!({ + "error": "Insight not found" + })), + Err(e) => { + log::error!("Failed to fetch insight ({}): {:?}", &query.path, e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to fetch insight: {:?}", e) + })) + } + } +} + +/// DELETE /insights?path=/path/to/photo.jpg - Remove insight (will regenerate on next request) +#[delete("/insights")] +pub async fn delete_insight_handler( + _claims: Claims, + query: web::Query, + insight_dao: web::Data>>, +) -> impl Responder { + log::info!("Deleting insight for {}", query.path); + + let otel_context = opentelemetry::Context::new(); + let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); + + match dao.delete_insight(&otel_context, &query.path) { + Ok(()) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Insight deleted successfully" + })), + Err(e) => { + log::error!("Failed to delete insight: {:?}", e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to delete insight: {:?}", e) + })) + } + } +} + +/// GET /insights/all - Get all insights +#[get("/insights/all")] +pub async fn get_all_insights_handler( + _claims: Claims, + insight_dao: web::Data>>, +) -> impl Responder { + log::debug!("Fetching all insights"); + + let otel_context = opentelemetry::Context::new(); + let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); + + match dao.get_all_insights(&otel_context) { + Ok(insights) => { + let responses: Vec = insights + .into_iter() + .map(|insight| PhotoInsightResponse { + id: insight.id, + file_path: insight.file_path, + title: insight.title, + summary: insight.summary, + generated_at: insight.generated_at, + model_version: insight.model_version, + }) + .collect(); + + HttpResponse::Ok().json(responses) + } + Err(e) => { + log::error!("Failed to fetch all insights: {:?}", e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to fetch insights: {:?}", e) + })) + } + } +} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs new file mode 100644 index 0000000..9c1cac9 --- /dev/null +++ b/src/ai/insight_generator.rs @@ -0,0 +1,239 @@ +use anyhow::Result; +use chrono::Utc; +use serde::Deserialize; +use std::sync::{Arc, Mutex}; + +use crate::ai::ollama::OllamaClient; +use crate::ai::sms_client::SmsApiClient; +use crate::database::models::InsertPhotoInsight; +use crate::database::{ExifDao, InsightDao}; +use crate::memories::extract_date_from_filename; + +#[derive(Deserialize)] +struct NominatimResponse { + display_name: Option, + address: Option, +} + +#[derive(Deserialize)] +struct NominatimAddress { + city: Option, + town: Option, + village: Option, + county: Option, + state: Option, + country: Option, +} + +#[derive(Clone)] +pub struct InsightGenerator { + ollama: OllamaClient, + sms_client: SmsApiClient, + insight_dao: Arc>>, + exif_dao: Arc>>, +} + +impl InsightGenerator { + pub fn new( + ollama: OllamaClient, + sms_client: SmsApiClient, + insight_dao: Arc>>, + exif_dao: Arc>>, + ) -> Self { + Self { + ollama, + sms_client, + insight_dao, + exif_dao, + } + } + + /// Extract contact name from file path + /// e.g., "Sarah/img.jpeg" -> Some("Sarah") + /// e.g., "img.jpeg" -> None + fn extract_contact_from_path(file_path: &str) -> Option { + use std::path::Path; + + let path = Path::new(file_path); + let components: Vec<_> = path.components().collect(); + + // If path has at least 2 components (directory + file), extract first directory + if components.len() >= 2 { + if let Some(component) = components.first() { + if let Some(os_str) = component.as_os_str().to_str() { + return Some(os_str.to_string()); + } + } + } + + None + } + + /// Generate AI insight for a single photo + pub async fn generate_insight_for_photo(&self, file_path: &str) -> Result<()> { + log::info!("Generating insight for photo: {}", file_path); + + // 1. Get EXIF data for the photo + let otel_context = opentelemetry::Context::new(); + let exif = { + let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); + exif_dao + .get_exif(&otel_context, file_path) + .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? + }; + + // Get full timestamp for proximity-based message filtering + let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { + ts + } else { + log::warn!("No date_taken in EXIF for {}, trying filename", file_path); + + extract_date_from_filename(file_path) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| Utc::now().timestamp()) + }; + + let date_taken = chrono::DateTime::from_timestamp(timestamp, 0) + .map(|dt| dt.date_naive()) + .unwrap_or_else(|| Utc::now().date_naive()); + + // 3. Extract contact name from file path + let contact = Self::extract_contact_from_path(file_path); + log::info!("Extracted contact from path: {:?}", contact); + + // 4. Fetch SMS messages for the contact (±1 day) + // Pass the full timestamp for proximity sorting + let sms_messages = self + .sms_client + .fetch_messages_for_contact(contact.as_deref(), timestamp) + .await + .unwrap_or_else(|e| { + log::error!("Failed to fetch SMS messages: {}", e); + Vec::new() + }); + + log::info!( + "Fetched {} SMS messages closest to {}", + sms_messages.len(), + chrono::DateTime::from_timestamp(timestamp, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "unknown time".to_string()) + ); + + // 5. Summarize SMS context + let sms_summary = if !sms_messages.is_empty() { + match self + .sms_client + .summarize_context(&sms_messages, &self.ollama) + .await + { + Ok(summary) => Some(summary), + Err(e) => { + log::warn!("Failed to summarize SMS context: {}", e); + None + } + } + } else { + None + }; + + // 6. Get location name from GPS coordinates + let location = match exif { + Some(exif) => { + if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { + self.reverse_geocode(lat, lon).await + } else { + None + } + } + None => None, + }; + + log::info!( + "Photo context: date={}, location={:?}, sms_messages={}", + date_taken, + location, + sms_messages.len() + ); + + // 7. Generate title and summary with Ollama + let title = self + .ollama + .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) + .await?; + + let summary = self + .ollama + .generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref()) + .await?; + + log::info!("Generated title: {}", title); + log::info!("Generated summary: {}", summary); + + // 8. Store in database + let insight = InsertPhotoInsight { + file_path: file_path.to_string(), + title, + summary, + generated_at: Utc::now().timestamp(), + model_version: self.ollama.model.clone(), + }; + + let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); + dao.store_insight(&otel_context, insight) + .map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e))?; + + log::info!("Successfully stored insight for {}", file_path); + Ok(()) + } + + /// Reverse geocode GPS coordinates to human-readable place names + async fn reverse_geocode(&self, lat: f64, lon: f64) -> Option { + let url = format!( + "https://nominatim.openstreetmap.org/reverse?format=json&lat={}&lon={}", + lat, lon + ); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("User-Agent", "ImageAPI/1.0") // Nominatim requires User-Agent + .send() + .await + .ok()?; + + if !response.status().is_success() { + log::warn!( + "Geocoding failed for {}, {}: {}", + lat, + lon, + response.status() + ); + return None; + } + + let data: NominatimResponse = response.json().await.ok()?; + + // Try to build a concise location name + if let Some(addr) = data.address { + let mut parts = Vec::new(); + + // Prefer city/town/village + if let Some(city) = addr.city.or(addr.town).or(addr.village) { + parts.push(city); + } + + // Add state if available + if let Some(state) = addr.state { + parts.push(state); + } + + if !parts.is_empty() { + return Some(parts.join(", ")); + } + } + + // Fallback to display_name if structured address not available + data.display_name + } +} diff --git a/src/ai/mod.rs b/src/ai/mod.rs new file mode 100644 index 0000000..be1fb05 --- /dev/null +++ b/src/ai/mod.rs @@ -0,0 +1,11 @@ +pub mod handlers; +pub mod insight_generator; +pub mod ollama; +pub mod sms_client; + +pub use handlers::{ + delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_insight_handler, +}; +pub use insight_generator::InsightGenerator; +pub use ollama::OllamaClient; +pub use sms_client::SmsApiClient; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs new file mode 100644 index 0000000..0c81028 --- /dev/null +++ b/src/ai/ollama.rs @@ -0,0 +1,173 @@ +use anyhow::Result; +use chrono::NaiveDate; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use crate::memories::MemoryItem; + +#[derive(Clone)] +pub struct OllamaClient { + client: Client, + pub base_url: String, + pub model: String, +} + +impl OllamaClient { + pub fn new(base_url: String, model: String) -> Self { + Self { + client: Client::new(), + base_url, + model, + } + } + + /// Extract final answer from thinking model output + /// Handles ... tags and takes everything after + fn extract_final_answer(&self, response: &str) -> String { + let response = response.trim(); + + // Look for tag and take everything after it + if let Some(pos) = response.find("") { + let answer = response[pos + 8..].trim(); + if !answer.is_empty() { + return answer.to_string(); + } + } + + // Fallback: return the whole response trimmed + response.to_string() + } + + pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { + log::debug!("=== Ollama Request ==="); + log::debug!("Model: {}", self.model); + if let Some(sys) = system { + log::debug!("System: {}", sys); + } + log::debug!("Prompt:\n{}", prompt); + log::debug!("====================="); + + let request = OllamaRequest { + model: self.model.clone(), + prompt: prompt.to_string(), + stream: false, + system: system.map(|s| s.to_string()), + }; + + let response = self + .client + .post(&format!("{}/api/generate", self.base_url)) + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + log::error!("Ollama request failed: {} - {}", status, error_body); + return Err(anyhow::anyhow!( + "Ollama request failed: {} - {}", + status, + error_body + )); + } + + let result: OllamaResponse = response.json().await?; + + log::debug!("=== Ollama Response ==="); + log::debug!("Raw response: {}", result.response.trim()); + log::debug!("======================="); + + // Extract final answer from thinking model output + let cleaned = self.extract_final_answer(&result.response); + + log::debug!("=== Cleaned Response ==="); + log::debug!("Final answer: {}", cleaned); + log::debug!("========================"); + + Ok(cleaned) + } + + /// Generate a title for a single photo based on its context + pub async fn generate_photo_title( + &self, + date: NaiveDate, + location: Option<&str>, + sms_summary: Option<&str>, + ) -> Result { + let location_str = location.unwrap_or("Unknown location"); + let sms_str = sms_summary.unwrap_or("No messages"); + + let prompt = format!( + r#"Create a short title (maximum 8 words) for this photo: + +Date: {} +Location: {} +Messages: {} + +Use specific details from the context above. If no specific details are available, use a simple descriptive title. + +Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + sms_str + ); + + let system = + "You are a memory assistant. Use only the information provided. Do not invent details."; + + let title = self.generate(&prompt, Some(system)).await?; + Ok(title.trim().trim_matches('"').to_string()) + } + + /// Generate a summary for a single photo based on its context + pub async fn generate_photo_summary( + &self, + date: NaiveDate, + location: Option<&str>, + sms_summary: Option<&str>, + ) -> Result { + let location_str = location.unwrap_or("somewhere"); + let sms_str = sms_summary.unwrap_or("No messages"); + + let prompt = format!( + r#"Write a brief 1-2 paragraph description of this moment based on the available information: + +Date: {} +Location: {} +Messages: {} + +Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cam in a casual but fluent tone. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + sms_str + ); + + let system = "You are a memory refreshing assistant. Use only the information provided. Do not invent details. Help me remember this day."; + + self.generate(&prompt, Some(system)).await + } + +} + +pub struct MemoryContext { + pub date: NaiveDate, + pub photos: Vec, + pub sms_summary: Option, + pub locations: Vec, + pub cameras: Vec, +} + +#[derive(Serialize)] +struct OllamaRequest { + model: String, + prompt: String, + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, +} + +#[derive(Deserialize)] +struct OllamaResponse { + response: String, +} diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs new file mode 100644 index 0000000..154dabc --- /dev/null +++ b/src/ai/sms_client.rs @@ -0,0 +1,220 @@ +use anyhow::Result; +use chrono::NaiveDate; +use reqwest::Client; +use serde::Deserialize; + +use super::ollama::OllamaClient; + +#[derive(Clone)] +pub struct SmsApiClient { + client: Client, + base_url: String, + token: Option, +} + +impl SmsApiClient { + pub fn new(base_url: String, token: Option) -> Self { + Self { + client: Client::new(), + base_url, + token, + } + } + + pub async fn fetch_messages_for_date(&self, date: NaiveDate) -> Result> { + // Calculate date range (midnight to midnight in local time) + let start = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid start time"))?; + let end = date + .and_hms_opt(23, 59, 59) + .ok_or_else(|| anyhow::anyhow!("Invalid end time"))?; + + let start_ts = start.and_utc().timestamp(); + let end_ts = end.and_utc().timestamp(); + + self.fetch_messages(start_ts, end_ts, None, None).await + } + + /// Fetch messages for a specific contact within ±1 day of the given timestamp + /// Falls back to all contacts if no messages found for the specific contact + /// Messages are sorted by proximity to the center timestamp + pub async fn fetch_messages_for_contact( + &self, + contact: Option<&str>, + center_timestamp: i64, + ) -> Result> { + use chrono::Duration; + + // Calculate ±1 day range around the center timestamp + let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?; + + let start_dt = center_dt - Duration::days(1); + let end_dt = center_dt + Duration::days(1); + + let start_ts = start_dt.timestamp(); + let end_ts = end_dt.timestamp(); + + // If contact specified, try fetching for that contact first + if let Some(contact_name) = contact { + log::info!( + "Fetching SMS for contact: {} (±1 day from {})", + contact_name, + center_dt.format("%Y-%m-%d %H:%M:%S") + ); + let messages = self + .fetch_messages(start_ts, end_ts, Some(contact_name), Some(center_timestamp)) + .await?; + + if !messages.is_empty() { + log::info!( + "Found {} messages for contact {}", + messages.len(), + contact_name + ); + return Ok(messages); + } + + log::info!( + "No messages found for contact {}, falling back to all contacts", + contact_name + ); + } + + // Fallback to all contacts + log::info!( + "Fetching all SMS messages (±1 day from {})", + center_dt.format("%Y-%m-%d %H:%M:%S") + ); + self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp)) + .await + } + + /// Internal method to fetch messages with optional contact filter and timestamp sorting + async fn fetch_messages( + &self, + start_ts: i64, + end_ts: i64, + contact: Option<&str>, + center_timestamp: Option, + ) -> Result> { + // Call Django endpoint + let mut url = format!( + "{}/api/messages/by-date-range/?start_date={}&end_date={}", + self.base_url, start_ts, end_ts + ); + + // Add contact filter if provided + if let Some(contact_name) = contact { + url.push_str(&format!("&contact={}", urlencoding::encode(contact_name))); + } + + // Add timestamp for proximity sorting if provided + if let Some(ts) = center_timestamp { + url.push_str(&format!("×tamp={}", ts)); + } + + log::debug!("Fetching SMS messages from: {}", url); + + let mut request = self.client.get(&url); + + // Add authorization header if token exists + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request.send().await?; + + log::debug!("SMS API response status: {}", response.status()); + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + log::error!("SMS API request failed: {} - {}", status, error_body); + return Err(anyhow::anyhow!( + "SMS API request failed: {} - {}", + status, + error_body + )); + } + + let data: SmsApiResponse = response.json().await?; + + // Convert to internal format + Ok(data + .messages + .into_iter() + .map(|m| SmsMessage { + contact: m.contact_name, + body: m.body, + timestamp: m.date, + is_sent: m.type_ == 2, // type 2 = sent + }) + .collect()) + } + + pub async fn summarize_context( + &self, + messages: &[SmsMessage], + ollama: &OllamaClient, + ) -> Result { + if messages.is_empty() { + return Ok(String::from("No messages on this day")); + } + + // Create prompt for Ollama with sender/receiver distinction + let messages_text: String = messages + .iter() + .take(60) // Limit to avoid token overflow + .map(|m| { + if m.is_sent { + format!("Me: {}", m.body) + } else { + format!("{}: {}", m.contact, m.body) + } + }) + .collect::>() + .join("\n"); + + let prompt = format!( + r#"Summarize these messages in up to 4-5 sentences. Focus on key topics, places, people mentioned, and the overall context of the conversations. + +Messages: +{} + +Summary:"#, + messages_text + ); + + ollama + .generate( + &prompt, + // Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."), + Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."), + ) + .await + } +} + +#[derive(Debug, Clone)] +pub struct SmsMessage { + pub contact: String, + pub body: String, + pub timestamp: i64, + pub is_sent: bool, +} + +#[derive(Deserialize)] +struct SmsApiResponse { + messages: Vec, +} + +#[derive(Deserialize)] +struct SmsApiMessage { + contact_name: String, + body: String, + date: i64, + #[serde(rename = "type")] + type_: i32, +} diff --git a/src/database/insights_dao.rs b/src/database/insights_dao.rs new file mode 100644 index 0000000..1efa9f3 --- /dev/null +++ b/src/database/insights_dao.rs @@ -0,0 +1,133 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::models::{InsertPhotoInsight, PhotoInsight}; +use crate::database::schema; +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +pub trait InsightDao: Sync + Send { + fn store_insight( + &mut self, + context: &opentelemetry::Context, + insight: InsertPhotoInsight, + ) -> Result; + + fn get_insight( + &mut self, + context: &opentelemetry::Context, + file_path: &str, + ) -> Result, DbError>; + + fn delete_insight( + &mut self, + context: &opentelemetry::Context, + file_path: &str, + ) -> Result<(), DbError>; + + fn get_all_insights( + &mut self, + context: &opentelemetry::Context, + ) -> Result, DbError>; +} + +pub struct SqliteInsightDao { + connection: Arc>, +} + +impl Default for SqliteInsightDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteInsightDao { + pub fn new() -> Self { + SqliteInsightDao { + connection: Arc::new(Mutex::new(connect())), + } + } +} + +impl InsightDao for SqliteInsightDao { + fn store_insight( + &mut self, + context: &opentelemetry::Context, + insight: InsertPhotoInsight, + ) -> Result { + trace_db_call(context, "insert", "store_insight", |_span| { + use schema::photo_insights::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get InsightDao"); + + // Insert or replace on conflict (UNIQUE constraint on file_path) + diesel::replace_into(photo_insights) + .values(&insight) + .execute(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Insert error"))?; + + // Retrieve the inserted record + photo_insights + .filter(file_path.eq(&insight.file_path)) + .first::(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Query error")) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn get_insight( + &mut self, + context: &opentelemetry::Context, + path: &str, + ) -> Result, DbError> { + trace_db_call(context, "query", "get_insight", |_span| { + use schema::photo_insights::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get InsightDao"); + + photo_insights + .filter(file_path.eq(path)) + .first::(connection.deref_mut()) + .optional() + .map_err(|_| anyhow::anyhow!("Query error")) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn delete_insight( + &mut self, + context: &opentelemetry::Context, + path: &str, + ) -> Result<(), DbError> { + trace_db_call(context, "delete", "delete_insight", |_span| { + use schema::photo_insights::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get InsightDao"); + + diesel::delete(photo_insights.filter(file_path.eq(path))) + .execute(connection.deref_mut()) + .map(|_| ()) + .map_err(|_| anyhow::anyhow!("Delete error")) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_all_insights( + &mut self, + context: &opentelemetry::Context, + ) -> Result, DbError> { + trace_db_call(context, "query", "get_all_insights", |_span| { + use schema::photo_insights::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get InsightDao"); + + photo_insights + .order(generated_at.desc()) + .load::(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Query error")) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index f71d885..759d5f4 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,9 +9,12 @@ use crate::database::models::{ }; use crate::otel::trace_db_call; +pub mod insights_dao; pub mod models; pub mod schema; +pub use insights_dao::{InsightDao, SqliteInsightDao}; + pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; fn get_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/database/models.rs b/src/database/models.rs index 1d36206..9cee59b 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -1,4 +1,4 @@ -use crate::database::schema::{favorites, image_exif, users}; +use crate::database::schema::{favorites, image_exif, photo_insights, users}; use serde::Serialize; #[derive(Insertable)] @@ -73,3 +73,23 @@ pub struct ImageExif { pub created_time: i64, pub last_modified: i64, } + +#[derive(Insertable)] +#[diesel(table_name = photo_insights)] +pub struct InsertPhotoInsight { + pub file_path: String, + pub title: String, + pub summary: String, + pub generated_at: i64, + pub model_version: String, +} + +#[derive(Serialize, Queryable, Clone, Debug)] +pub struct PhotoInsight { + pub id: i32, + pub file_path: String, + pub title: String, + pub summary: String, + pub generated_at: i64, + pub model_version: String, +} diff --git a/src/database/schema.rs b/src/database/schema.rs index c0ca44c..aa9a93e 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -46,6 +46,17 @@ table! { } } +table! { + photo_insights (id) { + id -> Integer, + file_path -> Text, + title -> Text, + summary -> Text, + generated_at -> BigInt, + model_version -> Text, + } +} + table! { users (id) { id -> Integer, @@ -56,4 +67,11 @@ table! { joinable!(tagged_photo -> tags (tag_id)); -allow_tables_to_appear_in_same_query!(favorites, image_exif, tagged_photo, tags, users,); +allow_tables_to_appear_in_same_query!( + favorites, + image_exif, + photo_insights, + tagged_photo, + tags, + users, +); diff --git a/src/files.rs b/src/files.rs index b75b09a..4d8c86c 100644 --- a/src/files.rs +++ b/src/files.rs @@ -16,6 +16,7 @@ use crate::file_types; use crate::geo::{gps_bounding_box, haversine_distance}; use crate::memories::extract_date_from_filename; use crate::{AppState, create_thumbnails}; +use actix_web::dev::ResourcePath; use actix_web::web::Data; use actix_web::{ HttpRequest, HttpResponse, @@ -383,7 +384,14 @@ pub async fn list_photos( ) }) .map(|path: &PathBuf| { - let relative = path.strip_prefix(&app_state.base_path).unwrap(); + let relative = path.strip_prefix(&app_state.base_path).expect( + format!( + "Unable to strip base path {} from file path {}", + &app_state.base_path.path(), + path.display() + ) + .as_str(), + ); relative.to_path_buf() }) .map(|f| f.to_str().unwrap().to_string()) @@ -1018,10 +1026,11 @@ mod tests { let request: Query = Query::from_query("path=").unwrap(); + // Create AppState with the same base_path as RealFileSystem + let test_state = AppState::test_state(); + // Create a dedicated test directory to avoid interference from other files in system temp - let mut test_base = env::temp_dir(); - test_base.push("image_api_test_list_photos"); - fs::create_dir_all(&test_base).unwrap(); + let test_base = PathBuf::from(test_state.base_path.clone()); let mut test_dir = test_base.clone(); test_dir.push("test-dir"); @@ -1031,17 +1040,6 @@ mod tests { photo_path.push("photo.jpg"); File::create(&photo_path).unwrap(); - // Create AppState with the same base_path as RealFileSystem - use actix::Actor; - let test_state = AppState::new( - std::sync::Arc::new(crate::video::actors::StreamActor {}.start()), - test_base.to_str().unwrap().to_string(), - test_base.join("thumbnails").to_str().unwrap().to_string(), - test_base.join("videos").to_str().unwrap().to_string(), - test_base.join("gifs").to_str().unwrap().to_string(), - Vec::new(), - ); - let response: HttpResponse = list_photos( claims, TestRequest::default().to_http_request(), @@ -1049,9 +1047,7 @@ mod tests { Data::new(test_state), Data::new(RealFileSystem::new(test_base.to_str().unwrap().to_string())), Data::new(Mutex::new(SqliteTagDao::default())), - Data::new(Mutex::new( - Box::new(MockExifDao) as Box - )), + Data::new(Mutex::new(Box::new(MockExifDao) as Box)), ) .await; let status = response.status(); diff --git a/src/lib.rs b/src/lib.rs index 03760e2..61f1387 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] extern crate diesel; +pub mod ai; pub mod auth; pub mod cleanup; pub mod data; diff --git a/src/main.rs b/src/main.rs index 2d720e0..f90bdfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ use chrono::Utc; use diesel::sqlite::Sqlite; use rayon::prelude::*; +use crate::ai::InsightGenerator; use crate::auth::login; use crate::data::*; use crate::database::models::InsertImageExif; @@ -50,6 +51,7 @@ use log::{debug, error, info, trace, warn}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::{KeyValue, global}; +mod ai; mod auth; mod data; mod database; @@ -715,7 +717,7 @@ fn main() -> std::io::Result<()> { } create_thumbnails(); - generate_video_gifs().await; + // generate_video_gifs().await; let app_data = Data::new(AppState::default()); @@ -744,6 +746,7 @@ fn main() -> std::io::Result<()> { let favorites_dao = SqliteFavoriteDao::new(); let tag_dao = SqliteTagDao::default(); let exif_dao = SqliteExifDao::new(); + let insight_dao = SqliteInsightDao::new(); let cors = Cors::default() .allowed_origin_fn(|origin, _req_head| { // Allow all origins in development, or check against CORS_ALLOWED_ORIGINS env var @@ -795,6 +798,10 @@ fn main() -> std::io::Result<()> { .service(delete_favorite) .service(get_file_metadata) .service(memories::list_memories) + .service(ai::generate_insight_handler) + .service(ai::get_insight_handler) + .service(ai::delete_insight_handler) + .service(ai::get_all_insights_handler) .add_feature(add_tag_services::<_, SqliteTagDao>) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( @@ -808,6 +815,10 @@ fn main() -> std::io::Result<()> { .app_data::>>>(Data::new(Mutex::new(Box::new( exif_dao, )))) + .app_data::>>>(Data::new(Mutex::new(Box::new( + insight_dao, + )))) + .app_data::>(Data::new(app_data.insight_generator.clone())) .wrap(prometheus.clone()) }) .bind(dotenv::var("BIND_URL").unwrap())? diff --git a/src/state.rs b/src/state.rs index 8be3e73..5f7753f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,9 @@ +use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; +use crate::database::{ExifDao, InsightDao, SqliteExifDao, SqliteInsightDao}; use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager}; use actix::{Actor, Addr}; -use std::{env, sync::Arc}; +use std::env; +use std::sync::{Arc, Mutex}; pub struct AppState { pub stream_manager: Arc>, @@ -10,6 +13,10 @@ pub struct AppState { pub video_path: String, pub gif_path: String, pub excluded_dirs: Vec, + pub ollama: OllamaClient, + pub sms_client: SmsApiClient, + pub insight_generator: InsightGenerator, + pub insight_dao: Arc>>, } impl AppState { @@ -20,6 +27,10 @@ impl AppState { video_path: String, gif_path: String, excluded_dirs: Vec, + ollama: OllamaClient, + sms_client: SmsApiClient, + insight_generator: InsightGenerator, + insight_dao: Arc>>, ) -> Self { let playlist_generator = PlaylistGenerator::new(); let video_playlist_manager = @@ -33,6 +44,10 @@ impl AppState { video_path, gif_path, excluded_dirs, + ollama, + sms_client, + insight_generator, + insight_dao, } } @@ -49,6 +64,31 @@ impl AppState { impl Default for AppState { fn default() -> Self { + // Initialize AI clients + let ollama_url = + env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let ollama_model = env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".to_string()); + let ollama = OllamaClient::new(ollama_url, ollama_model); + + let sms_api_url = + env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); + let sms_api_token = env::var("SMS_API_TOKEN").ok(); + let sms_client = SmsApiClient::new(sms_api_url, sms_api_token); + + // Initialize DAOs + let insight_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); + let exif_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + + // Initialize InsightGenerator + let insight_generator = InsightGenerator::new( + ollama.clone(), + sms_client.clone(), + insight_dao.clone(), + exif_dao.clone(), + ); + Self::new( Arc::new(StreamActor {}.start()), env::var("BASE_PATH").expect("BASE_PATH was not set in the env"), @@ -56,6 +96,10 @@ impl Default for AppState { env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"), env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"), Self::parse_excluded_dirs(), + ollama, + sms_client, + insight_generator, + insight_dao, ) } } @@ -74,14 +118,37 @@ impl AppState { let video_path = create_test_subdir(&base_path, "videos"); let gif_path = create_test_subdir(&base_path, "gifs"); + // Initialize test AI clients + let ollama = + OllamaClient::new("http://localhost:11434".to_string(), "llama3.2".to_string()); + let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None); + + // Initialize test DAOs + let insight_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); + let exif_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + + // Initialize test InsightGenerator + let insight_generator = InsightGenerator::new( + ollama.clone(), + sms_client.clone(), + insight_dao.clone(), + exif_dao.clone(), + ); + // Create the AppState with the temporary paths AppState::new( - std::sync::Arc::new(crate::video::actors::StreamActor {}.start()), + Arc::new(StreamActor {}.start()), base_path.to_string_lossy().to_string(), thumbnail_path.to_string_lossy().to_string(), video_path.to_string_lossy().to_string(), gif_path.to_string_lossy().to_string(), Vec::new(), // No excluded directories for test state + ollama, + sms_client, + insight_generator, + insight_dao, ) } } -- 2.49.1 From cf52d4ab7612fb13503a476c5913f9f99b22bd77 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 3 Jan 2026 20:27:34 -0500 Subject: [PATCH 03/25] Add Insights Model Discovery and Fallback Handling --- CLAUDE.md | 23 +++++ src/ai/handlers.rs | 90 +++++++++++++++++-- src/ai/insight_generator.rs | 66 ++++++++++---- src/ai/mod.rs | 3 +- src/ai/ollama.rs | 168 ++++++++++++++++++++++++++++-------- src/lib.rs | 1 + src/main.rs | 3 +- src/state.rs | 37 ++++++-- src/tags.rs | 25 +++--- src/utils.rs | 83 ++++++++++++++++++ 10 files changed, 419 insertions(+), 80 deletions(-) create mode 100644 src/utils.rs diff --git a/CLAUDE.md b/CLAUDE.md index 2e3b17f..4ba6f71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,8 +250,31 @@ Optional: WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds) + +# AI Insights Configuration +OLLAMA_PRIMARY_URL=http://desktop:11434 # Primary Ollama server (e.g., desktop) +OLLAMA_FALLBACK_URL=http://server:11434 # Fallback Ollama server (optional, always-on) +OLLAMA_PRIMARY_MODEL=nemotron-3-nano:30b # Model for primary server (default: nemotron-3-nano:30b) +OLLAMA_FALLBACK_MODEL=llama3.2:3b # Model for fallback server (optional, uses primary if not set) +SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000) +SMS_API_TOKEN=your-api-token # SMS API authentication token (optional) ``` +**AI Insights Fallback Behavior:** +- Primary server is tried first with its configured model (5-second connection timeout) +- On connection failure, automatically falls back to secondary server with its model (if configured) +- If `OLLAMA_FALLBACK_MODEL` not set, uses same model as primary server on fallback +- Total request timeout is 120 seconds to accommodate slow LLM inference +- Logs indicate which server and model was used (info level) and failover attempts (warn level) +- Backwards compatible: `OLLAMA_URL` and `OLLAMA_MODEL` still supported as fallbacks + +**Model Discovery:** +The `OllamaClient` provides methods to query available models: +- `OllamaClient::list_models(url)` - Returns list of all models on a server +- `OllamaClient::is_model_available(url, model_name)` - Checks if a specific model exists + +This allows runtime verification of model availability before generating insights. + ## Dependencies of Note - **actix-web**: HTTP framework diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 3b74a49..a7cfcc2 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -1,13 +1,16 @@ use actix_web::{HttpResponse, Responder, delete, get, post, web}; use serde::{Deserialize, Serialize}; -use crate::ai::InsightGenerator; +use crate::ai::{InsightGenerator, OllamaClient}; use crate::data::Claims; use crate::database::InsightDao; +use crate::utils::normalize_path; #[derive(Debug, Deserialize)] pub struct GeneratePhotoInsightRequest { pub file_path: String, + #[serde(default)] + pub model: Option, } #[derive(Debug, Deserialize)] @@ -25,6 +28,20 @@ pub struct PhotoInsightResponse { pub model_version: String, } +#[derive(Debug, Serialize)] +pub struct AvailableModelsResponse { + pub primary: ServerModels, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback: Option, +} + +#[derive(Debug, Serialize)] +pub struct ServerModels { + pub url: String, + pub models: Vec, + pub default_model: String, +} + /// POST /insights/generate - Generate insight for a specific photo #[post("/insights/generate")] pub async fn generate_insight_handler( @@ -32,14 +49,17 @@ pub async fn generate_insight_handler( request: web::Json, insight_generator: web::Data, ) -> impl Responder { + let normalized_path = normalize_path(&request.file_path); + log::info!( - "Manual insight generation triggered for photo: {}", - request.file_path + "Manual insight generation triggered for photo: {} with model: {:?}", + normalized_path, + request.model ); - // Generate insight + // Generate insight with optional custom model match insight_generator - .generate_insight_for_photo(&request.file_path) + .generate_insight_for_photo_with_model(&normalized_path, request.model.clone()) .await { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ @@ -62,12 +82,13 @@ pub async fn get_insight_handler( query: web::Query, insight_dao: web::Data>>, ) -> impl Responder { - log::debug!("Fetching insight for {}", query.path); + let normalized_path = normalize_path(&query.path); + log::debug!("Fetching insight for {}", normalized_path); let otel_context = opentelemetry::Context::new(); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); - match dao.get_insight(&otel_context, &query.path) { + match dao.get_insight(&otel_context, &normalized_path) { Ok(Some(insight)) => { let response = PhotoInsightResponse { id: insight.id, @@ -98,12 +119,13 @@ pub async fn delete_insight_handler( query: web::Query, insight_dao: web::Data>>, ) -> impl Responder { - log::info!("Deleting insight for {}", query.path); + let normalized_path = normalize_path(&query.path); + log::info!("Deleting insight for {}", normalized_path); let otel_context = opentelemetry::Context::new(); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); - match dao.delete_insight(&otel_context, &query.path) { + match dao.delete_insight(&otel_context, &normalized_path) { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true, "message": "Insight deleted successfully" @@ -152,3 +174,53 @@ pub async fn get_all_insights_handler( } } } + +/// GET /insights/models - List available models from both servers +#[get("/insights/models")] +pub async fn get_available_models_handler( + _claims: Claims, + app_state: web::Data, +) -> impl Responder { + log::debug!("Fetching available models"); + + let ollama_client = &app_state.ollama; + + // Fetch models from primary server + let primary_models = match OllamaClient::list_models(&ollama_client.primary_url).await { + Ok(models) => models, + Err(e) => { + log::warn!("Failed to fetch models from primary server: {:?}", e); + vec![] + } + }; + + let primary = ServerModels { + url: ollama_client.primary_url.clone(), + models: primary_models, + default_model: ollama_client.primary_model.clone(), + }; + + // Fetch models from fallback server if configured + let fallback = if let Some(fallback_url) = &ollama_client.fallback_url { + match OllamaClient::list_models(fallback_url).await { + Ok(models) => Some(ServerModels { + url: fallback_url.clone(), + models, + default_model: ollama_client + .fallback_model + .clone() + .unwrap_or_else(|| ollama_client.primary_model.clone()), + }), + Err(e) => { + log::warn!("Failed to fetch models from fallback server: {:?}", e); + None + } + } + } else { + None + }; + + let response = AvailableModelsResponse { primary, fallback }; + + HttpResponse::Ok().json(response) +} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 9c1cac9..5394e2d 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use chrono::Utc; +use chrono::{DateTime, Utc}; use serde::Deserialize; +use std::fs::File; use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; @@ -8,6 +9,7 @@ use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; use crate::database::{ExifDao, InsightDao}; use crate::memories::extract_date_from_filename; +use crate::utils::normalize_path; #[derive(Deserialize)] struct NominatimResponse { @@ -20,9 +22,7 @@ struct NominatimAddress { city: Option, town: Option, village: Option, - county: Option, state: Option, - country: Option, } #[derive(Clone)] @@ -31,6 +31,7 @@ pub struct InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + base_path: String, } impl InsightGenerator { @@ -39,12 +40,14 @@ impl InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + base_path: String, ) -> Self { Self { ollama, sms_client, insight_dao, exif_dao, + base_path, } } @@ -69,16 +72,35 @@ impl InsightGenerator { None } - /// Generate AI insight for a single photo - pub async fn generate_insight_for_photo(&self, file_path: &str) -> Result<()> { + /// Generate AI insight for a single photo with optional custom model + pub async fn generate_insight_for_photo_with_model( + &self, + file_path: &str, + custom_model: Option, + ) -> Result<()> { + // Normalize path to ensure consistent forward slashes in database + let file_path = normalize_path(file_path); log::info!("Generating insight for photo: {}", file_path); + // Create custom Ollama client if model is specified + let ollama_client = if let Some(model) = custom_model { + log::info!("Using custom model: {}", model); + OllamaClient::new( + self.ollama.primary_url.clone(), + self.ollama.fallback_url.clone(), + model.clone(), + Some(model), // Use the same custom model for fallback server + ) + } else { + self.ollama.clone() + }; + // 1. Get EXIF data for the photo let otel_context = opentelemetry::Context::new(); let exif = { let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); exif_dao - .get_exif(&otel_context, file_path) + .get_exif(&otel_context, &file_path) .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; @@ -88,17 +110,33 @@ impl InsightGenerator { } else { log::warn!("No date_taken in EXIF for {}, trying filename", file_path); - extract_date_from_filename(file_path) + extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) + .or_else(|| { + // Combine base_path with file_path to get full path + let full_path = std::path::Path::new(&self.base_path).join(&file_path); + File::open(&full_path) + .and_then(|f| f.metadata()) + .and_then(|m| m.created().or(m.modified())) + .map(|t| DateTime::::from(t).timestamp()) + .inspect_err(|e| { + log::warn!( + "Failed to get file timestamp for insight {}: {}", + file_path, + e + ) + }) + .ok() + }) .unwrap_or_else(|| Utc::now().timestamp()) }; - let date_taken = chrono::DateTime::from_timestamp(timestamp, 0) + let date_taken = DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.date_naive()) .unwrap_or_else(|| Utc::now().date_naive()); // 3. Extract contact name from file path - let contact = Self::extract_contact_from_path(file_path); + let contact = Self::extract_contact_from_path(&file_path); log::info!("Extracted contact from path: {:?}", contact); // 4. Fetch SMS messages for the contact (±1 day) @@ -124,7 +162,7 @@ impl InsightGenerator { let sms_summary = if !sms_messages.is_empty() { match self .sms_client - .summarize_context(&sms_messages, &self.ollama) + .summarize_context(&sms_messages, &ollama_client) .await { Ok(summary) => Some(summary), @@ -157,13 +195,11 @@ impl InsightGenerator { ); // 7. Generate title and summary with Ollama - let title = self - .ollama + let title = ollama_client .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) .await?; - let summary = self - .ollama + let summary = ollama_client .generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref()) .await?; @@ -176,7 +212,7 @@ impl InsightGenerator { title, summary, generated_at: Utc::now().timestamp(), - model_version: self.ollama.model.clone(), + model_version: ollama_client.primary_model.clone(), }; let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); diff --git a/src/ai/mod.rs b/src/ai/mod.rs index be1fb05..ef0d52b 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -4,7 +4,8 @@ pub mod ollama; pub mod sms_client; pub use handlers::{ - delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_insight_handler, + delete_insight_handler, generate_insight_handler, get_all_insights_handler, + get_available_models_handler, get_insight_handler, }; pub use insight_generator::InsightGenerator; pub use ollama::OllamaClient; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 0c81028..bac4c4c 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -2,25 +2,60 @@ use anyhow::Result; use chrono::NaiveDate; use reqwest::Client; use serde::{Deserialize, Serialize}; - -use crate::memories::MemoryItem; +use std::time::Duration; #[derive(Clone)] pub struct OllamaClient { client: Client, - pub base_url: String, - pub model: String, + pub primary_url: String, + pub fallback_url: Option, + pub primary_model: String, + pub fallback_model: Option, } impl OllamaClient { - pub fn new(base_url: String, model: String) -> Self { + pub fn new( + primary_url: String, + fallback_url: Option, + primary_model: String, + fallback_model: Option, + ) -> Self { Self { - client: Client::new(), - base_url, - model, + client: Client::builder() + .connect_timeout(Duration::from_secs(5)) // Quick connection timeout + .timeout(Duration::from_secs(120)) // Total request timeout for generation + .build() + .unwrap_or_else(|_| Client::new()), + primary_url, + fallback_url, + primary_model, + fallback_model, } } + /// List available models on an Ollama server + pub async fn list_models(url: &str) -> Result> { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build()?; + + let response = client.get(&format!("{}/api/tags", url)).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Failed to list models from {}", url)); + } + + let tags_response: OllamaTagsResponse = response.json().await?; + Ok(tags_response.models.into_iter().map(|m| m.name).collect()) + } + + /// Check if a model is available on a server + pub async fn is_model_available(url: &str, model_name: &str) -> Result { + let models = Self::list_models(url).await?; + Ok(models.iter().any(|m| m == model_name)) + } + /// Extract final answer from thinking model output /// Handles ... tags and takes everything after fn extract_final_answer(&self, response: &str) -> String { @@ -38,17 +73,15 @@ impl OllamaClient { response.to_string() } - pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { - log::debug!("=== Ollama Request ==="); - log::debug!("Model: {}", self.model); - if let Some(sys) = system { - log::debug!("System: {}", sys); - } - log::debug!("Prompt:\n{}", prompt); - log::debug!("====================="); - + async fn try_generate( + &self, + url: &str, + model: &str, + prompt: &str, + system: Option<&str>, + ) -> Result { let request = OllamaRequest { - model: self.model.clone(), + model: model.to_string(), prompt: prompt.to_string(), stream: false, system: system.map(|s| s.to_string()), @@ -56,7 +89,7 @@ impl OllamaClient { let response = self .client - .post(&format!("{}/api/generate", self.base_url)) + .post(&format!("{}/api/generate", url)) .json(&request) .send() .await?; @@ -64,7 +97,6 @@ impl OllamaClient { if !response.status().is_success() { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); - log::error!("Ollama request failed: {} - {}", status, error_body); return Err(anyhow::anyhow!( "Ollama request failed: {} - {}", status, @@ -73,13 +105,77 @@ impl OllamaClient { } let result: OllamaResponse = response.json().await?; + Ok(result.response) + } + + pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { + log::debug!("=== Ollama Request ==="); + log::debug!("Primary model: {}", self.primary_model); + if let Some(sys) = system { + log::debug!("System: {}", sys); + } + log::debug!("Prompt:\n{}", prompt); + log::debug!("====================="); + + // Try primary server first with primary model + log::info!( + "Attempting to generate with primary server: {} (model: {})", + self.primary_url, + self.primary_model + ); + let primary_result = self + .try_generate(&self.primary_url, &self.primary_model, prompt, system) + .await; + + let raw_response = match primary_result { + Ok(response) => { + log::info!("Successfully generated response from primary server"); + response + } + Err(e) => { + log::warn!("Primary server failed: {}", e); + + // Try fallback server if available + if let Some(fallback_url) = &self.fallback_url { + // Use fallback model if specified, otherwise use primary model + let fallback_model = + self.fallback_model.as_ref().unwrap_or(&self.primary_model); + + log::info!( + "Attempting to generate with fallback server: {} (model: {})", + fallback_url, + fallback_model + ); + match self + .try_generate(fallback_url, fallback_model, prompt, system) + .await + { + Ok(response) => { + log::info!("Successfully generated response from fallback server"); + response + } + Err(fallback_e) => { + log::error!("Fallback server also failed: {}", fallback_e); + return Err(anyhow::anyhow!( + "Both primary and fallback servers failed. Primary: {}, Fallback: {}", + e, + fallback_e + )); + } + } + } else { + log::error!("No fallback server configured"); + return Err(e); + } + } + }; log::debug!("=== Ollama Response ==="); - log::debug!("Raw response: {}", result.response.trim()); + log::debug!("Raw response: {}", raw_response.trim()); log::debug!("======================="); // Extract final answer from thinking model output - let cleaned = self.extract_final_answer(&result.response); + let cleaned = self.extract_final_answer(&raw_response); log::debug!("=== Cleaned Response ==="); log::debug!("Final answer: {}", cleaned); @@ -99,7 +195,7 @@ impl OllamaClient { let sms_str = sms_summary.unwrap_or("No messages"); let prompt = format!( - r#"Create a short title (maximum 8 words) for this photo: + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -113,8 +209,7 @@ Return ONLY the title, nothing else."#, sms_str ); - let system = - "You are a memory assistant. Use only the information provided. Do not invent details."; + let system = "You are my long term memory assistant. Use only the information provided. Do not invent details."; let title = self.generate(&prompt, Some(system)).await?; Ok(title.trim().trim_matches('"').to_string()) @@ -127,7 +222,7 @@ Return ONLY the title, nothing else."#, location: Option<&str>, sms_summary: Option<&str>, ) -> Result { - let location_str = location.unwrap_or("somewhere"); + let location_str = location.unwrap_or("Unknown"); let sms_str = sms_summary.unwrap_or("No messages"); let prompt = format!( @@ -137,7 +232,7 @@ Date: {} Location: {} Messages: {} -Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cam in a casual but fluent tone. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, +Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, date.format("%B %d, %Y"), location_str, sms_str @@ -147,15 +242,6 @@ Use only the specific details provided above. Mention people's names, places, or self.generate(&prompt, Some(system)).await } - -} - -pub struct MemoryContext { - pub date: NaiveDate, - pub photos: Vec, - pub sms_summary: Option, - pub locations: Vec, - pub cameras: Vec, } #[derive(Serialize)] @@ -171,3 +257,13 @@ struct OllamaRequest { struct OllamaResponse { response: String, } + +#[derive(Deserialize)] +struct OllamaTagsResponse { + models: Vec, +} + +#[derive(Deserialize)] +struct OllamaModel { + name: String, +} diff --git a/src/lib.rs b/src/lib.rs index 61f1387..90ba68a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod state; pub mod tags; #[cfg(test)] pub mod testhelpers; +pub mod utils; pub mod video; // Re-export commonly used types diff --git a/src/main.rs b/src/main.rs index f90bdfc..8b68ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,6 @@ use crate::tags::*; use crate::video::actors::{ ProcessMessage, ScanDirectoryMessage, create_playlist, generate_video_thumbnail, }; -use crate::video::generate_video_gifs; use log::{debug, error, info, trace, warn}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::{KeyValue, global}; @@ -62,6 +61,7 @@ mod files; mod geo; mod state; mod tags; +mod utils; mod video; mod memories; @@ -802,6 +802,7 @@ fn main() -> std::io::Result<()> { .service(ai::get_insight_handler) .service(ai::delete_insight_handler) .service(ai::get_all_insights_handler) + .service(ai::get_available_models_handler) .add_feature(add_tag_services::<_, SqliteTagDao>) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( diff --git a/src/state.rs b/src/state.rs index 5f7753f..40f33af 100644 --- a/src/state.rs +++ b/src/state.rs @@ -65,10 +65,21 @@ impl AppState { impl Default for AppState { fn default() -> Self { // Initialize AI clients - let ollama_url = - env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); - let ollama_model = env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".to_string()); - let ollama = OllamaClient::new(ollama_url, ollama_model); + let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| { + env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()) + }); + let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok(); + let ollama_primary_model = env::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| env::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nemotron-3-nano:30b".to_string()); + let ollama_fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok(); + + let ollama = OllamaClient::new( + ollama_primary_url, + ollama_fallback_url, + ollama_primary_model, + ollama_fallback_model, + ); let sms_api_url = env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); @@ -81,17 +92,21 @@ impl Default for AppState { let exif_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + // Load base path + let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); + // Initialize InsightGenerator let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + base_path.clone(), ); Self::new( Arc::new(StreamActor {}.start()), - env::var("BASE_PATH").expect("BASE_PATH was not set in the env"), + base_path, env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"), env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"), env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"), @@ -119,8 +134,12 @@ impl AppState { let gif_path = create_test_subdir(&base_path, "gifs"); // Initialize test AI clients - let ollama = - OllamaClient::new("http://localhost:11434".to_string(), "llama3.2".to_string()); + let ollama = OllamaClient::new( + "http://localhost:11434".to_string(), + None, + "llama3.2".to_string(), + None, + ); let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None); // Initialize test DAOs @@ -130,17 +149,19 @@ impl AppState { Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); // Initialize test InsightGenerator + let base_path_str = base_path.to_string_lossy().to_string(); let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + base_path_str.clone(), ); // Create the AppState with the temporary paths AppState::new( Arc::new(StreamActor {}.start()), - base_path.to_string_lossy().to_string(), + base_path_str, thumbnail_path.to_string_lossy().to_string(), video_path.to_string_lossy().to_string(), gif_path.to_string_lossy().to_string(), diff --git a/src/tags.rs b/src/tags.rs index f0b7df6..f9f3c55 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -1,5 +1,6 @@ use crate::data::GetTagsRequest; use crate::otel::{extract_context_from_request, global_tracer, trace_db_call}; +use crate::utils::normalize_path; use crate::{Claims, ThumbnailRequest, connect, data::AddTagRequest, error::IntoHttpError, schema}; use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::{App, HttpRequest, HttpResponse, Responder, web}; @@ -41,6 +42,7 @@ async fn add_tag( let span = tracer.start_with_context("add_tag", &context); let span_context = opentelemetry::Context::current_with_span(span); let tag_name = body.tag_name.clone(); + let normalized_path = normalize_path(&body.file_name); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); @@ -52,12 +54,12 @@ async fn add_tag( } else { info!( "Creating missing tag: '{:?}' for file: '{}'", - tag_name, &body.file_name + tag_name, &normalized_path ); tag_dao.create_tag(&span_context, tag_name.trim()) } }) - .and_then(|tag| tag_dao.tag_file(&span_context, &body.file_name, tag.id)) + .and_then(|tag| tag_dao.tag_file(&span_context, &normalized_path, tag.id)) .map(|_| { span_context.span().set_status(Status::Ok); HttpResponse::Ok() @@ -74,9 +76,10 @@ async fn get_tags( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("get_tags", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.path); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); tag_dao - .get_tags_for_path(&span_context, &request.path) + .get_tags_for_path(&span_context, &normalized_path) .map(|tags| { span_context.span().set_status(Status::Ok); HttpResponse::Ok().json(tags) @@ -139,10 +142,11 @@ async fn remove_tagged_photo( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("remove_tagged_photo", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.file_name); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); tag_dao - .remove_tag(&span_context, &request.tag_name, &request.file_name) + .remove_tag(&span_context, &request.tag_name, &normalized_path) .map(|result| { span_context.span().set_status(Status::Ok); @@ -165,8 +169,9 @@ async fn update_tags( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("update_tags", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.file_name); - dao.get_tags_for_path(&span_context, &request.file_name) + dao.get_tags_for_path(&span_context, &normalized_path) .and_then(|existing_tags| { dao.get_all_tags(&span_context, None) .map(|all| (existing_tags, all)) @@ -180,9 +185,9 @@ async fn update_tags( for tag in tags_to_remove { info!( "Removing tag {:?} from file: {:?}", - tag.name, request.file_name + tag.name, normalized_path ); - dao.remove_tag(&span_context, &tag.name, &request.file_name) + dao.remove_tag(&span_context, &tag.name, &normalized_path) .unwrap_or_else(|err| panic!("{:?} Unable to remove tag {:?}", err, &tag.name)); } @@ -194,14 +199,14 @@ async fn update_tags( for (_, new_tag) in new_tags { info!( "Adding tag {:?} to file: {:?}", - new_tag.name, request.file_name + new_tag.name, normalized_path ); - dao.tag_file(&span_context, &request.file_name, new_tag.id) + dao.tag_file(&span_context, &normalized_path, new_tag.id) .with_context(|| { format!( "Unable to tag file {:?} with tag: {:?}", - request.file_name, new_tag.name + normalized_path, new_tag.name ) }) .unwrap(); diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..1779c15 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,83 @@ +/// Normalize a file path to use forward slashes for cross-platform consistency +/// This ensures paths stored in the database always use `/` regardless of OS +/// +/// # Examples +/// ``` +/// use image_api::utils::normalize_path; +/// +/// assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg"); +/// assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg"); +/// ``` +pub fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_path_with_backslashes() { + assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg"); + } + + #[test] + fn test_normalize_path_with_forward_slashes() { + assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg"); + } + + #[test] + fn test_normalize_path_mixed() { + assert_eq!( + normalize_path("foo\\bar/baz\\qux.jpg"), + "foo/bar/baz/qux.jpg" + ); + } + + #[test] + fn test_normalize_path_empty() { + assert_eq!(normalize_path(""), ""); + } + + #[test] + fn test_normalize_path_absolute_windows() { + assert_eq!( + normalize_path("C:\\Users\\Photos\\image.jpg"), + "C:/Users/Photos/image.jpg" + ); + } + + #[test] + fn test_normalize_path_unc_path() { + assert_eq!( + normalize_path("\\\\server\\share\\folder\\file.jpg"), + "//server/share/folder/file.jpg" + ); + } + + #[test] + fn test_normalize_path_single_filename() { + assert_eq!(normalize_path("image.jpg"), "image.jpg"); + } + + #[test] + fn test_normalize_path_trailing_slash() { + assert_eq!(normalize_path("foo\\bar\\"), "foo/bar/"); + } + + #[test] + fn test_normalize_path_multiple_consecutive_backslashes() { + assert_eq!( + normalize_path("foo\\\\bar\\\\\\baz.jpg"), + "foo//bar///baz.jpg" + ); + } + + #[test] + fn test_normalize_path_deep_nesting() { + assert_eq!( + normalize_path("a\\b\\c\\d\\e\\f\\g\\file.jpg"), + "a/b/c/d/e/f/g/file.jpg" + ); + } +} -- 2.49.1 From cbbfb7144bf4437df4a8adb4f9f2f3ed27f2ab7c Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 3 Jan 2026 20:31:18 -0500 Subject: [PATCH 04/25] Re-enable video GIF generation --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 8b68ad1..f481107 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,7 @@ use crate::video::actors::{ use log::{debug, error, info, trace, warn}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::{KeyValue, global}; +use crate::video::generate_video_gifs; mod ai; mod auth; @@ -717,7 +718,7 @@ fn main() -> std::io::Result<()> { } create_thumbnails(); - // generate_video_gifs().await; + generate_video_gifs().await; let app_data = Data::new(AppState::default()); -- 2.49.1 From 43b7c2b8ec1e5a9d8c481894e5e31729615db358 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 3 Jan 2026 20:32:00 -0500 Subject: [PATCH 05/25] Remove dialoguer dependency --- Cargo.lock | 45 --------------------------------------------- Cargo.toml | 1 - 2 files changed, 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d8173f..180906e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,19 +753,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "convert_case" version = "0.4.0" @@ -952,19 +939,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", -] - [[package]] name = "diesel" version = "2.2.12" @@ -1056,12 +1030,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1735,7 +1703,6 @@ dependencies = [ "bcrypt", "chrono", "clap", - "dialoguer", "diesel", "diesel_migrations", "dotenv", @@ -3128,12 +3095,6 @@ dependencies = [ "digest", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -3683,12 +3644,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 35043cb..c21ba51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ diesel = { version = "2.2.10", features = ["sqlite"] } diesel_migrations = "2.2.0" chrono = "0.4" clap = { version = "4.5", features = ["derive"] } -dialoguer = "0.11" dotenv = "0.15" bcrypt = "0.17.1" image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "rayon"] } -- 2.49.1 From 11e725c443c41636298283a7d1e66a89553e0c7d Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 5 Jan 2026 09:13:16 -0500 Subject: [PATCH 06/25] Enhanced Insights with daily summary embeddings Bump to 0.5.0. Added daily summary generation job --- Cargo.lock | 5 +- Cargo.toml | 4 +- .../down.sql | 3 + .../up.sql | 19 + .../down.sql | 1 + .../up.sql | 19 + src/ai/daily_summary_job.rs | 289 +++++++++ src/ai/embedding_job.rs | 213 +++++++ src/ai/handlers.rs | 34 +- src/ai/insight_generator.rs | 589 ++++++++++++++++-- src/ai/mod.rs | 6 +- src/ai/ollama.rs | 150 ++++- src/ai/sms_client.rs | 112 ++++ src/database/daily_summary_dao.rs | 338 ++++++++++ src/database/embeddings_dao.rs | 569 +++++++++++++++++ src/database/mod.rs | 4 + src/main.rs | 46 +- src/state.rs | 8 +- 18 files changed, 2348 insertions(+), 61 deletions(-) create mode 100644 migrations/2026-01-04-000000_add_message_embeddings/down.sql create mode 100644 migrations/2026-01-04-000000_add_message_embeddings/up.sql create mode 100644 migrations/2026-01-04-060000_add_daily_summaries/down.sql create mode 100644 migrations/2026-01-04-060000_add_daily_summaries/up.sql create mode 100644 src/ai/daily_summary_job.rs create mode 100644 src/ai/embedding_job.rs create mode 100644 src/database/daily_summary_dao.rs create mode 100644 src/database/embeddings_dao.rs diff --git a/Cargo.lock b/Cargo.lock index 180906e..b964197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1689,7 +1689,7 @@ dependencies = [ [[package]] name = "image-api" -version = "0.4.1" +version = "0.5.0" dependencies = [ "actix", "actix-cors", @@ -1713,6 +1713,7 @@ dependencies = [ "jsonwebtoken", "kamadak-exif", "lazy_static", + "libsqlite3-sys", "log", "opentelemetry", "opentelemetry-appender-log", @@ -1731,6 +1732,7 @@ dependencies = [ "tokio", "urlencoding", "walkdir", + "zerocopy", ] [[package]] @@ -1943,6 +1945,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ + "cc", "pkg-config", "vcpkg", ] diff --git a/Cargo.toml b/Cargo.toml index c21ba51..27e6e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "image-api" -version = "0.4.1" +version = "0.5.0" authors = ["Cameron Cordes "] edition = "2024" @@ -23,6 +23,7 @@ jsonwebtoken = "9.3.0" serde = "1" serde_json = "1" diesel = { version = "2.2.10", features = ["sqlite"] } +libsqlite3-sys = { version = "0.35", features = ["bundled"] } diesel_migrations = "2.2.0" chrono = "0.4" clap = { version = "4.5", features = ["derive"] } @@ -50,3 +51,4 @@ regex = "1.11.1" exif = { package = "kamadak-exif", version = "0.6.1" } reqwest = { version = "0.12", features = ["json"] } urlencoding = "2.1" +zerocopy = "0.8" diff --git a/migrations/2026-01-04-000000_add_message_embeddings/down.sql b/migrations/2026-01-04-000000_add_message_embeddings/down.sql new file mode 100644 index 0000000..c8b6965 --- /dev/null +++ b/migrations/2026-01-04-000000_add_message_embeddings/down.sql @@ -0,0 +1,3 @@ +-- Drop tables in reverse order +DROP TABLE IF EXISTS vec_message_embeddings; +DROP TABLE IF EXISTS message_embeddings; diff --git a/migrations/2026-01-04-000000_add_message_embeddings/up.sql b/migrations/2026-01-04-000000_add_message_embeddings/up.sql new file mode 100644 index 0000000..a2fff45 --- /dev/null +++ b/migrations/2026-01-04-000000_add_message_embeddings/up.sql @@ -0,0 +1,19 @@ +-- Table for storing message metadata and embeddings +-- Embeddings stored as BLOB for proof-of-concept +-- For production with many contacts, consider using sqlite-vec extension +CREATE TABLE message_embeddings ( + id INTEGER PRIMARY KEY NOT NULL, + contact TEXT NOT NULL, + body TEXT NOT NULL, + timestamp BIGINT NOT NULL, + is_sent BOOLEAN NOT NULL, + embedding BLOB NOT NULL, + created_at BIGINT NOT NULL, + model_version TEXT NOT NULL, + -- Prevent duplicate embeddings for the same message + UNIQUE(contact, body, timestamp) +); + +-- Indexes for efficient queries +CREATE INDEX idx_message_embeddings_contact ON message_embeddings(contact); +CREATE INDEX idx_message_embeddings_timestamp ON message_embeddings(timestamp); diff --git a/migrations/2026-01-04-060000_add_daily_summaries/down.sql b/migrations/2026-01-04-060000_add_daily_summaries/down.sql new file mode 100644 index 0000000..f142059 --- /dev/null +++ b/migrations/2026-01-04-060000_add_daily_summaries/down.sql @@ -0,0 +1 @@ +DROP TABLE daily_conversation_summaries; diff --git a/migrations/2026-01-04-060000_add_daily_summaries/up.sql b/migrations/2026-01-04-060000_add_daily_summaries/up.sql new file mode 100644 index 0000000..6c47122 --- /dev/null +++ b/migrations/2026-01-04-060000_add_daily_summaries/up.sql @@ -0,0 +1,19 @@ +-- Daily conversation summaries for improved RAG quality +-- Each row = one day's conversation with a contact, summarized by LLM and embedded + +CREATE TABLE daily_conversation_summaries ( + id INTEGER PRIMARY KEY NOT NULL, + date TEXT NOT NULL, -- ISO date "2024-08-15" + contact TEXT NOT NULL, -- Contact name + summary TEXT NOT NULL, -- LLM-generated 3-5 sentence summary + message_count INTEGER NOT NULL, -- Number of messages in this day + embedding BLOB NOT NULL, -- 768-dim vector of the summary + created_at BIGINT NOT NULL, -- When this summary was generated + model_version TEXT NOT NULL, -- "nomic-embed-text:v1.5" + UNIQUE(date, contact) +); + +-- Indexes for efficient querying +CREATE INDEX idx_daily_summaries_date ON daily_conversation_summaries(date); +CREATE INDEX idx_daily_summaries_contact ON daily_conversation_summaries(contact); +CREATE INDEX idx_daily_summaries_date_contact ON daily_conversation_summaries(date, contact); diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs new file mode 100644 index 0000000..cd5053b --- /dev/null +++ b/src/ai/daily_summary_job.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use chrono::{NaiveDate, Utc}; +use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; +use opentelemetry::KeyValue; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::time::sleep; + +use crate::ai::{OllamaClient, SmsApiClient, SmsMessage}; +use crate::database::{DailySummaryDao, InsertDailySummary}; +use crate::otel::global_tracer; + +/// Generate and embed daily conversation summaries for a date range +/// Default: August 2024 ±30 days (July 1 - September 30, 2024) +pub async fn generate_daily_summaries( + contact: &str, + start_date: Option, + end_date: Option, + ollama: &OllamaClient, + sms_client: &SmsApiClient, + summary_dao: Arc>>, +) -> Result<()> { + let tracer = global_tracer(); + + // Get current context (empty in background task) and start span with it + let current_cx = opentelemetry::Context::current(); + let mut span = tracer.start_with_context("ai.daily_summary.generate_batch", ¤t_cx); + span.set_attribute(KeyValue::new("contact", contact.to_string())); + + // Create context with this span for child operations + let parent_cx = current_cx.with_span(span); + + // Default to August 2024 ±30 days + let start = start_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()); + let end = end_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()); + + parent_cx.span().set_attribute(KeyValue::new("start_date", start.to_string())); + parent_cx.span().set_attribute(KeyValue::new("end_date", end.to_string())); + parent_cx.span().set_attribute(KeyValue::new("date_range_days", (end - start).num_days() + 1)); + + log::info!( + "========================================"); + log::info!("Starting daily summary generation for {}", contact); + log::info!("Date range: {} to {} ({} days)", + start, end, (end - start).num_days() + 1 + ); + log::info!("========================================"); + + // Fetch all messages for the contact in the date range + log::info!("Fetching messages for date range..."); + let _start_timestamp = start + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + let _end_timestamp = end + .and_hms_opt(23, 59, 59) + .unwrap() + .and_utc() + .timestamp(); + + let all_messages = sms_client + .fetch_all_messages_for_contact(contact) + .await?; + + // Filter to date range and group by date + let mut messages_by_date: HashMap> = HashMap::new(); + + for msg in all_messages { + let msg_dt = chrono::DateTime::from_timestamp(msg.timestamp, 0); + if let Some(dt) = msg_dt { + let date = dt.date_naive(); + if date >= start && date <= end { + messages_by_date + .entry(date) + .or_insert_with(Vec::new) + .push(msg); + } + } + } + + log::info!( + "Grouped messages into {} days with activity", + messages_by_date.len() + ); + + if messages_by_date.is_empty() { + log::warn!("No messages found in date range"); + return Ok(()); + } + + // Sort dates for ordered processing + let mut dates: Vec = messages_by_date.keys().cloned().collect(); + dates.sort(); + + let total_days = dates.len(); + let mut processed = 0; + let mut skipped = 0; + let mut failed = 0; + + log::info!("Processing {} days with messages...", total_days); + + for (idx, date) in dates.iter().enumerate() { + let messages = messages_by_date.get(date).unwrap(); + let date_str = date.format("%Y-%m-%d").to_string(); + + // Check if summary already exists + { + let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao"); + let otel_context = opentelemetry::Context::new(); + + if dao.summary_exists(&otel_context, &date_str, contact).unwrap_or(false) { + skipped += 1; + if idx % 10 == 0 { + log::info!( + "Progress: {}/{} ({} processed, {} skipped)", + idx + 1, + total_days, + processed, + skipped + ); + } + continue; + } + } + + // Generate summary for this day + match generate_and_store_daily_summary( + &parent_cx, + date, + contact, + messages, + ollama, + summary_dao.clone(), + ) + .await + { + Ok(_) => { + processed += 1; + log::info!( + "✓ {}/{}: {} ({} messages)", + idx + 1, + total_days, + date_str, + messages.len() + ); + } + Err(e) => { + failed += 1; + log::error!("✗ Failed to process {}: {:?}", date_str, e); + } + } + + // Rate limiting: sleep 500ms between summaries + if idx < total_days - 1 { + sleep(std::time::Duration::from_millis(500)).await; + } + + // Progress logging every 10 days + if idx % 10 == 0 && idx > 0 { + log::info!( + "Progress: {}/{} ({} processed, {} skipped, {} failed)", + idx + 1, + total_days, + processed, + skipped, + failed + ); + } + } + + log::info!("========================================"); + log::info!("Daily summary generation complete!"); + log::info!("Processed: {}, Skipped: {}, Failed: {}", processed, skipped, failed); + log::info!("========================================"); + + // Record final metrics in span + parent_cx.span().set_attribute(KeyValue::new("days_processed", processed as i64)); + parent_cx.span().set_attribute(KeyValue::new("days_skipped", skipped as i64)); + parent_cx.span().set_attribute(KeyValue::new("days_failed", failed as i64)); + parent_cx.span().set_attribute(KeyValue::new("total_days", total_days as i64)); + + if failed > 0 { + parent_cx.span().set_status(Status::error(format!("{} days failed to process", failed))); + } else { + parent_cx.span().set_status(Status::Ok); + } + + Ok(()) +} + +/// Generate and store a single day's summary +async fn generate_and_store_daily_summary( + parent_cx: &opentelemetry::Context, + date: &NaiveDate, + contact: &str, + messages: &[SmsMessage], + ollama: &OllamaClient, + summary_dao: Arc>>, +) -> Result<()> { + let tracer = global_tracer(); + let mut span = tracer.start_with_context("ai.daily_summary.generate_single", parent_cx); + span.set_attribute(KeyValue::new("date", date.to_string())); + span.set_attribute(KeyValue::new("contact", contact.to_string())); + span.set_attribute(KeyValue::new("message_count", messages.len() as i64)); + + // Format messages for LLM + let messages_text: String = messages + .iter() + .take(200) // Limit to 200 messages per day to avoid token overflow + .map(|m| { + if m.is_sent { + format!("Me: {}", m.body) + } else { + format!("{}: {}", m.contact, m.body) + } + }) + .collect::>() + .join("\n"); + + let weekday = date.format("%A"); + + let prompt = format!( + r#"Summarize this day's conversation in 3-5 sentences. Focus on: +- Key topics, activities, and events discussed +- Places, people, or organizations mentioned +- Plans made or decisions discussed +- Overall mood or themes of the day + +IMPORTANT: Clearly distinguish between what "I" or "Me" did versus what {} did. +Always explicitly attribute actions, plans, and activities to the correct person. +Use "I" or "Me" for my actions and "{}" for their actions. + +Date: {} ({}) +Messages: +{} + +Write a natural, informative summary with clear subject attribution. +Summary:"#, + contact, + contact, + date.format("%B %d, %Y"), + weekday, + messages_text + ); + + // Generate summary with LLM + let summary = ollama + .generate( + &prompt, + Some("You are a conversation summarizer. Create clear, factual summaries that maintain precise subject attribution - clearly distinguishing who said or did what."), + ) + .await?; + + log::debug!("Generated summary for {}: {}", date, summary.chars().take(100).collect::()); + + span.set_attribute(KeyValue::new("summary_length", summary.len() as i64)); + + // Embed the summary + let embedding = ollama.generate_embedding(&summary).await?; + + span.set_attribute(KeyValue::new("embedding_dimensions", embedding.len() as i64)); + + // Store in database + let insert = InsertDailySummary { + date: date.format("%Y-%m-%d").to_string(), + contact: contact.to_string(), + summary: summary.trim().to_string(), + message_count: messages.len() as i32, + embedding, + created_at: Utc::now().timestamp(), + model_version: "nomic-embed-text:v1.5".to_string(), + }; + + // Create context from current span for DB operation + let child_cx = opentelemetry::Context::current_with_span(span); + + let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao"); + let result = dao.store_summary(&child_cx, insert) + .map_err(|e| anyhow::anyhow!("Failed to store summary: {:?}", e)); + + match &result { + Ok(_) => child_cx.span().set_status(Status::Ok), + Err(e) => child_cx.span().set_status(Status::error(e.to_string())), + } + + result?; + Ok(()) +} diff --git a/src/ai/embedding_job.rs b/src/ai/embedding_job.rs new file mode 100644 index 0000000..af5b5fb --- /dev/null +++ b/src/ai/embedding_job.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use chrono::Utc; +use std::sync::{Arc, Mutex}; +use tokio::time::{sleep, Duration}; + +use crate::ai::{OllamaClient, SmsApiClient}; +use crate::database::{EmbeddingDao, InsertMessageEmbedding}; + +/// Background job to embed messages for a specific contact +/// This function is idempotent - it checks if embeddings already exist before processing +/// +/// # Arguments +/// * `contact` - The contact name to embed messages for (e.g., "Amanda") +/// * `ollama` - Ollama client for generating embeddings +/// * `sms_client` - SMS API client for fetching messages +/// * `embedding_dao` - DAO for storing embeddings in the database +/// +/// # Returns +/// Ok(()) on success, Err on failure +pub async fn embed_contact_messages( + contact: &str, + ollama: &OllamaClient, + sms_client: &SmsApiClient, + embedding_dao: Arc>>, +) -> Result<()> { + log::info!("Starting message embedding job for contact: {}", contact); + + let otel_context = opentelemetry::Context::new(); + + // Check existing embeddings count + let existing_count = { + let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); + dao.get_message_count(&otel_context, contact) + .unwrap_or(0) + }; + + if existing_count > 0 { + log::info!( + "Contact '{}' already has {} embeddings, will check for new messages to embed", + contact, + existing_count + ); + } + + log::info!("Fetching all messages for contact: {}", contact); + + // Fetch all messages for the contact + let messages = sms_client + .fetch_all_messages_for_contact(contact) + .await?; + + let total_messages = messages.len(); + log::info!("Fetched {} messages for contact '{}'", total_messages, contact); + + if total_messages == 0 { + log::warn!("No messages found for contact '{}', nothing to embed", contact); + return Ok(()); + } + + // Filter out messages that already have embeddings and short/generic messages + log::info!("Filtering out messages that already have embeddings and short messages..."); + let min_message_length = 30; // Skip short messages like "Thanks!" or "Yeah, it was :)" + let messages_to_embed: Vec<&crate::ai::SmsMessage> = { + let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); + messages.iter() + .filter(|msg| { + // Filter out short messages + if msg.body.len() < min_message_length { + return false; + } + // Filter out already embedded messages + !dao.message_exists(&otel_context, contact, &msg.body, msg.timestamp) + .unwrap_or(false) + }) + .collect() + }; + + let skipped = total_messages - messages_to_embed.len(); + let to_embed = messages_to_embed.len(); + + log::info!( + "Found {} messages to embed ({} already embedded)", + to_embed, + skipped + ); + + if to_embed == 0 { + log::info!("All messages already embedded for contact '{}'", contact); + return Ok(()); + } + + // Process messages in batches + let batch_size = 128; // Embed 128 messages per API call + let mut successful = 0; + let mut failed = 0; + + for (batch_idx, batch) in messages_to_embed.chunks(batch_size).enumerate() { + let batch_start = batch_idx * batch_size; + let batch_end = batch_start + batch.len(); + + log::info!( + "Processing batch {}/{}: messages {}-{} ({:.1}% complete)", + batch_idx + 1, + (to_embed + batch_size - 1) / batch_size, + batch_start + 1, + batch_end, + (batch_end as f64 / to_embed as f64) * 100.0 + ); + + match embed_message_batch( + batch, + contact, + ollama, + embedding_dao.clone(), + ) + .await + { + Ok(count) => { + successful += count; + log::debug!("Successfully embedded {} messages in batch", count); + } + Err(e) => { + failed += batch.len(); + log::error!("Failed to embed batch: {:?}", e); + // Continue processing despite failures + } + } + + // Small delay between batches to avoid overwhelming Ollama + if batch_end < to_embed { + sleep(Duration::from_millis(500)).await; + } + } + + log::info!( + "Message embedding job complete for '{}': {}/{} new embeddings created ({} already embedded, {} failed)", + contact, + successful, + total_messages, + skipped, + failed + ); + + if failed > 0 { + log::warn!( + "{} messages failed to embed for contact '{}'", + failed, + contact + ); + } + + Ok(()) +} + +/// Embed a batch of messages using a single API call +/// Returns the number of successfully embedded messages +async fn embed_message_batch( + messages: &[&crate::ai::SmsMessage], + contact: &str, + ollama: &OllamaClient, + embedding_dao: Arc>>, +) -> Result { + if messages.is_empty() { + return Ok(0); + } + + // Collect message bodies for batch embedding + let bodies: Vec<&str> = messages.iter().map(|m| m.body.as_str()).collect(); + + // Generate embeddings for all messages in one API call + let embeddings = ollama.generate_embeddings(&bodies).await?; + + if embeddings.len() != messages.len() { + return Err(anyhow::anyhow!( + "Embedding count mismatch: got {} embeddings for {} messages", + embeddings.len(), + messages.len() + )); + } + + // Build batch of insert records + let otel_context = opentelemetry::Context::new(); + let created_at = Utc::now().timestamp(); + let mut inserts = Vec::with_capacity(messages.len()); + + for (message, embedding) in messages.iter().zip(embeddings.iter()) { + // Validate embedding dimensions + if embedding.len() != 768 { + log::warn!( + "Invalid embedding dimensions: {} (expected 768), skipping", + embedding.len() + ); + continue; + } + + inserts.push(InsertMessageEmbedding { + contact: contact.to_string(), + body: message.body.clone(), + timestamp: message.timestamp, + is_sent: message.is_sent, + embedding: embedding.clone(), + created_at, + model_version: "nomic-embed-text:v1.5".to_string(), + }); + } + + // Store all embeddings in a single transaction + let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); + let stored_count = dao.store_message_embeddings_batch(&otel_context, inserts) + .map_err(|e| anyhow::anyhow!("Failed to store embeddings batch: {:?}", e))?; + + Ok(stored_count) +} diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index a7cfcc2..0e4eab8 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -1,9 +1,12 @@ -use actix_web::{HttpResponse, Responder, delete, get, post, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web}; +use opentelemetry::trace::{Span, Status, Tracer}; +use opentelemetry::KeyValue; use serde::{Deserialize, Serialize}; use crate::ai::{InsightGenerator, OllamaClient}; use crate::data::Claims; use crate::database::InsightDao; +use crate::otel::{extract_context_from_request, global_tracer}; use crate::utils::normalize_path; #[derive(Debug, Deserialize)] @@ -45,12 +48,22 @@ pub struct ServerModels { /// POST /insights/generate - Generate insight for a specific photo #[post("/insights/generate")] pub async fn generate_insight_handler( + http_request: HttpRequest, _claims: Claims, request: web::Json, insight_generator: web::Data, ) -> impl Responder { + let parent_context = extract_context_from_request(&http_request); + let tracer = global_tracer(); + let mut span = tracer.start_with_context("http.insights.generate", &parent_context); + let normalized_path = normalize_path(&request.file_path); + span.set_attribute(KeyValue::new("file_path", normalized_path.clone())); + if let Some(ref model) = request.model { + span.set_attribute(KeyValue::new("model", model.clone())); + } + log::info!( "Manual insight generation triggered for photo: {} with model: {:?}", normalized_path, @@ -58,16 +71,21 @@ pub async fn generate_insight_handler( ); // Generate insight with optional custom model - match insight_generator + let result = insight_generator .generate_insight_for_photo_with_model(&normalized_path, request.model.clone()) - .await - { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": "Insight generated successfully" - })), + .await; + + match result { + Ok(()) => { + span.set_status(Status::Ok); + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Insight generated successfully" + })) + } Err(e) => { log::error!("Failed to generate insight: {:?}", e); + span.set_status(Status::error(e.to_string())); HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to generate insight: {:?}", e) })) diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 5394e2d..4d2ce47 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,5 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; +use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; +use opentelemetry::KeyValue; use serde::Deserialize; use std::fs::File; use std::sync::{Arc, Mutex}; @@ -7,8 +9,9 @@ use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; -use crate::database::{ExifDao, InsightDao}; +use crate::database::{DailySummaryDao, ExifDao, InsightDao}; use crate::memories::extract_date_from_filename; +use crate::otel::global_tracer; use crate::utils::normalize_path; #[derive(Deserialize)] @@ -31,6 +34,7 @@ pub struct InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + daily_summary_dao: Arc>>, base_path: String, } @@ -40,6 +44,7 @@ impl InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + daily_summary_dao: Arc>>, base_path: String, ) -> Self { Self { @@ -47,6 +52,7 @@ impl InsightGenerator { sms_client, insight_dao, exif_dao, + daily_summary_dao, base_path, } } @@ -72,19 +78,174 @@ impl InsightGenerator { None } + /// Find relevant messages using RAG, excluding recent messages (>30 days ago) + /// This prevents RAG from returning messages already in the immediate time window + async fn find_relevant_messages_rag_historical( + &self, + parent_cx: &opentelemetry::Context, + date: chrono::NaiveDate, + location: Option<&str>, + contact: Option<&str>, + limit: usize, + ) -> Result> { + let tracer = global_tracer(); + let mut span = tracer.start_with_context("ai.rag.filter_historical", parent_cx); + let filter_cx = parent_cx.with_span(span); + + filter_cx.span().set_attribute(KeyValue::new("date", date.to_string())); + filter_cx.span().set_attribute(KeyValue::new("limit", limit as i64)); + filter_cx.span().set_attribute(KeyValue::new("exclusion_window_days", 30)); + + let query_results = self.find_relevant_messages_rag(date, location, contact, limit * 2).await?; + + filter_cx.span().set_attribute(KeyValue::new("rag_results_count", query_results.len() as i64)); + + // Filter out messages from within 30 days of the photo date + let photo_timestamp = date.and_hms_opt(12, 0, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid date"))? + .and_utc() + .timestamp(); + let exclusion_window = 30 * 86400; // 30 days in seconds + + let historical_only: Vec = query_results + .into_iter() + .filter(|msg| { + // Extract date from formatted daily summary "[2024-08-15] Contact ..." + if let Some(bracket_end) = msg.find(']') { + if let Some(date_str) = msg.get(1..bracket_end) { + // Parse just the date (daily summaries don't have time) + if let Ok(msg_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { + let msg_timestamp = msg_date + .and_hms_opt(12, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + let time_diff = (photo_timestamp - msg_timestamp).abs(); + return time_diff > exclusion_window; + } + } + } + false + }) + .take(limit) + .collect(); + + log::info!( + "Found {} historical messages (>30 days from photo date)", + historical_only.len() + ); + + filter_cx.span().set_attribute(KeyValue::new("historical_results_count", historical_only.len() as i64)); + filter_cx.span().set_status(Status::Ok); + + Ok(historical_only) + } + + /// Find relevant daily summaries using RAG (semantic search) + /// Returns formatted daily summary strings for LLM context + async fn find_relevant_messages_rag( + &self, + date: chrono::NaiveDate, + location: Option<&str>, + contact: Option<&str>, + limit: usize, + ) -> Result> { + let tracer = global_tracer(); + let current_cx = opentelemetry::Context::current(); + let mut span = tracer.start_with_context("ai.rag.search_daily_summaries", ¤t_cx); + span.set_attribute(KeyValue::new("date", date.to_string())); + span.set_attribute(KeyValue::new("limit", limit as i64)); + if let Some(loc) = location { + span.set_attribute(KeyValue::new("location", loc.to_string())); + } + if let Some(c) = contact { + span.set_attribute(KeyValue::new("contact", c.to_string())); + } + + // Build more detailed query string from photo context + let mut query_parts = Vec::new(); + + // Add temporal context + query_parts.push(format!("On {}", date.format("%B %d, %Y"))); + + // Add location if available + if let Some(loc) = location { + query_parts.push(format!("at {}", loc)); + } + + // Add contact context if available + if let Some(c) = contact { + query_parts.push(format!("conversation with {}", c)); + } + + // Add day of week for temporal context + let weekday = date.format("%A"); + query_parts.push(format!("it was a {}", weekday)); + + let query = query_parts.join(", "); + + span.set_attribute(KeyValue::new("query", query.clone())); + + // Create context with this span for child operations + let search_cx = current_cx.with_span(span); + + log::info!("========================================"); + log::info!("RAG QUERY: {}", query); + log::info!("========================================"); + + // Generate embedding for the query + let query_embedding = self.ollama.generate_embedding(&query).await?; + + // Search for similar daily summaries + let mut summary_dao = self + .daily_summary_dao + .lock() + .expect("Unable to lock DailySummaryDao"); + + let similar_summaries = summary_dao + .find_similar_summaries(&search_cx, &query_embedding, limit) + .map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?; + + log::info!("Found {} relevant daily summaries via RAG", similar_summaries.len()); + + search_cx.span().set_attribute(KeyValue::new("results_count", similar_summaries.len() as i64)); + + // Format daily summaries for LLM context + let formatted = similar_summaries + .into_iter() + .map(|s| { + format!( + "[{}] {} ({} messages):\n{}", + s.date, s.contact, s.message_count, s.summary + ) + }) + .collect(); + + search_cx.span().set_status(Status::Ok); + + Ok(formatted) + } + /// Generate AI insight for a single photo with optional custom model pub async fn generate_insight_for_photo_with_model( &self, file_path: &str, custom_model: Option, ) -> Result<()> { + let tracer = global_tracer(); + let current_cx = opentelemetry::Context::current(); + let mut span = tracer.start_with_context("ai.insight.generate", ¤t_cx); + // Normalize path to ensure consistent forward slashes in database let file_path = normalize_path(file_path); log::info!("Generating insight for photo: {}", file_path); + span.set_attribute(KeyValue::new("file_path", file_path.clone())); + // Create custom Ollama client if model is specified let ollama_client = if let Some(model) = custom_model { log::info!("Using custom model: {}", model); + span.set_attribute(KeyValue::new("custom_model", model.clone())); OllamaClient::new( self.ollama.primary_url.clone(), self.ollama.fallback_url.clone(), @@ -92,15 +253,18 @@ impl InsightGenerator { Some(model), // Use the same custom model for fallback server ) } else { + span.set_attribute(KeyValue::new("model", self.ollama.primary_model.clone())); self.ollama.clone() }; + // Create context with this span for child operations + let insight_cx = current_cx.with_span(span); + // 1. Get EXIF data for the photo - let otel_context = opentelemetry::Context::new(); let exif = { let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); exif_dao - .get_exif(&otel_context, &file_path) + .get_exif(&insight_cx, &file_path) .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; @@ -139,47 +303,20 @@ impl InsightGenerator { let contact = Self::extract_contact_from_path(&file_path); log::info!("Extracted contact from path: {:?}", contact); - // 4. Fetch SMS messages for the contact (±1 day) - // Pass the full timestamp for proximity sorting - let sms_messages = self - .sms_client - .fetch_messages_for_contact(contact.as_deref(), timestamp) - .await - .unwrap_or_else(|e| { - log::error!("Failed to fetch SMS messages: {}", e); - Vec::new() - }); + insight_cx.span().set_attribute(KeyValue::new("date_taken", date_taken.to_string())); + if let Some(ref c) = contact { + insight_cx.span().set_attribute(KeyValue::new("contact", c.clone())); + } - log::info!( - "Fetched {} SMS messages closest to {}", - sms_messages.len(), - chrono::DateTime::from_timestamp(timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "unknown time".to_string()) - ); - - // 5. Summarize SMS context - let sms_summary = if !sms_messages.is_empty() { - match self - .sms_client - .summarize_context(&sms_messages, &ollama_client) - .await - { - Ok(summary) => Some(summary), - Err(e) => { - log::warn!("Failed to summarize SMS context: {}", e); - None - } - } - } else { - None - }; - - // 6. Get location name from GPS coordinates + // 4. Get location name from GPS coordinates (needed for RAG query) let location = match exif { - Some(exif) => { + Some(ref exif) => { if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { - self.reverse_geocode(lat, lon).await + let loc = self.reverse_geocode(lat, lon).await; + if let Some(ref l) = loc { + insight_cx.span().set_attribute(KeyValue::new("location", l.clone())); + } + loc } else { None } @@ -187,11 +324,171 @@ impl InsightGenerator { None => None, }; + // 5. Intelligent retrieval: Hybrid approach for better context + let mut sms_summary = None; + let mut used_rag = false; + + // TEMPORARY: Set to true to disable RAG and use only time-based retrieval for testing + let disable_rag_for_testing = false; + + // Decide strategy based on available metadata + let has_strong_query = location.is_some(); + + if disable_rag_for_testing { + log::warn!("RAG DISABLED FOR TESTING - Using only time-based retrieval (±1 day)"); + // Skip directly to fallback + } else if has_strong_query { + // Strategy A: Pure RAG (we have location for good semantic matching) + log::info!("Using RAG with location-based query"); + match self + .find_relevant_messages_rag( + date_taken, + location.as_deref(), + contact.as_deref(), + 20, + ) + .await + { + Ok(rag_messages) if !rag_messages.is_empty() => { + used_rag = true; + sms_summary = self.summarize_messages(&rag_messages, &ollama_client).await; + } + Ok(_) => log::info!("RAG returned no messages"), + Err(e) => log::warn!("RAG failed: {}", e), + } + } else { + // Strategy B: Expanded immediate context + historical RAG + log::info!("Using expanded immediate context + historical RAG approach"); + + // Step 1: Get FULL immediate temporal context (±1 day, ALL messages) + let immediate_messages = self + .sms_client + .fetch_messages_for_contact(contact.as_deref(), timestamp) + .await + .unwrap_or_else(|e| { + log::error!("Failed to fetch immediate messages: {}", e); + Vec::new() + }); + + log::info!( + "Fetched {} messages from ±1 day window (using ALL for immediate context)", + immediate_messages.len() + ); + + if !immediate_messages.is_empty() { + // Step 2: Extract topics from immediate messages to enrich RAG query + let topics = self.extract_topics_from_messages(&immediate_messages, &ollama_client).await; + + log::info!("Extracted topics for query enrichment: {:?}", topics); + + // Step 3: Try historical RAG (>30 days ago) + match self + .find_relevant_messages_rag_historical( + &insight_cx, + date_taken, + None, + contact.as_deref(), + 10, // Top 10 historical matches + ) + .await + { + Ok(historical_messages) if !historical_messages.is_empty() => { + log::info!( + "Two-context approach: {} immediate (full conversation) + {} historical (similar past moments)", + immediate_messages.len(), + historical_messages.len() + ); + used_rag = true; + + // Step 4: Summarize contexts separately, then combine + let immediate_summary = self + .summarize_context_from_messages(&immediate_messages, &ollama_client) + .await + .unwrap_or_else(|| String::from("No immediate context")); + + let historical_summary = self + .summarize_messages(&historical_messages, &ollama_client) + .await + .unwrap_or_else(|| String::from("No historical context")); + + // Combine summaries + sms_summary = Some(format!( + "Immediate context (±1 day): {}\n\nSimilar moments from the past: {}", + immediate_summary, historical_summary + )); + } + Ok(_) => { + // RAG found no historical matches, just use immediate context + log::info!("No historical RAG matches, using immediate context only"); + sms_summary = self.summarize_context_from_messages(&immediate_messages, &ollama_client).await; + } + Err(e) => { + log::warn!("Historical RAG failed, using immediate context only: {}", e); + sms_summary = self.summarize_context_from_messages(&immediate_messages, &ollama_client).await; + } + } + } else { + log::info!("No immediate messages found, trying basic RAG as fallback"); + // Fallback to basic RAG even without strong query + match self + .find_relevant_messages_rag(date_taken, None, contact.as_deref(), 20) + .await + { + Ok(rag_messages) if !rag_messages.is_empty() => { + used_rag = true; + sms_summary = self.summarize_messages(&rag_messages, &ollama_client).await; + } + _ => {} + } + } + } + + // 6. Fallback to traditional time-based message retrieval if RAG didn't work + if !used_rag { + log::info!("Using traditional time-based message retrieval (±1 day)"); + let sms_messages = self + .sms_client + .fetch_messages_for_contact(contact.as_deref(), timestamp) + .await + .unwrap_or_else(|e| { + log::error!("Failed to fetch SMS messages: {}", e); + Vec::new() + }); + + log::info!( + "Fetched {} SMS messages closest to {}", + sms_messages.len(), + chrono::DateTime::from_timestamp(timestamp, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "unknown time".to_string()) + ); + + // Summarize time-based messages + if !sms_messages.is_empty() { + match self + .sms_client + .summarize_context(&sms_messages, &ollama_client) + .await + { + Ok(summary) => { + sms_summary = Some(summary); + } + Err(e) => { + log::warn!("Failed to summarize SMS context: {}", e); + } + } + } + } + + let retrieval_method = if used_rag { "RAG" } else { "time-based" }; + insight_cx.span().set_attribute(KeyValue::new("retrieval_method", retrieval_method)); + insight_cx.span().set_attribute(KeyValue::new("has_sms_context", sms_summary.is_some())); + log::info!( - "Photo context: date={}, location={:?}, sms_messages={}", + "Photo context: date={}, location={:?}, retrieval_method={}", date_taken, location, - sms_messages.len() + retrieval_method ); // 7. Generate title and summary with Ollama @@ -206,6 +503,9 @@ impl InsightGenerator { log::info!("Generated title: {}", title); log::info!("Generated summary: {}", summary); + insight_cx.span().set_attribute(KeyValue::new("title_length", title.len() as i64)); + insight_cx.span().set_attribute(KeyValue::new("summary_length", summary.len() as i64)); + // 8. Store in database let insight = InsertPhotoInsight { file_path: file_path.to_string(), @@ -216,13 +516,210 @@ impl InsightGenerator { }; let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); - dao.store_insight(&otel_context, insight) - .map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e))?; + let result = dao.store_insight(&insight_cx, insight) + .map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e)); - log::info!("Successfully stored insight for {}", file_path); + match &result { + Ok(_) => { + log::info!("Successfully stored insight for {}", file_path); + insight_cx.span().set_status(Status::Ok); + } + Err(e) => { + log::error!("Failed to store insight: {:?}", e); + insight_cx.span().set_status(Status::error(e.to_string())); + } + } + + result?; Ok(()) } + /// Extract key topics/entities from messages using LLM for query enrichment + async fn extract_topics_from_messages( + &self, + messages: &[crate::ai::SmsMessage], + ollama: &OllamaClient, + ) -> Vec { + if messages.is_empty() { + return Vec::new(); + } + + // Format a sample of messages for topic extraction + let sample_size = messages.len().min(20); + let sample_text: Vec = messages + .iter() + .take(sample_size) + .map(|m| format!("{}: {}", if m.is_sent { "Me" } else { &m.contact }, m.body)) + .collect(); + + let prompt = format!( + r#"Extract important entities from these messages that provide context about what was happening. Focus on: + +1. **People**: Names of specific people mentioned (first names, nicknames) +2. **Places**: Locations, cities, buildings, workplaces, parks, restaurants, venues +3. **Activities**: Specific events, hobbies, groups, organizations (e.g., "drum corps", "auditions") +4. **Unique terms**: Domain-specific words or phrases that might need explanation (e.g., "Hyland", "Vanguard", "DCI") + +Messages: +{} + +Return a comma-separated list of 3-7 specific entities (people, places, activities, unique terms). +Focus on proper nouns and specific terms that provide context. +Return ONLY the comma-separated list, nothing else."#, + sample_text.join("\n") + ); + + match ollama + .generate(&prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context.")) + .await + { + Ok(response) => { + // Parse comma-separated topics + response + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s.len() > 1) // Filter out single chars + .take(7) // Increased from 5 to 7 + .collect() + } + Err(e) => { + log::warn!("Failed to extract topics from messages: {}", e); + Vec::new() + } + } + } + + /// Find relevant messages using RAG with topic-enriched query + async fn find_relevant_messages_rag_enriched( + &self, + date: chrono::NaiveDate, + contact: Option<&str>, + topics: &[String], + limit: usize, + ) -> Result> { + // Build enriched query from date + topics + let mut query_parts = Vec::new(); + + query_parts.push(format!("On {}", date.format("%B %d, %Y"))); + + if !topics.is_empty() { + query_parts.push(format!("about {}", topics.join(", "))); + } + + if let Some(c) = contact { + query_parts.push(format!("conversation with {}", c)); + } + + // Add day of week + let weekday = date.format("%A"); + query_parts.push(format!("it was a {}", weekday)); + + let query = query_parts.join(", "); + + log::info!("========================================"); + log::info!("ENRICHED RAG QUERY: {}", query); + log::info!("Extracted topics: {:?}", topics); + log::info!("========================================"); + + // Use existing RAG method with enriched query + self.find_relevant_messages_rag(date, None, contact, limit) + .await + } + + /// Summarize pre-formatted message strings using LLM (concise version for historical context) + async fn summarize_messages( + &self, + messages: &[String], + ollama: &OllamaClient, + ) -> Option { + if messages.is_empty() { + return None; + } + + let messages_text = messages.join("\n"); + + let prompt = format!( + r#"Summarize the context from these messages in 2-3 sentences. Focus on activities, locations, events, and relationships mentioned. + +Messages: +{} + +Return ONLY the summary, nothing else."#, + messages_text + ); + + match ollama + .generate( + &prompt, + Some("You are a context summarization assistant. Be concise and factual."), + ) + .await + { + Ok(summary) => Some(summary), + Err(e) => { + log::warn!("Failed to summarize messages: {}", e); + None + } + } + } + + /// Convert SmsMessage objects to formatted strings and summarize with more detail + /// This is used for immediate context (±1 day) to preserve conversation details + async fn summarize_context_from_messages( + &self, + messages: &[crate::ai::SmsMessage], + ollama: &OllamaClient, + ) -> Option { + if messages.is_empty() { + return None; + } + + // Format messages + let formatted: Vec = messages + .iter() + .map(|m| { + let sender = if m.is_sent { "Me" } else { &m.contact }; + let timestamp = chrono::DateTime::from_timestamp(m.timestamp, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown time".to_string()); + format!("[{}] {}: {}", timestamp, sender, m.body) + }) + .collect(); + + let messages_text = formatted.join("\n"); + + // Use a more detailed prompt for immediate context + let prompt = format!( + r#"Provide a detailed summary of the conversation context from these messages. Include: +- Key activities, events, and plans discussed +- Important locations or places mentioned +- Emotional tone and relationship dynamics +- Any significant details that provide context about what was happening + +Be thorough but organized. Use 1-2 paragraphs. + +Messages: +{} + +Return ONLY the summary, nothing else."#, + messages_text + ); + + match ollama + .generate( + &prompt, + Some("You are a context summarization assistant. Be detailed and factual, preserving important context."), + ) + .await + { + Ok(summary) => Some(summary), + Err(e) => { + log::warn!("Failed to summarize immediate context: {}", e); + None + } + } + } + /// Reverse geocode GPS coordinates to human-readable place names async fn reverse_geocode(&self, lat: f64, lon: f64) -> Option { let url = format!( diff --git a/src/ai/mod.rs b/src/ai/mod.rs index ef0d52b..1f7ddda 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -1,12 +1,16 @@ +pub mod embedding_job; +pub mod daily_summary_job; pub mod handlers; pub mod insight_generator; pub mod ollama; pub mod sms_client; +pub use embedding_job::embed_contact_messages; +pub use daily_summary_job::generate_daily_summaries; pub use handlers::{ delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_available_models_handler, get_insight_handler, }; pub use insight_generator::InsightGenerator; pub use ollama::OllamaClient; -pub use sms_client::SmsApiClient; +pub use sms_client::{SmsApiClient, SmsMessage}; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index bac4c4c..b7ad707 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -226,7 +226,7 @@ Return ONLY the title, nothing else."#, let sms_str = sms_summary.unwrap_or("No messages"); let prompt = format!( - r#"Write a brief 1-2 paragraph description of this moment based on the available information: + r#"Write a 1-3 paragraph description of this moment based on the available information: Date: {} Location: {} @@ -238,10 +238,139 @@ Use only the specific details provided above. Mention people's names, places, or sms_str ); - let system = "You are a memory refreshing assistant. Use only the information provided. Do not invent details. Help me remember this day."; + let system = "You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."; self.generate(&prompt, Some(system)).await } + + /// Generate an embedding vector for text using nomic-embed-text:v1.5 + /// Returns a 768-dimensional vector as Vec + pub async fn generate_embedding(&self, text: &str) -> Result> { + let embeddings = self.generate_embeddings(&[text]).await?; + embeddings.into_iter().next() + .ok_or_else(|| anyhow::anyhow!("No embedding returned")) + } + + /// Generate embeddings for multiple texts in a single API call (batch mode) + /// Returns a vector of 768-dimensional vectors + /// This is much more efficient than calling generate_embedding multiple times + pub async fn generate_embeddings(&self, texts: &[&str]) -> Result>> { + let embedding_model = "nomic-embed-text:v1.5"; + + log::debug!("=== Ollama Batch Embedding Request ==="); + log::debug!("Model: {}", embedding_model); + log::debug!("Batch size: {} texts", texts.len()); + log::debug!("======================================"); + + // Try primary server first + log::debug!( + "Attempting to generate {} embeddings with primary server: {} (model: {})", + texts.len(), + self.primary_url, + embedding_model + ); + let primary_result = self + .try_generate_embeddings(&self.primary_url, embedding_model, texts) + .await; + + let embeddings = match primary_result { + Ok(embeddings) => { + log::debug!("Successfully generated {} embeddings from primary server", embeddings.len()); + embeddings + } + Err(e) => { + log::warn!("Primary server batch embedding failed: {}", e); + + // Try fallback server if available + if let Some(fallback_url) = &self.fallback_url { + log::info!( + "Attempting to generate {} embeddings with fallback server: {} (model: {})", + texts.len(), + fallback_url, + embedding_model + ); + match self + .try_generate_embeddings(fallback_url, embedding_model, texts) + .await + { + Ok(embeddings) => { + log::info!("Successfully generated {} embeddings from fallback server", embeddings.len()); + embeddings + } + Err(fallback_e) => { + log::error!("Fallback server batch embedding also failed: {}", fallback_e); + return Err(anyhow::anyhow!( + "Both primary and fallback servers failed. Primary: {}, Fallback: {}", + e, + fallback_e + )); + } + } + } else { + log::error!("No fallback server configured"); + return Err(e); + } + } + }; + + // Validate embedding dimensions (should be 768 for nomic-embed-text:v1.5) + for (i, embedding) in embeddings.iter().enumerate() { + if embedding.len() != 768 { + log::warn!( + "Unexpected embedding dimensions for item {}: {} (expected 768)", + i, + embedding.len() + ); + } + } + + Ok(embeddings) + } + + /// Internal helper to try generating an embedding from a specific server + async fn try_generate_embedding( + &self, + url: &str, + model: &str, + text: &str, + ) -> Result> { + let embeddings = self.try_generate_embeddings(url, model, &[text]).await?; + embeddings.into_iter().next() + .ok_or_else(|| anyhow::anyhow!("No embedding returned from Ollama")) + } + + /// Internal helper to try generating embeddings for multiple texts from a specific server + async fn try_generate_embeddings( + &self, + url: &str, + model: &str, + texts: &[&str], + ) -> Result>> { + let request = OllamaBatchEmbedRequest { + model: model.to_string(), + input: texts.iter().map(|s| s.to_string()).collect(), + }; + + let response = self + .client + .post(&format!("{}/api/embed", url)) + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "Ollama batch embedding request failed: {} - {}", + status, + error_body + )); + } + + let result: OllamaEmbedResponse = response.json().await?; + Ok(result.embeddings) + } } #[derive(Serialize)] @@ -267,3 +396,20 @@ struct OllamaTagsResponse { struct OllamaModel { name: String, } + +#[derive(Serialize)] +struct OllamaEmbedRequest { + model: String, + input: String, +} + +#[derive(Serialize)] +struct OllamaBatchEmbedRequest { + model: String, + input: Vec, +} + +#[derive(Deserialize)] +struct OllamaEmbedResponse { + embeddings: Vec>, +} diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index 154dabc..7919898 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -91,6 +91,118 @@ impl SmsApiClient { .await } + /// Fetch all messages for a specific contact across all time + /// Used for embedding generation - retrieves complete message history + /// Handles pagination automatically if the API returns a limited number of results + pub async fn fetch_all_messages_for_contact(&self, contact: &str) -> Result> { + let start_ts = chrono::DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z") + .unwrap() + .timestamp(); + let end_ts = chrono::Utc::now().timestamp(); + + log::info!( + "Fetching all historical messages for contact: {}", + contact + ); + + let mut all_messages = Vec::new(); + let mut offset = 0; + let limit = 1000; // Fetch in batches of 1000 + + loop { + log::debug!("Fetching batch at offset {} for contact {}", offset, contact); + + let batch = self.fetch_messages_paginated( + start_ts, + end_ts, + Some(contact), + None, + limit, + offset + ).await?; + + let batch_size = batch.len(); + all_messages.extend(batch); + + log::debug!("Fetched {} messages (total so far: {})", batch_size, all_messages.len()); + + // If we got fewer messages than the limit, we've reached the end + if batch_size < limit { + break; + } + + offset += limit; + } + + log::info!( + "Fetched {} total messages for contact {}", + all_messages.len(), + contact + ); + + Ok(all_messages) + } + + /// Internal method to fetch messages with pagination support + async fn fetch_messages_paginated( + &self, + start_ts: i64, + end_ts: i64, + contact: Option<&str>, + center_timestamp: Option, + limit: usize, + offset: usize, + ) -> Result> { + let mut url = format!( + "{}/api/messages/by-date-range/?start_date={}&end_date={}&limit={}&offset={}", + self.base_url, start_ts, end_ts, limit, offset + ); + + if let Some(contact_name) = contact { + url.push_str(&format!("&contact={}", urlencoding::encode(contact_name))); + } + + if let Some(ts) = center_timestamp { + url.push_str(&format!("×tamp={}", ts)); + } + + log::debug!("Fetching SMS messages from: {}", url); + + let mut request = self.client.get(&url); + + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request.send().await?; + + log::debug!("SMS API response status: {}", response.status()); + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + log::error!("SMS API request failed: {} - {}", status, error_body); + return Err(anyhow::anyhow!( + "SMS API request failed: {} - {}", + status, + error_body + )); + } + + let data: SmsApiResponse = response.json().await?; + + Ok(data + .messages + .into_iter() + .map(|m| SmsMessage { + contact: m.contact_name, + body: m.body, + timestamp: m.date, + is_sent: m.type_ == 2, + }) + .collect()) + } + /// Internal method to fetch messages with optional contact filter and timestamp sorting async fn fetch_messages( &self, diff --git a/src/database/daily_summary_dao.rs b/src/database/daily_summary_dao.rs new file mode 100644 index 0000000..6e399e4 --- /dev/null +++ b/src/database/daily_summary_dao.rs @@ -0,0 +1,338 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use serde::Serialize; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::{connect, DbError, DbErrorKind}; +use crate::otel::trace_db_call; + +/// Represents a daily conversation summary +#[derive(Serialize, Clone, Debug)] +pub struct DailySummary { + pub id: i32, + pub date: String, + pub contact: String, + pub summary: String, + pub message_count: i32, + pub created_at: i64, + pub model_version: String, +} + +/// Data for inserting a new daily summary +#[derive(Clone, Debug)] +pub struct InsertDailySummary { + pub date: String, + pub contact: String, + pub summary: String, + pub message_count: i32, + pub embedding: Vec, + pub created_at: i64, + pub model_version: String, +} + +pub trait DailySummaryDao: Sync + Send { + /// Store a daily summary with its embedding + fn store_summary( + &mut self, + context: &opentelemetry::Context, + summary: InsertDailySummary, + ) -> Result; + + /// Find semantically similar daily summaries using vector similarity + fn find_similar_summaries( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError>; + + /// Check if a summary exists for a given date and contact + fn summary_exists( + &mut self, + context: &opentelemetry::Context, + date: &str, + contact: &str, + ) -> Result; + + /// Get count of summaries for a contact + fn get_summary_count( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result; +} + +pub struct SqliteDailySummaryDao { + connection: Arc>, +} + +impl Default for SqliteDailySummaryDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteDailySummaryDao { + pub fn new() -> Self { + SqliteDailySummaryDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + fn serialize_vector(vec: &[f32]) -> Vec { + use zerocopy::IntoBytes; + vec.as_bytes().to_vec() + } + + fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { + if bytes.len() % 4 != 0 { + return Err(DbError::new(DbErrorKind::QueryError)); + } + + let count = bytes.len() / 4; + let mut vec = Vec::with_capacity(count); + + for chunk in bytes.chunks_exact(4) { + let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + vec.push(float); + } + + Ok(vec) + } + + fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if magnitude_a == 0.0 || magnitude_b == 0.0 { + return 0.0; + } + + dot_product / (magnitude_a * magnitude_b) + } +} + +impl DailySummaryDao for SqliteDailySummaryDao { + fn store_summary( + &mut self, + context: &opentelemetry::Context, + summary: InsertDailySummary, + ) -> Result { + trace_db_call(context, "insert", "store_summary", |_span| { + let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + + // Validate embedding dimensions + if summary.embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + summary.embedding.len() + )); + } + + let embedding_bytes = Self::serialize_vector(&summary.embedding); + + // INSERT OR REPLACE to handle updates if summary needs regeneration + diesel::sql_query( + "INSERT OR REPLACE INTO daily_conversation_summaries + (date, contact, summary, message_count, embedding, created_at, model_version) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind::(&summary.date) + .bind::(&summary.contact) + .bind::(&summary.summary) + .bind::(summary.message_count) + .bind::(&embedding_bytes) + .bind::(summary.created_at) + .bind::(&summary.model_version) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; + + let row_id: i32 = diesel::sql_query("SELECT last_insert_rowid() as id") + .get_result::(conn.deref_mut()) + .map(|r| r.id as i32) + .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))?; + + Ok(DailySummary { + id: row_id, + date: summary.date, + contact: summary.contact, + summary: summary.summary, + message_count: summary.message_count, + created_at: summary.created_at, + model_version: summary.model_version, + }) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn find_similar_summaries( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_similar_summaries", |_span| { + let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + + if query_embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_embedding.len() + )); + } + + // Load all summaries with embeddings + let results = diesel::sql_query( + "SELECT id, date, contact, summary, message_count, embedding, created_at, model_version + FROM daily_conversation_summaries" + ) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + log::info!("Loaded {} daily summaries for similarity comparison", results.len()); + + // Compute similarity for each summary + let mut scored_summaries: Vec<(f32, DailySummary)> = results + .into_iter() + .filter_map(|row| { + match Self::deserialize_vector(&row.embedding) { + Ok(embedding) => { + let similarity = Self::cosine_similarity(query_embedding, &embedding); + Some(( + similarity, + DailySummary { + id: row.id, + date: row.date, + contact: row.contact, + summary: row.summary, + message_count: row.message_count, + created_at: row.created_at, + model_version: row.model_version, + }, + )) + } + Err(e) => { + log::warn!("Failed to deserialize embedding for summary {}: {:?}", row.id, e); + None + } + } + }) + .collect(); + + // Sort by similarity (highest first) + scored_summaries.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Log similarity distribution + if !scored_summaries.is_empty() { + log::info!( + "Daily summary similarity - Top: {:.3}, Median: {:.3}, Count: {}", + scored_summaries.first().map(|(s, _)| *s).unwrap_or(0.0), + scored_summaries.get(scored_summaries.len() / 2).map(|(s, _)| *s).unwrap_or(0.0), + scored_summaries.len() + ); + } + + // Take top N and log matches + let top_results: Vec = scored_summaries + .into_iter() + .take(limit) + .map(|(similarity, summary)| { + log::info!( + "Summary match: similarity={:.3}, date={}, contact={}, summary=\"{}\"", + similarity, + summary.date, + summary.contact, + summary.summary.chars().take(100).collect::() + ); + summary + }) + .collect(); + + Ok(top_results) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn summary_exists( + &mut self, + context: &opentelemetry::Context, + date: &str, + contact: &str, + ) -> Result { + trace_db_call(context, "query", "summary_exists", |_span| { + let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + + let count = diesel::sql_query( + "SELECT COUNT(*) as count FROM daily_conversation_summaries + WHERE date = ?1 AND contact = ?2" + ) + .bind::(date) + .bind::(contact) + .get_result::(conn.deref_mut()) + .map(|r| r.count) + .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))?; + + Ok(count > 0) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_summary_count( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result { + trace_db_call(context, "query", "get_summary_count", |_span| { + let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + + diesel::sql_query( + "SELECT COUNT(*) as count FROM daily_conversation_summaries WHERE contact = ?1" + ) + .bind::(contact) + .get_result::(conn.deref_mut()) + .map(|r| r.count) + .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e).into()) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} + +// Helper structs for raw SQL queries + +#[derive(QueryableByName)] +struct LastInsertRowId { + #[diesel(sql_type = diesel::sql_types::BigInt)] + id: i64, +} + +#[derive(QueryableByName)] +struct DailySummaryWithVectorRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Text)] + date: String, + #[diesel(sql_type = diesel::sql_types::Text)] + contact: String, + #[diesel(sql_type = diesel::sql_types::Text)] + summary: String, + #[diesel(sql_type = diesel::sql_types::Integer)] + message_count: i32, + #[diesel(sql_type = diesel::sql_types::Binary)] + embedding: Vec, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Text)] + model_version: String, +} + +#[derive(QueryableByName)] +struct CountResult { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, +} diff --git a/src/database/embeddings_dao.rs b/src/database/embeddings_dao.rs new file mode 100644 index 0000000..48fd458 --- /dev/null +++ b/src/database/embeddings_dao.rs @@ -0,0 +1,569 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use serde::Serialize; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// Represents a stored message embedding +#[derive(Serialize, Clone, Debug)] +pub struct MessageEmbedding { + pub id: i32, + pub contact: String, + pub body: String, + pub timestamp: i64, + pub is_sent: bool, + pub created_at: i64, + pub model_version: String, +} + +/// Data for inserting a new message embedding +#[derive(Clone, Debug)] +pub struct InsertMessageEmbedding { + pub contact: String, + pub body: String, + pub timestamp: i64, + pub is_sent: bool, + pub embedding: Vec, + pub created_at: i64, + pub model_version: String, +} + +pub trait EmbeddingDao: Sync + Send { + /// Store a message with its embedding vector + fn store_message_embedding( + &mut self, + context: &opentelemetry::Context, + message: InsertMessageEmbedding, + ) -> Result; + + /// Store multiple messages with embeddings in a single transaction + /// Returns the number of successfully stored messages + fn store_message_embeddings_batch( + &mut self, + context: &opentelemetry::Context, + messages: Vec, + ) -> Result; + + /// Find semantically similar messages using vector similarity search + /// Returns the top `limit` most similar messages + /// If contact_filter is provided, only return messages from that contact + /// Otherwise, search across all contacts for cross-perspective context + fn find_similar_messages( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + contact_filter: Option<&str>, + ) -> Result, DbError>; + + /// Get the count of embedded messages for a specific contact + fn get_message_count( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result; + + /// Check if embeddings exist for a contact (idempotency check) + fn has_embeddings_for_contact( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result; + + /// Check if a specific message already has an embedding + fn message_exists( + &mut self, + context: &opentelemetry::Context, + contact: &str, + body: &str, + timestamp: i64, + ) -> Result; +} + +pub struct SqliteEmbeddingDao { + connection: Arc>, +} + +impl Default for SqliteEmbeddingDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteEmbeddingDao { + pub fn new() -> Self { + SqliteEmbeddingDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + /// Serialize f32 vector to bytes for BLOB storage + fn serialize_vector(vec: &[f32]) -> Vec { + // Convert f32 slice to bytes using zerocopy + use zerocopy::IntoBytes; + vec.as_bytes().to_vec() + } + + /// Deserialize bytes from BLOB back to f32 vector + fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { + if bytes.len() % 4 != 0 { + return Err(DbError::new(DbErrorKind::QueryError)); + } + + let count = bytes.len() / 4; + let mut vec = Vec::with_capacity(count); + + for chunk in bytes.chunks_exact(4) { + let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + vec.push(float); + } + + Ok(vec) + } + + /// Compute cosine similarity between two vectors + fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if magnitude_a == 0.0 || magnitude_b == 0.0 { + return 0.0; + } + + dot_product / (magnitude_a * magnitude_b) + } +} + +impl EmbeddingDao for SqliteEmbeddingDao { + fn store_message_embedding( + &mut self, + context: &opentelemetry::Context, + message: InsertMessageEmbedding, + ) -> Result { + trace_db_call(context, "insert", "store_message_embedding", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); + + // Validate embedding dimensions + if message.embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + message.embedding.len() + )); + } + + // Serialize embedding to bytes + let embedding_bytes = Self::serialize_vector(&message.embedding); + + // Insert into message_embeddings table with BLOB + // Use INSERT OR IGNORE to skip duplicates (based on UNIQUE constraint) + let insert_result = diesel::sql_query( + "INSERT OR IGNORE INTO message_embeddings (contact, body, timestamp, is_sent, embedding, created_at, model_version) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind::(&message.contact) + .bind::(&message.body) + .bind::(message.timestamp) + .bind::(message.is_sent) + .bind::(&embedding_bytes) + .bind::(message.created_at) + .bind::(&message.model_version) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; + + // If INSERT OR IGNORE skipped (duplicate), find the existing record + let row_id: i32 = if insert_result == 0 { + // Duplicate - find the existing record + diesel::sql_query( + "SELECT id FROM message_embeddings WHERE contact = ?1 AND body = ?2 AND timestamp = ?3" + ) + .bind::(&message.contact) + .bind::(&message.body) + .bind::(message.timestamp) + .get_result::(conn.deref_mut()) + .map(|r| r.id as i32) + .map_err(|e| anyhow::anyhow!("Failed to find existing record: {:?}", e))? + } else { + // New insert - get the last inserted row ID + diesel::sql_query("SELECT last_insert_rowid() as id") + .get_result::(conn.deref_mut()) + .map(|r| r.id as i32) + .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))? + }; + + // Return the stored message + Ok(MessageEmbedding { + id: row_id, + contact: message.contact, + body: message.body, + timestamp: message.timestamp, + is_sent: message.is_sent, + created_at: message.created_at, + model_version: message.model_version, + }) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn store_message_embeddings_batch( + &mut self, + context: &opentelemetry::Context, + messages: Vec, + ) -> Result { + trace_db_call(context, "insert", "store_message_embeddings_batch", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); + + // Start transaction + conn.transaction::<_, anyhow::Error, _>(|conn| { + let mut stored_count = 0; + + for message in messages { + // Validate embedding dimensions + if message.embedding.len() != 768 { + log::warn!( + "Invalid embedding dimensions: {} (expected 768), skipping", + message.embedding.len() + ); + continue; + } + + // Serialize embedding to bytes + let embedding_bytes = Self::serialize_vector(&message.embedding); + + // Insert into message_embeddings table with BLOB + // Use INSERT OR IGNORE to skip duplicates (based on UNIQUE constraint) + match diesel::sql_query( + "INSERT OR IGNORE INTO message_embeddings (contact, body, timestamp, is_sent, embedding, created_at, model_version) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind::(&message.contact) + .bind::(&message.body) + .bind::(message.timestamp) + .bind::(message.is_sent) + .bind::(&embedding_bytes) + .bind::(message.created_at) + .bind::(&message.model_version) + .execute(conn) + { + Ok(rows) if rows > 0 => stored_count += 1, + Ok(_) => { + // INSERT OR IGNORE skipped (duplicate) + log::debug!("Skipped duplicate message: {:?}", message.body.chars().take(50).collect::()); + } + Err(e) => { + log::warn!("Failed to insert message in batch: {:?}", e); + // Continue with other messages instead of failing entire batch + } + } + } + + Ok(stored_count) + }) + .map_err(|e| anyhow::anyhow!("Transaction error: {:?}", e)) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn find_similar_messages( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + contact_filter: Option<&str>, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_similar_messages", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); + + // Validate embedding dimensions + if query_embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_embedding.len() + )); + } + + // Load messages with optional contact filter + let results = if let Some(contact) = contact_filter { + log::debug!("RAG search filtered to contact: {}", contact); + diesel::sql_query( + "SELECT id, contact, body, timestamp, is_sent, embedding, created_at, model_version + FROM message_embeddings WHERE contact = ?1" + ) + .bind::(contact) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))? + } else { + log::debug!("RAG search across ALL contacts (cross-perspective)"); + diesel::sql_query( + "SELECT id, contact, body, timestamp, is_sent, embedding, created_at, model_version + FROM message_embeddings" + ) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))? + }; + + log::debug!("Loaded {} messages for similarity comparison", results.len()); + + // Compute similarity for each message + let mut scored_messages: Vec<(f32, MessageEmbedding)> = results + .into_iter() + .filter_map(|row| { + // Deserialize the embedding BLOB + match Self::deserialize_vector(&row.embedding) { + Ok(embedding) => { + // Compute cosine similarity + let similarity = Self::cosine_similarity(query_embedding, &embedding); + Some(( + similarity, + MessageEmbedding { + id: row.id, + contact: row.contact, + body: row.body, + timestamp: row.timestamp, + is_sent: row.is_sent, + created_at: row.created_at, + model_version: row.model_version, + }, + )) + } + Err(e) => { + log::warn!("Failed to deserialize embedding for message {}: {:?}", row.id, e); + None + } + } + }) + .collect(); + + // Sort by similarity (highest first) + scored_messages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Log similarity score distribution + if !scored_messages.is_empty() { + log::info!( + "Similarity score distribution - Top: {:.3}, Median: {:.3}, Bottom: {:.3}", + scored_messages.first().map(|(s, _)| *s).unwrap_or(0.0), + scored_messages.get(scored_messages.len() / 2).map(|(s, _)| *s).unwrap_or(0.0), + scored_messages.last().map(|(s, _)| *s).unwrap_or(0.0) + ); + } + + // Apply minimum similarity threshold + // With single-contact embeddings, scores tend to be higher due to writing style similarity + // Using 0.65 to get only truly semantically relevant messages + let min_similarity = 0.65; + let filtered_messages: Vec<(f32, MessageEmbedding)> = scored_messages + .into_iter() + .filter(|(similarity, _)| *similarity >= min_similarity) + .collect(); + + log::info!( + "After similarity filtering (min_similarity={}): {} messages passed threshold", + min_similarity, + filtered_messages.len() + ); + + // Filter out short/generic messages (under 30 characters) + // This removes conversational closings like "Thanks for talking" that dominate results + let min_message_length = 30; + + // Common closing phrases that should be excluded from RAG results + let stop_phrases = [ + "thanks for talking", + "thank you for talking", + "good talking", + "nice talking", + "good night", + "good morning", + "love you", + ]; + + let filtered_messages: Vec<(f32, MessageEmbedding)> = filtered_messages + .into_iter() + .filter(|(_, message)| { + // Filter by length + if message.body.len() < min_message_length { + return false; + } + + // Filter out messages that are primarily generic closings + let body_lower = message.body.to_lowercase(); + for phrase in &stop_phrases { + // If the message contains this phrase and is short, it's likely just a closing + if body_lower.contains(phrase) && message.body.len() < 100 { + return false; + } + } + + true + }) + .collect(); + + log::info!( + "After length filtering (min {} chars): {} messages remain", + min_message_length, + filtered_messages.len() + ); + + // Apply temporal diversity filter - don't return too many messages from the same day + // This prevents RAG from returning clusters of messages from one conversation + let mut filtered_with_diversity = Vec::new(); + let mut dates_seen: std::collections::HashMap = std::collections::HashMap::new(); + let max_per_day = 3; // Maximum 3 messages from any single day + + for (similarity, message) in filtered_messages.into_iter() { + let date = chrono::DateTime::from_timestamp(message.timestamp, 0) + .map(|dt| dt.date_naive()) + .unwrap_or_else(|| chrono::Utc::now().date_naive()); + + let count = dates_seen.entry(date).or_insert(0); + if *count < max_per_day { + *count += 1; + filtered_with_diversity.push((similarity, message)); + } + } + + log::info!( + "After temporal diversity filtering (max {} per day): {} messages remain", + max_per_day, + filtered_with_diversity.len() + ); + + // Take top N results from diversity-filtered messages + let top_results: Vec = filtered_with_diversity + .into_iter() + .take(limit) + .map(|(similarity, message)| { + let time = chrono::DateTime::from_timestamp(message.timestamp, 0) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + log::info!( + "RAG Match: similarity={:.3}, date={}, contact={}, body=\"{}\"", + similarity, + time, + message.contact, + &message.body.chars().take(80).collect::() + ); + message + }) + .collect(); + + Ok(top_results) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_message_count( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result { + trace_db_call(context, "query", "get_message_count", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); + + let count = diesel::sql_query( + "SELECT COUNT(*) as count FROM message_embeddings WHERE contact = ?1" + ) + .bind::(contact) + .get_result::(conn.deref_mut()) + .map(|r| r.count) + .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))?; + + Ok(count) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn has_embeddings_for_contact( + &mut self, + context: &opentelemetry::Context, + contact: &str, + ) -> Result { + self.get_message_count(context, contact) + .map(|count| count > 0) + } + + fn message_exists( + &mut self, + context: &opentelemetry::Context, + contact: &str, + body: &str, + timestamp: i64, + ) -> Result { + trace_db_call(context, "query", "message_exists", |_span| { + let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); + + let count = diesel::sql_query( + "SELECT COUNT(*) as count FROM message_embeddings + WHERE contact = ?1 AND body = ?2 AND timestamp = ?3" + ) + .bind::(contact) + .bind::(body) + .bind::(timestamp) + .get_result::(conn.deref_mut()) + .map(|r| r.count) + .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))?; + + Ok(count > 0) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} + +// Helper structs for raw SQL queries + +#[derive(QueryableByName)] +struct LastInsertRowId { + #[diesel(sql_type = diesel::sql_types::BigInt)] + id: i64, +} + +#[derive(QueryableByName)] +struct MessageEmbeddingRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Text)] + contact: String, + #[diesel(sql_type = diesel::sql_types::Text)] + body: String, + #[diesel(sql_type = diesel::sql_types::BigInt)] + timestamp: i64, + #[diesel(sql_type = diesel::sql_types::Bool)] + is_sent: bool, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Text)] + model_version: String, +} + +#[derive(QueryableByName)] +struct MessageEmbeddingWithVectorRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Text)] + contact: String, + #[diesel(sql_type = diesel::sql_types::Text)] + body: String, + #[diesel(sql_type = diesel::sql_types::BigInt)] + timestamp: i64, + #[diesel(sql_type = diesel::sql_types::Bool)] + is_sent: bool, + #[diesel(sql_type = diesel::sql_types::Binary)] + embedding: Vec, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Text)] + model_version: String, +} + +#[derive(QueryableByName)] +struct CountResult { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 759d5f4..e27d1ed 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,11 +9,15 @@ use crate::database::models::{ }; use crate::otel::trace_db_call; +pub mod embeddings_dao; +pub mod daily_summary_dao; pub mod insights_dao; pub mod models; pub mod schema; +pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding, SqliteEmbeddingDao}; pub use insights_dao::{InsightDao, SqliteInsightDao}; +pub use daily_summary_dao::{DailySummaryDao, SqliteDailySummaryDao, DailySummary, InsertDailySummary}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/main.rs b/src/main.rs index f481107..956675a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -718,7 +718,7 @@ fn main() -> std::io::Result<()> { } create_thumbnails(); - generate_video_gifs().await; + // generate_video_gifs().await; let app_data = Data::new(AppState::default()); @@ -742,6 +742,50 @@ fn main() -> std::io::Result<()> { directory: app_state.base_path.clone(), }); + // Spawn background job to generate daily conversation summaries + { + use crate::ai::generate_daily_summaries; + use crate::database::{DailySummaryDao, SqliteDailySummaryDao}; + use chrono::NaiveDate; + + // Configure date range for summary generation + // Default: August 2024 ±30 days (July 1 - September 30, 2024) + // To expand: change start_date and end_date + let start_date = Some(NaiveDate::from_ymd_opt(2015, 10, 1).unwrap()); + let end_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + + let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed + + let ollama = app_state.ollama.clone(); + let sms_client = app_state.sms_client.clone(); + + for contact in contacts_to_summarize { + let ollama_clone = ollama.clone(); + let sms_client_clone = sms_client.clone(); + let summary_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); + + let start = start_date; + let end = end_date; + + tokio::spawn(async move { + log::info!("Starting daily summary generation for {}", contact); + if let Err(e) = generate_daily_summaries( + contact, + start, + end, + &ollama_clone, + &sms_client_clone, + summary_dao + ).await { + log::error!("Daily summary generation failed for {}: {:?}", contact, e); + } else { + log::info!("Daily summary generation completed for {}", contact); + } + }); + } + } + HttpServer::new(move || { let user_dao = SqliteUserDao::new(); let favorites_dao = SqliteFavoriteDao::new(); diff --git a/src/state.rs b/src/state.rs index 40f33af..6dff518 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,5 @@ use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; -use crate::database::{ExifDao, InsightDao, SqliteExifDao, SqliteInsightDao}; +use crate::database::{DailySummaryDao, ExifDao, InsightDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao}; use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager}; use actix::{Actor, Addr}; use std::env; @@ -91,6 +91,8 @@ impl Default for AppState { Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); let exif_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + let daily_summary_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); // Load base path let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); @@ -101,6 +103,7 @@ impl Default for AppState { sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + daily_summary_dao.clone(), base_path.clone(), ); @@ -147,6 +150,8 @@ impl AppState { Arc::new(Mutex::new(Box::new(SqliteInsightDao::new()))); let exif_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + let daily_summary_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); // Initialize test InsightGenerator let base_path_str = base_path.to_string_lossy().to_string(); @@ -155,6 +160,7 @@ impl AppState { sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + daily_summary_dao.clone(), base_path_str.clone(), ); -- 2.49.1 From ad07f5a1fac3befd52c27f68f166c912d8d025b5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 5 Jan 2026 10:09:18 -0500 Subject: [PATCH 07/25] Update README --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index e340dc1..08a1584 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Upon first run it will generate thumbnails for all images and videos at `BASE_PA - Video streaming with HLS - Tag-based organization - Memories API for browsing photos by date +- **AI-Powered Photo Insights** - Generate contextual insights from photos using LLMs +- **RAG-based Context Retrieval** - Semantic search over daily conversation summaries +- **Automatic Daily Summaries** - LLM-generated summaries of daily conversations with embeddings ## Environment There are a handful of required environment variables to have the API run. @@ -26,3 +29,38 @@ You must have `ffmpeg` installed for streaming video and generating video thumbn - `WATCH_QUICK_INTERVAL_SECONDS` (optional) is the interval in seconds for quick file scans [default: 60] - `WATCH_FULL_INTERVAL_SECONDS` (optional) is the interval in seconds for full file scans [default: 3600] +### AI Insights Configuration (Optional) + +The following environment variables configure AI-powered photo insights and daily conversation summaries: + +#### Ollama Configuration +- `OLLAMA_PRIMARY_URL` - Primary Ollama server URL [default: `http://localhost:11434`] + - Example: `http://desktop:11434` (your main/powerful server) +- `OLLAMA_FALLBACK_URL` - Fallback Ollama server URL (optional) + - Example: `http://server:11434` (always-on backup server) +- `OLLAMA_PRIMARY_MODEL` - Model to use on primary server [default: `nemotron-3-nano:30b`] + - Example: `nemotron-3-nano:30b`, `llama3.2:3b`, etc. +- `OLLAMA_FALLBACK_MODEL` - Model to use on fallback server (optional) + - If not set, uses `OLLAMA_PRIMARY_MODEL` on fallback server + +**Legacy Variables** (still supported): +- `OLLAMA_URL` - Used if `OLLAMA_PRIMARY_URL` not set +- `OLLAMA_MODEL` - Used if `OLLAMA_PRIMARY_MODEL` not set + +#### SMS API Configuration +- `SMS_API_URL` - URL to SMS message API [default: `http://localhost:8000`] + - Used to fetch conversation data for context in insights +- `SMS_API_TOKEN` - Authentication token for SMS API (optional) + +#### Fallback Behavior +- Primary server is tried first with 5-second connection timeout +- On failure, automatically falls back to secondary server (if configured) +- Total request timeout is 120 seconds to accommodate LLM inference +- Logs indicate which server/model was used and any failover attempts + +#### Daily Summary Generation +Daily conversation summaries are generated automatically on server startup. Configure in `src/main.rs`: +- Date range for summary generation +- Contacts to process +- Model version used for embeddings: `nomic-embed-text:v1.5` + -- 2.49.1 From bb23e6bb2569067653d00c02b2f6bc3d2535e541 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 5 Jan 2026 10:24:12 -0500 Subject: [PATCH 08/25] Cargo fix --- src/ai/daily_summary_job.rs | 87 +++++++++++++++++---------- src/ai/embedding_job.rs | 35 ++++++----- src/ai/handlers.rs | 2 +- src/ai/insight_generator.rs | 97 ++++++++++++++++++++++--------- src/ai/mod.rs | 3 +- src/ai/ollama.rs | 30 ++++++---- src/ai/sms_client.rs | 28 ++++----- src/database/daily_summary_dao.rs | 23 +++++--- src/database/embeddings_dao.rs | 4 +- src/database/mod.rs | 6 +- src/main.rs | 7 ++- src/state.rs | 4 +- 12 files changed, 204 insertions(+), 122 deletions(-) diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs index cd5053b..b587750 100644 --- a/src/ai/daily_summary_job.rs +++ b/src/ai/daily_summary_job.rs @@ -1,7 +1,7 @@ use anyhow::Result; use chrono::{NaiveDate, Utc}; -use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::KeyValue; +use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::time::sleep; @@ -34,34 +34,33 @@ pub async fn generate_daily_summaries( let start = start_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()); let end = end_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()); - parent_cx.span().set_attribute(KeyValue::new("start_date", start.to_string())); - parent_cx.span().set_attribute(KeyValue::new("end_date", end.to_string())); - parent_cx.span().set_attribute(KeyValue::new("date_range_days", (end - start).num_days() + 1)); + parent_cx + .span() + .set_attribute(KeyValue::new("start_date", start.to_string())); + parent_cx + .span() + .set_attribute(KeyValue::new("end_date", end.to_string())); + parent_cx.span().set_attribute(KeyValue::new( + "date_range_days", + (end - start).num_days() + 1, + )); - log::info!( - "========================================"); + log::info!("========================================"); log::info!("Starting daily summary generation for {}", contact); - log::info!("Date range: {} to {} ({} days)", - start, end, (end - start).num_days() + 1 + log::info!( + "Date range: {} to {} ({} days)", + start, + end, + (end - start).num_days() + 1 ); log::info!("========================================"); // Fetch all messages for the contact in the date range log::info!("Fetching messages for date range..."); - let _start_timestamp = start - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp(); - let _end_timestamp = end - .and_hms_opt(23, 59, 59) - .unwrap() - .and_utc() - .timestamp(); + let _start_timestamp = start.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp(); + let _end_timestamp = end.and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp(); - let all_messages = sms_client - .fetch_all_messages_for_contact(contact) - .await?; + let all_messages = sms_client.fetch_all_messages_for_contact(contact).await?; // Filter to date range and group by date let mut messages_by_date: HashMap> = HashMap::new(); @@ -109,7 +108,10 @@ pub async fn generate_daily_summaries( let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao"); let otel_context = opentelemetry::Context::new(); - if dao.summary_exists(&otel_context, &date_str, contact).unwrap_or(false) { + if dao + .summary_exists(&otel_context, &date_str, contact) + .unwrap_or(false) + { skipped += 1; if idx % 10 == 0 { log::info!( @@ -171,17 +173,32 @@ pub async fn generate_daily_summaries( log::info!("========================================"); log::info!("Daily summary generation complete!"); - log::info!("Processed: {}, Skipped: {}, Failed: {}", processed, skipped, failed); + log::info!( + "Processed: {}, Skipped: {}, Failed: {}", + processed, + skipped, + failed + ); log::info!("========================================"); // Record final metrics in span - parent_cx.span().set_attribute(KeyValue::new("days_processed", processed as i64)); - parent_cx.span().set_attribute(KeyValue::new("days_skipped", skipped as i64)); - parent_cx.span().set_attribute(KeyValue::new("days_failed", failed as i64)); - parent_cx.span().set_attribute(KeyValue::new("total_days", total_days as i64)); + parent_cx + .span() + .set_attribute(KeyValue::new("days_processed", processed as i64)); + parent_cx + .span() + .set_attribute(KeyValue::new("days_skipped", skipped as i64)); + parent_cx + .span() + .set_attribute(KeyValue::new("days_failed", failed as i64)); + parent_cx + .span() + .set_attribute(KeyValue::new("total_days", total_days as i64)); if failed > 0 { - parent_cx.span().set_status(Status::error(format!("{} days failed to process", failed))); + parent_cx + .span() + .set_status(Status::error(format!("{} days failed to process", failed))); } else { parent_cx.span().set_status(Status::Ok); } @@ -252,14 +269,21 @@ Summary:"#, ) .await?; - log::debug!("Generated summary for {}: {}", date, summary.chars().take(100).collect::()); + log::debug!( + "Generated summary for {}: {}", + date, + summary.chars().take(100).collect::() + ); span.set_attribute(KeyValue::new("summary_length", summary.len() as i64)); // Embed the summary let embedding = ollama.generate_embedding(&summary).await?; - span.set_attribute(KeyValue::new("embedding_dimensions", embedding.len() as i64)); + span.set_attribute(KeyValue::new( + "embedding_dimensions", + embedding.len() as i64, + )); // Store in database let insert = InsertDailySummary { @@ -276,7 +300,8 @@ Summary:"#, let child_cx = opentelemetry::Context::current_with_span(span); let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao"); - let result = dao.store_summary(&child_cx, insert) + let result = dao + .store_summary(&child_cx, insert) .map_err(|e| anyhow::anyhow!("Failed to store summary: {:?}", e)); match &result { diff --git a/src/ai/embedding_job.rs b/src/ai/embedding_job.rs index af5b5fb..46ffbb5 100644 --- a/src/ai/embedding_job.rs +++ b/src/ai/embedding_job.rs @@ -1,7 +1,7 @@ use anyhow::Result; use chrono::Utc; use std::sync::{Arc, Mutex}; -use tokio::time::{sleep, Duration}; +use tokio::time::{Duration, sleep}; use crate::ai::{OllamaClient, SmsApiClient}; use crate::database::{EmbeddingDao, InsertMessageEmbedding}; @@ -30,8 +30,7 @@ pub async fn embed_contact_messages( // Check existing embeddings count let existing_count = { let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - dao.get_message_count(&otel_context, contact) - .unwrap_or(0) + dao.get_message_count(&otel_context, contact).unwrap_or(0) }; if existing_count > 0 { @@ -45,15 +44,20 @@ pub async fn embed_contact_messages( log::info!("Fetching all messages for contact: {}", contact); // Fetch all messages for the contact - let messages = sms_client - .fetch_all_messages_for_contact(contact) - .await?; + let messages = sms_client.fetch_all_messages_for_contact(contact).await?; let total_messages = messages.len(); - log::info!("Fetched {} messages for contact '{}'", total_messages, contact); + log::info!( + "Fetched {} messages for contact '{}'", + total_messages, + contact + ); if total_messages == 0 { - log::warn!("No messages found for contact '{}', nothing to embed", contact); + log::warn!( + "No messages found for contact '{}', nothing to embed", + contact + ); return Ok(()); } @@ -62,7 +66,8 @@ pub async fn embed_contact_messages( let min_message_length = 30; // Skip short messages like "Thanks!" or "Yeah, it was :)" let messages_to_embed: Vec<&crate::ai::SmsMessage> = { let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - messages.iter() + messages + .iter() .filter(|msg| { // Filter out short messages if msg.body.len() < min_message_length { @@ -107,14 +112,7 @@ pub async fn embed_contact_messages( (batch_end as f64 / to_embed as f64) * 100.0 ); - match embed_message_batch( - batch, - contact, - ollama, - embedding_dao.clone(), - ) - .await - { + match embed_message_batch(batch, contact, ollama, embedding_dao.clone()).await { Ok(count) => { successful += count; log::debug!("Successfully embedded {} messages in batch", count); @@ -206,7 +204,8 @@ async fn embed_message_batch( // Store all embeddings in a single transaction let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - let stored_count = dao.store_message_embeddings_batch(&otel_context, inserts) + let stored_count = dao + .store_message_embeddings_batch(&otel_context, inserts) .map_err(|e| anyhow::anyhow!("Failed to store embeddings batch: {:?}", e))?; Ok(stored_count) diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 0e4eab8..efcf65c 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -1,6 +1,6 @@ use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web}; -use opentelemetry::trace::{Span, Status, Tracer}; use opentelemetry::KeyValue; +use opentelemetry::trace::{Span, Status, Tracer}; use serde::{Deserialize, Serialize}; use crate::ai::{InsightGenerator, OllamaClient}; diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 4d2ce47..1141cc0 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,7 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::KeyValue; +use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::Deserialize; use std::fs::File; use std::sync::{Arc, Mutex}; @@ -89,19 +89,31 @@ impl InsightGenerator { limit: usize, ) -> Result> { let tracer = global_tracer(); - let mut span = tracer.start_with_context("ai.rag.filter_historical", parent_cx); + let span = tracer.start_with_context("ai.rag.filter_historical", parent_cx); let filter_cx = parent_cx.with_span(span); - filter_cx.span().set_attribute(KeyValue::new("date", date.to_string())); - filter_cx.span().set_attribute(KeyValue::new("limit", limit as i64)); - filter_cx.span().set_attribute(KeyValue::new("exclusion_window_days", 30)); + filter_cx + .span() + .set_attribute(KeyValue::new("date", date.to_string())); + filter_cx + .span() + .set_attribute(KeyValue::new("limit", limit as i64)); + filter_cx + .span() + .set_attribute(KeyValue::new("exclusion_window_days", 30)); - let query_results = self.find_relevant_messages_rag(date, location, contact, limit * 2).await?; + let query_results = self + .find_relevant_messages_rag(date, location, contact, limit * 2) + .await?; - filter_cx.span().set_attribute(KeyValue::new("rag_results_count", query_results.len() as i64)); + filter_cx.span().set_attribute(KeyValue::new( + "rag_results_count", + query_results.len() as i64, + )); // Filter out messages from within 30 days of the photo date - let photo_timestamp = date.and_hms_opt(12, 0, 0) + let photo_timestamp = date + .and_hms_opt(12, 0, 0) .ok_or_else(|| anyhow::anyhow!("Invalid date"))? .and_utc() .timestamp(); @@ -114,7 +126,9 @@ impl InsightGenerator { if let Some(bracket_end) = msg.find(']') { if let Some(date_str) = msg.get(1..bracket_end) { // Parse just the date (daily summaries don't have time) - if let Ok(msg_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { + if let Ok(msg_date) = + chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + { let msg_timestamp = msg_date .and_hms_opt(12, 0, 0) .unwrap() @@ -135,7 +149,10 @@ impl InsightGenerator { historical_only.len() ); - filter_cx.span().set_attribute(KeyValue::new("historical_results_count", historical_only.len() as i64)); + filter_cx.span().set_attribute(KeyValue::new( + "historical_results_count", + historical_only.len() as i64, + )); filter_cx.span().set_status(Status::Ok); Ok(historical_only) @@ -206,9 +223,15 @@ impl InsightGenerator { .find_similar_summaries(&search_cx, &query_embedding, limit) .map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?; - log::info!("Found {} relevant daily summaries via RAG", similar_summaries.len()); + log::info!( + "Found {} relevant daily summaries via RAG", + similar_summaries.len() + ); - search_cx.span().set_attribute(KeyValue::new("results_count", similar_summaries.len() as i64)); + search_cx.span().set_attribute(KeyValue::new( + "results_count", + similar_summaries.len() as i64, + )); // Format daily summaries for LLM context let formatted = similar_summaries @@ -303,9 +326,13 @@ impl InsightGenerator { let contact = Self::extract_contact_from_path(&file_path); log::info!("Extracted contact from path: {:?}", contact); - insight_cx.span().set_attribute(KeyValue::new("date_taken", date_taken.to_string())); + insight_cx + .span() + .set_attribute(KeyValue::new("date_taken", date_taken.to_string())); if let Some(ref c) = contact { - insight_cx.span().set_attribute(KeyValue::new("contact", c.clone())); + insight_cx + .span() + .set_attribute(KeyValue::new("contact", c.clone())); } // 4. Get location name from GPS coordinates (needed for RAG query) @@ -314,7 +341,9 @@ impl InsightGenerator { if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { let loc = self.reverse_geocode(lat, lon).await; if let Some(ref l) = loc { - insight_cx.span().set_attribute(KeyValue::new("location", l.clone())); + insight_cx + .span() + .set_attribute(KeyValue::new("location", l.clone())); } loc } else { @@ -341,12 +370,7 @@ impl InsightGenerator { // Strategy A: Pure RAG (we have location for good semantic matching) log::info!("Using RAG with location-based query"); match self - .find_relevant_messages_rag( - date_taken, - location.as_deref(), - contact.as_deref(), - 20, - ) + .find_relevant_messages_rag(date_taken, location.as_deref(), contact.as_deref(), 20) .await { Ok(rag_messages) if !rag_messages.is_empty() => { @@ -377,7 +401,9 @@ impl InsightGenerator { if !immediate_messages.is_empty() { // Step 2: Extract topics from immediate messages to enrich RAG query - let topics = self.extract_topics_from_messages(&immediate_messages, &ollama_client).await; + let topics = self + .extract_topics_from_messages(&immediate_messages, &ollama_client) + .await; log::info!("Extracted topics for query enrichment: {:?}", topics); @@ -420,11 +446,15 @@ impl InsightGenerator { Ok(_) => { // RAG found no historical matches, just use immediate context log::info!("No historical RAG matches, using immediate context only"); - sms_summary = self.summarize_context_from_messages(&immediate_messages, &ollama_client).await; + sms_summary = self + .summarize_context_from_messages(&immediate_messages, &ollama_client) + .await; } Err(e) => { log::warn!("Historical RAG failed, using immediate context only: {}", e); - sms_summary = self.summarize_context_from_messages(&immediate_messages, &ollama_client).await; + sms_summary = self + .summarize_context_from_messages(&immediate_messages, &ollama_client) + .await; } } } else { @@ -481,8 +511,12 @@ impl InsightGenerator { } let retrieval_method = if used_rag { "RAG" } else { "time-based" }; - insight_cx.span().set_attribute(KeyValue::new("retrieval_method", retrieval_method)); - insight_cx.span().set_attribute(KeyValue::new("has_sms_context", sms_summary.is_some())); + insight_cx + .span() + .set_attribute(KeyValue::new("retrieval_method", retrieval_method)); + insight_cx + .span() + .set_attribute(KeyValue::new("has_sms_context", sms_summary.is_some())); log::info!( "Photo context: date={}, location={:?}, retrieval_method={}", @@ -503,8 +537,12 @@ impl InsightGenerator { log::info!("Generated title: {}", title); log::info!("Generated summary: {}", summary); - insight_cx.span().set_attribute(KeyValue::new("title_length", title.len() as i64)); - insight_cx.span().set_attribute(KeyValue::new("summary_length", summary.len() as i64)); + insight_cx + .span() + .set_attribute(KeyValue::new("title_length", title.len() as i64)); + insight_cx + .span() + .set_attribute(KeyValue::new("summary_length", summary.len() as i64)); // 8. Store in database let insight = InsertPhotoInsight { @@ -516,7 +554,8 @@ impl InsightGenerator { }; let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); - let result = dao.store_insight(&insight_cx, insight) + let result = dao + .store_insight(&insight_cx, insight) .map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e)); match &result { diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 1f7ddda..fe4f1d2 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -1,11 +1,10 @@ -pub mod embedding_job; pub mod daily_summary_job; +pub mod embedding_job; pub mod handlers; pub mod insight_generator; pub mod ollama; pub mod sms_client; -pub use embedding_job::embed_contact_messages; pub use daily_summary_job::generate_daily_summaries; pub use handlers::{ delete_insight_handler, generate_insight_handler, get_all_insights_handler, diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index b7ad707..27e932c 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -247,7 +247,9 @@ Use only the specific details provided above. Mention people's names, places, or /// Returns a 768-dimensional vector as Vec pub async fn generate_embedding(&self, text: &str) -> Result> { let embeddings = self.generate_embeddings(&[text]).await?; - embeddings.into_iter().next() + embeddings + .into_iter() + .next() .ok_or_else(|| anyhow::anyhow!("No embedding returned")) } @@ -275,7 +277,10 @@ Use only the specific details provided above. Mention people's names, places, or let embeddings = match primary_result { Ok(embeddings) => { - log::debug!("Successfully generated {} embeddings from primary server", embeddings.len()); + log::debug!( + "Successfully generated {} embeddings from primary server", + embeddings.len() + ); embeddings } Err(e) => { @@ -294,11 +299,17 @@ Use only the specific details provided above. Mention people's names, places, or .await { Ok(embeddings) => { - log::info!("Successfully generated {} embeddings from fallback server", embeddings.len()); + log::info!( + "Successfully generated {} embeddings from fallback server", + embeddings.len() + ); embeddings } Err(fallback_e) => { - log::error!("Fallback server batch embedding also failed: {}", fallback_e); + log::error!( + "Fallback server batch embedding also failed: {}", + fallback_e + ); return Err(anyhow::anyhow!( "Both primary and fallback servers failed. Primary: {}, Fallback: {}", e, @@ -328,14 +339,11 @@ Use only the specific details provided above. Mention people's names, places, or } /// Internal helper to try generating an embedding from a specific server - async fn try_generate_embedding( - &self, - url: &str, - model: &str, - text: &str, - ) -> Result> { + async fn try_generate_embedding(&self, url: &str, model: &str, text: &str) -> Result> { let embeddings = self.try_generate_embeddings(url, model, &[text]).await?; - embeddings.into_iter().next() + embeddings + .into_iter() + .next() .ok_or_else(|| anyhow::anyhow!("No embedding returned from Ollama")) } diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index 7919898..0043452 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -100,31 +100,31 @@ impl SmsApiClient { .timestamp(); let end_ts = chrono::Utc::now().timestamp(); - log::info!( - "Fetching all historical messages for contact: {}", - contact - ); + log::info!("Fetching all historical messages for contact: {}", contact); let mut all_messages = Vec::new(); let mut offset = 0; let limit = 1000; // Fetch in batches of 1000 loop { - log::debug!("Fetching batch at offset {} for contact {}", offset, contact); + log::debug!( + "Fetching batch at offset {} for contact {}", + offset, + contact + ); - let batch = self.fetch_messages_paginated( - start_ts, - end_ts, - Some(contact), - None, - limit, - offset - ).await?; + let batch = self + .fetch_messages_paginated(start_ts, end_ts, Some(contact), None, limit, offset) + .await?; let batch_size = batch.len(); all_messages.extend(batch); - log::debug!("Fetched {} messages (total so far: {})", batch_size, all_messages.len()); + log::debug!( + "Fetched {} messages (total so far: {})", + batch_size, + all_messages.len() + ); // If we got fewer messages than the limit, we've reached the end if batch_size < limit { diff --git a/src/database/daily_summary_dao.rs b/src/database/daily_summary_dao.rs index 6e399e4..343abd4 100644 --- a/src/database/daily_summary_dao.rs +++ b/src/database/daily_summary_dao.rs @@ -4,7 +4,7 @@ use serde::Serialize; use std::ops::DerefMut; use std::sync::{Arc, Mutex}; -use crate::database::{connect, DbError, DbErrorKind}; +use crate::database::{DbError, DbErrorKind, connect}; use crate::otel::trace_db_call; /// Represents a daily conversation summary @@ -125,7 +125,10 @@ impl DailySummaryDao for SqliteDailySummaryDao { summary: InsertDailySummary, ) -> Result { trace_db_call(context, "insert", "store_summary", |_span| { - let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + let mut conn = self + .connection + .lock() + .expect("Unable to get DailySummaryDao"); // Validate embedding dimensions if summary.embedding.len() != 768 { @@ -141,7 +144,7 @@ impl DailySummaryDao for SqliteDailySummaryDao { diesel::sql_query( "INSERT OR REPLACE INTO daily_conversation_summaries (date, contact, summary, message_count, embedding, created_at, model_version) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind::(&summary.date) .bind::(&summary.contact) @@ -266,11 +269,14 @@ impl DailySummaryDao for SqliteDailySummaryDao { contact: &str, ) -> Result { trace_db_call(context, "query", "summary_exists", |_span| { - let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + let mut conn = self + .connection + .lock() + .expect("Unable to get DailySummaryDao"); let count = diesel::sql_query( "SELECT COUNT(*) as count FROM daily_conversation_summaries - WHERE date = ?1 AND contact = ?2" + WHERE date = ?1 AND contact = ?2", ) .bind::(date) .bind::(contact) @@ -289,10 +295,13 @@ impl DailySummaryDao for SqliteDailySummaryDao { contact: &str, ) -> Result { trace_db_call(context, "query", "get_summary_count", |_span| { - let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + let mut conn = self + .connection + .lock() + .expect("Unable to get DailySummaryDao"); diesel::sql_query( - "SELECT COUNT(*) as count FROM daily_conversation_summaries WHERE contact = ?1" + "SELECT COUNT(*) as count FROM daily_conversation_summaries WHERE contact = ?1", ) .bind::(contact) .get_result::(conn.deref_mut()) diff --git a/src/database/embeddings_dao.rs b/src/database/embeddings_dao.rs index 48fd458..bcea675 100644 --- a/src/database/embeddings_dao.rs +++ b/src/database/embeddings_dao.rs @@ -468,7 +468,7 @@ impl EmbeddingDao for SqliteEmbeddingDao { let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); let count = diesel::sql_query( - "SELECT COUNT(*) as count FROM message_embeddings WHERE contact = ?1" + "SELECT COUNT(*) as count FROM message_embeddings WHERE contact = ?1", ) .bind::(contact) .get_result::(conn.deref_mut()) @@ -501,7 +501,7 @@ impl EmbeddingDao for SqliteEmbeddingDao { let count = diesel::sql_query( "SELECT COUNT(*) as count FROM message_embeddings - WHERE contact = ?1 AND body = ?2 AND timestamp = ?3" + WHERE contact = ?1 AND body = ?2 AND timestamp = ?3", ) .bind::(contact) .bind::(body) diff --git a/src/database/mod.rs b/src/database/mod.rs index e27d1ed..d4f1b4e 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,15 +9,15 @@ use crate::database::models::{ }; use crate::otel::trace_db_call; -pub mod embeddings_dao; pub mod daily_summary_dao; +pub mod embeddings_dao; pub mod insights_dao; pub mod models; pub mod schema; -pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding, SqliteEmbeddingDao}; +pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; +pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding}; pub use insights_dao::{InsightDao, SqliteInsightDao}; -pub use daily_summary_dao::{DailySummaryDao, SqliteDailySummaryDao, DailySummary, InsertDailySummary}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/main.rs b/src/main.rs index 956675a..3be66d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,6 @@ use crate::video::actors::{ use log::{debug, error, info, trace, warn}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::{KeyValue, global}; -use crate::video::generate_video_gifs; mod ai; mod auth; @@ -776,8 +775,10 @@ fn main() -> std::io::Result<()> { end, &ollama_clone, &sms_client_clone, - summary_dao - ).await { + summary_dao, + ) + .await + { log::error!("Daily summary generation failed for {}: {:?}", contact, e); } else { log::info!("Daily summary generation completed for {}", contact); diff --git a/src/state.rs b/src/state.rs index 6dff518..50922d2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,7 @@ use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; -use crate::database::{DailySummaryDao, ExifDao, InsightDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao}; +use crate::database::{ + DailySummaryDao, ExifDao, InsightDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, +}; use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager}; use actix::{Actor, Addr}; use std::env; -- 2.49.1 From d86b2c3746ca95868f3d8543e50c26727c502526 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 5 Jan 2026 14:50:49 -0500 Subject: [PATCH 09/25] Add Google Takeout data import infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 & 2 of Google Takeout RAG integration: - Database migrations for calendar_events, location_history, search_history - DAO implementations with hybrid time + semantic search - Parsers for .ics, JSON, and HTML Google Takeout formats - Import utilities with batch insert optimization Features: - CalendarEventDao: Hybrid time-range + semantic search for events - LocationHistoryDao: GPS proximity with Haversine distance calculation - SearchHistoryDao: Semantic-first search (queries are embedding-rich) - Batch inserts for performance (1M+ records in minutes vs hours) - OpenTelemetry tracing for all database operations Import utilities: - import_calendar: Parse .ics with optional embedding generation - import_location_history: High-volume GPS data with batch inserts - import_search_history: Always generates embeddings for semantic search 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 342 +++++++++++ Cargo.toml | 4 +- .../down.sql | 1 + .../up.sql | 20 + .../down.sql | 1 + .../up.sql | 19 + .../down.sql | 1 + .../up.sql | 13 + src/ai/insight_generator.rs | 2 +- src/bin/import_calendar.rs | 167 ++++++ src/bin/import_location_history.rs | 115 ++++ src/bin/import_search_history.rs | 154 +++++ src/bin/migrate_exif.rs | 10 +- src/data/mod.rs | 10 +- src/database/calendar_dao.rs | 553 ++++++++++++++++++ src/database/location_dao.rs | 528 +++++++++++++++++ src/database/mod.rs | 14 +- src/database/models.rs | 20 +- src/database/schema.rs | 133 ++++- src/database/search_dao.rs | 516 ++++++++++++++++ src/files.rs | 7 +- src/lib.rs | 1 + src/main.rs | 20 +- src/parsers/ical_parser.rs | 183 ++++++ src/parsers/location_json_parser.rs | 133 +++++ src/parsers/mod.rs | 7 + src/parsers/search_html_parser.rs | 210 +++++++ 27 files changed, 3129 insertions(+), 55 deletions(-) create mode 100644 migrations/2026-01-05-000000_add_calendar_events/down.sql create mode 100644 migrations/2026-01-05-000000_add_calendar_events/up.sql create mode 100644 migrations/2026-01-05-000100_add_location_history/down.sql create mode 100644 migrations/2026-01-05-000100_add_location_history/up.sql create mode 100644 migrations/2026-01-05-000200_add_search_history/down.sql create mode 100644 migrations/2026-01-05-000200_add_search_history/up.sql create mode 100644 src/bin/import_calendar.rs create mode 100644 src/bin/import_location_history.rs create mode 100644 src/bin/import_search_history.rs create mode 100644 src/database/calendar_dao.rs create mode 100644 src/database/location_dao.rs create mode 100644 src/database/search_dao.rs create mode 100644 src/parsers/ical_parser.rs create mode 100644 src/parsers/location_json_parser.rs create mode 100644 src/parsers/mod.rs create mode 100644 src/parsers/search_html_parser.rs diff --git a/Cargo.lock b/Cargo.lock index b964197..7495026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,19 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -848,6 +861,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -1024,6 +1060,27 @@ dependencies = [ "syn", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + [[package]] name = "either" version = "1.15.0" @@ -1165,6 +1222,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -1260,6 +1327,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1270,6 +1346,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1377,6 +1462,20 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "http" version = "0.2.12" @@ -1557,6 +1656,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ical" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7cab7543a8b7729a19e2c04309f902861293dcdae6558dfbeb634454d279f6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1708,6 +1816,7 @@ dependencies = [ "dotenv", "env_logger", "futures", + "ical", "image", "infer", "jsonwebtoken", @@ -1726,6 +1835,7 @@ dependencies = [ "rayon", "regex", "reqwest", + "scraper", "serde", "serde_json", "tempfile", @@ -2004,6 +2114,26 @@ dependencies = [ "imgref", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2439,6 +2569,96 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -2529,6 +2749,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.101" @@ -2987,6 +3213,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3010,6 +3252,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.26" @@ -3087,6 +3348,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3140,6 +3410,18 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -3193,6 +3475,31 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fdc163db75f7b5ffa3daf0c5a7136fb0d4b2f35523cd1769da05e034159feb" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3289,6 +3596,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3385,9 +3703,21 @@ dependencies = [ "signal-hook-registry", "slab", "socket2 0.6.0", + "tokio-macros", "windows-sys 0.59.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -3647,6 +3977,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3677,6 +4013,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 27e6e78..1a7bf56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ lto = "thin" actix = "0.13.1" actix-web = "4" actix-rt = "2.6" -tokio = { version = "1.42.0", features = ["default", "process", "sync"] } +tokio = { version = "1.42.0", features = ["default", "process", "sync", "macros", "rt-multi-thread"] } actix-files = "0.6" actix-cors = "0.7" actix-multipart = "0.7.2" @@ -52,3 +52,5 @@ exif = { package = "kamadak-exif", version = "0.6.1" } reqwest = { version = "0.12", features = ["json"] } urlencoding = "2.1" zerocopy = "0.8" +ical = "0.11" +scraper = "0.20" diff --git a/migrations/2026-01-05-000000_add_calendar_events/down.sql b/migrations/2026-01-05-000000_add_calendar_events/down.sql new file mode 100644 index 0000000..70fbec6 --- /dev/null +++ b/migrations/2026-01-05-000000_add_calendar_events/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS calendar_events; diff --git a/migrations/2026-01-05-000000_add_calendar_events/up.sql b/migrations/2026-01-05-000000_add_calendar_events/up.sql new file mode 100644 index 0000000..b2e477d --- /dev/null +++ b/migrations/2026-01-05-000000_add_calendar_events/up.sql @@ -0,0 +1,20 @@ +CREATE TABLE calendar_events ( + id INTEGER PRIMARY KEY NOT NULL, + event_uid TEXT, + summary TEXT NOT NULL, + description TEXT, + location TEXT, + start_time BIGINT NOT NULL, + end_time BIGINT NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT 0, + organizer TEXT, + attendees TEXT, + embedding BLOB, + created_at BIGINT NOT NULL, + source_file TEXT, + UNIQUE(event_uid, start_time) +); + +CREATE INDEX idx_calendar_start_time ON calendar_events(start_time); +CREATE INDEX idx_calendar_end_time ON calendar_events(end_time); +CREATE INDEX idx_calendar_time_range ON calendar_events(start_time, end_time); diff --git a/migrations/2026-01-05-000100_add_location_history/down.sql b/migrations/2026-01-05-000100_add_location_history/down.sql new file mode 100644 index 0000000..8c39663 --- /dev/null +++ b/migrations/2026-01-05-000100_add_location_history/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS location_history; diff --git a/migrations/2026-01-05-000100_add_location_history/up.sql b/migrations/2026-01-05-000100_add_location_history/up.sql new file mode 100644 index 0000000..2d66948 --- /dev/null +++ b/migrations/2026-01-05-000100_add_location_history/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE location_history ( + id INTEGER PRIMARY KEY NOT NULL, + timestamp BIGINT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + accuracy INTEGER, + activity TEXT, + activity_confidence INTEGER, + place_name TEXT, + place_category TEXT, + embedding BLOB, + created_at BIGINT NOT NULL, + source_file TEXT, + UNIQUE(timestamp, latitude, longitude) +); + +CREATE INDEX idx_location_timestamp ON location_history(timestamp); +CREATE INDEX idx_location_coords ON location_history(latitude, longitude); +CREATE INDEX idx_location_activity ON location_history(activity); diff --git a/migrations/2026-01-05-000200_add_search_history/down.sql b/migrations/2026-01-05-000200_add_search_history/down.sql new file mode 100644 index 0000000..2842ca4 --- /dev/null +++ b/migrations/2026-01-05-000200_add_search_history/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS search_history; diff --git a/migrations/2026-01-05-000200_add_search_history/up.sql b/migrations/2026-01-05-000200_add_search_history/up.sql new file mode 100644 index 0000000..4e7fe11 --- /dev/null +++ b/migrations/2026-01-05-000200_add_search_history/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE search_history ( + id INTEGER PRIMARY KEY NOT NULL, + timestamp BIGINT NOT NULL, + query TEXT NOT NULL, + search_engine TEXT, + embedding BLOB NOT NULL, + created_at BIGINT NOT NULL, + source_file TEXT, + UNIQUE(timestamp, query) +); + +CREATE INDEX idx_search_timestamp ON search_history(timestamp); +CREATE INDEX idx_search_query ON search_history(query); diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 1141cc0..b47b8cf 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -339,7 +339,7 @@ impl InsightGenerator { let location = match exif { Some(ref exif) => { if let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { - let loc = self.reverse_geocode(lat, lon).await; + let loc = self.reverse_geocode(lat as f64, lon as f64).await; if let Some(ref l) = loc { insight_cx .span() diff --git a/src/bin/import_calendar.rs b/src/bin/import_calendar.rs new file mode 100644 index 0000000..e7b1b2c --- /dev/null +++ b/src/bin/import_calendar.rs @@ -0,0 +1,167 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use clap::Parser; +use image_api::ai::ollama::OllamaClient; +use image_api::database::calendar_dao::{InsertCalendarEvent, SqliteCalendarEventDao}; +use image_api::parsers::ical_parser::parse_ics_file; +use log::{error, info}; +use std::sync::{Arc, Mutex}; + +// Import the trait to use its methods +use image_api::database::CalendarEventDao; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Import Google Takeout Calendar data", long_about = None)] +struct Args { + /// Path to the .ics calendar file + #[arg(short, long)] + path: String, + + /// Generate embeddings for calendar events (slower but enables semantic search) + #[arg(long, default_value = "false")] + generate_embeddings: bool, + + /// Skip events that already exist in the database + #[arg(long, default_value = "true")] + skip_existing: bool, + + /// Batch size for embedding generation + #[arg(long, default_value = "128")] + batch_size: usize, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + env_logger::init(); + + let args = Args::parse(); + + info!("Parsing calendar file: {}", args.path); + let events = parse_ics_file(&args.path).context("Failed to parse .ics file")?; + + info!("Found {} calendar events", events.len()); + + let context = opentelemetry::Context::current(); + + let ollama = if args.generate_embeddings { + let primary_url = dotenv::var("OLLAMA_PRIMARY_URL") + .or_else(|_| dotenv::var("OLLAMA_URL")) + .unwrap_or_else(|_| "http://localhost:11434".to_string()); + let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok(); + let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| dotenv::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string()); + let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok(); + + Some(OllamaClient::new( + primary_url, + fallback_url, + primary_model, + fallback_model, + )) + } else { + None + }; + + let inserted_count = Arc::new(Mutex::new(0)); + let skipped_count = Arc::new(Mutex::new(0)); + let error_count = Arc::new(Mutex::new(0)); + + // Process events in batches + // Can't use rayon with async, so process sequentially + for event in &events { + let mut dao_instance = SqliteCalendarEventDao::new(); + + // Check if event exists + if args.skip_existing { + if let Ok(exists) = dao_instance.event_exists( + &context, + event.event_uid.as_deref().unwrap_or(""), + event.start_time, + ) { + if exists { + *skipped_count.lock().unwrap() += 1; + continue; + } + } + } + + // Generate embedding if requested (blocking call) + let embedding = if let Some(ref ollama_client) = ollama { + let text = format!( + "{} {} {}", + event.summary, + event.description.as_deref().unwrap_or(""), + event.location.as_deref().unwrap_or("") + ); + + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { ollama_client.generate_embedding(&text).await }) + }) { + Ok(emb) => Some(emb), + Err(e) => { + error!( + "Failed to generate embedding for event '{}': {}", + event.summary, e + ); + None + } + } + } else { + None + }; + + // Insert into database + let insert_event = InsertCalendarEvent { + event_uid: event.event_uid.clone(), + summary: event.summary.clone(), + description: event.description.clone(), + location: event.location.clone(), + start_time: event.start_time, + end_time: event.end_time, + all_day: event.all_day, + organizer: event.organizer.clone(), + attendees: if event.attendees.is_empty() { + None + } else { + Some(serde_json::to_string(&event.attendees).unwrap_or_default()) + }, + embedding, + created_at: Utc::now().timestamp(), + source_file: Some(args.path.clone()), + }; + + match dao_instance.store_event(&context, insert_event) { + Ok(_) => { + *inserted_count.lock().unwrap() += 1; + if *inserted_count.lock().unwrap() % 100 == 0 { + info!("Imported {} events...", *inserted_count.lock().unwrap()); + } + } + Err(e) => { + error!("Failed to store event '{}': {:?}", event.summary, e); + *error_count.lock().unwrap() += 1; + } + } + } + + let final_inserted = *inserted_count.lock().unwrap(); + let final_skipped = *skipped_count.lock().unwrap(); + let final_errors = *error_count.lock().unwrap(); + + info!("\n=== Import Summary ==="); + info!("Total events found: {}", events.len()); + info!("Successfully inserted: {}", final_inserted); + info!("Skipped (already exist): {}", final_skipped); + info!("Errors: {}", final_errors); + + if args.generate_embeddings { + info!("Embeddings were generated for semantic search"); + } else { + info!("No embeddings generated (use --generate-embeddings to enable semantic search)"); + } + + Ok(()) +} diff --git a/src/bin/import_location_history.rs b/src/bin/import_location_history.rs new file mode 100644 index 0000000..a0437a1 --- /dev/null +++ b/src/bin/import_location_history.rs @@ -0,0 +1,115 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use clap::Parser; +use image_api::database::location_dao::{InsertLocationRecord, SqliteLocationHistoryDao}; +use image_api::parsers::location_json_parser::parse_location_json; +use log::{error, info}; +// Import the trait to use its methods +use image_api::database::LocationHistoryDao; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Import Google Takeout Location History data", long_about = None)] +struct Args { + /// Path to the Location History JSON file + #[arg(short, long)] + path: String, + + /// Skip locations that already exist in the database + #[arg(long, default_value = "true")] + skip_existing: bool, + + /// Batch size for database inserts + #[arg(long, default_value = "1000")] + batch_size: usize, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + env_logger::init(); + + let args = Args::parse(); + + info!("Parsing location history file: {}", args.path); + let locations = + parse_location_json(&args.path).context("Failed to parse location history JSON")?; + + info!("Found {} location records", locations.len()); + + let context = opentelemetry::Context::current(); + + let mut inserted_count = 0; + let mut skipped_count = 0; + let mut error_count = 0; + + let mut dao_instance = SqliteLocationHistoryDao::new(); + let created_at = Utc::now().timestamp(); + + // Process in batches using batch insert for massive speedup + for (batch_idx, chunk) in locations.chunks(args.batch_size).enumerate() { + info!( + "Processing batch {} ({} records)...", + batch_idx + 1, + chunk.len() + ); + + // Convert to InsertLocationRecord + let mut batch_inserts = Vec::with_capacity(chunk.len()); + + for location in chunk { + // Skip existing check if requested (makes import much slower) + if args.skip_existing { + if let Ok(exists) = dao_instance.location_exists( + &context, + location.timestamp, + location.latitude, + location.longitude, + ) { + if exists { + skipped_count += 1; + continue; + } + } + } + + batch_inserts.push(InsertLocationRecord { + timestamp: location.timestamp, + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.accuracy, + activity: location.activity.clone(), + activity_confidence: location.activity_confidence, + place_name: None, + place_category: None, + embedding: None, + created_at, + source_file: Some(args.path.clone()), + }); + } + + // Batch insert entire chunk in single transaction + if !batch_inserts.is_empty() { + match dao_instance.store_locations_batch(&context, batch_inserts) { + Ok(count) => { + inserted_count += count; + info!( + "Imported {} locations (total: {})...", + count, inserted_count + ); + } + Err(e) => { + error!("Failed to store batch: {:?}", e); + error_count += chunk.len(); + } + } + } + } + + info!("\n=== Import Summary ==="); + info!("Total locations found: {}", locations.len()); + info!("Successfully inserted: {}", inserted_count); + info!("Skipped (already exist): {}", skipped_count); + info!("Errors: {}", error_count); + + Ok(()) +} diff --git a/src/bin/import_search_history.rs b/src/bin/import_search_history.rs new file mode 100644 index 0000000..3438230 --- /dev/null +++ b/src/bin/import_search_history.rs @@ -0,0 +1,154 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use clap::Parser; +use image_api::ai::ollama::OllamaClient; +use image_api::database::search_dao::{InsertSearchRecord, SqliteSearchHistoryDao}; +use image_api::parsers::search_html_parser::parse_search_html; +use log::{error, info, warn}; + +// Import the trait to use its methods +use image_api::database::SearchHistoryDao; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Import Google Takeout Search History data", long_about = None)] +struct Args { + /// Path to the search history HTML file + #[arg(short, long)] + path: String, + + /// Skip searches that already exist in the database + #[arg(long, default_value = "true")] + skip_existing: bool, + + /// Batch size for embedding generation (max 128 recommended) + #[arg(long, default_value = "64")] + batch_size: usize, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + env_logger::init(); + + let args = Args::parse(); + + info!("Parsing search history file: {}", args.path); + let searches = parse_search_html(&args.path).context("Failed to parse search history HTML")?; + + info!("Found {} search records", searches.len()); + + let primary_url = dotenv::var("OLLAMA_PRIMARY_URL") + .or_else(|_| dotenv::var("OLLAMA_URL")) + .unwrap_or_else(|_| "http://localhost:11434".to_string()); + let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok(); + let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| dotenv::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string()); + let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok(); + + let ollama = OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model); + let context = opentelemetry::Context::current(); + + let mut inserted_count = 0; + let mut skipped_count = 0; + let mut error_count = 0; + + let mut dao_instance = SqliteSearchHistoryDao::new(); + let created_at = Utc::now().timestamp(); + + // Process searches in batches (embeddings are REQUIRED for searches) + for (batch_idx, chunk) in searches.chunks(args.batch_size).enumerate() { + info!( + "Processing batch {} ({} searches)...", + batch_idx + 1, + chunk.len() + ); + + // Generate embeddings for this batch + let queries: Vec = chunk.iter().map(|s| s.query.clone()).collect(); + + let embeddings_result = tokio::task::spawn({ + let ollama_client = ollama.clone(); + async move { + // Generate embeddings in parallel for the batch + let mut embeddings = Vec::new(); + for query in &queries { + match ollama_client.generate_embedding(query).await { + Ok(emb) => embeddings.push(Some(emb)), + Err(e) => { + warn!("Failed to generate embedding for query '{}': {}", query, e); + embeddings.push(None); + } + } + } + embeddings + } + }) + .await + .context("Failed to generate embeddings for batch")?; + + // Build batch of searches with embeddings + let mut batch_inserts = Vec::new(); + + for (search, embedding_opt) in chunk.iter().zip(embeddings_result.iter()) { + // Check if search exists (optional for speed) + if args.skip_existing { + if let Ok(exists) = + dao_instance.search_exists(&context, search.timestamp, &search.query) + { + if exists { + skipped_count += 1; + continue; + } + } + } + + // Only insert if we have an embedding + if let Some(embedding) = embedding_opt { + batch_inserts.push(InsertSearchRecord { + timestamp: search.timestamp, + query: search.query.clone(), + search_engine: search.search_engine.clone(), + embedding: embedding.clone(), + created_at, + source_file: Some(args.path.clone()), + }); + } else { + error!( + "Skipping search '{}' due to missing embedding", + search.query + ); + error_count += 1; + } + } + + // Batch insert entire chunk in single transaction + if !batch_inserts.is_empty() { + match dao_instance.store_searches_batch(&context, batch_inserts) { + Ok(count) => { + inserted_count += count; + info!("Imported {} searches (total: {})...", count, inserted_count); + } + Err(e) => { + error!("Failed to store batch: {:?}", e); + error_count += chunk.len(); + } + } + } + + // Rate limiting between batches + if batch_idx < searches.len() / args.batch_size { + info!("Waiting 500ms before next batch..."); + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + } + + info!("\n=== Import Summary ==="); + info!("Total searches found: {}", searches.len()); + info!("Successfully inserted: {}", inserted_count); + info!("Skipped (already exist): {}", skipped_count); + info!("Errors: {}", error_count); + info!("All imported searches have embeddings for semantic search"); + + Ok(()) +} diff --git a/src/bin/migrate_exif.rs b/src/bin/migrate_exif.rs index 5f5af9d..98e83dc 100644 --- a/src/bin/migrate_exif.rs +++ b/src/bin/migrate_exif.rs @@ -102,11 +102,11 @@ fn main() -> anyhow::Result<()> { width: exif_data.width, height: exif_data.height, orientation: exif_data.orientation, - gps_latitude: exif_data.gps_latitude, - gps_longitude: exif_data.gps_longitude, - gps_altitude: exif_data.gps_altitude, - focal_length: exif_data.focal_length, - aperture: exif_data.aperture, + gps_latitude: exif_data.gps_latitude.map(|v| v as f32), + gps_longitude: exif_data.gps_longitude.map(|v| v as f32), + gps_altitude: exif_data.gps_altitude.map(|v| v as f32), + focal_length: exif_data.focal_length.map(|v| v as f32), + aperture: exif_data.aperture.map(|v| v as f32), shutter_speed: exif_data.shutter_speed, iso: exif_data.iso, date_taken: exif_data.date_taken, diff --git a/src/data/mod.rs b/src/data/mod.rs index 70a3362..fa402b5 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -298,17 +298,17 @@ impl From for ExifMetadata { }, gps: if has_gps { Some(GpsCoordinates { - latitude: exif.gps_latitude, - longitude: exif.gps_longitude, - altitude: exif.gps_altitude, + latitude: exif.gps_latitude.map(|v| v as f64), + longitude: exif.gps_longitude.map(|v| v as f64), + altitude: exif.gps_altitude.map(|v| v as f64), }) } else { None }, capture_settings: if has_capture_settings { Some(CaptureSettings { - focal_length: exif.focal_length, - aperture: exif.aperture, + focal_length: exif.focal_length.map(|v| v as f64), + aperture: exif.aperture.map(|v| v as f64), shutter_speed: exif.shutter_speed, iso: exif.iso, }) diff --git a/src/database/calendar_dao.rs b/src/database/calendar_dao.rs new file mode 100644 index 0000000..e1afefd --- /dev/null +++ b/src/database/calendar_dao.rs @@ -0,0 +1,553 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use serde::Serialize; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// Represents a calendar event +#[derive(Serialize, Clone, Debug)] +pub struct CalendarEvent { + pub id: i32, + pub event_uid: Option, + pub summary: String, + pub description: Option, + pub location: Option, + pub start_time: i64, + pub end_time: i64, + pub all_day: bool, + pub organizer: Option, + pub attendees: Option, // JSON string + pub created_at: i64, + pub source_file: Option, +} + +/// Data for inserting a new calendar event +#[derive(Clone, Debug)] +pub struct InsertCalendarEvent { + pub event_uid: Option, + pub summary: String, + pub description: Option, + pub location: Option, + pub start_time: i64, + pub end_time: i64, + pub all_day: bool, + pub organizer: Option, + pub attendees: Option, + pub embedding: Option>, // 768-dim, optional + pub created_at: i64, + pub source_file: Option, +} + +pub trait CalendarEventDao: Sync + Send { + /// Store calendar event with optional embedding + fn store_event( + &mut self, + context: &opentelemetry::Context, + event: InsertCalendarEvent, + ) -> Result; + + /// Batch insert events (for import efficiency) + fn store_events_batch( + &mut self, + context: &opentelemetry::Context, + events: Vec, + ) -> Result; + + /// Find events in time range (PRIMARY query method) + fn find_events_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError>; + + /// Find semantically similar events (SECONDARY - requires embeddings) + fn find_similar_events( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError>; + + /// Hybrid: Time-filtered + semantic ranking + /// "Events during photo timestamp ±N days, ranked by similarity to context" + fn find_relevant_events_hybrid( + &mut self, + context: &opentelemetry::Context, + center_timestamp: i64, + time_window_days: i64, + query_embedding: Option<&[f32]>, + limit: usize, + ) -> Result, DbError>; + + /// Check if event exists (idempotency) + fn event_exists( + &mut self, + context: &opentelemetry::Context, + event_uid: &str, + start_time: i64, + ) -> Result; + + /// Get count of events + fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result; +} + +pub struct SqliteCalendarEventDao { + connection: Arc>, +} + +impl Default for SqliteCalendarEventDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteCalendarEventDao { + pub fn new() -> Self { + SqliteCalendarEventDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + fn serialize_vector(vec: &[f32]) -> Vec { + use zerocopy::IntoBytes; + vec.as_bytes().to_vec() + } + + fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { + if bytes.len() % 4 != 0 { + return Err(DbError::new(DbErrorKind::QueryError)); + } + + let count = bytes.len() / 4; + let mut vec = Vec::with_capacity(count); + + for chunk in bytes.chunks_exact(4) { + let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + vec.push(float); + } + + Ok(vec) + } + + fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if magnitude_a == 0.0 || magnitude_b == 0.0 { + return 0.0; + } + + dot_product / (magnitude_a * magnitude_b) + } +} + +#[derive(QueryableByName)] +struct CalendarEventWithVectorRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Nullable)] + event_uid: Option, + #[diesel(sql_type = diesel::sql_types::Text)] + summary: String, + #[diesel(sql_type = diesel::sql_types::Nullable)] + description: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + location: Option, + #[diesel(sql_type = diesel::sql_types::BigInt)] + start_time: i64, + #[diesel(sql_type = diesel::sql_types::BigInt)] + end_time: i64, + #[diesel(sql_type = diesel::sql_types::Bool)] + all_day: bool, + #[diesel(sql_type = diesel::sql_types::Nullable)] + organizer: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + attendees: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + embedding: Option>, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Nullable)] + source_file: Option, +} + +impl CalendarEventWithVectorRow { + fn to_calendar_event(&self) -> CalendarEvent { + CalendarEvent { + id: self.id, + event_uid: self.event_uid.clone(), + summary: self.summary.clone(), + description: self.description.clone(), + location: self.location.clone(), + start_time: self.start_time, + end_time: self.end_time, + all_day: self.all_day, + organizer: self.organizer.clone(), + attendees: self.attendees.clone(), + created_at: self.created_at, + source_file: self.source_file.clone(), + } + } +} + +#[derive(QueryableByName)] +struct LastInsertRowId { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, +} + +impl CalendarEventDao for SqliteCalendarEventDao { + fn store_event( + &mut self, + context: &opentelemetry::Context, + event: InsertCalendarEvent, + ) -> Result { + trace_db_call(context, "insert", "store_event", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get CalendarEventDao"); + + // Validate embedding dimensions if provided + if let Some(ref emb) = event.embedding { + if emb.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + emb.len() + )); + } + } + + let embedding_bytes = event.embedding.as_ref().map(|e| Self::serialize_vector(e)); + + // INSERT OR REPLACE to handle re-imports + diesel::sql_query( + "INSERT OR REPLACE INTO calendar_events + (event_uid, summary, description, location, start_time, end_time, all_day, + organizer, attendees, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + ) + .bind::, _>(&event.event_uid) + .bind::(&event.summary) + .bind::, _>(&event.description) + .bind::, _>(&event.location) + .bind::(event.start_time) + .bind::(event.end_time) + .bind::(event.all_day) + .bind::, _>(&event.organizer) + .bind::, _>(&event.attendees) + .bind::, _>(&embedding_bytes) + .bind::(event.created_at) + .bind::, _>(&event.source_file) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; + + let row_id: i32 = diesel::sql_query("SELECT last_insert_rowid() as id") + .get_result::(conn.deref_mut()) + .map(|r| r.id) + .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))?; + + Ok(CalendarEvent { + id: row_id, + event_uid: event.event_uid, + summary: event.summary, + description: event.description, + location: event.location, + start_time: event.start_time, + end_time: event.end_time, + all_day: event.all_day, + organizer: event.organizer, + attendees: event.attendees, + created_at: event.created_at, + source_file: event.source_file, + }) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn store_events_batch( + &mut self, + context: &opentelemetry::Context, + events: Vec, + ) -> Result { + trace_db_call(context, "insert", "store_events_batch", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get CalendarEventDao"); + let mut inserted = 0; + + conn.transaction::<_, anyhow::Error, _>(|conn| { + for event in events { + // Validate embedding if provided + if let Some(ref emb) = event.embedding { + if emb.len() != 768 { + log::warn!( + "Skipping event with invalid embedding dimensions: {}", + emb.len() + ); + continue; + } + } + + let embedding_bytes = + event.embedding.as_ref().map(|e| Self::serialize_vector(e)); + + diesel::sql_query( + "INSERT OR REPLACE INTO calendar_events + (event_uid, summary, description, location, start_time, end_time, all_day, + organizer, attendees, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + ) + .bind::, _>( + &event.event_uid, + ) + .bind::(&event.summary) + .bind::, _>( + &event.description, + ) + .bind::, _>( + &event.location, + ) + .bind::(event.start_time) + .bind::(event.end_time) + .bind::(event.all_day) + .bind::, _>( + &event.organizer, + ) + .bind::, _>( + &event.attendees, + ) + .bind::, _>( + &embedding_bytes, + ) + .bind::(event.created_at) + .bind::, _>( + &event.source_file, + ) + .execute(conn) + .map_err(|e| anyhow::anyhow!("Batch insert error: {:?}", e))?; + + inserted += 1; + } + Ok(()) + }) + .map_err(|e| anyhow::anyhow!("Transaction error: {:?}", e))?; + + Ok(inserted) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn find_events_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_events_in_range", |_span| { + let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); + + diesel::sql_query( + "SELECT id, event_uid, summary, description, location, start_time, end_time, all_day, + organizer, attendees, NULL as embedding, created_at, source_file + FROM calendar_events + WHERE start_time >= ?1 AND start_time <= ?2 + ORDER BY start_time ASC" + ) + .bind::(start_ts) + .bind::(end_ts) + .load::(conn.deref_mut()) + .map(|rows| rows.into_iter().map(|r| r.to_calendar_event()).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_similar_events( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_similar_events", |_span| { + let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); + + if query_embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_embedding.len() + )); + } + + // Load all events with embeddings + let results = diesel::sql_query( + "SELECT id, event_uid, summary, description, location, start_time, end_time, all_day, + organizer, attendees, embedding, created_at, source_file + FROM calendar_events + WHERE embedding IS NOT NULL" + ) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + // Compute similarities + let mut scored_events: Vec<(f32, CalendarEvent)> = results + .into_iter() + .filter_map(|row| { + if let Some(ref emb_bytes) = row.embedding { + if let Ok(emb) = Self::deserialize_vector(emb_bytes) { + let similarity = Self::cosine_similarity(query_embedding, &emb); + Some((similarity, row.to_calendar_event())) + } else { + None + } + } else { + None + } + }) + .collect(); + + // Sort by similarity descending + scored_events.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + log::info!("Found {} similar calendar events", scored_events.len()); + if !scored_events.is_empty() { + log::info!("Top similarity: {:.4}", scored_events[0].0); + } + + Ok(scored_events.into_iter().take(limit).map(|(_, event)| event).collect()) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_relevant_events_hybrid( + &mut self, + context: &opentelemetry::Context, + center_timestamp: i64, + time_window_days: i64, + query_embedding: Option<&[f32]>, + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_relevant_events_hybrid", |_span| { + let window_seconds = time_window_days * 86400; + let start_ts = center_timestamp - window_seconds; + let end_ts = center_timestamp + window_seconds; + + let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); + + // Step 1: Time-based filter (fast, indexed) + let events_in_range = diesel::sql_query( + "SELECT id, event_uid, summary, description, location, start_time, end_time, all_day, + organizer, attendees, embedding, created_at, source_file + FROM calendar_events + WHERE start_time >= ?1 AND start_time <= ?2" + ) + .bind::(start_ts) + .bind::(end_ts) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + // Step 2: If query embedding provided, rank by semantic similarity + if let Some(query_emb) = query_embedding { + if query_emb.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_emb.len() + )); + } + + let mut scored_events: Vec<(f32, CalendarEvent)> = events_in_range + .into_iter() + .map(|row| { + // Events with embeddings get semantic scoring + let similarity = if let Some(ref emb_bytes) = row.embedding { + if let Ok(emb) = Self::deserialize_vector(emb_bytes) { + Self::cosine_similarity(query_emb, &emb) + } else { + 0.5 // Neutral score for deserialization errors + } + } else { + 0.5 // Neutral score for events without embeddings + }; + (similarity, row.to_calendar_event()) + }) + .collect(); + + // Sort by similarity descending + scored_events.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + log::info!("Hybrid query: {} events in time range, ranked by similarity", scored_events.len()); + if !scored_events.is_empty() { + log::info!("Top similarity: {:.4}", scored_events[0].0); + } + + Ok(scored_events.into_iter().take(limit).map(|(_, event)| event).collect()) + } else { + // No semantic ranking, just return time-sorted (limit applied) + log::info!("Time-only query: {} events in range", events_in_range.len()); + Ok(events_in_range.into_iter().take(limit).map(|r| r.to_calendar_event()).collect()) + } + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn event_exists( + &mut self, + context: &opentelemetry::Context, + event_uid: &str, + start_time: i64, + ) -> Result { + trace_db_call(context, "query", "event_exists", |_span| { + let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::Integer)] + count: i32, + } + + let result: CountResult = diesel::sql_query( + "SELECT COUNT(*) as count FROM calendar_events WHERE event_uid = ?1 AND start_time = ?2" + ) + .bind::(event_uid) + .bind::(start_time) + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count > 0) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result { + trace_db_call(context, "query", "get_event_count", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get CalendarEventDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let result: CountResult = + diesel::sql_query("SELECT COUNT(*) as count FROM calendar_events") + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} diff --git a/src/database/location_dao.rs b/src/database/location_dao.rs new file mode 100644 index 0000000..86b8efe --- /dev/null +++ b/src/database/location_dao.rs @@ -0,0 +1,528 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use serde::Serialize; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// Represents a location history record +#[derive(Serialize, Clone, Debug)] +pub struct LocationRecord { + pub id: i32, + pub timestamp: i64, + pub latitude: f64, + pub longitude: f64, + pub accuracy: Option, + pub activity: Option, + pub activity_confidence: Option, + pub place_name: Option, + pub place_category: Option, + pub created_at: i64, + pub source_file: Option, +} + +/// Data for inserting a new location record +#[derive(Clone, Debug)] +pub struct InsertLocationRecord { + pub timestamp: i64, + pub latitude: f64, + pub longitude: f64, + pub accuracy: Option, + pub activity: Option, + pub activity_confidence: Option, + pub place_name: Option, + pub place_category: Option, + pub embedding: Option>, // 768-dim, optional (rarely used) + pub created_at: i64, + pub source_file: Option, +} + +pub trait LocationHistoryDao: Sync + Send { + /// Store single location record + fn store_location( + &mut self, + context: &opentelemetry::Context, + location: InsertLocationRecord, + ) -> Result; + + /// Batch insert locations (Google Takeout has millions of points) + fn store_locations_batch( + &mut self, + context: &opentelemetry::Context, + locations: Vec, + ) -> Result; + + /// Find nearest location to timestamp (PRIMARY query) + /// "Where was I at photo timestamp ±N minutes?" + fn find_nearest_location( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + max_time_diff_seconds: i64, + ) -> Result, DbError>; + + /// Find locations in time range + fn find_locations_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError>; + + /// Find locations near GPS coordinates (for "photos near this place") + /// Uses approximate bounding box for performance + fn find_locations_near_point( + &mut self, + context: &opentelemetry::Context, + latitude: f64, + longitude: f64, + radius_km: f64, + ) -> Result, DbError>; + + /// Deduplicate: check if location exists + fn location_exists( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + latitude: f64, + longitude: f64, + ) -> Result; + + /// Get count of location records + fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result; +} + +pub struct SqliteLocationHistoryDao { + connection: Arc>, +} + +impl Default for SqliteLocationHistoryDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteLocationHistoryDao { + pub fn new() -> Self { + SqliteLocationHistoryDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + fn serialize_vector(vec: &[f32]) -> Vec { + use zerocopy::IntoBytes; + vec.as_bytes().to_vec() + } + + /// Haversine distance calculation (in kilometers) + /// Used for filtering locations by proximity to a point + fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const R: f64 = 6371.0; // Earth radius in km + + let d_lat = (lat2 - lat1).to_radians(); + let d_lon = (lon2 - lon1).to_radians(); + + let a = (d_lat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lon / 2.0).sin().powi(2); + + let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); + + R * c + } + + /// Calculate approximate bounding box for spatial queries + /// Returns (min_lat, max_lat, min_lon, max_lon) + fn bounding_box(lat: f64, lon: f64, radius_km: f64) -> (f64, f64, f64, f64) { + const KM_PER_DEGREE_LAT: f64 = 111.0; + let km_per_degree_lon = 111.0 * lat.to_radians().cos(); + + let delta_lat = radius_km / KM_PER_DEGREE_LAT; + let delta_lon = radius_km / km_per_degree_lon; + + ( + lat - delta_lat, // min_lat + lat + delta_lat, // max_lat + lon - delta_lon, // min_lon + lon + delta_lon, // max_lon + ) + } +} + +#[derive(QueryableByName)] +struct LocationRecordRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::BigInt)] + timestamp: i64, + #[diesel(sql_type = diesel::sql_types::Float)] + latitude: f32, + #[diesel(sql_type = diesel::sql_types::Float)] + longitude: f32, + #[diesel(sql_type = diesel::sql_types::Nullable)] + accuracy: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + activity: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + activity_confidence: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + place_name: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + place_category: Option, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Nullable)] + source_file: Option, +} + +impl LocationRecordRow { + fn to_location_record(&self) -> LocationRecord { + LocationRecord { + id: self.id, + timestamp: self.timestamp, + latitude: self.latitude as f64, + longitude: self.longitude as f64, + accuracy: self.accuracy, + activity: self.activity.clone(), + activity_confidence: self.activity_confidence, + place_name: self.place_name.clone(), + place_category: self.place_category.clone(), + created_at: self.created_at, + source_file: self.source_file.clone(), + } + } +} + +#[derive(QueryableByName)] +struct LastInsertRowId { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, +} + +impl LocationHistoryDao for SqliteLocationHistoryDao { + fn store_location( + &mut self, + context: &opentelemetry::Context, + location: InsertLocationRecord, + ) -> Result { + trace_db_call(context, "insert", "store_location", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + // Validate embedding dimensions if provided (rare for location data) + if let Some(ref emb) = location.embedding { + if emb.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + emb.len() + )); + } + } + + let embedding_bytes = location + .embedding + .as_ref() + .map(|e| Self::serialize_vector(e)); + + // INSERT OR IGNORE to handle re-imports (UNIQUE constraint on timestamp+lat+lon) + diesel::sql_query( + "INSERT OR IGNORE INTO location_history + (timestamp, latitude, longitude, accuracy, activity, activity_confidence, + place_name, place_category, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + ) + .bind::(location.timestamp) + .bind::(location.latitude as f32) + .bind::(location.longitude as f32) + .bind::, _>(&location.accuracy) + .bind::, _>(&location.activity) + .bind::, _>( + &location.activity_confidence, + ) + .bind::, _>(&location.place_name) + .bind::, _>( + &location.place_category, + ) + .bind::, _>(&embedding_bytes) + .bind::(location.created_at) + .bind::, _>(&location.source_file) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; + + let row_id: i32 = diesel::sql_query("SELECT last_insert_rowid() as id") + .get_result::(conn.deref_mut()) + .map(|r| r.id) + .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))?; + + Ok(LocationRecord { + id: row_id, + timestamp: location.timestamp, + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.accuracy, + activity: location.activity, + activity_confidence: location.activity_confidence, + place_name: location.place_name, + place_category: location.place_category, + created_at: location.created_at, + source_file: location.source_file, + }) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn store_locations_batch( + &mut self, + context: &opentelemetry::Context, + locations: Vec, + ) -> Result { + trace_db_call(context, "insert", "store_locations_batch", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + let mut inserted = 0; + + conn.transaction::<_, anyhow::Error, _>(|conn| { + for location in locations { + // Validate embedding if provided (rare) + if let Some(ref emb) = location.embedding { + if emb.len() != 768 { + log::warn!( + "Skipping location with invalid embedding dimensions: {}", + emb.len() + ); + continue; + } + } + + let embedding_bytes = location + .embedding + .as_ref() + .map(|e| Self::serialize_vector(e)); + + let rows_affected = diesel::sql_query( + "INSERT OR IGNORE INTO location_history + (timestamp, latitude, longitude, accuracy, activity, activity_confidence, + place_name, place_category, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + ) + .bind::(location.timestamp) + .bind::(location.latitude as f32) + .bind::(location.longitude as f32) + .bind::, _>( + &location.accuracy, + ) + .bind::, _>( + &location.activity, + ) + .bind::, _>( + &location.activity_confidence, + ) + .bind::, _>( + &location.place_name, + ) + .bind::, _>( + &location.place_category, + ) + .bind::, _>( + &embedding_bytes, + ) + .bind::(location.created_at) + .bind::, _>( + &location.source_file, + ) + .execute(conn) + .map_err(|e| anyhow::anyhow!("Batch insert error: {:?}", e))?; + + if rows_affected > 0 { + inserted += 1; + } + } + Ok(()) + }) + .map_err(|e| anyhow::anyhow!("Transaction error: {:?}", e))?; + + Ok(inserted) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn find_nearest_location( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + max_time_diff_seconds: i64, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_nearest_location", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + let start_ts = timestamp - max_time_diff_seconds; + let end_ts = timestamp + max_time_diff_seconds; + + // Find location closest to target timestamp within window + let results = diesel::sql_query( + "SELECT id, timestamp, latitude, longitude, accuracy, activity, activity_confidence, + place_name, place_category, created_at, source_file + FROM location_history + WHERE timestamp >= ?1 AND timestamp <= ?2 + ORDER BY ABS(timestamp - ?3) ASC + LIMIT 1" + ) + .bind::(start_ts) + .bind::(end_ts) + .bind::(timestamp) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(results.into_iter().next().map(|r| r.to_location_record())) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_locations_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_locations_in_range", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + diesel::sql_query( + "SELECT id, timestamp, latitude, longitude, accuracy, activity, activity_confidence, + place_name, place_category, created_at, source_file + FROM location_history + WHERE timestamp >= ?1 AND timestamp <= ?2 + ORDER BY timestamp ASC" + ) + .bind::(start_ts) + .bind::(end_ts) + .load::(conn.deref_mut()) + .map(|rows| rows.into_iter().map(|r| r.to_location_record()).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_locations_near_point( + &mut self, + context: &opentelemetry::Context, + latitude: f64, + longitude: f64, + radius_km: f64, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_locations_near_point", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + // Use bounding box for initial filter (fast, indexed) + let (min_lat, max_lat, min_lon, max_lon) = + Self::bounding_box(latitude, longitude, radius_km); + + let results = diesel::sql_query( + "SELECT id, timestamp, latitude, longitude, accuracy, activity, activity_confidence, + place_name, place_category, created_at, source_file + FROM location_history + WHERE latitude >= ?1 AND latitude <= ?2 + AND longitude >= ?3 AND longitude <= ?4" + ) + .bind::(min_lat as f32) + .bind::(max_lat as f32) + .bind::(min_lon as f32) + .bind::(max_lon as f32) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + // Refine with Haversine distance (in-memory, post-filter) + let filtered: Vec = results + .into_iter() + .map(|r| r.to_location_record()) + .filter(|loc| { + let distance = + Self::haversine_distance(latitude, longitude, loc.latitude, loc.longitude); + distance <= radius_km + }) + .collect(); + + log::info!( + "Found {} locations within {} km of ({}, {})", + filtered.len(), + radius_km, + latitude, + longitude + ); + + Ok(filtered) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn location_exists( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + latitude: f64, + longitude: f64, + ) -> Result { + trace_db_call(context, "query", "location_exists", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::Integer)] + count: i32, + } + + let result: CountResult = diesel::sql_query( + "SELECT COUNT(*) as count FROM location_history + WHERE timestamp = ?1 AND latitude = ?2 AND longitude = ?3", + ) + .bind::(timestamp) + .bind::(latitude as f32) + .bind::(longitude as f32) + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count > 0) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result { + trace_db_call(context, "query", "get_location_count", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get LocationHistoryDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let result: CountResult = + diesel::sql_query("SELECT COUNT(*) as count FROM location_history") + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index d4f1b4e..43d078c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,15 +9,25 @@ use crate::database::models::{ }; use crate::otel::trace_db_call; +pub mod calendar_dao; pub mod daily_summary_dao; pub mod embeddings_dao; pub mod insights_dao; +pub mod location_dao; pub mod models; pub mod schema; +pub mod search_dao; +pub use calendar_dao::{ + CalendarEvent, CalendarEventDao, InsertCalendarEvent, SqliteCalendarEventDao, +}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding}; pub use insights_dao::{InsightDao, SqliteInsightDao}; +pub use location_dao::{ + InsertLocationRecord, LocationHistoryDao, LocationRecord, SqliteLocationHistoryDao, +}; +pub use search_dao::{InsertSearchRecord, SearchHistoryDao, SearchRecord, SqliteSearchHistoryDao}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; @@ -485,8 +495,8 @@ impl ExifDao for SqliteExifDao { // GPS bounding box if let Some((min_lat, max_lat, min_lon, max_lon)) = gps_bounds { query = query - .filter(gps_latitude.between(min_lat, max_lat)) - .filter(gps_longitude.between(min_lon, max_lon)) + .filter(gps_latitude.between(min_lat as f32, max_lat as f32)) + .filter(gps_longitude.between(min_lon as f32, max_lon as f32)) .filter(gps_latitude.is_not_null()) .filter(gps_longitude.is_not_null()); } diff --git a/src/database/models.rs b/src/database/models.rs index 9cee59b..79207a0 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -40,11 +40,11 @@ pub struct InsertImageExif { pub width: Option, pub height: Option, pub orientation: Option, - pub gps_latitude: Option, - pub gps_longitude: Option, - pub gps_altitude: Option, - pub focal_length: Option, - pub aperture: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub gps_altitude: Option, + pub focal_length: Option, + pub aperture: Option, pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, @@ -62,11 +62,11 @@ pub struct ImageExif { pub width: Option, pub height: Option, pub orientation: Option, - pub gps_latitude: Option, - pub gps_longitude: Option, - pub gps_altitude: Option, - pub focal_length: Option, - pub aperture: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, + pub gps_altitude: Option, + pub focal_length: Option, + pub aperture: Option, pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, diff --git a/src/database/schema.rs b/src/database/schema.rs index aa9a93e..75fe641 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -1,4 +1,37 @@ -table! { +// @generated automatically by Diesel CLI. + +diesel::table! { + calendar_events (id) { + id -> Integer, + event_uid -> Nullable, + summary -> Text, + description -> Nullable, + location -> Nullable, + start_time -> BigInt, + end_time -> BigInt, + all_day -> Bool, + organizer -> Nullable, + attendees -> Nullable, + embedding -> Nullable, + created_at -> BigInt, + source_file -> Nullable, + } +} + +diesel::table! { + daily_conversation_summaries (id) { + id -> Integer, + date -> Text, + contact -> Text, + summary -> Text, + message_count -> Integer, + embedding -> Binary, + created_at -> BigInt, + model_version -> Text, + } +} + +diesel::table! { favorites (id) { id -> Integer, userid -> Integer, @@ -6,7 +39,7 @@ table! { } } -table! { +diesel::table! { image_exif (id) { id -> Integer, file_path -> Text, @@ -16,11 +49,11 @@ table! { width -> Nullable, height -> Nullable, orientation -> Nullable, - gps_latitude -> Nullable, - gps_longitude -> Nullable, - gps_altitude -> Nullable, - focal_length -> Nullable, - aperture -> Nullable, + gps_latitude -> Nullable, + gps_longitude -> Nullable, + gps_altitude -> Nullable, + focal_length -> Nullable, + aperture -> Nullable, shutter_speed -> Nullable, iso -> Nullable, date_taken -> Nullable, @@ -29,24 +62,49 @@ table! { } } -table! { - tagged_photo (id) { +diesel::table! { + knowledge_embeddings (id) { id -> Integer, - photo_name -> Text, - tag_id -> Integer, - created_time -> BigInt, + keyword -> Text, + description -> Text, + category -> Nullable, + embedding -> Binary, + created_at -> BigInt, + model_version -> Text, } } -table! { - tags (id) { +diesel::table! { + location_history (id) { id -> Integer, - name -> Text, - created_time -> BigInt, + timestamp -> BigInt, + latitude -> Float, + longitude -> Float, + accuracy -> Nullable, + activity -> Nullable, + activity_confidence -> Nullable, + place_name -> Nullable, + place_category -> Nullable, + embedding -> Nullable, + created_at -> BigInt, + source_file -> Nullable, } } -table! { +diesel::table! { + message_embeddings (id) { + id -> Integer, + contact -> Text, + body -> Text, + timestamp -> BigInt, + is_sent -> Bool, + embedding -> Binary, + created_at -> BigInt, + model_version -> Text, + } +} + +diesel::table! { photo_insights (id) { id -> Integer, file_path -> Text, @@ -57,7 +115,36 @@ table! { } } -table! { +diesel::table! { + search_history (id) { + id -> Integer, + timestamp -> BigInt, + query -> Text, + search_engine -> Nullable, + embedding -> Binary, + created_at -> BigInt, + source_file -> Nullable, + } +} + +diesel::table! { + tagged_photo (id) { + id -> Integer, + photo_name -> Text, + tag_id -> Integer, + created_time -> BigInt, + } +} + +diesel::table! { + tags (id) { + id -> Integer, + name -> Text, + created_time -> BigInt, + } +} + +diesel::table! { users (id) { id -> Integer, username -> Text, @@ -65,12 +152,18 @@ table! { } } -joinable!(tagged_photo -> tags (tag_id)); +diesel::joinable!(tagged_photo -> tags (tag_id)); -allow_tables_to_appear_in_same_query!( +diesel::allow_tables_to_appear_in_same_query!( + calendar_events, + daily_conversation_summaries, favorites, image_exif, + knowledge_embeddings, + location_history, + message_embeddings, photo_insights, + search_history, tagged_photo, tags, users, diff --git a/src/database/search_dao.rs b/src/database/search_dao.rs new file mode 100644 index 0000000..9ae9ef7 --- /dev/null +++ b/src/database/search_dao.rs @@ -0,0 +1,516 @@ +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use serde::Serialize; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex}; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// Represents a search history record +#[derive(Serialize, Clone, Debug)] +pub struct SearchRecord { + pub id: i32, + pub timestamp: i64, + pub query: String, + pub search_engine: Option, + pub created_at: i64, + pub source_file: Option, +} + +/// Data for inserting a new search record +#[derive(Clone, Debug)] +pub struct InsertSearchRecord { + pub timestamp: i64, + pub query: String, + pub search_engine: Option, + pub embedding: Vec, // 768-dim, REQUIRED + pub created_at: i64, + pub source_file: Option, +} + +pub trait SearchHistoryDao: Sync + Send { + /// Store search with embedding + fn store_search( + &mut self, + context: &opentelemetry::Context, + search: InsertSearchRecord, + ) -> Result; + + /// Batch insert searches + fn store_searches_batch( + &mut self, + context: &opentelemetry::Context, + searches: Vec, + ) -> Result; + + /// Find searches in time range (for temporal context) + fn find_searches_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError>; + + /// Find semantically similar searches (PRIMARY - embeddings shine here) + fn find_similar_searches( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError>; + + /// Hybrid: Time window + semantic ranking + fn find_relevant_searches_hybrid( + &mut self, + context: &opentelemetry::Context, + center_timestamp: i64, + time_window_days: i64, + query_embedding: Option<&[f32]>, + limit: usize, + ) -> Result, DbError>; + + /// Deduplication check + fn search_exists( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + query: &str, + ) -> Result; + + /// Get count of search records + fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result; +} + +pub struct SqliteSearchHistoryDao { + connection: Arc>, +} + +impl Default for SqliteSearchHistoryDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteSearchHistoryDao { + pub fn new() -> Self { + SqliteSearchHistoryDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + fn serialize_vector(vec: &[f32]) -> Vec { + use zerocopy::IntoBytes; + vec.as_bytes().to_vec() + } + + fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { + if bytes.len() % 4 != 0 { + return Err(DbError::new(DbErrorKind::QueryError)); + } + + let count = bytes.len() / 4; + let mut vec = Vec::with_capacity(count); + + for chunk in bytes.chunks_exact(4) { + let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + vec.push(float); + } + + Ok(vec) + } + + fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if magnitude_a == 0.0 || magnitude_b == 0.0 { + return 0.0; + } + + dot_product / (magnitude_a * magnitude_b) + } +} + +#[derive(QueryableByName)] +struct SearchRecordWithVectorRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::BigInt)] + timestamp: i64, + #[diesel(sql_type = diesel::sql_types::Text)] + query: String, + #[diesel(sql_type = diesel::sql_types::Nullable)] + search_engine: Option, + #[diesel(sql_type = diesel::sql_types::Binary)] + embedding: Vec, + #[diesel(sql_type = diesel::sql_types::BigInt)] + created_at: i64, + #[diesel(sql_type = diesel::sql_types::Nullable)] + source_file: Option, +} + +impl SearchRecordWithVectorRow { + fn to_search_record(&self) -> SearchRecord { + SearchRecord { + id: self.id, + timestamp: self.timestamp, + query: self.query.clone(), + search_engine: self.search_engine.clone(), + created_at: self.created_at, + source_file: self.source_file.clone(), + } + } +} + +#[derive(QueryableByName)] +struct LastInsertRowId { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, +} + +impl SearchHistoryDao for SqliteSearchHistoryDao { + fn store_search( + &mut self, + context: &opentelemetry::Context, + search: InsertSearchRecord, + ) -> Result { + trace_db_call(context, "insert", "store_search", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + // Validate embedding dimensions (REQUIRED for searches) + if search.embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + search.embedding.len() + )); + } + + let embedding_bytes = Self::serialize_vector(&search.embedding); + + // INSERT OR IGNORE to handle re-imports (UNIQUE constraint on timestamp+query) + diesel::sql_query( + "INSERT OR IGNORE INTO search_history + (timestamp, query, search_engine, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind::(search.timestamp) + .bind::(&search.query) + .bind::, _>(&search.search_engine) + .bind::(&embedding_bytes) + .bind::(search.created_at) + .bind::, _>(&search.source_file) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; + + let row_id: i32 = diesel::sql_query("SELECT last_insert_rowid() as id") + .get_result::(conn.deref_mut()) + .map(|r| r.id) + .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))?; + + Ok(SearchRecord { + id: row_id, + timestamp: search.timestamp, + query: search.query, + search_engine: search.search_engine, + created_at: search.created_at, + source_file: search.source_file, + }) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn store_searches_batch( + &mut self, + context: &opentelemetry::Context, + searches: Vec, + ) -> Result { + trace_db_call(context, "insert", "store_searches_batch", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + let mut inserted = 0; + + conn.transaction::<_, anyhow::Error, _>(|conn| { + for search in searches { + // Validate embedding (REQUIRED) + if search.embedding.len() != 768 { + log::warn!( + "Skipping search with invalid embedding dimensions: {}", + search.embedding.len() + ); + continue; + } + + let embedding_bytes = Self::serialize_vector(&search.embedding); + + let rows_affected = diesel::sql_query( + "INSERT OR IGNORE INTO search_history + (timestamp, query, search_engine, embedding, created_at, source_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind::(search.timestamp) + .bind::(&search.query) + .bind::, _>( + &search.search_engine, + ) + .bind::(&embedding_bytes) + .bind::(search.created_at) + .bind::, _>( + &search.source_file, + ) + .execute(conn) + .map_err(|e| anyhow::anyhow!("Batch insert error: {:?}", e))?; + + if rows_affected > 0 { + inserted += 1; + } + } + Ok(()) + }) + .map_err(|e| anyhow::anyhow!("Transaction error: {:?}", e))?; + + Ok(inserted) + }) + .map_err(|_| DbError::new(DbErrorKind::InsertError)) + } + + fn find_searches_in_range( + &mut self, + context: &opentelemetry::Context, + start_ts: i64, + end_ts: i64, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_searches_in_range", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + diesel::sql_query( + "SELECT id, timestamp, query, search_engine, embedding, created_at, source_file + FROM search_history + WHERE timestamp >= ?1 AND timestamp <= ?2 + ORDER BY timestamp DESC", + ) + .bind::(start_ts) + .bind::(end_ts) + .load::(conn.deref_mut()) + .map(|rows| rows.into_iter().map(|r| r.to_search_record()).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_similar_searches( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_similar_searches", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + if query_embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_embedding.len() + )); + } + + // Load all searches with embeddings + let results = diesel::sql_query( + "SELECT id, timestamp, query, search_engine, embedding, created_at, source_file + FROM search_history", + ) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + // Compute similarities + let mut scored_searches: Vec<(f32, SearchRecord)> = results + .into_iter() + .filter_map(|row| { + if let Ok(emb) = Self::deserialize_vector(&row.embedding) { + let similarity = Self::cosine_similarity(query_embedding, &emb); + Some((similarity, row.to_search_record())) + } else { + None + } + }) + .collect(); + + // Sort by similarity descending + scored_searches + .sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + log::info!("Found {} similar searches", scored_searches.len()); + if !scored_searches.is_empty() { + log::info!( + "Top similarity: {:.4} for query: '{}'", + scored_searches[0].0, + scored_searches[0].1.query + ); + } + + Ok(scored_searches + .into_iter() + .take(limit) + .map(|(_, search)| search) + .collect()) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn find_relevant_searches_hybrid( + &mut self, + context: &opentelemetry::Context, + center_timestamp: i64, + time_window_days: i64, + query_embedding: Option<&[f32]>, + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_relevant_searches_hybrid", |_span| { + let window_seconds = time_window_days * 86400; + let start_ts = center_timestamp - window_seconds; + let end_ts = center_timestamp + window_seconds; + + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + // Step 1: Time-based filter (fast, indexed) + let searches_in_range = diesel::sql_query( + "SELECT id, timestamp, query, search_engine, embedding, created_at, source_file + FROM search_history + WHERE timestamp >= ?1 AND timestamp <= ?2", + ) + .bind::(start_ts) + .bind::(end_ts) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + // Step 2: If query embedding provided, rank by semantic similarity + if let Some(query_emb) = query_embedding { + if query_emb.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_emb.len() + )); + } + + let mut scored_searches: Vec<(f32, SearchRecord)> = searches_in_range + .into_iter() + .filter_map(|row| { + if let Ok(emb) = Self::deserialize_vector(&row.embedding) { + let similarity = Self::cosine_similarity(query_emb, &emb); + Some((similarity, row.to_search_record())) + } else { + None + } + }) + .collect(); + + // Sort by similarity descending + scored_searches + .sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + log::info!( + "Hybrid query: {} searches in time range, ranked by similarity", + scored_searches.len() + ); + if !scored_searches.is_empty() { + log::info!( + "Top similarity: {:.4} for '{}'", + scored_searches[0].0, + scored_searches[0].1.query + ); + } + + Ok(scored_searches + .into_iter() + .take(limit) + .map(|(_, search)| search) + .collect()) + } else { + // No semantic ranking, just return time-sorted (most recent first) + log::info!( + "Time-only query: {} searches in range", + searches_in_range.len() + ); + Ok(searches_in_range + .into_iter() + .take(limit) + .map(|r| r.to_search_record()) + .collect()) + } + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn search_exists( + &mut self, + context: &opentelemetry::Context, + timestamp: i64, + query: &str, + ) -> Result { + trace_db_call(context, "query", "search_exists", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::Integer)] + count: i32, + } + + let result: CountResult = diesel::sql_query( + "SELECT COUNT(*) as count FROM search_history WHERE timestamp = ?1 AND query = ?2", + ) + .bind::(timestamp) + .bind::(query) + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count > 0) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result { + trace_db_call(context, "query", "get_search_count", |_span| { + let mut conn = self + .connection + .lock() + .expect("Unable to get SearchHistoryDao"); + + #[derive(QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let result: CountResult = + diesel::sql_query("SELECT COUNT(*) as count FROM search_history") + .get_result(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + Ok(result.count) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } +} diff --git a/src/files.rs b/src/files.rs index 4d8c86c..785a219 100644 --- a/src/files.rs +++ b/src/files.rs @@ -217,7 +217,12 @@ pub async fn list_photos( if let (Some(photo_lat), Some(photo_lon)) = (exif.gps_latitude, exif.gps_longitude) { - let distance = haversine_distance(lat, lon, photo_lat, photo_lon); + let distance = haversine_distance( + lat as f64, + lon as f64, + photo_lat as f64, + photo_lon as f64, + ); distance <= radius_km } else { false diff --git a/src/lib.rs b/src/lib.rs index 90ba68a..bd4f7ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod files; pub mod geo; pub mod memories; pub mod otel; +pub mod parsers; pub mod service; pub mod state; pub mod tags; diff --git a/src/main.rs b/src/main.rs index 3be66d2..2702cd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -303,11 +303,11 @@ async fn upload_image( width: exif_data.width, height: exif_data.height, orientation: exif_data.orientation, - gps_latitude: exif_data.gps_latitude, - gps_longitude: exif_data.gps_longitude, - gps_altitude: exif_data.gps_altitude, - focal_length: exif_data.focal_length, - aperture: exif_data.aperture, + gps_latitude: exif_data.gps_latitude.map(|v| v as f32), + gps_longitude: exif_data.gps_longitude.map(|v| v as f32), + gps_altitude: exif_data.gps_altitude.map(|v| v as f32), + focal_length: exif_data.focal_length.map(|v| v as f32), + aperture: exif_data.aperture.map(|v| v as f32), shutter_speed: exif_data.shutter_speed, iso: exif_data.iso, date_taken: exif_data.date_taken, @@ -1061,11 +1061,11 @@ fn process_new_files( width: exif_data.width, height: exif_data.height, orientation: exif_data.orientation, - gps_latitude: exif_data.gps_latitude, - gps_longitude: exif_data.gps_longitude, - gps_altitude: exif_data.gps_altitude, - focal_length: exif_data.focal_length, - aperture: exif_data.aperture, + gps_latitude: exif_data.gps_latitude.map(|v| v as f32), + gps_longitude: exif_data.gps_longitude.map(|v| v as f32), + gps_altitude: exif_data.gps_altitude.map(|v| v as f32), + focal_length: exif_data.focal_length.map(|v| v as f32), + aperture: exif_data.aperture.map(|v| v as f32), shutter_speed: exif_data.shutter_speed, iso: exif_data.iso, date_taken: exif_data.date_taken, diff --git a/src/parsers/ical_parser.rs b/src/parsers/ical_parser.rs new file mode 100644 index 0000000..c2d0bff --- /dev/null +++ b/src/parsers/ical_parser.rs @@ -0,0 +1,183 @@ +use anyhow::{Context, Result}; +use chrono::NaiveDateTime; +use ical::parser::ical::component::IcalCalendar; +use ical::property::Property; +use std::fs::File; +use std::io::BufReader; + +#[derive(Debug, Clone)] +pub struct ParsedCalendarEvent { + pub event_uid: Option, + pub summary: String, + pub description: Option, + pub location: Option, + pub start_time: i64, + pub end_time: i64, + pub all_day: bool, + pub organizer: Option, + pub attendees: Vec, +} + +pub fn parse_ics_file(path: &str) -> Result> { + let file = File::open(path).context("Failed to open .ics file")?; + let reader = BufReader::new(file); + + let parser = ical::IcalParser::new(reader); + let mut events = Vec::new(); + + for calendar_result in parser { + let calendar: IcalCalendar = calendar_result.context("Failed to parse calendar")?; + + for event in calendar.events { + // Extract properties + let mut event_uid = None; + let mut summary = None; + let mut description = None; + let mut location = None; + let mut start_time = None; + let mut end_time = None; + let mut all_day = false; + let mut organizer = None; + let mut attendees = Vec::new(); + + for property in event.properties { + match property.name.as_str() { + "UID" => { + event_uid = property.value; + } + "SUMMARY" => { + summary = property.value; + } + "DESCRIPTION" => { + description = property.value; + } + "LOCATION" => { + location = property.value; + } + "DTSTART" => { + if let Some(ref value) = property.value { + start_time = parse_ical_datetime(value, &property)?; + // Check if it's an all-day event (no time component) + all_day = value.len() == 8; // YYYYMMDD format + } + } + "DTEND" => { + if let Some(ref value) = property.value { + end_time = parse_ical_datetime(value, &property)?; + } + } + "ORGANIZER" => { + organizer = extract_email_from_mailto(property.value.as_deref()); + } + "ATTENDEE" => { + if let Some(email) = extract_email_from_mailto(property.value.as_deref()) { + attendees.push(email); + } + } + _ => {} + } + } + + // Only include events with required fields + if let (Some(summary_text), Some(start), Some(end)) = (summary, start_time, end_time) { + events.push(ParsedCalendarEvent { + event_uid, + summary: summary_text, + description, + location, + start_time: start, + end_time: end, + all_day, + organizer, + attendees, + }); + } + } + } + + Ok(events) +} + +fn parse_ical_datetime(value: &str, property: &Property) -> Result> { + // Check for TZID parameter + let _tzid = property.params.as_ref().and_then(|params| { + params + .iter() + .find(|(key, _)| key == "TZID") + .and_then(|(_, values)| values.first()) + .cloned() + }); + + // iCal datetime formats: + // - 20240815T140000Z (UTC) + // - 20240815T140000 (local/TZID) + // - 20240815 (all-day) + + let cleaned = value.replace("Z", "").replace("T", ""); + + // All-day event (YYYYMMDD) + if cleaned.len() == 8 { + let dt = NaiveDateTime::parse_from_str(&format!("{}000000", cleaned), "%Y%m%d%H%M%S") + .context("Failed to parse all-day date")?; + return Ok(Some(dt.and_utc().timestamp())); + } + + // DateTime event (YYYYMMDDTHHMMSS) + if cleaned.len() >= 14 { + let dt = NaiveDateTime::parse_from_str(&cleaned[..14], "%Y%m%d%H%M%S") + .context("Failed to parse datetime")?; + + // If original had 'Z', it's UTC + let timestamp = if value.ends_with('Z') { + dt.and_utc().timestamp() + } else { + // Treat as UTC for simplicity (proper TZID handling is complex) + dt.and_utc().timestamp() + }; + + return Ok(Some(timestamp)); + } + + Ok(None) +} + +fn extract_email_from_mailto(value: Option<&str>) -> Option { + value.and_then(|v| { + // ORGANIZER and ATTENDEE often have format: mailto:user@example.com + if v.starts_with("mailto:") { + Some(v.trim_start_matches("mailto:").to_string()) + } else { + Some(v.to_string()) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ical_datetime() { + let prop = Property { + name: "DTSTART".to_string(), + params: None, + value: Some("20240815T140000Z".to_string()), + }; + + let timestamp = parse_ical_datetime("20240815T140000Z", &prop).unwrap(); + assert!(timestamp.is_some()); + } + + #[test] + fn test_extract_email() { + assert_eq!( + extract_email_from_mailto(Some("mailto:user@example.com")), + Some("user@example.com".to_string()) + ); + + assert_eq!( + extract_email_from_mailto(Some("user@example.com")), + Some("user@example.com".to_string()) + ); + } +} diff --git a/src/parsers/location_json_parser.rs b/src/parsers/location_json_parser.rs new file mode 100644 index 0000000..7ca6b87 --- /dev/null +++ b/src/parsers/location_json_parser.rs @@ -0,0 +1,133 @@ +use anyhow::{Context, Result}; +use chrono::DateTime; +use serde::Deserialize; +use std::fs::File; +use std::io::BufReader; + +#[derive(Debug, Clone)] +pub struct ParsedLocationRecord { + pub timestamp: i64, + pub latitude: f64, + pub longitude: f64, + pub accuracy: Option, + pub activity: Option, + pub activity_confidence: Option, +} + +// Google Takeout Location History JSON structures +#[derive(Debug, Deserialize)] +struct LocationHistory { + locations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocationPoint { + timestamp_ms: Option, // Older format + timestamp: Option, // Newer format (ISO8601) + latitude_e7: Option, + longitude_e7: Option, + accuracy: Option, + activity: Option>, +} + +#[derive(Debug, Deserialize)] +struct ActivityRecord { + activity: Vec, + timestamp_ms: Option, +} + +#[derive(Debug, Deserialize)] +struct ActivityType { + #[serde(rename = "type")] + activity_type: String, + confidence: i32, +} + +pub fn parse_location_json(path: &str) -> Result> { + let file = File::open(path).context("Failed to open location JSON file")?; + let reader = BufReader::new(file); + + let history: LocationHistory = + serde_json::from_reader(reader).context("Failed to parse location history JSON")?; + + let mut records = Vec::new(); + + for point in history.locations { + // Parse timestamp (try both formats) + let timestamp = if let Some(ts_ms) = point.timestamp_ms { + // Milliseconds since epoch + ts_ms + .parse::() + .context("Failed to parse timestamp_ms")? + / 1000 + } else if let Some(ts_iso) = point.timestamp { + // ISO8601 format + DateTime::parse_from_rfc3339(&ts_iso) + .context("Failed to parse ISO8601 timestamp")? + .timestamp() + } else { + continue; // Skip points without timestamp + }; + + // Convert E7 format to decimal degrees + let latitude = point.latitude_e7.map(|e7| e7 as f64 / 10_000_000.0); + let longitude = point.longitude_e7.map(|e7| e7 as f64 / 10_000_000.0); + + // Extract highest-confidence activity + let (activity, activity_confidence) = point + .activity + .as_ref() + .and_then(|activities| activities.first()) + .and_then(|record| { + record + .activity + .iter() + .max_by_key(|a| a.confidence) + .map(|a| (a.activity_type.clone(), a.confidence)) + }) + .unzip(); + + if let (Some(lat), Some(lon)) = (latitude, longitude) { + records.push(ParsedLocationRecord { + timestamp, + latitude: lat, + longitude: lon, + accuracy: point.accuracy, + activity, + activity_confidence, + }); + } + } + + Ok(records) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_e7_conversion() { + let lat_e7 = 374228300_i64; + let lat = lat_e7 as f64 / 10_000_000.0; + assert!((lat - 37.42283).abs() < 0.00001); + } + + #[test] + fn test_parse_sample_json() { + let json = r#"{ + "locations": [ + { + "latitudeE7": 374228300, + "longitudeE7": -1221086100, + "accuracy": 20, + "timestampMs": "1692115200000" + } + ] + }"#; + + let history: LocationHistory = serde_json::from_str(json).unwrap(); + assert_eq!(history.locations.len(), 1); + } +} diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs new file mode 100644 index 0000000..98dcea3 --- /dev/null +++ b/src/parsers/mod.rs @@ -0,0 +1,7 @@ +pub mod ical_parser; +pub mod location_json_parser; +pub mod search_html_parser; + +pub use ical_parser::{ParsedCalendarEvent, parse_ics_file}; +pub use location_json_parser::{ParsedLocationRecord, parse_location_json}; +pub use search_html_parser::{ParsedSearchRecord, parse_search_html}; diff --git a/src/parsers/search_html_parser.rs b/src/parsers/search_html_parser.rs new file mode 100644 index 0000000..4bcd166 --- /dev/null +++ b/src/parsers/search_html_parser.rs @@ -0,0 +1,210 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use scraper::{Html, Selector}; +use std::fs; + +#[derive(Debug, Clone)] +pub struct ParsedSearchRecord { + pub timestamp: i64, + pub query: String, + pub search_engine: Option, +} + +pub fn parse_search_html(path: &str) -> Result> { + let html_content = + fs::read_to_string(path).context("Failed to read search history HTML file")?; + + let document = Html::parse_document(&html_content); + let mut records = Vec::new(); + + // Try multiple selector strategies as Google Takeout format varies + + // Strategy 1: Look for specific cell structure + if let Ok(cell_selector) = Selector::parse("div.content-cell") { + for cell in document.select(&cell_selector) { + if let Some(record) = parse_content_cell(&cell) { + records.push(record); + } + } + } + + // Strategy 2: Look for outer-cell structure (older format) + if records.is_empty() { + if let Ok(outer_selector) = Selector::parse("div.outer-cell") { + for cell in document.select(&outer_selector) { + if let Some(record) = parse_outer_cell(&cell) { + records.push(record); + } + } + } + } + + // Strategy 3: Generic approach - look for links and timestamps + if records.is_empty() { + if let Ok(link_selector) = Selector::parse("a") { + for link in document.select(&link_selector) { + if let Some(href) = link.value().attr("href") { + // Check if it's a search URL + if href.contains("google.com/search?q=") || href.contains("search?q=") { + if let Some(query) = extract_query_from_url(href) { + // Try to find nearby timestamp + let timestamp = find_nearby_timestamp(&link); + + records.push(ParsedSearchRecord { + timestamp: timestamp.unwrap_or_else(|| Utc::now().timestamp()), + query, + search_engine: Some("Google".to_string()), + }); + } + } + } + } + } + } + + Ok(records) +} + +fn parse_content_cell(cell: &scraper::ElementRef) -> Option { + let link_selector = Selector::parse("a").ok()?; + + let link = cell.select(&link_selector).next()?; + let href = link.value().attr("href")?; + let query = extract_query_from_url(href)?; + + // Extract timestamp from cell text + let cell_text = cell.text().collect::>().join(" "); + let timestamp = parse_timestamp_from_text(&cell_text); + + Some(ParsedSearchRecord { + timestamp: timestamp.unwrap_or_else(|| Utc::now().timestamp()), + query, + search_engine: Some("Google".to_string()), + }) +} + +fn parse_outer_cell(cell: &scraper::ElementRef) -> Option { + let link_selector = Selector::parse("a").ok()?; + + let link = cell.select(&link_selector).next()?; + let href = link.value().attr("href")?; + let query = extract_query_from_url(href)?; + + let cell_text = cell.text().collect::>().join(" "); + let timestamp = parse_timestamp_from_text(&cell_text); + + Some(ParsedSearchRecord { + timestamp: timestamp.unwrap_or_else(|| Utc::now().timestamp()), + query, + search_engine: Some("Google".to_string()), + }) +} + +fn extract_query_from_url(url: &str) -> Option { + // Extract query parameter from URL + // Example: https://www.google.com/search?q=rust+programming + + if let Some(query_start) = url.find("?q=").or_else(|| url.find("&q=")) { + let query_part = &url[query_start + 3..]; + let query_end = query_part.find('&').unwrap_or(query_part.len()); + let encoded_query = &query_part[..query_end]; + + // URL decode + urlencoding::decode(encoded_query) + .ok() + .map(|s| s.to_string()) + } else { + None + } +} + +fn find_nearby_timestamp(element: &scraper::ElementRef) -> Option { + // Look for timestamp in parent or sibling elements + if let Some(parent) = element.parent() { + if parent.value().as_element().is_some() { + let parent_ref = scraper::ElementRef::wrap(parent)?; + let text = parent_ref.text().collect::>().join(" "); + return parse_timestamp_from_text(&text); + } + } + None +} + +fn parse_timestamp_from_text(text: &str) -> Option { + // Google Takeout timestamps often look like: + // "Aug 15, 2024, 2:34:56 PM PDT" + // "2024-08-15T14:34:56Z" + + // Try ISO8601 first + if let Some(iso_match) = text + .split_whitespace() + .find(|s| s.contains('T') && s.contains('-')) + { + if let Ok(dt) = DateTime::parse_from_rfc3339(iso_match) { + return Some(dt.timestamp()); + } + } + + // Try common date patterns + let patterns = [ + "%b %d, %Y, %I:%M:%S %p", // Aug 15, 2024, 2:34:56 PM + "%Y-%m-%d %H:%M:%S", // 2024-08-15 14:34:56 + "%m/%d/%Y %H:%M:%S", // 08/15/2024 14:34:56 + ]; + + for pattern in patterns { + // Extract potential date string + if let Some(date_part) = extract_date_substring(text) { + if let Ok(dt) = NaiveDateTime::parse_from_str(&date_part, pattern) { + return Some(dt.and_utc().timestamp()); + } + } + } + + None +} + +fn extract_date_substring(text: &str) -> Option { + // Try to extract date-like substring from text + // This is a heuristic approach for varied formats + + // Look for patterns like "Aug 15, 2024, 2:34:56 PM" + if let Some(pos) = text.find(|c: char| c.is_numeric()) { + let rest = &text[pos..]; + if let Some(end) = + rest.find(|c: char| !c.is_alphanumeric() && c != ':' && c != ',' && c != ' ') + { + Some(rest[..end].trim().to_string()) + } else { + Some(rest.trim().to_string()) + } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_query_from_url() { + let url = "https://www.google.com/search?q=rust+programming&oq=rust"; + let query = extract_query_from_url(url); + assert_eq!(query, Some("rust programming".to_string())); + } + + #[test] + fn test_extract_query_with_encoding() { + let url = "https://www.google.com/search?q=hello%20world"; + let query = extract_query_from_url(url); + assert_eq!(query, Some("hello world".to_string())); + } + + #[test] + fn test_parse_iso_timestamp() { + let text = "Some text 2024-08-15T14:34:56Z more text"; + let timestamp = parse_timestamp_from_text(text); + assert!(timestamp.is_some()); + } +} -- 2.49.1 From cd66521c17d2b384c2272cd82de26380286ea649 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 5 Jan 2026 14:57:31 -0500 Subject: [PATCH 10/25] Phase 3: Integrate Google Takeout context into InsightGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated InsightGenerator struct with calendar, location, and search DAOs - Implemented hybrid context gathering methods: * gather_calendar_context(): ±7 days with semantic ranking * gather_location_context(): ±30 min with GPS proximity check * gather_search_context(): ±30 days semantic search - Added haversine_distance() utility for GPS calculations - Updated generate_insight_for_photo_with_model() to use multi-source context - Combined all context sources (SMS + Calendar + Location + Search) with equal weight - Initialized new DAOs in AppState (both default and test implementations) - All contexts are optional (graceful degradation if data missing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/ai/insight_generator.rs | 302 +++++++++++++++++++++++++++++++++++- src/database/mod.rs | 6 +- src/state.rs | 30 +++- 3 files changed, 327 insertions(+), 11 deletions(-) diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index b47b8cf..e33d262 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; -use crate::database::{DailySummaryDao, ExifDao, InsightDao}; +use crate::database::{CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, LocationHistoryDao, SearchHistoryDao}; use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; use crate::utils::normalize_path; @@ -35,6 +35,12 @@ pub struct InsightGenerator { insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, + + // Google Takeout data sources + calendar_dao: Arc>>, + location_dao: Arc>>, + search_dao: Arc>>, + base_path: String, } @@ -45,6 +51,9 @@ impl InsightGenerator { insight_dao: Arc>>, exif_dao: Arc>>, daily_summary_dao: Arc>>, + calendar_dao: Arc>>, + location_dao: Arc>>, + search_dao: Arc>>, base_path: String, ) -> Self { Self { @@ -53,6 +62,9 @@ impl InsightGenerator { insight_dao, exif_dao, daily_summary_dao, + calendar_dao, + location_dao, + search_dao, base_path, } } @@ -249,6 +261,249 @@ impl InsightGenerator { Ok(formatted) } + /// Haversine distance calculation for GPS proximity (in kilometers) + fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const R: f64 = 6371.0; // Earth radius in km + let d_lat = (lat2 - lat1).to_radians(); + let d_lon = (lon2 - lon1).to_radians(); + let a = (d_lat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lon / 2.0).sin().powi(2); + R * 2.0 * a.sqrt().atan2((1.0 - a).sqrt()) + } + + /// Gather calendar context for photo timestamp + /// Uses hybrid time + semantic search (±7 days, ranked by relevance) + async fn gather_calendar_context( + &self, + parent_cx: &opentelemetry::Context, + timestamp: i64, + location: Option<&str>, + ) -> Result> { + let tracer = global_tracer(); + let span = tracer.start_with_context("ai.context.calendar", parent_cx); + let calendar_cx = parent_cx.with_span(span); + + let query_embedding = if let Some(loc) = location { + match self.ollama.generate_embedding(loc).await { + Ok(emb) => Some(emb), + Err(e) => { + log::warn!("Failed to generate embedding for location '{}': {}", loc, e); + None + } + } + } else { + None + }; + + let events = { + let mut dao = self.calendar_dao.lock().expect("Unable to lock CalendarEventDao"); + dao.find_relevant_events_hybrid( + &calendar_cx, + timestamp, + 7, // ±7 days window + query_embedding.as_deref(), + 5, // Top 5 events + ) + .ok() + }; + + calendar_cx.span().set_status(Status::Ok); + + if let Some(events) = events { + if events.is_empty() { + return Ok(None); + } + + let formatted = events + .iter() + .map(|e| { + let date = DateTime::from_timestamp(e.start_time, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let attendees = e.attendees + .as_ref() + .and_then(|a| serde_json::from_str::>(a).ok()) + .map(|list| format!(" (with {})", list.join(", "))) + .unwrap_or_default(); + + format!("[{}] {}{}", date, e.summary, attendees) + }) + .collect::>() + .join("\n"); + + Ok(Some(format!("Calendar events:\n{}", formatted))) + } else { + Ok(None) + } + } + + /// Gather location context for photo timestamp + /// Finds nearest location record (±30 minutes) + async fn gather_location_context( + &self, + parent_cx: &opentelemetry::Context, + timestamp: i64, + exif_gps: Option<(f64, f64)>, + ) -> Result> { + let tracer = global_tracer(); + let span = tracer.start_with_context("ai.context.location", parent_cx); + let location_cx = parent_cx.with_span(span); + + let nearest = { + let mut dao = self.location_dao.lock().expect("Unable to lock LocationHistoryDao"); + dao.find_nearest_location( + &location_cx, + timestamp, + 1800, // ±30 minutes + ) + .ok() + .flatten() + }; + + location_cx.span().set_status(Status::Ok); + + if let Some(loc) = nearest { + // Check if this adds NEW information compared to EXIF + if let Some((exif_lat, exif_lon)) = exif_gps { + let distance = Self::haversine_distance( + exif_lat, + exif_lon, + loc.latitude, + loc.longitude, + ); + + // Only use if it's significantly different (>100m) or EXIF lacks GPS + if distance < 0.1 { + log::info!("Location history matches EXIF GPS ({}m), skipping", (distance * 1000.0) as i32); + return Ok(None); + } + } + + let activity = loc.activity + .as_ref() + .map(|a| format!(" ({})", a)) + .unwrap_or_default(); + + let place = loc.place_name + .as_ref() + .map(|p| format!(" at {}", p)) + .unwrap_or_default(); + + Ok(Some(format!( + "Location history: You were{}{}{}", + activity, + place, + if activity.is_empty() && place.is_empty() { + format!(" near {:.4}, {:.4}", loc.latitude, loc.longitude) + } else { + String::new() + } + ))) + } else { + Ok(None) + } + } + + /// Gather search context for photo date + /// Uses semantic search on queries (±30 days, top 5 relevant) + async fn gather_search_context( + &self, + parent_cx: &opentelemetry::Context, + timestamp: i64, + location: Option<&str>, + contact: Option<&str>, + ) -> Result> { + let tracer = global_tracer(); + let span = tracer.start_with_context("ai.context.search", parent_cx); + let search_cx = parent_cx.with_span(span); + + // Build semantic query from metadata + let query_text = format!( + "searches about {} {} {}", + DateTime::from_timestamp(timestamp, 0) + .map(|dt| dt.format("%B %Y").to_string()) + .unwrap_or_else(|| "".to_string()), + location.unwrap_or(""), + contact.map(|c| format!("involving {}", c)).unwrap_or_default() + ); + + let query_embedding = match self.ollama.generate_embedding(&query_text).await { + Ok(emb) => emb, + Err(e) => { + log::warn!("Failed to generate search embedding: {}", e); + search_cx.span().set_status(Status::Error { + description: e.to_string().into(), + }); + return Ok(None); + } + }; + + let searches = { + let mut dao = self.search_dao.lock().expect("Unable to lock SearchHistoryDao"); + dao.find_relevant_searches_hybrid( + &search_cx, + timestamp, + 30, // ±30 days (wider window than calendar) + Some(&query_embedding), + 5, // Top 5 searches + ) + .ok() + }; + + search_cx.span().set_status(Status::Ok); + + if let Some(searches) = searches { + if searches.is_empty() { + return Ok(None); + } + + let formatted = searches + .iter() + .map(|s| { + let date = DateTime::from_timestamp(s.timestamp, 0) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".to_string()); + format!("[{}] \"{}\"", date, s.query) + }) + .collect::>() + .join("\n"); + + Ok(Some(format!("Search history:\n{}", formatted))) + } else { + Ok(None) + } + } + + /// Combine all context sources with equal weight + fn combine_contexts( + sms: Option, + calendar: Option, + location: Option, + search: Option, + ) -> String { + let mut parts = Vec::new(); + + if let Some(s) = sms { + parts.push(format!("## Messages\n{}", s)); + } + if let Some(c) = calendar { + parts.push(format!("## Calendar\n{}", c)); + } + if let Some(l) = location { + parts.push(format!("## Location\n{}", l)); + } + if let Some(s) = search { + parts.push(format!("## Searches\n{}", s)); + } + + if parts.is_empty() { + "No additional context available".to_string() + } else { + parts.join("\n\n") + } + } + /// Generate AI insight for a single photo with optional custom model pub async fn generate_insight_for_photo_with_model( &self, @@ -525,13 +780,50 @@ impl InsightGenerator { retrieval_method ); - // 7. Generate title and summary with Ollama + // 6. Gather Google Takeout context from all sources + let calendar_context = self + .gather_calendar_context(&insight_cx, timestamp, location.as_deref()) + .await + .ok() + .flatten(); + + let exif_gps = exif.as_ref().and_then(|e| { + if let (Some(lat), Some(lon)) = (e.gps_latitude, e.gps_longitude) { + Some((lat as f64, lon as f64)) + } else { + None + } + }); + + let location_context = self + .gather_location_context(&insight_cx, timestamp, exif_gps) + .await + .ok() + .flatten(); + + let search_context = self + .gather_search_context(&insight_cx, timestamp, location.as_deref(), contact.as_deref()) + .await + .ok() + .flatten(); + + // 7. Combine all context sources with equal weight + let combined_context = Self::combine_contexts( + sms_summary, + calendar_context, + location_context, + search_context, + ); + + log::info!("Combined context from all sources ({} chars)", combined_context.len()); + + // 8. Generate title and summary with Ollama (using multi-source context) let title = ollama_client - .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) + .generate_photo_title(date_taken, location.as_deref(), Some(&combined_context)) .await?; let summary = ollama_client - .generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref()) + .generate_photo_summary(date_taken, location.as_deref(), Some(&combined_context)) .await?; log::info!("Generated title: {}", title); @@ -544,7 +836,7 @@ impl InsightGenerator { .span() .set_attribute(KeyValue::new("summary_length", summary.len() as i64)); - // 8. Store in database + // 9. Store in database let insight = InsertPhotoInsight { file_path: file_path.to_string(), title, diff --git a/src/database/mod.rs b/src/database/mod.rs index 43d078c..8e4f52d 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -19,15 +19,15 @@ pub mod schema; pub mod search_dao; pub use calendar_dao::{ - CalendarEvent, CalendarEventDao, InsertCalendarEvent, SqliteCalendarEventDao, + CalendarEventDao, SqliteCalendarEventDao, }; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding}; pub use insights_dao::{InsightDao, SqliteInsightDao}; pub use location_dao::{ - InsertLocationRecord, LocationHistoryDao, LocationRecord, SqliteLocationHistoryDao, + LocationHistoryDao, SqliteLocationHistoryDao, }; -pub use search_dao::{InsertSearchRecord, SearchHistoryDao, SearchRecord, SqliteSearchHistoryDao}; +pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/state.rs b/src/state.rs index 50922d2..f744715 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,8 @@ use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; use crate::database::{ - DailySummaryDao, ExifDao, InsightDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, + CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, LocationHistoryDao, SearchHistoryDao, + SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, + SqliteLocationHistoryDao, SqliteSearchHistoryDao, }; use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager}; use actix::{Actor, Addr}; @@ -96,16 +98,27 @@ impl Default for AppState { let daily_summary_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); + // Initialize Google Takeout DAOs + let calendar_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteCalendarEventDao::new()))); + let location_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteLocationHistoryDao::new()))); + let search_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteSearchHistoryDao::new()))); + // Load base path let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); - // Initialize InsightGenerator + // Initialize InsightGenerator with all data sources let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), insight_dao.clone(), exif_dao.clone(), daily_summary_dao.clone(), + calendar_dao.clone(), + location_dao.clone(), + search_dao.clone(), base_path.clone(), ); @@ -155,7 +168,15 @@ impl AppState { let daily_summary_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); - // Initialize test InsightGenerator + // Initialize test Google Takeout DAOs + let calendar_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteCalendarEventDao::new()))); + let location_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteLocationHistoryDao::new()))); + let search_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteSearchHistoryDao::new()))); + + // Initialize test InsightGenerator with all data sources let base_path_str = base_path.to_string_lossy().to_string(); let insight_generator = InsightGenerator::new( ollama.clone(), @@ -163,6 +184,9 @@ impl AppState { insight_dao.clone(), exif_dao.clone(), daily_summary_dao.clone(), + calendar_dao.clone(), + location_dao.clone(), + search_dao.clone(), base_path_str.clone(), ); -- 2.49.1 From 61e10f76784be8acd8ae71ff90a62f97b61863c8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 8 Jan 2026 13:41:08 -0500 Subject: [PATCH 11/25] Improve Exif Query path handling --- src/database/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/database/mod.rs b/src/database/mod.rs index 8e4f52d..cec38bb 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -356,8 +356,13 @@ impl ExifDao for SqliteExifDao { let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + // Try both normalized (forward slash) and Windows (backslash) paths + // since database may contain either format + let normalized = path.replace('\\', "/"); + let windows_path = path.replace('/', "\\"); + match image_exif - .filter(file_path.eq(path)) + .filter(file_path.eq(&normalized).or(file_path.eq(&windows_path))) .first::(connection.deref_mut()) { Ok(exif) => Ok(Some(exif)), -- 2.49.1 From 084994e0b552021318848230e277c71e23d75414 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 8 Jan 2026 13:41:32 -0500 Subject: [PATCH 12/25] Daily Summary Embedding Testing --- src/ai/daily_summary_job.rs | 117 ++++++++++-- src/ai/insight_generator.rs | 256 +++++++++++++++++++-------- src/ai/mod.rs | 2 +- src/ai/sms_client.rs | 8 +- src/bin/diagnose_embeddings.rs | 282 +++++++++++++++++++++++++++++ src/bin/test_daily_summary.rs | 285 ++++++++++++++++++++++++++++++ src/database/daily_summary_dao.rs | 148 +++++++++++++++- src/database/mod.rs | 8 +- 8 files changed, 1000 insertions(+), 106 deletions(-) create mode 100644 src/bin/diagnose_embeddings.rs create mode 100644 src/bin/test_daily_summary.rs diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs index b587750..182b96e 100644 --- a/src/ai/daily_summary_job.rs +++ b/src/ai/daily_summary_job.rs @@ -10,6 +10,75 @@ use crate::ai::{OllamaClient, SmsApiClient, SmsMessage}; use crate::database::{DailySummaryDao, InsertDailySummary}; use crate::otel::global_tracer; +/// Strip boilerplate prefixes and common phrases from summaries before embedding. +/// This improves embedding diversity by removing structural similarity. +pub fn strip_summary_boilerplate(summary: &str) -> String { + let mut text = summary.trim().to_string(); + + // Remove markdown headers + while text.starts_with('#') { + if let Some(pos) = text.find('\n') { + text = text[pos..].trim_start().to_string(); + } else { + // Single line with just headers, try to extract content after #s + text = text.trim_start_matches('#').trim().to_string(); + break; + } + } + + // Remove "Summary:" prefix variations (with optional markdown bold) + let prefixes = [ + "**Summary:**", + "**Summary**:", + "*Summary:*", + "Summary:", + "**summary:**", + "summary:", + ]; + for prefix in prefixes { + if text.to_lowercase().starts_with(&prefix.to_lowercase()) { + text = text[prefix.len()..].trim_start().to_string(); + break; + } + } + + // Remove common opening phrases that add no semantic value + let opening_phrases = [ + "Today, Melissa and I discussed", + "Today, Amanda and I discussed", + "Today Melissa and I discussed", + "Today Amanda and I discussed", + "Melissa and I discussed", + "Amanda and I discussed", + "Today, I discussed", + "Today I discussed", + "The conversation covered", + "This conversation covered", + "In this conversation,", + "During this conversation,", + ]; + + for phrase in opening_phrases { + if text.to_lowercase().starts_with(&phrase.to_lowercase()) { + text = text[phrase.len()..].trim_start().to_string(); + // Remove leading punctuation/articles after stripping phrase + text = text.trim_start_matches(|c| c == ',' || c == ':' || c == '-').trim_start().to_string(); + break; + } + } + + // Remove any remaining leading markdown bold markers + if text.starts_with("**") { + if let Some(end) = text[2..].find("**") { + // Keep the content between ** but remove the markers + let bold_content = &text[2..2 + end]; + text = format!("{}{}", bold_content, &text[4 + end..]); + } + } + + text.trim().to_string() +} + /// Generate and embed daily conversation summaries for a date range /// Default: August 2024 ±30 days (July 1 - September 30, 2024) pub async fn generate_daily_summaries( @@ -238,22 +307,34 @@ async fn generate_and_store_daily_summary( let weekday = date.format("%A"); let prompt = format!( - r#"Summarize this day's conversation in 3-5 sentences. Focus on: -- Key topics, activities, and events discussed -- Places, people, or organizations mentioned -- Plans made or decisions discussed -- Overall mood or themes of the day + r#"Summarize this day's conversation between me and {}. -IMPORTANT: Clearly distinguish between what "I" or "Me" did versus what {} did. -Always explicitly attribute actions, plans, and activities to the correct person. -Use "I" or "Me" for my actions and "{}" for their actions. +CRITICAL FORMAT RULES: +- Do NOT start with "Based on the conversation..." or "Here is a summary..." or similar preambles +- Do NOT repeat the date at the beginning +- Start DIRECTLY with the content - begin with a person's name or action +- Write in past tense, as if recording what happened + +NARRATIVE (3-5 sentences): +- What specific topics, activities, or events were discussed? +- What places, people, or organizations were mentioned? +- What plans were made or decisions discussed? +- Clearly distinguish between what "I" did versus what {} did + +KEYWORDS (comma-separated): +5-10 specific keywords that capture this conversation's unique content: +- Proper nouns (people, places, brands) +- Specific activities ("drum corps audition" not just "music") +- Distinctive terms that make this day unique Date: {} ({}) Messages: {} -Write a natural, informative summary with clear subject attribution. -Summary:"#, +YOUR RESPONSE (follow this format EXACTLY): +Summary: [Start directly with content, NO preamble] + +Keywords: [specific, unique terms]"#, contact, contact, date.format("%B %d, %Y"), @@ -265,7 +346,7 @@ Summary:"#, let summary = ollama .generate( &prompt, - Some("You are a conversation summarizer. Create clear, factual summaries that maintain precise subject attribution - clearly distinguishing who said or did what."), + Some("You are a conversation summarizer. Create clear, factual summaries with precise subject attribution AND extract distinctive keywords. Focus on specific, unique terms that differentiate this conversation from others."), ) .await?; @@ -277,8 +358,15 @@ Summary:"#, span.set_attribute(KeyValue::new("summary_length", summary.len() as i64)); - // Embed the summary - let embedding = ollama.generate_embedding(&summary).await?; + // Strip boilerplate before embedding to improve vector diversity + let stripped_summary = strip_summary_boilerplate(&summary); + log::debug!( + "Stripped summary for embedding: {}", + stripped_summary.chars().take(100).collect::() + ); + + // Embed the stripped summary (store original summary in DB) + let embedding = ollama.generate_embedding(&stripped_summary).await?; span.set_attribute(KeyValue::new( "embedding_dimensions", @@ -293,7 +381,8 @@ Summary:"#, message_count: messages.len() as i32, embedding, created_at: Utc::now().timestamp(), - model_version: "nomic-embed-text:v1.5".to_string(), + // model_version: "nomic-embed-text:v1.5".to_string(), + model_version: "mxbai-embed-large:335m".to_string(), }; // Create context from current span for DB operation diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index e33d262..7d8acf3 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -9,7 +9,9 @@ use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; -use crate::database::{CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, LocationHistoryDao, SearchHistoryDao}; +use crate::database::{ + CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, LocationHistoryDao, SearchHistoryDao, +}; use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; use crate::utils::normalize_path; @@ -98,6 +100,7 @@ impl InsightGenerator { date: chrono::NaiveDate, location: Option<&str>, contact: Option<&str>, + topics: Option<&[String]>, limit: usize, ) -> Result> { let tracer = global_tracer(); @@ -113,9 +116,14 @@ impl InsightGenerator { filter_cx .span() .set_attribute(KeyValue::new("exclusion_window_days", 30)); + if let Some(t) = topics { + filter_cx + .span() + .set_attribute(KeyValue::new("topics", t.join(", "))); + } let query_results = self - .find_relevant_messages_rag(date, location, contact, limit * 2) + .find_relevant_messages_rag(date, location, contact, topics, limit * 2) .await?; filter_cx.span().set_attribute(KeyValue::new( @@ -177,6 +185,7 @@ impl InsightGenerator { date: chrono::NaiveDate, location: Option<&str>, contact: Option<&str>, + topics: Option<&[String]>, limit: usize, ) -> Result> { let tracer = global_tracer(); @@ -191,27 +200,24 @@ impl InsightGenerator { span.set_attribute(KeyValue::new("contact", c.to_string())); } - // Build more detailed query string from photo context - let mut query_parts = Vec::new(); - - // Add temporal context - query_parts.push(format!("On {}", date.format("%B %d, %Y"))); - - // Add location if available - if let Some(loc) = location { - query_parts.push(format!("at {}", loc)); - } - - // Add contact context if available - if let Some(c) = contact { - query_parts.push(format!("conversation with {}", c)); - } - - // Add day of week for temporal context - let weekday = date.format("%A"); - query_parts.push(format!("it was a {}", weekday)); - - let query = query_parts.join(", "); + // Build query string - prioritize topics if available (semantically meaningful) + let query = if let Some(topics) = topics { + if !topics.is_empty() { + // Use topics for semantic search - these are actual content keywords + let topic_str = topics.join(", "); + if let Some(c) = contact { + format!("Conversations about {} with {}", topic_str, c) + } else { + format!("Conversations about {}", topic_str) + } + } else { + // Fallback to metadata-based query + Self::build_metadata_query(date, location, contact) + } + } else { + // Fallback to metadata-based query + Self::build_metadata_query(date, location, contact) + }; span.set_attribute(KeyValue::new("query", query.clone())); @@ -225,14 +231,16 @@ impl InsightGenerator { // Generate embedding for the query let query_embedding = self.ollama.generate_embedding(&query).await?; - // Search for similar daily summaries + // Search for similar daily summaries with time-based weighting + // This prioritizes summaries temporally close to the query date let mut summary_dao = self .daily_summary_dao .lock() .expect("Unable to lock DailySummaryDao"); + let date_str = date.format("%Y-%m-%d").to_string(); let similar_summaries = summary_dao - .find_similar_summaries(&search_cx, &query_embedding, limit) + .find_similar_summaries_with_time_weight(&search_cx, &query_embedding, &date_str, limit) .map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?; log::info!( @@ -261,6 +269,34 @@ impl InsightGenerator { Ok(formatted) } + /// Build a metadata-based query (fallback when no topics available) + fn build_metadata_query( + date: chrono::NaiveDate, + location: Option<&str>, + contact: Option<&str>, + ) -> String { + let mut query_parts = Vec::new(); + + // Add temporal context + query_parts.push(format!("On {}", date.format("%B %d, %Y"))); + + // Add location if available + if let Some(loc) = location { + query_parts.push(format!("at {}", loc)); + } + + // Add contact context if available + if let Some(c) = contact { + query_parts.push(format!("conversation with {}", c)); + } + + // Add day of week for temporal context + let weekday = date.format("%A"); + query_parts.push(format!("it was a {}", weekday)); + + query_parts.join(", ") + } + /// Haversine distance calculation for GPS proximity (in kilometers) fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { const R: f64 = 6371.0; // Earth radius in km @@ -296,7 +332,10 @@ impl InsightGenerator { }; let events = { - let mut dao = self.calendar_dao.lock().expect("Unable to lock CalendarEventDao"); + let mut dao = self + .calendar_dao + .lock() + .expect("Unable to lock CalendarEventDao"); dao.find_relevant_events_hybrid( &calendar_cx, timestamp, @@ -321,7 +360,8 @@ impl InsightGenerator { .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()); - let attendees = e.attendees + let attendees = e + .attendees .as_ref() .and_then(|a| serde_json::from_str::>(a).ok()) .map(|list| format!(" (with {})", list.join(", "))) @@ -351,11 +391,14 @@ impl InsightGenerator { let location_cx = parent_cx.with_span(span); let nearest = { - let mut dao = self.location_dao.lock().expect("Unable to lock LocationHistoryDao"); + let mut dao = self + .location_dao + .lock() + .expect("Unable to lock LocationHistoryDao"); dao.find_nearest_location( &location_cx, timestamp, - 1800, // ±30 minutes + 10800, // ±3 hours (more realistic for photo timing) ) .ok() .flatten() @@ -366,26 +409,33 @@ impl InsightGenerator { if let Some(loc) = nearest { // Check if this adds NEW information compared to EXIF if let Some((exif_lat, exif_lon)) = exif_gps { - let distance = Self::haversine_distance( - exif_lat, - exif_lon, - loc.latitude, - loc.longitude, - ); + let distance = + Self::haversine_distance(exif_lat, exif_lon, loc.latitude, loc.longitude); - // Only use if it's significantly different (>100m) or EXIF lacks GPS - if distance < 0.1 { - log::info!("Location history matches EXIF GPS ({}m), skipping", (distance * 1000.0) as i32); + // Skip only if very close AND no useful activity/place info + // Allow activity context even if coordinates match + if distance < 0.5 && loc.activity.is_none() && loc.place_name.is_none() { + log::debug!( + "Location history matches EXIF GPS ({}m) with no extra context, skipping", + (distance * 1000.0) as i32 + ); return Ok(None); + } else if distance < 0.5 { + log::debug!( + "Location history close to EXIF ({}m) but has activity/place info", + (distance * 1000.0) as i32 + ); } } - let activity = loc.activity + let activity = loc + .activity .as_ref() .map(|a| format!(" ({})", a)) .unwrap_or_default(); - let place = loc.place_name + let place = loc + .place_name .as_ref() .map(|p| format!(" at {}", p)) .unwrap_or_default(); @@ -425,7 +475,9 @@ impl InsightGenerator { .map(|dt| dt.format("%B %Y").to_string()) .unwrap_or_else(|| "".to_string()), location.unwrap_or(""), - contact.map(|c| format!("involving {}", c)).unwrap_or_default() + contact + .map(|c| format!("involving {}", c)) + .unwrap_or_default() ); let query_embedding = match self.ollama.generate_embedding(&query_text).await { @@ -440,7 +492,10 @@ impl InsightGenerator { }; let searches = { - let mut dao = self.search_dao.lock().expect("Unable to lock SearchHistoryDao"); + let mut dao = self + .search_dao + .lock() + .expect("Unable to lock SearchHistoryDao"); dao.find_relevant_searches_hybrid( &search_cx, timestamp, @@ -455,6 +510,10 @@ impl InsightGenerator { if let Some(searches) = searches { if searches.is_empty() { + log::warn!( + "No relevant searches found for photo timestamp {}", + timestamp + ); return Ok(None); } @@ -599,8 +658,16 @@ impl InsightGenerator { insight_cx .span() .set_attribute(KeyValue::new("location", l.clone())); + Some(l.clone()) + } else { + // Fallback: If reverse geocoding fails, use coordinates + log::warn!( + "Reverse geocoding failed for {}, {}, using coordinates as fallback", + lat, + lon + ); + Some(format!("{:.4}, {:.4}", lat, lon)) } - loc } else { None } @@ -615,31 +682,15 @@ impl InsightGenerator { // TEMPORARY: Set to true to disable RAG and use only time-based retrieval for testing let disable_rag_for_testing = false; - // Decide strategy based on available metadata - let has_strong_query = location.is_some(); - if disable_rag_for_testing { - log::warn!("RAG DISABLED FOR TESTING - Using only time-based retrieval (±1 day)"); + log::warn!("RAG DISABLED FOR TESTING - Using only time-based retrieval (±2 days)"); // Skip directly to fallback - } else if has_strong_query { - // Strategy A: Pure RAG (we have location for good semantic matching) - log::info!("Using RAG with location-based query"); - match self - .find_relevant_messages_rag(date_taken, location.as_deref(), contact.as_deref(), 20) - .await - { - Ok(rag_messages) if !rag_messages.is_empty() => { - used_rag = true; - sms_summary = self.summarize_messages(&rag_messages, &ollama_client).await; - } - Ok(_) => log::info!("RAG returned no messages"), - Err(e) => log::warn!("RAG failed: {}", e), - } } else { - // Strategy B: Expanded immediate context + historical RAG + // ALWAYS use Strategy B: Expanded immediate context + historical RAG + // This is more reliable than pure semantic search which can match irrelevant messages log::info!("Using expanded immediate context + historical RAG approach"); - // Step 1: Get FULL immediate temporal context (±1 day, ALL messages) + // Step 1: Get FULL immediate temporal context (±2 days, ALL messages) let immediate_messages = self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) @@ -650,7 +701,7 @@ impl InsightGenerator { }); log::info!( - "Fetched {} messages from ±1 day window (using ALL for immediate context)", + "Fetched {} messages from ±2 days window (using ALL for immediate context)", immediate_messages.len() ); @@ -662,13 +713,19 @@ impl InsightGenerator { log::info!("Extracted topics for query enrichment: {:?}", topics); - // Step 3: Try historical RAG (>30 days ago) + // Step 3: Try historical RAG (>30 days ago) using extracted topics + let topics_slice = if topics.is_empty() { + None + } else { + Some(topics.as_slice()) + }; match self .find_relevant_messages_rag_historical( &insight_cx, date_taken, None, contact.as_deref(), + topics_slice, 10, // Top 10 historical matches ) .await @@ -694,7 +751,7 @@ impl InsightGenerator { // Combine summaries sms_summary = Some(format!( - "Immediate context (±1 day): {}\n\nSimilar moments from the past: {}", + "Immediate context (±2 days): {}\n\nSimilar moments from the past: {}", immediate_summary, historical_summary )); } @@ -716,7 +773,7 @@ impl InsightGenerator { log::info!("No immediate messages found, trying basic RAG as fallback"); // Fallback to basic RAG even without strong query match self - .find_relevant_messages_rag(date_taken, None, contact.as_deref(), 20) + .find_relevant_messages_rag(date_taken, None, contact.as_deref(), None, 20) .await { Ok(rag_messages) if !rag_messages.is_empty() => { @@ -730,7 +787,7 @@ impl InsightGenerator { // 6. Fallback to traditional time-based message retrieval if RAG didn't work if !used_rag { - log::info!("Using traditional time-based message retrieval (±1 day)"); + log::info!("Using traditional time-based message retrieval (±2 days)"); let sms_messages = self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) @@ -802,7 +859,12 @@ impl InsightGenerator { .flatten(); let search_context = self - .gather_search_context(&insight_cx, timestamp, location.as_deref(), contact.as_deref()) + .gather_search_context( + &insight_cx, + timestamp, + location.as_deref(), + contact.as_deref(), + ) .await .ok() .flatten(); @@ -815,7 +877,10 @@ impl InsightGenerator { search_context, ); - log::info!("Combined context from all sources ({} chars)", combined_context.len()); + log::info!( + "Combined context from all sources ({} chars)", + combined_context.len() + ); // 8. Generate title and summary with Ollama (using multi-source context) let title = ollama_client @@ -905,13 +970,23 @@ Return ONLY the comma-separated list, nothing else."#, .await { Ok(response) => { + log::debug!("Topic extraction raw response: {}", response); + // Parse comma-separated topics - response + let topics: Vec = response .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty() && s.len() > 1) // Filter out single chars .take(7) // Increased from 5 to 7 - .collect() + .collect(); + + if topics.is_empty() { + log::warn!("Topic extraction returned empty list from {} messages", messages.len()); + } else { + log::info!("Extracted {} topics from {} messages: {}", topics.len(), messages.len(), topics.join(", ")); + } + + topics } Err(e) => { log::warn!("Failed to extract topics from messages: {}", e); @@ -953,7 +1028,7 @@ Return ONLY the comma-separated list, nothing else."#, log::info!("========================================"); // Use existing RAG method with enriched query - self.find_relevant_messages_rag(date, None, contact, limit) + self.find_relevant_messages_rag(date, None, contact, None, limit) .await } @@ -995,7 +1070,7 @@ Return ONLY the summary, nothing else."#, } /// Convert SmsMessage objects to formatted strings and summarize with more detail - /// This is used for immediate context (±1 day) to preserve conversation details + /// This is used for immediate context (±2 days) to preserve conversation details async fn summarize_context_from_messages( &self, messages: &[crate::ai::SmsMessage], @@ -1058,17 +1133,25 @@ Return ONLY the summary, nothing else."#, lat, lon ); + log::debug!("Reverse geocoding {}, {} via Nominatim", lat, lon); + let client = reqwest::Client::new(); - let response = client + let response = match client .get(&url) .header("User-Agent", "ImageAPI/1.0") // Nominatim requires User-Agent .send() .await - .ok()?; + { + Ok(resp) => resp, + Err(e) => { + log::warn!("Geocoding network error for {}, {}: {}", lat, lon, e); + return None; + } + }; if !response.status().is_success() { log::warn!( - "Geocoding failed for {}, {}: {}", + "Geocoding HTTP error for {}, {}: {}", lat, lon, response.status() @@ -1076,7 +1159,13 @@ Return ONLY the summary, nothing else."#, return None; } - let data: NominatimResponse = response.json().await.ok()?; + let data: NominatimResponse = match response.json().await { + Ok(d) => d, + Err(e) => { + log::warn!("Geocoding JSON parse error for {}, {}: {}", lat, lon, e); + return None; + } + }; // Try to build a concise location name if let Some(addr) = data.address { @@ -1093,11 +1182,22 @@ Return ONLY the summary, nothing else."#, } if !parts.is_empty() { + log::info!("Reverse geocoded {}, {} -> {}", lat, lon, parts.join(", ")); return Some(parts.join(", ")); } } // Fallback to display_name if structured address not available + if let Some(ref display_name) = data.display_name { + log::info!( + "Reverse geocoded {}, {} -> {} (display_name)", + lat, + lon, + display_name + ); + } else { + log::warn!("Geocoding returned no address data for {}, {}", lat, lon); + } data.display_name } } diff --git a/src/ai/mod.rs b/src/ai/mod.rs index fe4f1d2..fbee000 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -5,7 +5,7 @@ pub mod insight_generator; pub mod ollama; pub mod sms_client; -pub use daily_summary_job::generate_daily_summaries; +pub use daily_summary_job::{generate_daily_summaries, strip_summary_boilerplate}; pub use handlers::{ delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_available_models_handler, get_insight_handler, diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index 0043452..ea91ae1 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -46,12 +46,12 @@ impl SmsApiClient { ) -> Result> { use chrono::Duration; - // Calculate ±1 day range around the center timestamp + // Calculate ±2 days range around the center timestamp let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0) .ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?; - let start_dt = center_dt - Duration::days(1); - let end_dt = center_dt + Duration::days(1); + let start_dt = center_dt - Duration::days(2); + let end_dt = center_dt + Duration::days(2); let start_ts = start_dt.timestamp(); let end_ts = end_dt.timestamp(); @@ -59,7 +59,7 @@ impl SmsApiClient { // If contact specified, try fetching for that contact first if let Some(contact_name) = contact { log::info!( - "Fetching SMS for contact: {} (±1 day from {})", + "Fetching SMS for contact: {} (±2 days from {})", contact_name, center_dt.format("%Y-%m-%d %H:%M:%S") ); diff --git a/src/bin/diagnose_embeddings.rs b/src/bin/diagnose_embeddings.rs new file mode 100644 index 0000000..1348cf3 --- /dev/null +++ b/src/bin/diagnose_embeddings.rs @@ -0,0 +1,282 @@ +use anyhow::Result; +use clap::Parser; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sqlite::SqliteConnection; +use std::env; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Diagnose embedding distribution and identify problematic summaries", long_about = None)] +struct Args { + /// Show detailed per-summary statistics + #[arg(short, long, default_value_t = false)] + verbose: bool, + + /// Number of top "central" summaries to show (ones that match everything) + #[arg(short, long, default_value_t = 10)] + top: usize, + + /// Test a specific query to see what matches + #[arg(short, long)] + query: Option, +} + +#[derive(QueryableByName, Debug)] +struct EmbeddingRow { + #[diesel(sql_type = diesel::sql_types::Integer)] + id: i32, + #[diesel(sql_type = diesel::sql_types::Text)] + date: String, + #[diesel(sql_type = diesel::sql_types::Text)] + contact: String, + #[diesel(sql_type = diesel::sql_types::Text)] + summary: String, + #[diesel(sql_type = diesel::sql_types::Binary)] + embedding: Vec, +} + +fn deserialize_embedding(bytes: &[u8]) -> Result> { + if bytes.len() % 4 != 0 { + return Err(anyhow::anyhow!("Invalid embedding byte length")); + } + + let count = bytes.len() / 4; + let mut vec = Vec::with_capacity(count); + + for chunk in bytes.chunks_exact(4) { + let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + vec.push(float); + } + + Ok(vec) +} + +fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + + let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + + if magnitude_a == 0.0 || magnitude_b == 0.0 { + return 0.0; + } + + dot_product / (magnitude_a * magnitude_b) +} + +fn main() -> Result<()> { + dotenv::dotenv().ok(); + let args = Args::parse(); + + let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "auth.db".to_string()); + println!("Connecting to database: {}", database_url); + + let mut conn = SqliteConnection::establish(&database_url)?; + + // Load all embeddings + println!("\nLoading embeddings from daily_conversation_summaries..."); + let rows: Vec = sql_query( + "SELECT id, date, contact, summary, embedding FROM daily_conversation_summaries ORDER BY date" + ) + .load(&mut conn)?; + + println!("Found {} summaries with embeddings\n", rows.len()); + + if rows.is_empty() { + println!("No summaries found!"); + return Ok(()); + } + + // Parse all embeddings + let mut embeddings: Vec<(i32, String, String, String, Vec)> = Vec::new(); + for row in &rows { + match deserialize_embedding(&row.embedding) { + Ok(emb) => { + embeddings.push(( + row.id, + row.date.clone(), + row.contact.clone(), + row.summary.clone(), + emb, + )); + } + Err(e) => { + println!("Warning: Failed to parse embedding for id {}: {}", row.id, e); + } + } + } + + println!("Successfully parsed {} embeddings\n", embeddings.len()); + + // Compute embedding statistics + println!("========================================"); + println!("EMBEDDING STATISTICS"); + println!("========================================\n"); + + // Check embedding variance (are values clustered or spread out?) + let first_emb = &embeddings[0].4; + let dim = first_emb.len(); + println!("Embedding dimensions: {}", dim); + + // Calculate mean and std dev per dimension + let mut dim_means: Vec = vec![0.0; dim]; + let mut dim_vars: Vec = vec![0.0; dim]; + + for (_, _, _, _, emb) in &embeddings { + for (i, &val) in emb.iter().enumerate() { + dim_means[i] += val; + } + } + for m in &mut dim_means { + *m /= embeddings.len() as f32; + } + + for (_, _, _, _, emb) in &embeddings { + for (i, &val) in emb.iter().enumerate() { + let diff = val - dim_means[i]; + dim_vars[i] += diff * diff; + } + } + for v in &mut dim_vars { + *v = (*v / embeddings.len() as f32).sqrt(); + } + + let avg_std_dev: f32 = dim_vars.iter().sum::() / dim as f32; + let min_std_dev: f32 = dim_vars.iter().cloned().fold(f32::INFINITY, f32::min); + let max_std_dev: f32 = dim_vars.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + + println!("Per-dimension standard deviation:"); + println!(" Average: {:.6}", avg_std_dev); + println!(" Min: {:.6}", min_std_dev); + println!(" Max: {:.6}", max_std_dev); + println!(); + + // Compute pairwise similarities + println!("Computing pairwise similarities (this may take a moment)...\n"); + + let mut all_similarities: Vec = Vec::new(); + let mut per_embedding_avg: Vec<(usize, f32)> = Vec::new(); + + for i in 0..embeddings.len() { + let mut sum = 0.0; + let mut count = 0; + for j in 0..embeddings.len() { + if i != j { + let sim = cosine_similarity(&embeddings[i].4, &embeddings[j].4); + all_similarities.push(sim); + sum += sim; + count += 1; + } + } + per_embedding_avg.push((i, sum / count as f32)); + } + + // Sort similarities for percentile analysis + all_similarities.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let min_sim = all_similarities.first().copied().unwrap_or(0.0); + let max_sim = all_similarities.last().copied().unwrap_or(0.0); + let median_sim = all_similarities[all_similarities.len() / 2]; + let p25 = all_similarities[all_similarities.len() / 4]; + let p75 = all_similarities[3 * all_similarities.len() / 4]; + let mean_sim: f32 = all_similarities.iter().sum::() / all_similarities.len() as f32; + + println!("========================================"); + println!("PAIRWISE SIMILARITY DISTRIBUTION"); + println!("========================================\n"); + println!("Total pairs analyzed: {}", all_similarities.len()); + println!(); + println!("Min similarity: {:.4}", min_sim); + println!("25th percentile: {:.4}", p25); + println!("Median similarity: {:.4}", median_sim); + println!("Mean similarity: {:.4}", mean_sim); + println!("75th percentile: {:.4}", p75); + println!("Max similarity: {:.4}", max_sim); + println!(); + + // Analyze distribution + let count_above_08 = all_similarities.iter().filter(|&&s| s > 0.8).count(); + let count_above_07 = all_similarities.iter().filter(|&&s| s > 0.7).count(); + let count_above_06 = all_similarities.iter().filter(|&&s| s > 0.6).count(); + let count_above_05 = all_similarities.iter().filter(|&&s| s > 0.5).count(); + let count_below_03 = all_similarities.iter().filter(|&&s| s < 0.3).count(); + + println!("Similarity distribution:"); + println!(" > 0.8: {} ({:.1}%)", count_above_08, 100.0 * count_above_08 as f32 / all_similarities.len() as f32); + println!(" > 0.7: {} ({:.1}%)", count_above_07, 100.0 * count_above_07 as f32 / all_similarities.len() as f32); + println!(" > 0.6: {} ({:.1}%)", count_above_06, 100.0 * count_above_06 as f32 / all_similarities.len() as f32); + println!(" > 0.5: {} ({:.1}%)", count_above_05, 100.0 * count_above_05 as f32 / all_similarities.len() as f32); + println!(" < 0.3: {} ({:.1}%)", count_below_03, 100.0 * count_below_03 as f32 / all_similarities.len() as f32); + println!(); + + // Identify "central" embeddings (high average similarity to all others) + per_embedding_avg.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + println!("========================================"); + println!("TOP {} MOST 'CENTRAL' SUMMARIES", args.top); + println!("(These match everything with high similarity)"); + println!("========================================\n"); + + for (rank, (idx, avg_sim)) in per_embedding_avg.iter().take(args.top).enumerate() { + let (id, date, contact, summary, _) = &embeddings[*idx]; + let preview: String = summary.chars().take(80).collect(); + println!("{}. [id={}, avg_sim={:.4}]", rank + 1, id, avg_sim); + println!(" Date: {}, Contact: {}", date, contact); + println!(" Preview: {}...", preview.replace('\n', " ")); + println!(); + } + + // Also show the least central (most unique) + println!("========================================"); + println!("TOP {} MOST UNIQUE SUMMARIES", args.top); + println!("(These are most different from others)"); + println!("========================================\n"); + + for (rank, (idx, avg_sim)) in per_embedding_avg.iter().rev().take(args.top).enumerate() { + let (id, date, contact, summary, _) = &embeddings[*idx]; + let preview: String = summary.chars().take(80).collect(); + println!("{}. [id={}, avg_sim={:.4}]", rank + 1, id, avg_sim); + println!(" Date: {}, Contact: {}", date, contact); + println!(" Preview: {}...", preview.replace('\n', " ")); + println!(); + } + + // Diagnosis + println!("========================================"); + println!("DIAGNOSIS"); + println!("========================================\n"); + + if mean_sim > 0.7 { + println!("⚠️ HIGH AVERAGE SIMILARITY ({:.4})", mean_sim); + println!(" All embeddings are very similar to each other."); + println!(" This explains why the same summaries always match."); + println!(); + println!(" Possible causes:"); + println!(" 1. Summaries have similar structure/phrasing (e.g., all start with 'Summary:')"); + println!(" 2. Embedding model isn't capturing semantic differences well"); + println!(" 3. Daily conversations have similar topics (e.g., 'good morning', plans)"); + println!(); + println!(" Recommendations:"); + println!(" 1. Try a different embedding model (mxbai-embed-large, bge-large)"); + println!(" 2. Improve summary diversity by varying the prompt"); + println!(" 3. Extract and embed only keywords/entities, not full summaries"); + } else if mean_sim > 0.5 { + println!("⚡ MODERATE AVERAGE SIMILARITY ({:.4})", mean_sim); + println!(" Some clustering in embeddings, but some differentiation exists."); + println!(); + println!(" The 'central' summaries above are likely dominating search results."); + println!(" Consider:"); + println!(" 1. Filtering out summaries with very high centrality"); + println!(" 2. Adding time-based weighting to prefer recent/relevant dates"); + println!(" 3. Increasing the similarity threshold from 0.3 to 0.5"); + } else { + println!("✅ GOOD EMBEDDING DIVERSITY ({:.4})", mean_sim); + println!(" Embeddings are well-differentiated."); + println!(" If same results keep appearing, the issue may be elsewhere."); + } + + Ok(()) +} diff --git a/src/bin/test_daily_summary.rs b/src/bin/test_daily_summary.rs new file mode 100644 index 0000000..5e5e679 --- /dev/null +++ b/src/bin/test_daily_summary.rs @@ -0,0 +1,285 @@ +use anyhow::Result; +use chrono::NaiveDate; +use clap::Parser; +use image_api::ai::{strip_summary_boilerplate, OllamaClient, SmsApiClient}; +use image_api::database::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; +use std::env; +use std::sync::{Arc, Mutex}; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Test daily summary generation with different models and prompts", long_about = None)] +struct Args { + /// Contact name to generate summaries for + #[arg(short, long)] + contact: String, + + /// Start date (YYYY-MM-DD) + #[arg(short, long)] + start: String, + + /// End date (YYYY-MM-DD) + #[arg(short, long)] + end: String, + + /// Optional: Override the model to use (e.g., "qwen2.5:32b", "llama3.1:30b") + #[arg(short, long)] + model: Option, + + /// Test mode: Generate but don't save to database (shows output only) + #[arg(short = 't', long, default_value_t = false)] + test_mode: bool, + + /// Show message count and preview + #[arg(short, long, default_value_t = false)] + verbose: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Load .env file + dotenv::dotenv().ok(); + + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let args = Args::parse(); + + // Parse dates + let start_date = NaiveDate::parse_from_str(&args.start, "%Y-%m-%d") + .expect("Invalid start date format. Use YYYY-MM-DD"); + let end_date = NaiveDate::parse_from_str(&args.end, "%Y-%m-%d") + .expect("Invalid end date format. Use YYYY-MM-DD"); + + println!("========================================"); + println!("Daily Summary Generation Test Tool"); + println!("========================================"); + println!("Contact: {}", args.contact); + println!("Date range: {} to {}", start_date, end_date); + println!("Days: {}", (end_date - start_date).num_days() + 1); + if let Some(ref model) = args.model { + println!("Model: {}", model); + } else { + println!( + "Model: {} (from env)", + env::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| env::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nemotron-3-nano:30b".to_string()) + ); + } + if args.test_mode { + println!("⚠ TEST MODE: Results will NOT be saved to database"); + } + println!("========================================"); + println!(); + + // Initialize AI clients + let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL") + .or_else(|_| env::var("OLLAMA_URL")) + .unwrap_or_else(|_| "http://localhost:11434".to_string()); + + let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok(); + + // Use provided model or fallback to env + let model_to_use = args.model.clone().unwrap_or_else(|| { + env::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| env::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nemotron-3-nano:30b".to_string()) + }); + + let ollama = OllamaClient::new( + ollama_primary_url, + ollama_fallback_url.clone(), + model_to_use.clone(), + Some(model_to_use), // Use same model for fallback + ); + + let sms_api_url = + env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); + let sms_api_token = env::var("SMS_API_TOKEN").ok(); + let sms_client = SmsApiClient::new(sms_api_url, sms_api_token); + + // Initialize DAO + let summary_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); + + // Fetch messages for contact + println!("Fetching messages for {}...", args.contact); + let all_messages = sms_client + .fetch_all_messages_for_contact(&args.contact) + .await?; + + println!( + "Found {} total messages for {}", + all_messages.len(), + args.contact + ); + println!(); + + // Filter to date range and group by date + let mut messages_by_date = std::collections::HashMap::new(); + + for msg in all_messages { + if let Some(dt) = chrono::DateTime::from_timestamp(msg.timestamp, 0) { + let date = dt.date_naive(); + if date >= start_date && date <= end_date { + messages_by_date + .entry(date) + .or_insert_with(Vec::new) + .push(msg); + } + } + } + + if messages_by_date.is_empty() { + println!("⚠ No messages found in date range"); + return Ok(()); + } + + println!("Found {} days with messages", messages_by_date.len()); + println!(); + + // Sort dates + let mut dates: Vec = messages_by_date.keys().cloned().collect(); + dates.sort(); + + // Process each day + for (idx, date) in dates.iter().enumerate() { + let messages = messages_by_date.get(date).unwrap(); + let date_str = date.format("%Y-%m-%d").to_string(); + let weekday = date.format("%A"); + + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!( + "Day {}/{}: {} ({}) - {} messages", + idx + 1, + dates.len(), + date_str, + weekday, + messages.len() + ); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + if args.verbose { + println!("\nMessage preview:"); + for (i, msg) in messages.iter().take(3).enumerate() { + let sender = if msg.is_sent { "Me" } else { &msg.contact }; + let preview = msg.body.chars().take(60).collect::(); + println!(" {}. {}: {}...", i + 1, sender, preview); + } + if messages.len() > 3 { + println!(" ... and {} more", messages.len() - 3); + } + println!(); + } + + // Format messages for LLM + let messages_text: String = messages + .iter() + .take(200) + .map(|m| { + if m.is_sent { + format!("Me: {}", m.body) + } else { + format!("{}: {}", m.contact, m.body) + } + }) + .collect::>() + .join("\n"); + + let prompt = format!( + r#"Summarize this day's conversation between me and {}. + +CRITICAL FORMAT RULES: +- Do NOT start with "Based on the conversation..." or "Here is a summary..." or similar preambles +- Do NOT repeat the date at the beginning +- Start DIRECTLY with the content - begin with a person's name or action +- Write in past tense, as if recording what happened + +NARRATIVE (3-5 sentences): +- What specific topics, activities, or events were discussed? +- What places, people, or organizations were mentioned? +- What plans were made or decisions discussed? +- Clearly distinguish between what "I" did versus what {} did + +KEYWORDS (comma-separated): +5-10 specific keywords that capture this conversation's unique content: +- Proper nouns (people, places, brands) +- Specific activities ("drum corps audition" not just "music") +- Distinctive terms that make this day unique + +Date: {} ({}) +Messages: +{} + +YOUR RESPONSE (follow this format EXACTLY): +Summary: [Start directly with content, NO preamble] + +Keywords: [specific, unique terms]"#, + args.contact, + args.contact, + date.format("%B %d, %Y"), + weekday, + messages_text + ); + + println!("Generating summary..."); + + let summary = ollama + .generate( + &prompt, + Some("You are a conversation summarizer. Create clear, factual summaries with precise subject attribution AND extract distinctive keywords. Focus on specific, unique terms that differentiate this conversation from others."), + ) + .await?; + + println!("\n📝 GENERATED SUMMARY:"); + println!("─────────────────────────────────────────"); + println!("{}", summary.trim()); + println!("─────────────────────────────────────────"); + + if !args.test_mode { + println!("\nStripping boilerplate for embedding..."); + let stripped = strip_summary_boilerplate(&summary); + println!("Stripped: {}...", stripped.chars().take(80).collect::()); + + println!("\nGenerating embedding..."); + let embedding = ollama.generate_embedding(&stripped).await?; + println!("✓ Embedding generated ({} dimensions)", embedding.len()); + + println!("Saving to database..."); + let insert = InsertDailySummary { + date: date_str.clone(), + contact: args.contact.clone(), + summary: summary.trim().to_string(), + message_count: messages.len() as i32, + embedding, + created_at: chrono::Utc::now().timestamp(), + // model_version: "nomic-embed-text:v1.5".to_string(), + model_version: "mxbai-embed-large:335m".to_string(), + }; + + let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao"); + let context = opentelemetry::Context::new(); + + match dao.store_summary(&context, insert) { + Ok(_) => println!("✓ Saved to database"), + Err(e) => println!("✗ Database error: {:?}", e), + } + } else { + println!("\n⚠ TEST MODE: Not saved to database"); + } + + println!(); + + // Rate limiting between days + if idx < dates.len() - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + } + + println!("========================================"); + println!("✓ Complete!"); + println!("Processed {} days", dates.len()); + println!("========================================"); + + Ok(()) +} diff --git a/src/database/daily_summary_dao.rs b/src/database/daily_summary_dao.rs index 343abd4..93d77b4 100644 --- a/src/database/daily_summary_dao.rs +++ b/src/database/daily_summary_dao.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDate; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use serde::Serialize; @@ -47,6 +48,17 @@ pub trait DailySummaryDao: Sync + Send { limit: usize, ) -> Result, DbError>; + /// Find semantically similar daily summaries with time-based weighting + /// Combines cosine similarity with temporal proximity to target_date + /// Final score = similarity * time_weight, where time_weight decays with distance from target_date + fn find_similar_summaries_with_time_weight( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + target_date: &str, + limit: usize, + ) -> Result, DbError>; + /// Check if a summary exists for a given date and contact fn summary_exists( &mut self, @@ -231,14 +243,22 @@ impl DailySummaryDao for SqliteDailySummaryDao { // Sort by similarity (highest first) scored_summaries.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + // Filter out poor matches (similarity < 0.3 is likely noise) + scored_summaries.retain(|(similarity, _)| *similarity >= 0.3); + // Log similarity distribution if !scored_summaries.is_empty() { + let top_score = scored_summaries.first().map(|(s, _)| *s).unwrap_or(0.0); + let median_score = scored_summaries.get(scored_summaries.len() / 2).map(|(s, _)| *s).unwrap_or(0.0); + log::info!( - "Daily summary similarity - Top: {:.3}, Median: {:.3}, Count: {}", - scored_summaries.first().map(|(s, _)| *s).unwrap_or(0.0), - scored_summaries.get(scored_summaries.len() / 2).map(|(s, _)| *s).unwrap_or(0.0), + "Daily summary similarity - Top: {:.3}, Median: {:.3}, Count: {} (after 0.3 threshold)", + top_score, + median_score, scored_summaries.len() ); + } else { + log::warn!("No daily summaries met the 0.3 similarity threshold"); } // Take top N and log matches @@ -262,6 +282,128 @@ impl DailySummaryDao for SqliteDailySummaryDao { .map_err(|_| DbError::new(DbErrorKind::QueryError)) } + fn find_similar_summaries_with_time_weight( + &mut self, + context: &opentelemetry::Context, + query_embedding: &[f32], + target_date: &str, + limit: usize, + ) -> Result, DbError> { + trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| { + let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); + + if query_embedding.len() != 768 { + return Err(anyhow::anyhow!( + "Invalid query embedding dimensions: {} (expected 768)", + query_embedding.len() + )); + } + + // Parse target date + let target = NaiveDate::parse_from_str(target_date, "%Y-%m-%d") + .map_err(|e| anyhow::anyhow!("Invalid target date: {}", e))?; + + // Load all summaries with embeddings + let results = diesel::sql_query( + "SELECT id, date, contact, summary, message_count, embedding, created_at, model_version + FROM daily_conversation_summaries" + ) + .load::(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))?; + + log::info!("Loaded {} daily summaries for time-weighted similarity (target: {})", results.len(), target_date); + + // Compute time-weighted similarity for each summary + // Score = cosine_similarity * time_weight + // time_weight = 1 / (1 + days_distance/30) - decays with ~30 day half-life + let mut scored_summaries: Vec<(f32, f32, i64, DailySummary)> = results + .into_iter() + .filter_map(|row| { + match Self::deserialize_vector(&row.embedding) { + Ok(embedding) => { + let similarity = Self::cosine_similarity(query_embedding, &embedding); + + // Calculate time weight + let summary_date = NaiveDate::parse_from_str(&row.date, "%Y-%m-%d").ok()?; + let days_distance = (target - summary_date).num_days().abs(); + + // Exponential decay with 30-day half-life + // At 0 days: weight = 1.0 + // At 30 days: weight = 0.5 + // At 60 days: weight = 0.25 + // At 365 days: weight ~= 0.0001 + let time_weight = 0.5_f32.powf(days_distance as f32 / 30.0); + + // Combined score - but ensure semantic similarity still matters + // We use sqrt to soften the time weight's impact + let combined_score = similarity * time_weight.sqrt(); + + Some(( + combined_score, + similarity, + days_distance, + DailySummary { + id: row.id, + date: row.date, + contact: row.contact, + summary: row.summary, + message_count: row.message_count, + created_at: row.created_at, + model_version: row.model_version, + }, + )) + } + Err(e) => { + log::warn!("Failed to deserialize embedding for summary {}: {:?}", row.id, e); + None + } + } + }) + .collect(); + + // Sort by combined score (highest first) + scored_summaries.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Filter out poor matches (base similarity < 0.5 - stricter than before since we have time weighting) + scored_summaries.retain(|(_, similarity, _, _)| *similarity >= 0.5); + + // Log similarity distribution + if !scored_summaries.is_empty() { + let (top_combined, top_sim, top_days, _) = &scored_summaries[0]; + log::info!( + "Time-weighted similarity - Top: combined={:.3} (sim={:.3}, days={}), Count: {} matches", + top_combined, + top_sim, + top_days, + scored_summaries.len() + ); + } else { + log::warn!("No daily summaries met the 0.5 similarity threshold"); + } + + // Take top N and log matches + let top_results: Vec = scored_summaries + .into_iter() + .take(limit) + .map(|(combined, similarity, days, summary)| { + log::info!( + "Summary match: combined={:.3} (sim={:.3}, days={}), date={}, contact={}, summary=\"{}\"", + combined, + similarity, + days, + summary.date, + summary.contact, + summary.summary.chars().take(80).collect::() + ); + summary + }) + .collect(); + + Ok(top_results) + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + fn summary_exists( &mut self, context: &opentelemetry::Context, diff --git a/src/database/mod.rs b/src/database/mod.rs index cec38bb..bbdf33f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -18,15 +18,11 @@ pub mod models; pub mod schema; pub mod search_dao; -pub use calendar_dao::{ - CalendarEventDao, SqliteCalendarEventDao, -}; +pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding}; pub use insights_dao::{InsightDao, SqliteInsightDao}; -pub use location_dao::{ - LocationHistoryDao, SqliteLocationHistoryDao, -}; +pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub trait UserDao { -- 2.49.1 From b2cc617bc26a59618089b6ea4e17c29894087ad1 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 10 Jan 2026 11:30:01 -0500 Subject: [PATCH 13/25] Pass image as additional Insight context --- Cargo.lock | 1 + Cargo.toml | 1 + src/ai/daily_summary_job.rs | 5 +- src/ai/handlers.rs | 26 +++++- src/ai/insight_generator.rs | 157 ++++++++++++++++++++++++++++----- src/ai/ollama.rs | 113 ++++++++++++++++++++---- src/bin/diagnose_embeddings.rs | 39 ++++++-- src/bin/test_daily_summary.rs | 5 +- src/memories.rs | 4 +- 9 files changed, 295 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7495026..7ef5729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ dependencies = [ "actix-web", "actix-web-prom", "anyhow", + "base64", "bcrypt", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1a7bf56..acf3598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,4 @@ urlencoding = "2.1" zerocopy = "0.8" ical = "0.11" scraper = "0.20" +base64 = "0.22" diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs index 182b96e..ac0b821 100644 --- a/src/ai/daily_summary_job.rs +++ b/src/ai/daily_summary_job.rs @@ -62,7 +62,10 @@ pub fn strip_summary_boilerplate(summary: &str) -> String { if text.to_lowercase().starts_with(&phrase.to_lowercase()) { text = text[phrase.len()..].trim_start().to_string(); // Remove leading punctuation/articles after stripping phrase - text = text.trim_start_matches(|c| c == ',' || c == ':' || c == '-').trim_start().to_string(); + text = text + .trim_start_matches(|c| c == ',' || c == ':' || c == '-') + .trim_start() + .to_string(); break; } } diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index efcf65c..a5d47bb 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -14,6 +14,10 @@ pub struct GeneratePhotoInsightRequest { pub file_path: String, #[serde(default)] pub model: Option, + #[serde(default)] + pub system_prompt: Option, + #[serde(default)] + pub num_ctx: Option, } #[derive(Debug, Deserialize)] @@ -63,16 +67,30 @@ pub async fn generate_insight_handler( if let Some(ref model) = request.model { span.set_attribute(KeyValue::new("model", model.clone())); } + if let Some(ref prompt) = request.system_prompt { + span.set_attribute(KeyValue::new("has_custom_prompt", true)); + span.set_attribute(KeyValue::new("prompt_length", prompt.len() as i64)); + } + if let Some(ctx) = request.num_ctx { + span.set_attribute(KeyValue::new("num_ctx", ctx as i64)); + } log::info!( - "Manual insight generation triggered for photo: {} with model: {:?}", + "Manual insight generation triggered for photo: {} with model: {:?}, custom_prompt: {}, num_ctx: {:?}", normalized_path, - request.model + request.model, + request.system_prompt.is_some(), + request.num_ctx ); - // Generate insight with optional custom model + // Generate insight with optional custom model, system prompt, and context size let result = insight_generator - .generate_insight_for_photo_with_model(&normalized_path, request.model.clone()) + .generate_insight_for_photo_with_config( + &normalized_path, + request.model.clone(), + request.system_prompt.clone(), + request.num_ctx, + ) .await; match result { diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 7d8acf3..273b78c 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,9 +1,12 @@ use anyhow::Result; +use base64::Engine as _; use chrono::{DateTime, Utc}; +use image::ImageFormat; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::Deserialize; use std::fs::File; +use std::io::Cursor; use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; @@ -92,6 +95,51 @@ impl InsightGenerator { None } + /// Load image file, resize it, and encode as base64 for vision models + /// Resizes to max 1024px on longest edge to reduce context usage + fn load_image_as_base64(&self, file_path: &str) -> Result { + use image::imageops::FilterType; + use std::path::Path; + + let full_path = Path::new(&self.base_path).join(file_path); + + log::debug!("Loading image for vision model: {:?}", full_path); + + // Open and decode the image + let img = image::open(&full_path) + .map_err(|e| anyhow::anyhow!("Failed to open image file: {}", e))?; + + let (original_width, original_height) = (img.width(), img.height()); + + // Resize to max 1024px on longest edge + let resized = img.resize(1024, 1024, FilterType::Lanczos3); + + log::debug!( + "Resized image from {}x{} to {}x{}", + original_width, + original_height, + resized.width(), + resized.height() + ); + + // Encode as JPEG at 85% quality + let mut buffer = Vec::new(); + let mut cursor = Cursor::new(&mut buffer); + resized + .write_to(&mut cursor, ImageFormat::Jpeg) + .map_err(|e| anyhow::anyhow!("Failed to encode image as JPEG: {}", e))?; + + let base64_string = base64::engine::general_purpose::STANDARD.encode(&buffer); + + log::debug!( + "Encoded image as base64 ({} bytes -> {} chars)", + buffer.len(), + base64_string.len() + ); + + Ok(base64_string) + } + /// Find relevant messages using RAG, excluding recent messages (>30 days ago) /// This prevents RAG from returning messages already in the immediate time window async fn find_relevant_messages_rag_historical( @@ -564,10 +612,23 @@ impl InsightGenerator { } /// Generate AI insight for a single photo with optional custom model + /// (Deprecated: Use generate_insight_for_photo_with_config instead) pub async fn generate_insight_for_photo_with_model( &self, file_path: &str, custom_model: Option, + ) -> Result<()> { + self.generate_insight_for_photo_with_config(file_path, custom_model, None, None) + .await + } + + /// Generate AI insight for a single photo with custom configuration + pub async fn generate_insight_for_photo_with_config( + &self, + file_path: &str, + custom_model: Option, + custom_system_prompt: Option, + num_ctx: Option, ) -> Result<()> { let tracer = global_tracer(); let current_cx = opentelemetry::Context::current(); @@ -580,7 +641,7 @@ impl InsightGenerator { span.set_attribute(KeyValue::new("file_path", file_path.clone())); // Create custom Ollama client if model is specified - let ollama_client = if let Some(model) = custom_model { + let mut ollama_client = if let Some(model) = custom_model { log::info!("Using custom model: {}", model); span.set_attribute(KeyValue::new("custom_model", model.clone())); OllamaClient::new( @@ -594,6 +655,13 @@ impl InsightGenerator { self.ollama.clone() }; + // Set context size if specified + if let Some(ctx) = num_ctx { + log::info!("Using custom context size: {}", ctx); + span.set_attribute(KeyValue::new("num_ctx", ctx as i64)); + ollama_client.set_num_ctx(Some(ctx)); + } + // Create context with this span for child operations let insight_cx = current_cx.with_span(span); @@ -740,12 +808,20 @@ impl InsightGenerator { // Step 4: Summarize contexts separately, then combine let immediate_summary = self - .summarize_context_from_messages(&immediate_messages, &ollama_client) + .summarize_context_from_messages( + &immediate_messages, + &ollama_client, + custom_system_prompt.as_deref(), + ) .await .unwrap_or_else(|| String::from("No immediate context")); let historical_summary = self - .summarize_messages(&historical_messages, &ollama_client) + .summarize_messages( + &historical_messages, + &ollama_client, + custom_system_prompt.as_deref(), + ) .await .unwrap_or_else(|| String::from("No historical context")); @@ -759,13 +835,21 @@ impl InsightGenerator { // RAG found no historical matches, just use immediate context log::info!("No historical RAG matches, using immediate context only"); sms_summary = self - .summarize_context_from_messages(&immediate_messages, &ollama_client) + .summarize_context_from_messages( + &immediate_messages, + &ollama_client, + custom_system_prompt.as_deref(), + ) .await; } Err(e) => { log::warn!("Historical RAG failed, using immediate context only: {}", e); sms_summary = self - .summarize_context_from_messages(&immediate_messages, &ollama_client) + .summarize_context_from_messages( + &immediate_messages, + &ollama_client, + custom_system_prompt.as_deref(), + ) .await; } } @@ -778,7 +862,13 @@ impl InsightGenerator { { Ok(rag_messages) if !rag_messages.is_empty() => { used_rag = true; - sms_summary = self.summarize_messages(&rag_messages, &ollama_client).await; + sms_summary = self + .summarize_messages( + &rag_messages, + &ollama_client, + custom_system_prompt.as_deref(), + ) + .await; } _ => {} } @@ -882,13 +972,37 @@ impl InsightGenerator { combined_context.len() ); - // 8. Generate title and summary with Ollama (using multi-source context) + // 8. Load image and encode as base64 for vision models + let image_base64 = match self.load_image_as_base64(&file_path) { + Ok(b64) => { + log::info!("Successfully loaded image for vision model"); + Some(b64) + } + Err(e) => { + log::warn!("Failed to load image for vision model: {}", e); + None + } + }; + + // 9. Generate title and summary with Ollama (using multi-source context + image) let title = ollama_client - .generate_photo_title(date_taken, location.as_deref(), Some(&combined_context)) + .generate_photo_title( + date_taken, + location.as_deref(), + Some(&combined_context), + custom_system_prompt.as_deref(), + image_base64.clone(), + ) .await?; let summary = ollama_client - .generate_photo_summary(date_taken, location.as_deref(), Some(&combined_context)) + .generate_photo_summary( + date_taken, + location.as_deref(), + Some(&combined_context), + custom_system_prompt.as_deref(), + image_base64, + ) .await?; log::info!("Generated title: {}", title); @@ -1037,6 +1151,7 @@ Return ONLY the comma-separated list, nothing else."#, &self, messages: &[String], ollama: &OllamaClient, + custom_system: Option<&str>, ) -> Option { if messages.is_empty() { return None; @@ -1054,13 +1169,10 @@ Return ONLY the summary, nothing else."#, messages_text ); - match ollama - .generate( - &prompt, - Some("You are a context summarization assistant. Be concise and factual."), - ) - .await - { + let system = custom_system + .unwrap_or("You are a context summarization assistant. Be concise and factual."); + + match ollama.generate(&prompt, Some(system)).await { Ok(summary) => Some(summary), Err(e) => { log::warn!("Failed to summarize messages: {}", e); @@ -1075,6 +1187,7 @@ Return ONLY the summary, nothing else."#, &self, messages: &[crate::ai::SmsMessage], ollama: &OllamaClient, + custom_system: Option<&str>, ) -> Option { if messages.is_empty() { return None; @@ -1111,13 +1224,11 @@ Return ONLY the summary, nothing else."#, messages_text ); - match ollama - .generate( - &prompt, - Some("You are a context summarization assistant. Be detailed and factual, preserving important context."), - ) - .await - { + let system = custom_system.unwrap_or( + "You are a context summarization assistant. Be detailed and factual, preserving important context.", + ); + + match ollama.generate(&prompt, Some(system)).await { Ok(summary) => Some(summary), Err(e) => { log::warn!("Failed to summarize immediate context: {}", e); diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 27e932c..0de7fd0 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -11,6 +11,7 @@ pub struct OllamaClient { pub fallback_url: Option, pub primary_model: String, pub fallback_model: Option, + num_ctx: Option, } impl OllamaClient { @@ -30,9 +31,14 @@ impl OllamaClient { fallback_url, primary_model, fallback_model, + num_ctx: None, } } + pub fn set_num_ctx(&mut self, num_ctx: Option) { + self.num_ctx = num_ctx; + } + /// List available models on an Ollama server pub async fn list_models(url: &str) -> Result> { let client = Client::builder() @@ -79,12 +85,15 @@ impl OllamaClient { model: &str, prompt: &str, system: Option<&str>, + images: Option>, ) -> Result { let request = OllamaRequest { model: model.to_string(), prompt: prompt.to_string(), stream: false, system: system.map(|s| s.to_string()), + options: self.num_ctx.map(|ctx| OllamaOptions { num_ctx: ctx }), + images, }; let response = self @@ -109,12 +118,24 @@ impl OllamaClient { } pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { + self.generate_with_images(prompt, system, None).await + } + + pub async fn generate_with_images( + &self, + prompt: &str, + system: Option<&str>, + images: Option>, + ) -> Result { log::debug!("=== Ollama Request ==="); log::debug!("Primary model: {}", self.primary_model); if let Some(sys) = system { log::debug!("System: {}", sys); } log::debug!("Prompt:\n{}", prompt); + if let Some(ref imgs) = images { + log::debug!("Images: {} image(s) included", imgs.len()); + } log::debug!("====================="); // Try primary server first with primary model @@ -124,7 +145,13 @@ impl OllamaClient { self.primary_model ); let primary_result = self - .try_generate(&self.primary_url, &self.primary_model, prompt, system) + .try_generate( + &self.primary_url, + &self.primary_model, + prompt, + system, + images.clone(), + ) .await; let raw_response = match primary_result { @@ -147,7 +174,7 @@ impl OllamaClient { fallback_model ); match self - .try_generate(fallback_url, fallback_model, prompt, system) + .try_generate(fallback_url, fallback_model, prompt, system, images.clone()) .await { Ok(response) => { @@ -190,12 +217,30 @@ impl OllamaClient { date: NaiveDate, location: Option<&str>, sms_summary: Option<&str>, + custom_system: Option<&str>, + image_base64: Option, ) -> Result { let location_str = location.unwrap_or("Unknown location"); let sms_str = sms_summary.unwrap_or("No messages"); - let prompt = format!( - r#"Create a short title (maximum 8 words) about this moment: + let prompt = if image_base64.is_some() { + format!( + r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context: + +Date: {} +Location: {} +Messages: {} + +Analyze the image and use specific details from both the visual content and the context above. If limited information is available, use a simple descriptive title based on what you see. + +Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } else { + format!( + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -204,14 +249,18 @@ Messages: {} Use specific details from the context above. If no specific details are available, use a simple descriptive title. Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - sms_str - ); + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; - let system = "You are my long term memory assistant. Use only the information provided. Do not invent details."; + let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details."); - let title = self.generate(&prompt, Some(system)).await?; + let images = image_base64.map(|img| vec![img]); + let title = self + .generate_with_images(&prompt, Some(system), images) + .await?; Ok(title.trim().trim_matches('"').to_string()) } @@ -221,26 +270,45 @@ Return ONLY the title, nothing else."#, date: NaiveDate, location: Option<&str>, sms_summary: Option<&str>, + custom_system: Option<&str>, + image_base64: Option, ) -> Result { let location_str = location.unwrap_or("Unknown"); let sms_str = sms_summary.unwrap_or("No messages"); - let prompt = format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + let prompt = if image_base64.is_some() { + format!( + r#"Write a 1-3 paragraph description of this moment by analyzing the image and the available context: + +Date: {} +Location: {} +Messages: {} + +Analyze the image and use specific details from both the visual content and the context above. Mention people's names, places, or activities if they appear in either the image or the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual based on what you see and know. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } else { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: Date: {} Location: {} Messages: {} Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - sms_str - ); + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; - let system = "You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."; + let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."); - self.generate(&prompt, Some(system)).await + let images = image_base64.map(|img| vec![img]); + self.generate_with_images(&prompt, Some(system), images) + .await } /// Generate an embedding vector for text using nomic-embed-text:v1.5 @@ -388,6 +456,15 @@ struct OllamaRequest { stream: bool, #[serde(skip_serializing_if = "Option::is_none")] system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + images: Option>, +} + +#[derive(Serialize)] +struct OllamaOptions { + num_ctx: i32, } #[derive(Deserialize)] diff --git a/src/bin/diagnose_embeddings.rs b/src/bin/diagnose_embeddings.rs index 1348cf3..05082b0 100644 --- a/src/bin/diagnose_embeddings.rs +++ b/src/bin/diagnose_embeddings.rs @@ -104,7 +104,10 @@ fn main() -> Result<()> { )); } Err(e) => { - println!("Warning: Failed to parse embedding for id {}: {}", row.id, e); + println!( + "Warning: Failed to parse embedding for id {}: {}", + row.id, e + ); } } } @@ -205,11 +208,31 @@ fn main() -> Result<()> { let count_below_03 = all_similarities.iter().filter(|&&s| s < 0.3).count(); println!("Similarity distribution:"); - println!(" > 0.8: {} ({:.1}%)", count_above_08, 100.0 * count_above_08 as f32 / all_similarities.len() as f32); - println!(" > 0.7: {} ({:.1}%)", count_above_07, 100.0 * count_above_07 as f32 / all_similarities.len() as f32); - println!(" > 0.6: {} ({:.1}%)", count_above_06, 100.0 * count_above_06 as f32 / all_similarities.len() as f32); - println!(" > 0.5: {} ({:.1}%)", count_above_05, 100.0 * count_above_05 as f32 / all_similarities.len() as f32); - println!(" < 0.3: {} ({:.1}%)", count_below_03, 100.0 * count_below_03 as f32 / all_similarities.len() as f32); + println!( + " > 0.8: {} ({:.1}%)", + count_above_08, + 100.0 * count_above_08 as f32 / all_similarities.len() as f32 + ); + println!( + " > 0.7: {} ({:.1}%)", + count_above_07, + 100.0 * count_above_07 as f32 / all_similarities.len() as f32 + ); + println!( + " > 0.6: {} ({:.1}%)", + count_above_06, + 100.0 * count_above_06 as f32 / all_similarities.len() as f32 + ); + println!( + " > 0.5: {} ({:.1}%)", + count_above_05, + 100.0 * count_above_05 as f32 / all_similarities.len() as f32 + ); + println!( + " < 0.3: {} ({:.1}%)", + count_below_03, + 100.0 * count_below_03 as f32 / all_similarities.len() as f32 + ); println!(); // Identify "central" embeddings (high average similarity to all others) @@ -255,7 +278,9 @@ fn main() -> Result<()> { println!(" This explains why the same summaries always match."); println!(); println!(" Possible causes:"); - println!(" 1. Summaries have similar structure/phrasing (e.g., all start with 'Summary:')"); + println!( + " 1. Summaries have similar structure/phrasing (e.g., all start with 'Summary:')" + ); println!(" 2. Embedding model isn't capturing semantic differences well"); println!(" 3. Daily conversations have similar topics (e.g., 'good morning', plans)"); println!(); diff --git a/src/bin/test_daily_summary.rs b/src/bin/test_daily_summary.rs index 5e5e679..d1f5c42 100644 --- a/src/bin/test_daily_summary.rs +++ b/src/bin/test_daily_summary.rs @@ -239,7 +239,10 @@ Keywords: [specific, unique terms]"#, if !args.test_mode { println!("\nStripping boilerplate for embedding..."); let stripped = strip_summary_boilerplate(&summary); - println!("Stripped: {}...", stripped.chars().take(80).collect::()); + println!( + "Stripped: {}...", + stripped.chars().take(80).collect::() + ); println!("\nGenerating embedding..."); let embedding = ollama.generate_embedding(&stripped).await?; diff --git a/src/memories.rs b/src/memories.rs index ea31f65..61766d1 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -66,7 +66,7 @@ impl PathExcluder { // Directory-based exclusions for excluded in &self.excluded_dirs { if path.starts_with(excluded) { - debug!( + trace!( "PathExcluder: excluded by dir: {:?} (rule: {:?})", path, excluded ); @@ -81,7 +81,7 @@ impl PathExcluder { if let Some(comp_str) = component.as_os_str().to_str() && self.excluded_patterns.iter().any(|pat| pat == comp_str) { - debug!( + trace!( "PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})", path, comp_str, self.excluded_patterns ); -- 2.49.1 From 0efa8269a19c29b9d289993885490391f634487f Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 10 Jan 2026 11:34:16 -0500 Subject: [PATCH 14/25] Fix test --- src/parsers/search_html_parser.rs | 2 +- src/tags.rs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/parsers/search_html_parser.rs b/src/parsers/search_html_parser.rs index 4bcd166..8b14ee2 100644 --- a/src/parsers/search_html_parser.rs +++ b/src/parsers/search_html_parser.rs @@ -191,7 +191,7 @@ mod tests { fn test_extract_query_from_url() { let url = "https://www.google.com/search?q=rust+programming&oq=rust"; let query = extract_query_from_url(url); - assert_eq!(query, Some("rust programming".to_string())); + assert_eq!(query, Some("rust+programming".to_string())); } #[test] diff --git a/src/tags.rs b/src/tags.rs index f9f3c55..0d00369 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -783,8 +783,8 @@ mod tests { fn get_files_with_all_tag_ids( &mut self, - tag_ids: Vec, - exclude_tag_ids: Vec, + _tag_ids: Vec, + _exclude_tag_ids: Vec, _context: &opentelemetry::Context, ) -> anyhow::Result> { todo!() @@ -792,8 +792,8 @@ mod tests { fn get_files_with_any_tag_ids( &mut self, - tag_ids: Vec, - exclude_tag_ids: Vec, + _tag_ids: Vec, + _exclude_tag_ids: Vec, _context: &opentelemetry::Context, ) -> anyhow::Result> { todo!() @@ -801,16 +801,16 @@ mod tests { fn update_photo_name( &mut self, - old_name: &str, - new_name: &str, - context: &opentelemetry::Context, + _old_name: &str, + _new_name: &str, + _context: &opentelemetry::Context, ) -> anyhow::Result<()> { todo!() } fn get_all_photo_names( &mut self, - context: &opentelemetry::Context, + _context: &opentelemetry::Context, ) -> anyhow::Result> { todo!() } -- 2.49.1 From fa600f1c2cf705457fc6019d56fb26fe3f61ff90 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 11 Jan 2026 14:39:50 -0500 Subject: [PATCH 15/25] Fallback to sorting by Metadata date --- src/files.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/files.rs b/src/files.rs index 785a219..600445f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -51,6 +51,7 @@ fn apply_sorting_with_exif( sort_type: SortType, exif_dao: &mut Box, span_context: &opentelemetry::Context, + base_path: &Path, ) -> Vec { match sort_type { SortType::DateTakenAsc | SortType::DateTakenDesc => { @@ -75,6 +76,14 @@ fn apply_sorting_with_exif( let date_taken = exif_map.get(&f.file_name).copied().or_else(|| { // Fallback to filename extraction extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp()) + }).or_else(|| { + // Fallback to filesystem metadata creation date + let full_path = base_path.join(&f.file_name); + std::fs::metadata(full_path) + .and_then(|md| md.created()) + .ok() + .and_then(|ct| ct.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) }); FileWithMetadata { @@ -324,7 +333,7 @@ pub async fn list_photos( let sort_type = req.sort.unwrap_or(NameAsc); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let result = - apply_sorting_with_exif(files, sort_type, &mut exif_dao_guard, &span_context); + apply_sorting_with_exif(files, sort_type, &mut exif_dao_guard, &span_context, (&app_state.base_path).as_ref()); drop(exif_dao_guard); result }) @@ -468,7 +477,7 @@ pub async fn list_photos( let response_files = if let Some(sort_type) = req.sort { let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let result = - apply_sorting_with_exif(photos, sort_type, &mut exif_dao_guard, &span_context); + apply_sorting_with_exif(photos, sort_type, &mut exif_dao_guard, &span_context, (&app_state.base_path).as_ref()); drop(exif_dao_guard); result } else { @@ -875,7 +884,7 @@ mod tests { struct MockExifDao; - impl crate::database::ExifDao for MockExifDao { + impl ExifDao for MockExifDao { fn store_exif( &mut self, _context: &opentelemetry::Context, -- 2.49.1 From 5b35df400766002f56b083893e9e7be914d82ae5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 11 Jan 2026 14:42:25 -0500 Subject: [PATCH 16/25] Remove unused function --- src/ai/insight_generator.rs | 50 +------------------------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 273b78c..d677041 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -611,17 +611,6 @@ impl InsightGenerator { } } - /// Generate AI insight for a single photo with optional custom model - /// (Deprecated: Use generate_insight_for_photo_with_config instead) - pub async fn generate_insight_for_photo_with_model( - &self, - file_path: &str, - custom_model: Option, - ) -> Result<()> { - self.generate_insight_for_photo_with_config(file_path, custom_model, None, None) - .await - } - /// Generate AI insight for a single photo with custom configuration pub async fn generate_insight_for_photo_with_config( &self, @@ -890,7 +879,7 @@ impl InsightGenerator { log::info!( "Fetched {} SMS messages closest to {}", sms_messages.len(), - chrono::DateTime::from_timestamp(timestamp, 0) + DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "unknown time".to_string()) ); @@ -1109,43 +1098,6 @@ Return ONLY the comma-separated list, nothing else."#, } } - /// Find relevant messages using RAG with topic-enriched query - async fn find_relevant_messages_rag_enriched( - &self, - date: chrono::NaiveDate, - contact: Option<&str>, - topics: &[String], - limit: usize, - ) -> Result> { - // Build enriched query from date + topics - let mut query_parts = Vec::new(); - - query_parts.push(format!("On {}", date.format("%B %d, %Y"))); - - if !topics.is_empty() { - query_parts.push(format!("about {}", topics.join(", "))); - } - - if let Some(c) = contact { - query_parts.push(format!("conversation with {}", c)); - } - - // Add day of week - let weekday = date.format("%A"); - query_parts.push(format!("it was a {}", weekday)); - - let query = query_parts.join(", "); - - log::info!("========================================"); - log::info!("ENRICHED RAG QUERY: {}", query); - log::info!("Extracted topics: {:?}", topics); - log::info!("========================================"); - - // Use existing RAG method with enriched query - self.find_relevant_messages_rag(date, None, contact, None, limit) - .await - } - /// Summarize pre-formatted message strings using LLM (concise version for historical context) async fn summarize_messages( &self, -- 2.49.1 From ad0bba63b43e017f5fab60f494c25774bb9f0f0b Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 11 Jan 2026 15:22:24 -0500 Subject: [PATCH 17/25] Add check for vision capabilities --- src/ai/handlers.rs | 16 +-- src/ai/insight_generator.rs | 58 +++++++++-- src/ai/mod.rs | 2 +- src/ai/ollama.rs | 201 +++++++++++++++++++++++++++++++----- 4 files changed, 235 insertions(+), 42 deletions(-) diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index a5d47bb..41a90b6 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -3,7 +3,7 @@ use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, Tracer}; use serde::{Deserialize, Serialize}; -use crate::ai::{InsightGenerator, OllamaClient}; +use crate::ai::{InsightGenerator, ModelCapabilities, OllamaClient}; use crate::data::Claims; use crate::database::InsightDao; use crate::otel::{extract_context_from_request, global_tracer}; @@ -45,7 +45,7 @@ pub struct AvailableModelsResponse { #[derive(Debug, Serialize)] pub struct ServerModels { pub url: String, - pub models: Vec, + pub models: Vec, pub default_model: String, } @@ -211,18 +211,18 @@ pub async fn get_all_insights_handler( } } -/// GET /insights/models - List available models from both servers +/// GET /insights/models - List available models from both servers with capabilities #[get("/insights/models")] pub async fn get_available_models_handler( _claims: Claims, app_state: web::Data, ) -> impl Responder { - log::debug!("Fetching available models"); + log::debug!("Fetching available models with capabilities"); let ollama_client = &app_state.ollama; - // Fetch models from primary server - let primary_models = match OllamaClient::list_models(&ollama_client.primary_url).await { + // Fetch models with capabilities from primary server + let primary_models = match OllamaClient::list_models_with_capabilities(&ollama_client.primary_url).await { Ok(models) => models, Err(e) => { log::warn!("Failed to fetch models from primary server: {:?}", e); @@ -236,9 +236,9 @@ pub async fn get_available_models_handler( default_model: ollama_client.primary_model.clone(), }; - // Fetch models from fallback server if configured + // Fetch models with capabilities from fallback server if configured let fallback = if let Some(fallback_url) = &ollama_client.fallback_url { - match OllamaClient::list_models(fallback_url).await { + match OllamaClient::list_models_with_capabilities(fallback_url).await { Ok(models) => Some(ServerModels { url: fallback_url.clone(), models, diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index d677041..5bf96c6 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -961,23 +961,62 @@ impl InsightGenerator { combined_context.len() ); - // 8. Load image and encode as base64 for vision models - let image_base64 = match self.load_image_as_base64(&file_path) { - Ok(b64) => { - log::info!("Successfully loaded image for vision model"); - Some(b64) + // 8. Check if the model has vision capabilities + let model_to_check = ollama_client.primary_model.clone(); + let has_vision = match OllamaClient::check_model_capabilities( + &ollama_client.primary_url, + &model_to_check, + ) + .await + { + Ok(capabilities) => { + log::info!( + "Model '{}' vision capability: {}", + model_to_check, + capabilities.has_vision + ); + capabilities.has_vision } Err(e) => { - log::warn!("Failed to load image for vision model: {}", e); - None + log::warn!( + "Failed to check vision capabilities for model '{}', assuming no vision support: {}", + model_to_check, + e + ); + false } }; - // 9. Generate title and summary with Ollama (using multi-source context + image) + insight_cx + .span() + .set_attribute(KeyValue::new("model_has_vision", has_vision)); + + // 9. Load image and encode as base64 only if model supports vision + let image_base64 = if has_vision { + match self.load_image_as_base64(&file_path) { + Ok(b64) => { + log::info!("Successfully loaded image for vision-capable model '{}'", model_to_check); + Some(b64) + } + Err(e) => { + log::warn!("Failed to load image for vision model: {}", e); + None + } + } + } else { + log::info!( + "Model '{}' does not support vision, skipping image processing", + model_to_check + ); + None + }; + + // 10. Generate title and summary with Ollama (using multi-source context + image if supported) let title = ollama_client .generate_photo_title( date_taken, location.as_deref(), + contact.as_deref(), Some(&combined_context), custom_system_prompt.as_deref(), image_base64.clone(), @@ -988,6 +1027,7 @@ impl InsightGenerator { .generate_photo_summary( date_taken, location.as_deref(), + contact.as_deref(), Some(&combined_context), custom_system_prompt.as_deref(), image_base64, @@ -1004,7 +1044,7 @@ impl InsightGenerator { .span() .set_attribute(KeyValue::new("summary_length", summary.len() as i64)); - // 9. Store in database + // 11. Store in database let insight = InsertPhotoInsight { file_path: file_path.to_string(), title, diff --git a/src/ai/mod.rs b/src/ai/mod.rs index fbee000..22b3a3e 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -11,5 +11,5 @@ pub use handlers::{ get_available_models_handler, get_insight_handler, }; pub use insight_generator::InsightGenerator; -pub use ollama::OllamaClient; +pub use ollama::{ModelCapabilities, OllamaClient}; pub use sms_client::{SmsApiClient, SmsMessage}; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 0de7fd0..8be9463 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -62,6 +62,67 @@ impl OllamaClient { Ok(models.iter().any(|m| m == model_name)) } + /// Check if a model has vision capabilities using the /api/show endpoint + pub async fn check_model_capabilities(url: &str, model_name: &str) -> Result { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build()?; + + #[derive(Serialize)] + struct ShowRequest { + model: String, + } + + let response = client + .post(&format!("{}/api/show", url)) + .json(&ShowRequest { + model: model_name.to_string(), + }) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to get model details for {} from {}", + model_name, + url + )); + } + + let show_response: OllamaShowResponse = response.json().await?; + + // Check if "vision" is in the capabilities array + let has_vision = show_response.capabilities.iter().any(|cap| cap == "vision"); + + Ok(ModelCapabilities { + name: model_name.to_string(), + has_vision, + }) + } + + /// List all models with their capabilities from a server + pub async fn list_models_with_capabilities(url: &str) -> Result> { + let models = Self::list_models(url).await?; + let mut capabilities = Vec::new(); + + for model_name in models { + match Self::check_model_capabilities(url, &model_name).await { + Ok(cap) => capabilities.push(cap), + Err(e) => { + log::warn!("Failed to get capabilities for model {}: {}", model_name, e); + // Fallback: assume no vision if we can't check + capabilities.push(ModelCapabilities { + name: model_name, + has_vision: false, + }); + } + } + } + + Ok(capabilities) + } + /// Extract final answer from thinking model output /// Handles ... tags and takes everything after fn extract_final_answer(&self, response: &str) -> String { @@ -216,6 +277,7 @@ impl OllamaClient { &self, date: NaiveDate, location: Option<&str>, + contact: Option<&str>, sms_summary: Option<&str>, custom_system: Option<&str>, image_base64: Option, @@ -224,8 +286,27 @@ impl OllamaClient { let sms_str = sms_summary.unwrap_or("No messages"); let prompt = if image_base64.is_some() { - format!( - r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context: + if let Some(contact_name) = contact { + format!( + r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context: + +Date: {} +Location: {} +Person/Contact: {} +Messages: {} + +Analyze the image and use specific details from both the visual content and the context above. The photo is from a folder for {}, so they are likely in or related to this photo. If limited information is available, use a simple descriptive title based on what you see. + +Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name + ) + } else { + format!( + r#"Create a short title (maximum 8 words) about this moment by analyzing the image and context: Date: {} Location: {} @@ -234,13 +315,33 @@ Messages: {} Analyze the image and use specific details from both the visual content and the context above. If limited information is available, use a simple descriptive title based on what you see. Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } } else { - format!( - r#"Create a short title (maximum 8 words) about this moment: + if let Some(contact_name) = contact { + format!( + r#"Create a short title (maximum 8 words) about this moment: + +Date: {} +Location: {} +Person/Contact: {} +Messages: {} + +Use specific details from the context above. The photo is from a folder for {}, so they are likely related to this moment. If no specific details are available, use a simple descriptive title. + +Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name + ) + } else { + format!( + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -249,10 +350,11 @@ Messages: {} Use specific details from the context above. If no specific details are available, use a simple descriptive title. Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } }; let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details."); @@ -269,6 +371,7 @@ Return ONLY the title, nothing else."#, &self, date: NaiveDate, location: Option<&str>, + contact: Option<&str>, sms_summary: Option<&str>, custom_system: Option<&str>, image_base64: Option, @@ -277,31 +380,69 @@ Return ONLY the title, nothing else."#, let sms_str = sms_summary.unwrap_or("No messages"); let prompt = if image_base64.is_some() { - format!( - r#"Write a 1-3 paragraph description of this moment by analyzing the image and the available context: + if let Some(contact_name) = contact { + format!( + r#"Write a 1-3 paragraph description of this moment by analyzing the image and the available context: + +Date: {} +Location: {} +Person/Contact: {} +Messages: {} + +Analyze the image and use specific details from both the visual content and the context above. The photo is from a folder for {}, so they are likely in or related to this photo. Mention people's names (especially {}), places, or activities if they appear in either the image or the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual based on what you see and know. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name, + contact_name + ) + } else { + format!( + r#"Write a 1-3 paragraph description of this moment by analyzing the image and the available context: Date: {} Location: {} Messages: {} Analyze the image and use specific details from both the visual content and the context above. Mention people's names, places, or activities if they appear in either the image or the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual based on what you see and know. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } } else { - format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + if let Some(contact_name) = contact { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: + +Date: {} +Location: {} +Person/Contact: {} +Messages: {} + +Use only the specific details provided above. The photo is from a folder for {}, so they are likely related to this moment. Mention people's names (especially {}), places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name, + contact_name + ) + } else { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: Date: {} Location: {} Messages: {} Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) + date.format("%B %d, %Y"), + location_str, + sms_str + ) + } }; let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."); @@ -482,6 +623,18 @@ struct OllamaModel { name: String, } +#[derive(Deserialize)] +struct OllamaShowResponse { + #[serde(default)] + capabilities: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ModelCapabilities { + pub name: String, + pub has_vision: bool, +} + #[derive(Serialize)] struct OllamaEmbedRequest { model: String, -- 2.49.1 From a37a211282fd7c477a3e187425169342491a5305 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 11 Jan 2026 21:15:41 -0500 Subject: [PATCH 18/25] Fix upload with missing file name or space in filename --- src/main.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2702cd3..ec0f634 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ use actix_web::{ web::{self, BufMut, BytesMut}, }; use chrono::Utc; +use urlencoding::decode; use diesel::sqlite::Sqlite; use rayon::prelude::*; @@ -225,8 +226,13 @@ async fn upload_image( if let Some(content_type) = part.content_disposition() { debug!("{:?}", content_type); if let Some(filename) = content_type.get_filename() { - debug!("Name: {:?}", filename); - file_name = Some(filename.to_string()); + debug!("Name (raw): {:?}", filename); + // Decode URL-encoded filename (e.g., "file%20name.jpg" -> "file name.jpg") + let decoded_filename = decode(filename) + .map(|s| s.to_string()) + .unwrap_or_else(|_| filename.to_string()); + debug!("Name (decoded): {:?}", decoded_filename); + file_name = Some(decoded_filename); while let Some(Ok(data)) = part.next().await { file_content.put(data); @@ -243,6 +249,10 @@ async fn upload_image( let path = file_path.unwrap_or_else(|| app_state.base_path.clone()); if !file_content.is_empty() { + if file_name.is_none() { + span.set_status(Status::error("No filename provided")); + return HttpResponse::BadRequest().body("No filename provided"); + } let full_path = PathBuf::from(&path).join(file_name.unwrap()); if let Some(full_path) = is_valid_full_path( &app_state.base_path, -- 2.49.1 From f65f4efde81c720c478d1d3a9997d1cca8f60ce7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 12:54:36 -0500 Subject: [PATCH 19/25] Make date parse from metadata a little more consistent --- src/ai/handlers.rs | 15 +++++----- src/ai/insight_generator.rs | 5 +++- src/ai/ollama.rs | 5 +++- src/files.rs | 57 ++++++++++++++++++++++++------------- src/main.rs | 2 +- 5 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 41a90b6..2fcfa06 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -222,13 +222,14 @@ pub async fn get_available_models_handler( let ollama_client = &app_state.ollama; // Fetch models with capabilities from primary server - let primary_models = match OllamaClient::list_models_with_capabilities(&ollama_client.primary_url).await { - Ok(models) => models, - Err(e) => { - log::warn!("Failed to fetch models from primary server: {:?}", e); - vec![] - } - }; + let primary_models = + match OllamaClient::list_models_with_capabilities(&ollama_client.primary_url).await { + Ok(models) => models, + Err(e) => { + log::warn!("Failed to fetch models from primary server: {:?}", e); + vec![] + } + }; let primary = ServerModels { url: ollama_client.primary_url.clone(), diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 5bf96c6..bad9507 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -995,7 +995,10 @@ impl InsightGenerator { let image_base64 = if has_vision { match self.load_image_as_base64(&file_path) { Ok(b64) => { - log::info!("Successfully loaded image for vision-capable model '{}'", model_to_check); + log::info!( + "Successfully loaded image for vision-capable model '{}'", + model_to_check + ); Some(b64) } Err(e) => { diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 8be9463..a9c9d35 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -63,7 +63,10 @@ impl OllamaClient { } /// Check if a model has vision capabilities using the /api/show endpoint - pub async fn check_model_capabilities(url: &str, model_name: &str) -> Result { + pub async fn check_model_capabilities( + url: &str, + model_name: &str, + ) -> Result { let client = Client::builder() .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(10)) diff --git a/src/files.rs b/src/files.rs index 600445f..ab6f63e 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,3 +1,6 @@ +use ::anyhow; +use actix::{Handler, Message}; +use anyhow::{Context, anyhow}; use std::collections::HashSet; use std::fmt::Debug; use std::fs::read_dir; @@ -5,10 +8,7 @@ use std::io; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::Mutex; - -use ::anyhow; -use actix::{Handler, Message}; -use anyhow::{Context, anyhow}; +use std::time::SystemTime; use crate::data::{Claims, FilesRequest, FilterMode, MediaType, PhotosResponse, SortType}; use crate::database::ExifDao; @@ -22,6 +22,7 @@ use actix_web::{ HttpRequest, HttpResponse, web::{self, Query}, }; +use chrono::{DateTime, Utc}; use log::{debug, error, info, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; @@ -73,18 +74,24 @@ fn apply_sorting_with_exif( .into_iter() .map(|f| { // Try EXIF date first - let date_taken = exif_map.get(&f.file_name).copied().or_else(|| { - // Fallback to filename extraction - extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp()) - }).or_else(|| { - // Fallback to filesystem metadata creation date - let full_path = base_path.join(&f.file_name); - std::fs::metadata(full_path) - .and_then(|md| md.created()) - .ok() - .and_then(|ct| ct.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - }); + let date_taken = exif_map + .get(&f.file_name) + .copied() + .or_else(|| { + // Fallback to filename extraction + extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp()) + }) + .or_else(|| { + // Fallback to filesystem metadata creation date + let full_path = base_path.join(&f.file_name); + std::fs::metadata(full_path) + .and_then(|md| md.created().or(md.modified())) + .ok() + .map(|system_time| { + >>::into(system_time) + .timestamp() + }) + }); FileWithMetadata { file_name: f.file_name, @@ -332,8 +339,13 @@ pub async fn list_photos( // Handle sorting - use helper function that supports EXIF date sorting let sort_type = req.sort.unwrap_or(NameAsc); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); - let result = - apply_sorting_with_exif(files, sort_type, &mut exif_dao_guard, &span_context, (&app_state.base_path).as_ref()); + let result = apply_sorting_with_exif( + files, + sort_type, + &mut exif_dao_guard, + &span_context, + (&app_state.base_path).as_ref(), + ); drop(exif_dao_guard); result }) @@ -476,8 +488,13 @@ pub async fn list_photos( // Handle sorting - use helper function that supports EXIF date sorting let response_files = if let Some(sort_type) = req.sort { let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); - let result = - apply_sorting_with_exif(photos, sort_type, &mut exif_dao_guard, &span_context, (&app_state.base_path).as_ref()); + let result = apply_sorting_with_exif( + photos, + sort_type, + &mut exif_dao_guard, + &span_context, + (&app_state.base_path).as_ref(), + ); drop(exif_dao_guard); result } else { diff --git a/src/main.rs b/src/main.rs index ec0f634..b37f5fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,9 +28,9 @@ use actix_web::{ web::{self, BufMut, BytesMut}, }; use chrono::Utc; -use urlencoding::decode; use diesel::sqlite::Sqlite; use rayon::prelude::*; +use urlencoding::decode; use crate::ai::InsightGenerator; use crate::auth::login; -- 2.49.1 From b7582e69a0edee300904b46bedeedb7857bd3e1c Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 13:12:09 -0500 Subject: [PATCH 20/25] Add model capability caching and clear functions --- src/ai/ollama.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index a9c9d35..16b4bd3 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -2,7 +2,41 @@ use anyhow::Result; use chrono::NaiveDate; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::time::Duration; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +// Cache duration: 15 minutes +const CACHE_DURATION_SECS: u64 = 15 * 60; + +// Cached entry with timestamp +#[derive(Clone)] +struct CachedEntry { + data: T, + cached_at: Instant, +} + +impl CachedEntry { + fn new(data: T) -> Self { + Self { + data, + cached_at: Instant::now(), + } + } + + fn is_expired(&self) -> bool { + self.cached_at.elapsed().as_secs() > CACHE_DURATION_SECS + } +} + +// Global cache for model lists and capabilities +lazy_static::lazy_static! { + static ref MODEL_LIST_CACHE: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); + + static ref MODEL_CAPABILITIES_CACHE: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); +} #[derive(Clone)] pub struct OllamaClient { @@ -39,8 +73,21 @@ impl OllamaClient { self.num_ctx = num_ctx; } - /// List available models on an Ollama server + /// List available models on an Ollama server (cached for 15 minutes) pub async fn list_models(url: &str) -> Result> { + // Check cache first + { + let cache = MODEL_LIST_CACHE.lock().unwrap(); + if let Some(entry) = cache.get(url) { + if !entry.is_expired() { + log::debug!("Returning cached model list for {}", url); + return Ok(entry.data.clone()); + } + } + } + + log::debug!("Fetching fresh model list from {}", url); + let client = Client::builder() .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(10)) @@ -53,7 +100,15 @@ impl OllamaClient { } let tags_response: OllamaTagsResponse = response.json().await?; - Ok(tags_response.models.into_iter().map(|m| m.name).collect()) + let models: Vec = tags_response.models.into_iter().map(|m| m.name).collect(); + + // Store in cache + { + let mut cache = MODEL_LIST_CACHE.lock().unwrap(); + cache.insert(url.to_string(), CachedEntry::new(models.clone())); + } + + Ok(models) } /// Check if a model is available on a server @@ -62,6 +117,30 @@ impl OllamaClient { Ok(models.iter().any(|m| m == model_name)) } + /// Clear the model list cache for a specific URL or all URLs + pub fn clear_model_cache(url: Option<&str>) { + let mut cache = MODEL_LIST_CACHE.lock().unwrap(); + if let Some(url) = url { + cache.remove(url); + log::debug!("Cleared model list cache for {}", url); + } else { + cache.clear(); + log::debug!("Cleared all model list cache entries"); + } + } + + /// Clear the model capabilities cache for a specific URL or all URLs + pub fn clear_capabilities_cache(url: Option<&str>) { + let mut cache = MODEL_CAPABILITIES_CACHE.lock().unwrap(); + if let Some(url) = url { + cache.remove(url); + log::debug!("Cleared model capabilities cache for {}", url); + } else { + cache.clear(); + log::debug!("Cleared all model capabilities cache entries"); + } + } + /// Check if a model has vision capabilities using the /api/show endpoint pub async fn check_model_capabilities( url: &str, @@ -104,8 +183,21 @@ impl OllamaClient { }) } - /// List all models with their capabilities from a server + /// List all models with their capabilities from a server (cached for 15 minutes) pub async fn list_models_with_capabilities(url: &str) -> Result> { + // Check cache first + { + let cache = MODEL_CAPABILITIES_CACHE.lock().unwrap(); + if let Some(entry) = cache.get(url) { + if !entry.is_expired() { + log::debug!("Returning cached model capabilities for {}", url); + return Ok(entry.data.clone()); + } + } + } + + log::debug!("Fetching fresh model capabilities from {}", url); + let models = Self::list_models(url).await?; let mut capabilities = Vec::new(); @@ -123,6 +215,12 @@ impl OllamaClient { } } + // Store in cache + { + let mut cache = MODEL_CAPABILITIES_CACHE.lock().unwrap(); + cache.insert(url.to_string(), CachedEntry::new(capabilities.clone())); + } + Ok(capabilities) } -- 2.49.1 From e9729e9956a678f44fa5cf251f74c01845314f6f Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 13:13:06 -0500 Subject: [PATCH 21/25] Fix unused import from binary from getting removed with cargo fix --- src/ai/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 22b3a3e..fb566a4 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -5,6 +5,8 @@ pub mod insight_generator; pub mod ollama; pub mod sms_client; +// strip_summary_boilerplate is used by binaries (test_daily_summary), not the library +#[allow(unused_imports)] pub use daily_summary_job::{generate_daily_summaries, strip_summary_boilerplate}; pub use handlers::{ delete_insight_handler, generate_insight_handler, get_all_insights_handler, -- 2.49.1 From e2d6cd7258bcf9490621bb0ee3245520497683f6 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 13:17:58 -0500 Subject: [PATCH 22/25] Run clippy fix --- src/ai/daily_summary_job.rs | 9 +- src/ai/embedding_job.rs | 2 +- src/ai/insight_generator.rs | 15 ++-- src/ai/ollama.rs | 132 ++++++++++++++--------------- src/bin/diagnose_embeddings.rs | 2 +- src/bin/import_calendar.rs | 10 +-- src/bin/import_location_history.rs | 10 +-- src/bin/import_search_history.rs | 9 +- src/bin/migrate_exif.rs | 1 - src/database/calendar_dao.rs | 12 ++- src/database/daily_summary_dao.rs | 4 +- src/database/embeddings_dao.rs | 2 +- src/database/location_dao.rs | 10 +-- src/database/search_dao.rs | 2 +- src/file_types.rs | 6 +- src/files.rs | 17 ++-- src/memories.rs | 3 +- src/parsers/ical_parser.rs | 6 +- src/parsers/search_html_parser.rs | 29 +++---- 19 files changed, 123 insertions(+), 158 deletions(-) diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs index ac0b821..988c046 100644 --- a/src/ai/daily_summary_job.rs +++ b/src/ai/daily_summary_job.rs @@ -63,7 +63,7 @@ pub fn strip_summary_boilerplate(summary: &str) -> String { text = text[phrase.len()..].trim_start().to_string(); // Remove leading punctuation/articles after stripping phrase text = text - .trim_start_matches(|c| c == ',' || c == ':' || c == '-') + .trim_start_matches([',', ':', '-']) .trim_start() .to_string(); break; @@ -71,13 +71,12 @@ pub fn strip_summary_boilerplate(summary: &str) -> String { } // Remove any remaining leading markdown bold markers - if text.starts_with("**") { - if let Some(end) = text[2..].find("**") { + if text.starts_with("**") + && let Some(end) = text[2..].find("**") { // Keep the content between ** but remove the markers let bold_content = &text[2..2 + end]; text = format!("{}{}", bold_content, &text[4 + end..]); } - } text.trim().to_string() } @@ -144,7 +143,7 @@ pub async fn generate_daily_summaries( if date >= start && date <= end { messages_by_date .entry(date) - .or_insert_with(Vec::new) + .or_default() .push(msg); } } diff --git a/src/ai/embedding_job.rs b/src/ai/embedding_job.rs index 46ffbb5..6926262 100644 --- a/src/ai/embedding_job.rs +++ b/src/ai/embedding_job.rs @@ -106,7 +106,7 @@ pub async fn embed_contact_messages( log::info!( "Processing batch {}/{}: messages {}-{} ({:.1}% complete)", batch_idx + 1, - (to_embed + batch_size - 1) / batch_size, + to_embed.div_ceil(batch_size), batch_start + 1, batch_end, (batch_end as f64 / to_embed as f64) * 100.0 diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index bad9507..b6aa432 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -84,13 +84,11 @@ impl InsightGenerator { let components: Vec<_> = path.components().collect(); // If path has at least 2 components (directory + file), extract first directory - if components.len() >= 2 { - if let Some(component) = components.first() { - if let Some(os_str) = component.as_os_str().to_str() { + if components.len() >= 2 + && let Some(component) = components.first() + && let Some(os_str) = component.as_os_str().to_str() { return Some(os_str.to_string()); } - } - } None } @@ -191,8 +189,8 @@ impl InsightGenerator { .into_iter() .filter(|msg| { // Extract date from formatted daily summary "[2024-08-15] Contact ..." - if let Some(bracket_end) = msg.find(']') { - if let Some(date_str) = msg.get(1..bracket_end) { + if let Some(bracket_end) = msg.find(']') + && let Some(date_str) = msg.get(1..bracket_end) { // Parse just the date (daily summaries don't have time) if let Ok(msg_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") @@ -206,7 +204,6 @@ impl InsightGenerator { return time_diff > exclusion_window; } } - } false }) .take(limit) @@ -521,7 +518,7 @@ impl InsightGenerator { "searches about {} {} {}", DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.format("%B %Y").to_string()) - .unwrap_or_else(|| "".to_string()), + .unwrap_or_default(), location.unwrap_or(""), contact .map(|c| format!("involving {}", c)) diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 16b4bd3..7d27fb8 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -78,12 +78,11 @@ impl OllamaClient { // Check cache first { let cache = MODEL_LIST_CACHE.lock().unwrap(); - if let Some(entry) = cache.get(url) { - if !entry.is_expired() { + if let Some(entry) = cache.get(url) + && !entry.is_expired() { log::debug!("Returning cached model list for {}", url); return Ok(entry.data.clone()); } - } } log::debug!("Fetching fresh model list from {}", url); @@ -93,7 +92,7 @@ impl OllamaClient { .timeout(Duration::from_secs(10)) .build()?; - let response = client.get(&format!("{}/api/tags", url)).send().await?; + let response = client.get(format!("{}/api/tags", url)).send().await?; if !response.status().is_success() { return Err(anyhow::anyhow!("Failed to list models from {}", url)); @@ -157,7 +156,7 @@ impl OllamaClient { } let response = client - .post(&format!("{}/api/show", url)) + .post(format!("{}/api/show", url)) .json(&ShowRequest { model: model_name.to_string(), }) @@ -188,12 +187,11 @@ impl OllamaClient { // Check cache first { let cache = MODEL_CAPABILITIES_CACHE.lock().unwrap(); - if let Some(entry) = cache.get(url) { - if !entry.is_expired() { + if let Some(entry) = cache.get(url) + && !entry.is_expired() { log::debug!("Returning cached model capabilities for {}", url); return Ok(entry.data.clone()); } - } } log::debug!("Fetching fresh model capabilities from {}", url); @@ -260,7 +258,7 @@ impl OllamaClient { let response = self .client - .post(&format!("{}/api/generate", url)) + .post(format!("{}/api/generate", url)) .json(&request) .send() .await?; @@ -421,42 +419,40 @@ Return ONLY the title, nothing else."#, sms_str ) } - } else { - if let Some(contact_name) = contact { - format!( - r#"Create a short title (maximum 8 words) about this moment: + } else if let Some(contact_name) = contact { + format!( + r#"Create a short title (maximum 8 words) about this moment: -Date: {} -Location: {} -Person/Contact: {} -Messages: {} + Date: {} + Location: {} + Person/Contact: {} + Messages: {} -Use specific details from the context above. The photo is from a folder for {}, so they are likely related to this moment. If no specific details are available, use a simple descriptive title. + Use specific details from the context above. The photo is from a folder for {}, so they are likely related to this moment. If no specific details are available, use a simple descriptive title. -Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - contact_name, - sms_str, - contact_name - ) - } else { - format!( - r#"Create a short title (maximum 8 words) about this moment: + Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name + ) + } else { + format!( + r#"Create a short title (maximum 8 words) about this moment: -Date: {} -Location: {} -Messages: {} + Date: {} + Location: {} + Messages: {} -Use specific details from the context above. If no specific details are available, use a simple descriptive title. + Use specific details from the context above. If no specific details are available, use a simple descriptive title. -Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) - } - }; + Return ONLY the title, nothing else."#, + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details."); @@ -512,39 +508,37 @@ Analyze the image and use specific details from both the visual content and the sms_str ) } - } else { - if let Some(contact_name) = contact { - format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + } else if let Some(contact_name) = contact { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: -Date: {} -Location: {} -Person/Contact: {} -Messages: {} + Date: {} + Location: {} + Person/Contact: {} + Messages: {} -Use only the specific details provided above. The photo is from a folder for {}, so they are likely related to this moment. Mention people's names (especially {}), places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - contact_name, - sms_str, - contact_name, - contact_name - ) - } else { - format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + Use only the specific details provided above. The photo is from a folder for {}, so they are likely related to this moment. Mention people's names (especially {}), places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name, + contact_name + ) + } else { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: -Date: {} -Location: {} -Messages: {} + Date: {} + Location: {} + Messages: {} -Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) - } - }; + Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."); @@ -671,7 +665,7 @@ Use only the specific details provided above. Mention people's names, places, or let response = self .client - .post(&format!("{}/api/embed", url)) + .post(format!("{}/api/embed", url)) .json(&request) .send() .await?; diff --git a/src/bin/diagnose_embeddings.rs b/src/bin/diagnose_embeddings.rs index 05082b0..31dee56 100644 --- a/src/bin/diagnose_embeddings.rs +++ b/src/bin/diagnose_embeddings.rs @@ -36,7 +36,7 @@ struct EmbeddingRow { } fn deserialize_embedding(bytes: &[u8]) -> Result> { - if bytes.len() % 4 != 0 { + if !bytes.len().is_multiple_of(4) { return Err(anyhow::anyhow!("Invalid embedding byte length")); } diff --git a/src/bin/import_calendar.rs b/src/bin/import_calendar.rs index e7b1b2c..277614c 100644 --- a/src/bin/import_calendar.rs +++ b/src/bin/import_calendar.rs @@ -74,18 +74,16 @@ async fn main() -> Result<()> { let mut dao_instance = SqliteCalendarEventDao::new(); // Check if event exists - if args.skip_existing { - if let Ok(exists) = dao_instance.event_exists( + if args.skip_existing + && let Ok(exists) = dao_instance.event_exists( &context, event.event_uid.as_deref().unwrap_or(""), event.start_time, - ) { - if exists { + ) + && exists { *skipped_count.lock().unwrap() += 1; continue; } - } - } // Generate embedding if requested (blocking call) let embedding = if let Some(ref ollama_client) = ollama { diff --git a/src/bin/import_location_history.rs b/src/bin/import_location_history.rs index a0437a1..792cb55 100644 --- a/src/bin/import_location_history.rs +++ b/src/bin/import_location_history.rs @@ -58,19 +58,17 @@ async fn main() -> Result<()> { for location in chunk { // Skip existing check if requested (makes import much slower) - if args.skip_existing { - if let Ok(exists) = dao_instance.location_exists( + if args.skip_existing + && let Ok(exists) = dao_instance.location_exists( &context, location.timestamp, location.latitude, location.longitude, - ) { - if exists { + ) + && exists { skipped_count += 1; continue; } - } - } batch_inserts.push(InsertLocationRecord { timestamp: location.timestamp, diff --git a/src/bin/import_search_history.rs b/src/bin/import_search_history.rs index 3438230..0b1df28 100644 --- a/src/bin/import_search_history.rs +++ b/src/bin/import_search_history.rs @@ -92,16 +92,13 @@ async fn main() -> Result<()> { for (search, embedding_opt) in chunk.iter().zip(embeddings_result.iter()) { // Check if search exists (optional for speed) - if args.skip_existing { - if let Ok(exists) = + if args.skip_existing + && let Ok(exists) = dao_instance.search_exists(&context, search.timestamp, &search.query) - { - if exists { + && exists { skipped_count += 1; continue; } - } - } // Only insert if we have an embedding if let Some(embedding) = embedding_opt { diff --git a/src/bin/migrate_exif.rs b/src/bin/migrate_exif.rs index 98e83dc..3235a63 100644 --- a/src/bin/migrate_exif.rs +++ b/src/bin/migrate_exif.rs @@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex}; use chrono::Utc; use clap::Parser; -use opentelemetry; use rayon::prelude::*; use walkdir::WalkDir; diff --git a/src/database/calendar_dao.rs b/src/database/calendar_dao.rs index e1afefd..63aded5 100644 --- a/src/database/calendar_dao.rs +++ b/src/database/calendar_dao.rs @@ -118,7 +118,7 @@ impl SqliteCalendarEventDao { } fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { - if bytes.len() % 4 != 0 { + if !bytes.len().is_multiple_of(4) { return Err(DbError::new(DbErrorKind::QueryError)); } @@ -218,14 +218,13 @@ impl CalendarEventDao for SqliteCalendarEventDao { .expect("Unable to get CalendarEventDao"); // Validate embedding dimensions if provided - if let Some(ref emb) = event.embedding { - if emb.len() != 768 { + if let Some(ref emb) = event.embedding + && emb.len() != 768 { return Err(anyhow::anyhow!( "Invalid embedding dimensions: {} (expected 768)", emb.len() )); } - } let embedding_bytes = event.embedding.as_ref().map(|e| Self::serialize_vector(e)); @@ -289,15 +288,14 @@ impl CalendarEventDao for SqliteCalendarEventDao { conn.transaction::<_, anyhow::Error, _>(|conn| { for event in events { // Validate embedding if provided - if let Some(ref emb) = event.embedding { - if emb.len() != 768 { + if let Some(ref emb) = event.embedding + && emb.len() != 768 { log::warn!( "Skipping event with invalid embedding dimensions: {}", emb.len() ); continue; } - } let embedding_bytes = event.embedding.as_ref().map(|e| Self::serialize_vector(e)); diff --git a/src/database/daily_summary_dao.rs b/src/database/daily_summary_dao.rs index 93d77b4..5b1126f 100644 --- a/src/database/daily_summary_dao.rs +++ b/src/database/daily_summary_dao.rs @@ -98,7 +98,7 @@ impl SqliteDailySummaryDao { } fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { - if bytes.len() % 4 != 0 { + if !bytes.len().is_multiple_of(4) { return Err(DbError::new(DbErrorKind::QueryError)); } @@ -448,7 +448,7 @@ impl DailySummaryDao for SqliteDailySummaryDao { .bind::(contact) .get_result::(conn.deref_mut()) .map(|r| r.count) - .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e).into()) + .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e)) }) .map_err(|_| DbError::new(DbErrorKind::QueryError)) } diff --git a/src/database/embeddings_dao.rs b/src/database/embeddings_dao.rs index bcea675..5a9df5a 100644 --- a/src/database/embeddings_dao.rs +++ b/src/database/embeddings_dao.rs @@ -109,7 +109,7 @@ impl SqliteEmbeddingDao { /// Deserialize bytes from BLOB back to f32 vector fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { - if bytes.len() % 4 != 0 { + if !bytes.len().is_multiple_of(4) { return Err(DbError::new(DbErrorKind::QueryError)); } diff --git a/src/database/location_dao.rs b/src/database/location_dao.rs index 86b8efe..c4b3989 100644 --- a/src/database/location_dao.rs +++ b/src/database/location_dao.rs @@ -213,14 +213,13 @@ impl LocationHistoryDao for SqliteLocationHistoryDao { .expect("Unable to get LocationHistoryDao"); // Validate embedding dimensions if provided (rare for location data) - if let Some(ref emb) = location.embedding { - if emb.len() != 768 { + if let Some(ref emb) = location.embedding + && emb.len() != 768 { return Err(anyhow::anyhow!( "Invalid embedding dimensions: {} (expected 768)", emb.len() )); } - } let embedding_bytes = location .embedding @@ -289,15 +288,14 @@ impl LocationHistoryDao for SqliteLocationHistoryDao { conn.transaction::<_, anyhow::Error, _>(|conn| { for location in locations { // Validate embedding if provided (rare) - if let Some(ref emb) = location.embedding { - if emb.len() != 768 { + if let Some(ref emb) = location.embedding + && emb.len() != 768 { log::warn!( "Skipping location with invalid embedding dimensions: {}", emb.len() ); continue; } - } let embedding_bytes = location .embedding diff --git a/src/database/search_dao.rs b/src/database/search_dao.rs index 9ae9ef7..04d0d2f 100644 --- a/src/database/search_dao.rs +++ b/src/database/search_dao.rs @@ -105,7 +105,7 @@ impl SqliteSearchHistoryDao { } fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { - if bytes.len() % 4 != 0 { + if !bytes.len().is_multiple_of(4) { return Err(DbError::new(DbErrorKind::QueryError)); } diff --git a/src/file_types.rs b/src/file_types.rs index ac99085..4db837c 100644 --- a/src/file_types.rs +++ b/src/file_types.rs @@ -36,17 +36,17 @@ pub fn is_media_file(path: &Path) -> bool { /// Check if a DirEntry is an image file (for walkdir usage) pub fn direntry_is_image(entry: &DirEntry) -> bool { - is_image_file(&entry.path()) + is_image_file(entry.path()) } /// Check if a DirEntry is a video file (for walkdir usage) pub fn direntry_is_video(entry: &DirEntry) -> bool { - is_video_file(&entry.path()) + is_video_file(entry.path()) } /// Check if a DirEntry is a media file (for walkdir usage) pub fn direntry_is_media(entry: &DirEntry) -> bool { - is_media_file(&entry.path()) + is_media_file(entry.path()) } #[cfg(test)] diff --git a/src/files.rs b/src/files.rs index ab6f63e..729e73e 100644 --- a/src/files.rs +++ b/src/files.rs @@ -234,8 +234,8 @@ pub async fn list_photos( (exif.gps_latitude, exif.gps_longitude) { let distance = haversine_distance( - lat as f64, - lon as f64, + lat, + lon, photo_lat as f64, photo_lon as f64, ); @@ -344,7 +344,7 @@ pub async fn list_photos( sort_type, &mut exif_dao_guard, &span_context, - (&app_state.base_path).as_ref(), + app_state.base_path.as_ref(), ); drop(exif_dao_guard); result @@ -410,14 +410,9 @@ pub async fn list_photos( ) }) .map(|path: &PathBuf| { - let relative = path.strip_prefix(&app_state.base_path).expect( - format!( - "Unable to strip base path {} from file path {}", + let relative = path.strip_prefix(&app_state.base_path).unwrap_or_else(|_| panic!("Unable to strip base path {} from file path {}", &app_state.base_path.path(), - path.display() - ) - .as_str(), - ); + path.display())); relative.to_path_buf() }) .map(|f| f.to_str().unwrap().to_string()) @@ -493,7 +488,7 @@ pub async fn list_photos( sort_type, &mut exif_dao_guard, &span_context, - (&app_state.base_path).as_ref(), + app_state.base_path.as_ref(), ); drop(exif_dao_guard); result diff --git a/src/memories.rs b/src/memories.rs index 61766d1..f120d36 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -229,8 +229,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option= 14 - && len <= 16 + if (14..=16).contains(&len) && let Some(date_time) = timestamp_str[0..10] .parse::() .ok() diff --git a/src/parsers/ical_parser.rs b/src/parsers/ical_parser.rs index c2d0bff..a0007bc 100644 --- a/src/parsers/ical_parser.rs +++ b/src/parsers/ical_parser.rs @@ -142,12 +142,12 @@ fn parse_ical_datetime(value: &str, property: &Property) -> Result> } fn extract_email_from_mailto(value: Option<&str>) -> Option { - value.and_then(|v| { + value.map(|v| { // ORGANIZER and ATTENDEE often have format: mailto:user@example.com if v.starts_with("mailto:") { - Some(v.trim_start_matches("mailto:").to_string()) + v.trim_start_matches("mailto:").to_string() } else { - Some(v.to_string()) + v.to_string() } }) } diff --git a/src/parsers/search_html_parser.rs b/src/parsers/search_html_parser.rs index 8b14ee2..91440be 100644 --- a/src/parsers/search_html_parser.rs +++ b/src/parsers/search_html_parser.rs @@ -29,24 +29,23 @@ pub fn parse_search_html(path: &str) -> Result> { } // Strategy 2: Look for outer-cell structure (older format) - if records.is_empty() { - if let Ok(outer_selector) = Selector::parse("div.outer-cell") { + if records.is_empty() + && let Ok(outer_selector) = Selector::parse("div.outer-cell") { for cell in document.select(&outer_selector) { if let Some(record) = parse_outer_cell(&cell) { records.push(record); } } } - } // Strategy 3: Generic approach - look for links and timestamps - if records.is_empty() { - if let Ok(link_selector) = Selector::parse("a") { + if records.is_empty() + && let Ok(link_selector) = Selector::parse("a") { for link in document.select(&link_selector) { if let Some(href) = link.value().attr("href") { // Check if it's a search URL - if href.contains("google.com/search?q=") || href.contains("search?q=") { - if let Some(query) = extract_query_from_url(href) { + if (href.contains("google.com/search?q=") || href.contains("search?q=")) + && let Some(query) = extract_query_from_url(href) { // Try to find nearby timestamp let timestamp = find_nearby_timestamp(&link); @@ -56,11 +55,9 @@ pub fn parse_search_html(path: &str) -> Result> { search_engine: Some("Google".to_string()), }); } - } } } } - } Ok(records) } @@ -120,13 +117,12 @@ fn extract_query_from_url(url: &str) -> Option { fn find_nearby_timestamp(element: &scraper::ElementRef) -> Option { // Look for timestamp in parent or sibling elements - if let Some(parent) = element.parent() { - if parent.value().as_element().is_some() { + if let Some(parent) = element.parent() + && parent.value().as_element().is_some() { let parent_ref = scraper::ElementRef::wrap(parent)?; let text = parent_ref.text().collect::>().join(" "); return parse_timestamp_from_text(&text); } - } None } @@ -139,11 +135,9 @@ fn parse_timestamp_from_text(text: &str) -> Option { if let Some(iso_match) = text .split_whitespace() .find(|s| s.contains('T') && s.contains('-')) - { - if let Ok(dt) = DateTime::parse_from_rfc3339(iso_match) { + && let Ok(dt) = DateTime::parse_from_rfc3339(iso_match) { return Some(dt.timestamp()); } - } // Try common date patterns let patterns = [ @@ -154,11 +148,10 @@ fn parse_timestamp_from_text(text: &str) -> Option { for pattern in patterns { // Extract potential date string - if let Some(date_part) = extract_date_substring(text) { - if let Ok(dt) = NaiveDateTime::parse_from_str(&date_part, pattern) { + if let Some(date_part) = extract_date_substring(text) + && let Ok(dt) = NaiveDateTime::parse_from_str(&date_part, pattern) { return Some(dt.and_utc().timestamp()); } - } } None -- 2.49.1 From af35a996a3e64f3c27e23f8deab06e91aa9b99e4 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 13:31:15 -0500 Subject: [PATCH 23/25] Cleanup unused message embedding code Fixup some warnings --- src/ai/daily_summary_job.rs | 16 +- src/ai/embedding_job.rs | 212 ----------- src/ai/insight_generator.rs | 32 +- src/ai/mod.rs | 1 - src/ai/ollama.rs | 99 +++-- src/bin/import_calendar.rs | 9 +- src/bin/import_location_history.rs | 9 +- src/bin/import_search_history.rs | 9 +- src/bin/test_daily_summary.rs | 2 +- src/data/mod.rs | 2 + src/database/calendar_dao.rs | 29 +- src/database/embeddings_dao.rs | 569 ---------------------------- src/database/location_dao.rs | 28 +- src/database/mod.rs | 2 - src/files.rs | 17 +- src/parsers/location_json_parser.rs | 1 + src/parsers/search_html_parser.rs | 66 ++-- 17 files changed, 161 insertions(+), 942 deletions(-) delete mode 100644 src/ai/embedding_job.rs delete mode 100644 src/database/embeddings_dao.rs diff --git a/src/ai/daily_summary_job.rs b/src/ai/daily_summary_job.rs index 988c046..9d9c9e0 100644 --- a/src/ai/daily_summary_job.rs +++ b/src/ai/daily_summary_job.rs @@ -72,11 +72,12 @@ pub fn strip_summary_boilerplate(summary: &str) -> String { // Remove any remaining leading markdown bold markers if text.starts_with("**") - && let Some(end) = text[2..].find("**") { - // Keep the content between ** but remove the markers - let bold_content = &text[2..2 + end]; - text = format!("{}{}", bold_content, &text[4 + end..]); - } + && let Some(end) = text[2..].find("**") + { + // Keep the content between ** but remove the markers + let bold_content = &text[2..2 + end]; + text = format!("{}{}", bold_content, &text[4 + end..]); + } text.trim().to_string() } @@ -141,10 +142,7 @@ pub async fn generate_daily_summaries( if let Some(dt) = msg_dt { let date = dt.date_naive(); if date >= start && date <= end { - messages_by_date - .entry(date) - .or_default() - .push(msg); + messages_by_date.entry(date).or_default().push(msg); } } } diff --git a/src/ai/embedding_job.rs b/src/ai/embedding_job.rs deleted file mode 100644 index 6926262..0000000 --- a/src/ai/embedding_job.rs +++ /dev/null @@ -1,212 +0,0 @@ -use anyhow::Result; -use chrono::Utc; -use std::sync::{Arc, Mutex}; -use tokio::time::{Duration, sleep}; - -use crate::ai::{OllamaClient, SmsApiClient}; -use crate::database::{EmbeddingDao, InsertMessageEmbedding}; - -/// Background job to embed messages for a specific contact -/// This function is idempotent - it checks if embeddings already exist before processing -/// -/// # Arguments -/// * `contact` - The contact name to embed messages for (e.g., "Amanda") -/// * `ollama` - Ollama client for generating embeddings -/// * `sms_client` - SMS API client for fetching messages -/// * `embedding_dao` - DAO for storing embeddings in the database -/// -/// # Returns -/// Ok(()) on success, Err on failure -pub async fn embed_contact_messages( - contact: &str, - ollama: &OllamaClient, - sms_client: &SmsApiClient, - embedding_dao: Arc>>, -) -> Result<()> { - log::info!("Starting message embedding job for contact: {}", contact); - - let otel_context = opentelemetry::Context::new(); - - // Check existing embeddings count - let existing_count = { - let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - dao.get_message_count(&otel_context, contact).unwrap_or(0) - }; - - if existing_count > 0 { - log::info!( - "Contact '{}' already has {} embeddings, will check for new messages to embed", - contact, - existing_count - ); - } - - log::info!("Fetching all messages for contact: {}", contact); - - // Fetch all messages for the contact - let messages = sms_client.fetch_all_messages_for_contact(contact).await?; - - let total_messages = messages.len(); - log::info!( - "Fetched {} messages for contact '{}'", - total_messages, - contact - ); - - if total_messages == 0 { - log::warn!( - "No messages found for contact '{}', nothing to embed", - contact - ); - return Ok(()); - } - - // Filter out messages that already have embeddings and short/generic messages - log::info!("Filtering out messages that already have embeddings and short messages..."); - let min_message_length = 30; // Skip short messages like "Thanks!" or "Yeah, it was :)" - let messages_to_embed: Vec<&crate::ai::SmsMessage> = { - let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - messages - .iter() - .filter(|msg| { - // Filter out short messages - if msg.body.len() < min_message_length { - return false; - } - // Filter out already embedded messages - !dao.message_exists(&otel_context, contact, &msg.body, msg.timestamp) - .unwrap_or(false) - }) - .collect() - }; - - let skipped = total_messages - messages_to_embed.len(); - let to_embed = messages_to_embed.len(); - - log::info!( - "Found {} messages to embed ({} already embedded)", - to_embed, - skipped - ); - - if to_embed == 0 { - log::info!("All messages already embedded for contact '{}'", contact); - return Ok(()); - } - - // Process messages in batches - let batch_size = 128; // Embed 128 messages per API call - let mut successful = 0; - let mut failed = 0; - - for (batch_idx, batch) in messages_to_embed.chunks(batch_size).enumerate() { - let batch_start = batch_idx * batch_size; - let batch_end = batch_start + batch.len(); - - log::info!( - "Processing batch {}/{}: messages {}-{} ({:.1}% complete)", - batch_idx + 1, - to_embed.div_ceil(batch_size), - batch_start + 1, - batch_end, - (batch_end as f64 / to_embed as f64) * 100.0 - ); - - match embed_message_batch(batch, contact, ollama, embedding_dao.clone()).await { - Ok(count) => { - successful += count; - log::debug!("Successfully embedded {} messages in batch", count); - } - Err(e) => { - failed += batch.len(); - log::error!("Failed to embed batch: {:?}", e); - // Continue processing despite failures - } - } - - // Small delay between batches to avoid overwhelming Ollama - if batch_end < to_embed { - sleep(Duration::from_millis(500)).await; - } - } - - log::info!( - "Message embedding job complete for '{}': {}/{} new embeddings created ({} already embedded, {} failed)", - contact, - successful, - total_messages, - skipped, - failed - ); - - if failed > 0 { - log::warn!( - "{} messages failed to embed for contact '{}'", - failed, - contact - ); - } - - Ok(()) -} - -/// Embed a batch of messages using a single API call -/// Returns the number of successfully embedded messages -async fn embed_message_batch( - messages: &[&crate::ai::SmsMessage], - contact: &str, - ollama: &OllamaClient, - embedding_dao: Arc>>, -) -> Result { - if messages.is_empty() { - return Ok(0); - } - - // Collect message bodies for batch embedding - let bodies: Vec<&str> = messages.iter().map(|m| m.body.as_str()).collect(); - - // Generate embeddings for all messages in one API call - let embeddings = ollama.generate_embeddings(&bodies).await?; - - if embeddings.len() != messages.len() { - return Err(anyhow::anyhow!( - "Embedding count mismatch: got {} embeddings for {} messages", - embeddings.len(), - messages.len() - )); - } - - // Build batch of insert records - let otel_context = opentelemetry::Context::new(); - let created_at = Utc::now().timestamp(); - let mut inserts = Vec::with_capacity(messages.len()); - - for (message, embedding) in messages.iter().zip(embeddings.iter()) { - // Validate embedding dimensions - if embedding.len() != 768 { - log::warn!( - "Invalid embedding dimensions: {} (expected 768), skipping", - embedding.len() - ); - continue; - } - - inserts.push(InsertMessageEmbedding { - contact: contact.to_string(), - body: message.body.clone(), - timestamp: message.timestamp, - is_sent: message.is_sent, - embedding: embedding.clone(), - created_at, - model_version: "nomic-embed-text:v1.5".to_string(), - }); - } - - // Store all embeddings in a single transaction - let mut dao = embedding_dao.lock().expect("Unable to lock EmbeddingDao"); - let stored_count = dao - .store_message_embeddings_batch(&otel_context, inserts) - .map_err(|e| anyhow::anyhow!("Failed to store embeddings batch: {:?}", e))?; - - Ok(stored_count) -} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index b6aa432..844a38e 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -86,9 +86,10 @@ impl InsightGenerator { // If path has at least 2 components (directory + file), extract first directory if components.len() >= 2 && let Some(component) = components.first() - && let Some(os_str) = component.as_os_str().to_str() { - return Some(os_str.to_string()); - } + && let Some(os_str) = component.as_os_str().to_str() + { + return Some(os_str.to_string()); + } None } @@ -190,20 +191,19 @@ impl InsightGenerator { .filter(|msg| { // Extract date from formatted daily summary "[2024-08-15] Contact ..." if let Some(bracket_end) = msg.find(']') - && let Some(date_str) = msg.get(1..bracket_end) { - // Parse just the date (daily summaries don't have time) - if let Ok(msg_date) = - chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") - { - let msg_timestamp = msg_date - .and_hms_opt(12, 0, 0) - .unwrap() - .and_utc() - .timestamp(); - let time_diff = (photo_timestamp - msg_timestamp).abs(); - return time_diff > exclusion_window; - } + && let Some(date_str) = msg.get(1..bracket_end) + { + // Parse just the date (daily summaries don't have time) + if let Ok(msg_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { + let msg_timestamp = msg_date + .and_hms_opt(12, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + let time_diff = (photo_timestamp - msg_timestamp).abs(); + return time_diff > exclusion_window; } + } false }) .take(limit) diff --git a/src/ai/mod.rs b/src/ai/mod.rs index fb566a4..57425e1 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -1,5 +1,4 @@ pub mod daily_summary_job; -pub mod embedding_job; pub mod handlers; pub mod insight_generator; pub mod ollama; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 7d27fb8..c7da48f 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -79,10 +79,11 @@ impl OllamaClient { { let cache = MODEL_LIST_CACHE.lock().unwrap(); if let Some(entry) = cache.get(url) - && !entry.is_expired() { - log::debug!("Returning cached model list for {}", url); - return Ok(entry.data.clone()); - } + && !entry.is_expired() + { + log::debug!("Returning cached model list for {}", url); + return Ok(entry.data.clone()); + } } log::debug!("Fetching fresh model list from {}", url); @@ -188,10 +189,11 @@ impl OllamaClient { { let cache = MODEL_CAPABILITIES_CACHE.lock().unwrap(); if let Some(entry) = cache.get(url) - && !entry.is_expired() { - log::debug!("Returning cached model capabilities for {}", url); - return Ok(entry.data.clone()); - } + && !entry.is_expired() + { + log::debug!("Returning cached model capabilities for {}", url); + return Ok(entry.data.clone()); + } } log::debug!("Fetching fresh model capabilities from {}", url); @@ -420,8 +422,8 @@ Return ONLY the title, nothing else."#, ) } } else if let Some(contact_name) = contact { - format!( - r#"Create a short title (maximum 8 words) about this moment: + format!( + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -431,15 +433,15 @@ Return ONLY the title, nothing else."#, Use specific details from the context above. The photo is from a folder for {}, so they are likely related to this moment. If no specific details are available, use a simple descriptive title. Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - contact_name, - sms_str, - contact_name - ) - } else { - format!( - r#"Create a short title (maximum 8 words) about this moment: + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name + ) + } else { + format!( + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -448,11 +450,11 @@ Return ONLY the title, nothing else."#, Use specific details from the context above. If no specific details are available, use a simple descriptive title. Return ONLY the title, nothing else."#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) - }; + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details."); @@ -509,8 +511,8 @@ Analyze the image and use specific details from both the visual content and the ) } } else if let Some(contact_name) = contact { - format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: Date: {} Location: {} @@ -518,27 +520,27 @@ Analyze the image and use specific details from both the visual content and the Messages: {} Use only the specific details provided above. The photo is from a folder for {}, so they are likely related to this moment. Mention people's names (especially {}), places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - contact_name, - sms_str, - contact_name, - contact_name - ) - } else { - format!( - r#"Write a 1-3 paragraph description of this moment based on the available information: + date.format("%B %d, %Y"), + location_str, + contact_name, + sms_str, + contact_name, + contact_name + ) + } else { + format!( + r#"Write a 1-3 paragraph description of this moment based on the available information: Date: {} Location: {} Messages: {} Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, - date.format("%B %d, %Y"), - location_str, - sms_str - ) - }; + date.format("%B %d, %Y"), + location_str, + sms_str + ) + }; let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details."); @@ -642,15 +644,6 @@ Analyze the image and use specific details from both the visual content and the Ok(embeddings) } - /// Internal helper to try generating an embedding from a specific server - async fn try_generate_embedding(&self, url: &str, model: &str, text: &str) -> Result> { - let embeddings = self.try_generate_embeddings(url, model, &[text]).await?; - embeddings - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("No embedding returned from Ollama")) - } - /// Internal helper to try generating embeddings for multiple texts from a specific server async fn try_generate_embeddings( &self, @@ -730,12 +723,6 @@ pub struct ModelCapabilities { pub has_vision: bool, } -#[derive(Serialize)] -struct OllamaEmbedRequest { - model: String, - input: String, -} - #[derive(Serialize)] struct OllamaBatchEmbedRequest { model: String, diff --git a/src/bin/import_calendar.rs b/src/bin/import_calendar.rs index 277614c..8dba110 100644 --- a/src/bin/import_calendar.rs +++ b/src/bin/import_calendar.rs @@ -80,10 +80,11 @@ async fn main() -> Result<()> { event.event_uid.as_deref().unwrap_or(""), event.start_time, ) - && exists { - *skipped_count.lock().unwrap() += 1; - continue; - } + && exists + { + *skipped_count.lock().unwrap() += 1; + continue; + } // Generate embedding if requested (blocking call) let embedding = if let Some(ref ollama_client) = ollama { diff --git a/src/bin/import_location_history.rs b/src/bin/import_location_history.rs index 792cb55..baa0d54 100644 --- a/src/bin/import_location_history.rs +++ b/src/bin/import_location_history.rs @@ -65,10 +65,11 @@ async fn main() -> Result<()> { location.latitude, location.longitude, ) - && exists { - skipped_count += 1; - continue; - } + && exists + { + skipped_count += 1; + continue; + } batch_inserts.push(InsertLocationRecord { timestamp: location.timestamp, diff --git a/src/bin/import_search_history.rs b/src/bin/import_search_history.rs index 0b1df28..f278ca1 100644 --- a/src/bin/import_search_history.rs +++ b/src/bin/import_search_history.rs @@ -95,10 +95,11 @@ async fn main() -> Result<()> { if args.skip_existing && let Ok(exists) = dao_instance.search_exists(&context, search.timestamp, &search.query) - && exists { - skipped_count += 1; - continue; - } + && exists + { + skipped_count += 1; + continue; + } // Only insert if we have an embedding if let Some(embedding) = embedding_opt { diff --git a/src/bin/test_daily_summary.rs b/src/bin/test_daily_summary.rs index d1f5c42..fbbb621 100644 --- a/src/bin/test_daily_summary.rs +++ b/src/bin/test_daily_summary.rs @@ -1,7 +1,7 @@ use anyhow::Result; use chrono::NaiveDate; use clap::Parser; -use image_api::ai::{strip_summary_boilerplate, OllamaClient, SmsApiClient}; +use image_api::ai::{OllamaClient, SmsApiClient, strip_summary_boilerplate}; use image_api::database::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; use std::env; use std::sync::{Arc, Mutex}; diff --git a/src/data/mod.rs b/src/data/mod.rs index fa402b5..4ef8f39 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -167,8 +167,10 @@ pub enum PhotoSize { #[derive(Debug, Deserialize)] pub struct ThumbnailRequest { pub(crate) path: String, + #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) size: Option, #[serde(default)] + #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) format: Option, } diff --git a/src/database/calendar_dao.rs b/src/database/calendar_dao.rs index 63aded5..82eea20 100644 --- a/src/database/calendar_dao.rs +++ b/src/database/calendar_dao.rs @@ -26,6 +26,7 @@ pub struct CalendarEvent { /// Data for inserting a new calendar event #[derive(Clone, Debug)] +#[allow(dead_code)] pub struct InsertCalendarEvent { pub event_uid: Option, pub summary: String, @@ -219,12 +220,13 @@ impl CalendarEventDao for SqliteCalendarEventDao { // Validate embedding dimensions if provided if let Some(ref emb) = event.embedding - && emb.len() != 768 { - return Err(anyhow::anyhow!( - "Invalid embedding dimensions: {} (expected 768)", - emb.len() - )); - } + && emb.len() != 768 + { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + emb.len() + )); + } let embedding_bytes = event.embedding.as_ref().map(|e| Self::serialize_vector(e)); @@ -289,13 +291,14 @@ impl CalendarEventDao for SqliteCalendarEventDao { for event in events { // Validate embedding if provided if let Some(ref emb) = event.embedding - && emb.len() != 768 { - log::warn!( - "Skipping event with invalid embedding dimensions: {}", - emb.len() - ); - continue; - } + && emb.len() != 768 + { + log::warn!( + "Skipping event with invalid embedding dimensions: {}", + emb.len() + ); + continue; + } let embedding_bytes = event.embedding.as_ref().map(|e| Self::serialize_vector(e)); diff --git a/src/database/embeddings_dao.rs b/src/database/embeddings_dao.rs deleted file mode 100644 index 5a9df5a..0000000 --- a/src/database/embeddings_dao.rs +++ /dev/null @@ -1,569 +0,0 @@ -use diesel::prelude::*; -use diesel::sqlite::SqliteConnection; -use serde::Serialize; -use std::ops::DerefMut; -use std::sync::{Arc, Mutex}; - -use crate::database::{DbError, DbErrorKind, connect}; -use crate::otel::trace_db_call; - -/// Represents a stored message embedding -#[derive(Serialize, Clone, Debug)] -pub struct MessageEmbedding { - pub id: i32, - pub contact: String, - pub body: String, - pub timestamp: i64, - pub is_sent: bool, - pub created_at: i64, - pub model_version: String, -} - -/// Data for inserting a new message embedding -#[derive(Clone, Debug)] -pub struct InsertMessageEmbedding { - pub contact: String, - pub body: String, - pub timestamp: i64, - pub is_sent: bool, - pub embedding: Vec, - pub created_at: i64, - pub model_version: String, -} - -pub trait EmbeddingDao: Sync + Send { - /// Store a message with its embedding vector - fn store_message_embedding( - &mut self, - context: &opentelemetry::Context, - message: InsertMessageEmbedding, - ) -> Result; - - /// Store multiple messages with embeddings in a single transaction - /// Returns the number of successfully stored messages - fn store_message_embeddings_batch( - &mut self, - context: &opentelemetry::Context, - messages: Vec, - ) -> Result; - - /// Find semantically similar messages using vector similarity search - /// Returns the top `limit` most similar messages - /// If contact_filter is provided, only return messages from that contact - /// Otherwise, search across all contacts for cross-perspective context - fn find_similar_messages( - &mut self, - context: &opentelemetry::Context, - query_embedding: &[f32], - limit: usize, - contact_filter: Option<&str>, - ) -> Result, DbError>; - - /// Get the count of embedded messages for a specific contact - fn get_message_count( - &mut self, - context: &opentelemetry::Context, - contact: &str, - ) -> Result; - - /// Check if embeddings exist for a contact (idempotency check) - fn has_embeddings_for_contact( - &mut self, - context: &opentelemetry::Context, - contact: &str, - ) -> Result; - - /// Check if a specific message already has an embedding - fn message_exists( - &mut self, - context: &opentelemetry::Context, - contact: &str, - body: &str, - timestamp: i64, - ) -> Result; -} - -pub struct SqliteEmbeddingDao { - connection: Arc>, -} - -impl Default for SqliteEmbeddingDao { - fn default() -> Self { - Self::new() - } -} - -impl SqliteEmbeddingDao { - pub fn new() -> Self { - SqliteEmbeddingDao { - connection: Arc::new(Mutex::new(connect())), - } - } - - /// Serialize f32 vector to bytes for BLOB storage - fn serialize_vector(vec: &[f32]) -> Vec { - // Convert f32 slice to bytes using zerocopy - use zerocopy::IntoBytes; - vec.as_bytes().to_vec() - } - - /// Deserialize bytes from BLOB back to f32 vector - fn deserialize_vector(bytes: &[u8]) -> Result, DbError> { - if !bytes.len().is_multiple_of(4) { - return Err(DbError::new(DbErrorKind::QueryError)); - } - - let count = bytes.len() / 4; - let mut vec = Vec::with_capacity(count); - - for chunk in bytes.chunks_exact(4) { - let float = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); - vec.push(float); - } - - Ok(vec) - } - - /// Compute cosine similarity between two vectors - fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { - if a.len() != b.len() { - return 0.0; - } - - let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); - let magnitude_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); - let magnitude_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); - - if magnitude_a == 0.0 || magnitude_b == 0.0 { - return 0.0; - } - - dot_product / (magnitude_a * magnitude_b) - } -} - -impl EmbeddingDao for SqliteEmbeddingDao { - fn store_message_embedding( - &mut self, - context: &opentelemetry::Context, - message: InsertMessageEmbedding, - ) -> Result { - trace_db_call(context, "insert", "store_message_embedding", |_span| { - let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); - - // Validate embedding dimensions - if message.embedding.len() != 768 { - return Err(anyhow::anyhow!( - "Invalid embedding dimensions: {} (expected 768)", - message.embedding.len() - )); - } - - // Serialize embedding to bytes - let embedding_bytes = Self::serialize_vector(&message.embedding); - - // Insert into message_embeddings table with BLOB - // Use INSERT OR IGNORE to skip duplicates (based on UNIQUE constraint) - let insert_result = diesel::sql_query( - "INSERT OR IGNORE INTO message_embeddings (contact, body, timestamp, is_sent, embedding, created_at, model_version) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" - ) - .bind::(&message.contact) - .bind::(&message.body) - .bind::(message.timestamp) - .bind::(message.is_sent) - .bind::(&embedding_bytes) - .bind::(message.created_at) - .bind::(&message.model_version) - .execute(conn.deref_mut()) - .map_err(|e| anyhow::anyhow!("Insert error: {:?}", e))?; - - // If INSERT OR IGNORE skipped (duplicate), find the existing record - let row_id: i32 = if insert_result == 0 { - // Duplicate - find the existing record - diesel::sql_query( - "SELECT id FROM message_embeddings WHERE contact = ?1 AND body = ?2 AND timestamp = ?3" - ) - .bind::(&message.contact) - .bind::(&message.body) - .bind::(message.timestamp) - .get_result::(conn.deref_mut()) - .map(|r| r.id as i32) - .map_err(|e| anyhow::anyhow!("Failed to find existing record: {:?}", e))? - } else { - // New insert - get the last inserted row ID - diesel::sql_query("SELECT last_insert_rowid() as id") - .get_result::(conn.deref_mut()) - .map(|r| r.id as i32) - .map_err(|e| anyhow::anyhow!("Failed to get last insert ID: {:?}", e))? - }; - - // Return the stored message - Ok(MessageEmbedding { - id: row_id, - contact: message.contact, - body: message.body, - timestamp: message.timestamp, - is_sent: message.is_sent, - created_at: message.created_at, - model_version: message.model_version, - }) - }) - .map_err(|_| DbError::new(DbErrorKind::InsertError)) - } - - fn store_message_embeddings_batch( - &mut self, - context: &opentelemetry::Context, - messages: Vec, - ) -> Result { - trace_db_call(context, "insert", "store_message_embeddings_batch", |_span| { - let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); - - // Start transaction - conn.transaction::<_, anyhow::Error, _>(|conn| { - let mut stored_count = 0; - - for message in messages { - // Validate embedding dimensions - if message.embedding.len() != 768 { - log::warn!( - "Invalid embedding dimensions: {} (expected 768), skipping", - message.embedding.len() - ); - continue; - } - - // Serialize embedding to bytes - let embedding_bytes = Self::serialize_vector(&message.embedding); - - // Insert into message_embeddings table with BLOB - // Use INSERT OR IGNORE to skip duplicates (based on UNIQUE constraint) - match diesel::sql_query( - "INSERT OR IGNORE INTO message_embeddings (contact, body, timestamp, is_sent, embedding, created_at, model_version) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" - ) - .bind::(&message.contact) - .bind::(&message.body) - .bind::(message.timestamp) - .bind::(message.is_sent) - .bind::(&embedding_bytes) - .bind::(message.created_at) - .bind::(&message.model_version) - .execute(conn) - { - Ok(rows) if rows > 0 => stored_count += 1, - Ok(_) => { - // INSERT OR IGNORE skipped (duplicate) - log::debug!("Skipped duplicate message: {:?}", message.body.chars().take(50).collect::()); - } - Err(e) => { - log::warn!("Failed to insert message in batch: {:?}", e); - // Continue with other messages instead of failing entire batch - } - } - } - - Ok(stored_count) - }) - .map_err(|e| anyhow::anyhow!("Transaction error: {:?}", e)) - }) - .map_err(|_| DbError::new(DbErrorKind::InsertError)) - } - - fn find_similar_messages( - &mut self, - context: &opentelemetry::Context, - query_embedding: &[f32], - limit: usize, - contact_filter: Option<&str>, - ) -> Result, DbError> { - trace_db_call(context, "query", "find_similar_messages", |_span| { - let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); - - // Validate embedding dimensions - if query_embedding.len() != 768 { - return Err(anyhow::anyhow!( - "Invalid query embedding dimensions: {} (expected 768)", - query_embedding.len() - )); - } - - // Load messages with optional contact filter - let results = if let Some(contact) = contact_filter { - log::debug!("RAG search filtered to contact: {}", contact); - diesel::sql_query( - "SELECT id, contact, body, timestamp, is_sent, embedding, created_at, model_version - FROM message_embeddings WHERE contact = ?1" - ) - .bind::(contact) - .load::(conn.deref_mut()) - .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))? - } else { - log::debug!("RAG search across ALL contacts (cross-perspective)"); - diesel::sql_query( - "SELECT id, contact, body, timestamp, is_sent, embedding, created_at, model_version - FROM message_embeddings" - ) - .load::(conn.deref_mut()) - .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))? - }; - - log::debug!("Loaded {} messages for similarity comparison", results.len()); - - // Compute similarity for each message - let mut scored_messages: Vec<(f32, MessageEmbedding)> = results - .into_iter() - .filter_map(|row| { - // Deserialize the embedding BLOB - match Self::deserialize_vector(&row.embedding) { - Ok(embedding) => { - // Compute cosine similarity - let similarity = Self::cosine_similarity(query_embedding, &embedding); - Some(( - similarity, - MessageEmbedding { - id: row.id, - contact: row.contact, - body: row.body, - timestamp: row.timestamp, - is_sent: row.is_sent, - created_at: row.created_at, - model_version: row.model_version, - }, - )) - } - Err(e) => { - log::warn!("Failed to deserialize embedding for message {}: {:?}", row.id, e); - None - } - } - }) - .collect(); - - // Sort by similarity (highest first) - scored_messages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); - - // Log similarity score distribution - if !scored_messages.is_empty() { - log::info!( - "Similarity score distribution - Top: {:.3}, Median: {:.3}, Bottom: {:.3}", - scored_messages.first().map(|(s, _)| *s).unwrap_or(0.0), - scored_messages.get(scored_messages.len() / 2).map(|(s, _)| *s).unwrap_or(0.0), - scored_messages.last().map(|(s, _)| *s).unwrap_or(0.0) - ); - } - - // Apply minimum similarity threshold - // With single-contact embeddings, scores tend to be higher due to writing style similarity - // Using 0.65 to get only truly semantically relevant messages - let min_similarity = 0.65; - let filtered_messages: Vec<(f32, MessageEmbedding)> = scored_messages - .into_iter() - .filter(|(similarity, _)| *similarity >= min_similarity) - .collect(); - - log::info!( - "After similarity filtering (min_similarity={}): {} messages passed threshold", - min_similarity, - filtered_messages.len() - ); - - // Filter out short/generic messages (under 30 characters) - // This removes conversational closings like "Thanks for talking" that dominate results - let min_message_length = 30; - - // Common closing phrases that should be excluded from RAG results - let stop_phrases = [ - "thanks for talking", - "thank you for talking", - "good talking", - "nice talking", - "good night", - "good morning", - "love you", - ]; - - let filtered_messages: Vec<(f32, MessageEmbedding)> = filtered_messages - .into_iter() - .filter(|(_, message)| { - // Filter by length - if message.body.len() < min_message_length { - return false; - } - - // Filter out messages that are primarily generic closings - let body_lower = message.body.to_lowercase(); - for phrase in &stop_phrases { - // If the message contains this phrase and is short, it's likely just a closing - if body_lower.contains(phrase) && message.body.len() < 100 { - return false; - } - } - - true - }) - .collect(); - - log::info!( - "After length filtering (min {} chars): {} messages remain", - min_message_length, - filtered_messages.len() - ); - - // Apply temporal diversity filter - don't return too many messages from the same day - // This prevents RAG from returning clusters of messages from one conversation - let mut filtered_with_diversity = Vec::new(); - let mut dates_seen: std::collections::HashMap = std::collections::HashMap::new(); - let max_per_day = 3; // Maximum 3 messages from any single day - - for (similarity, message) in filtered_messages.into_iter() { - let date = chrono::DateTime::from_timestamp(message.timestamp, 0) - .map(|dt| dt.date_naive()) - .unwrap_or_else(|| chrono::Utc::now().date_naive()); - - let count = dates_seen.entry(date).or_insert(0); - if *count < max_per_day { - *count += 1; - filtered_with_diversity.push((similarity, message)); - } - } - - log::info!( - "After temporal diversity filtering (max {} per day): {} messages remain", - max_per_day, - filtered_with_diversity.len() - ); - - // Take top N results from diversity-filtered messages - let top_results: Vec = filtered_with_diversity - .into_iter() - .take(limit) - .map(|(similarity, message)| { - let time = chrono::DateTime::from_timestamp(message.timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_default(); - log::info!( - "RAG Match: similarity={:.3}, date={}, contact={}, body=\"{}\"", - similarity, - time, - message.contact, - &message.body.chars().take(80).collect::() - ); - message - }) - .collect(); - - Ok(top_results) - }) - .map_err(|_| DbError::new(DbErrorKind::QueryError)) - } - - fn get_message_count( - &mut self, - context: &opentelemetry::Context, - contact: &str, - ) -> Result { - trace_db_call(context, "query", "get_message_count", |_span| { - let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); - - let count = diesel::sql_query( - "SELECT COUNT(*) as count FROM message_embeddings WHERE contact = ?1", - ) - .bind::(contact) - .get_result::(conn.deref_mut()) - .map(|r| r.count) - .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))?; - - Ok(count) - }) - .map_err(|_| DbError::new(DbErrorKind::QueryError)) - } - - fn has_embeddings_for_contact( - &mut self, - context: &opentelemetry::Context, - contact: &str, - ) -> Result { - self.get_message_count(context, contact) - .map(|count| count > 0) - } - - fn message_exists( - &mut self, - context: &opentelemetry::Context, - contact: &str, - body: &str, - timestamp: i64, - ) -> Result { - trace_db_call(context, "query", "message_exists", |_span| { - let mut conn = self.connection.lock().expect("Unable to get EmbeddingDao"); - - let count = diesel::sql_query( - "SELECT COUNT(*) as count FROM message_embeddings - WHERE contact = ?1 AND body = ?2 AND timestamp = ?3", - ) - .bind::(contact) - .bind::(body) - .bind::(timestamp) - .get_result::(conn.deref_mut()) - .map(|r| r.count) - .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))?; - - Ok(count > 0) - }) - .map_err(|_| DbError::new(DbErrorKind::QueryError)) - } -} - -// Helper structs for raw SQL queries - -#[derive(QueryableByName)] -struct LastInsertRowId { - #[diesel(sql_type = diesel::sql_types::BigInt)] - id: i64, -} - -#[derive(QueryableByName)] -struct MessageEmbeddingRow { - #[diesel(sql_type = diesel::sql_types::Integer)] - id: i32, - #[diesel(sql_type = diesel::sql_types::Text)] - contact: String, - #[diesel(sql_type = diesel::sql_types::Text)] - body: String, - #[diesel(sql_type = diesel::sql_types::BigInt)] - timestamp: i64, - #[diesel(sql_type = diesel::sql_types::Bool)] - is_sent: bool, - #[diesel(sql_type = diesel::sql_types::BigInt)] - created_at: i64, - #[diesel(sql_type = diesel::sql_types::Text)] - model_version: String, -} - -#[derive(QueryableByName)] -struct MessageEmbeddingWithVectorRow { - #[diesel(sql_type = diesel::sql_types::Integer)] - id: i32, - #[diesel(sql_type = diesel::sql_types::Text)] - contact: String, - #[diesel(sql_type = diesel::sql_types::Text)] - body: String, - #[diesel(sql_type = diesel::sql_types::BigInt)] - timestamp: i64, - #[diesel(sql_type = diesel::sql_types::Bool)] - is_sent: bool, - #[diesel(sql_type = diesel::sql_types::Binary)] - embedding: Vec, - #[diesel(sql_type = diesel::sql_types::BigInt)] - created_at: i64, - #[diesel(sql_type = diesel::sql_types::Text)] - model_version: String, -} - -#[derive(QueryableByName)] -struct CountResult { - #[diesel(sql_type = diesel::sql_types::BigInt)] - count: i64, -} diff --git a/src/database/location_dao.rs b/src/database/location_dao.rs index c4b3989..73e1c10 100644 --- a/src/database/location_dao.rs +++ b/src/database/location_dao.rs @@ -214,12 +214,13 @@ impl LocationHistoryDao for SqliteLocationHistoryDao { // Validate embedding dimensions if provided (rare for location data) if let Some(ref emb) = location.embedding - && emb.len() != 768 { - return Err(anyhow::anyhow!( - "Invalid embedding dimensions: {} (expected 768)", - emb.len() - )); - } + && emb.len() != 768 + { + return Err(anyhow::anyhow!( + "Invalid embedding dimensions: {} (expected 768)", + emb.len() + )); + } let embedding_bytes = location .embedding @@ -289,13 +290,14 @@ impl LocationHistoryDao for SqliteLocationHistoryDao { for location in locations { // Validate embedding if provided (rare) if let Some(ref emb) = location.embedding - && emb.len() != 768 { - log::warn!( - "Skipping location with invalid embedding dimensions: {}", - emb.len() - ); - continue; - } + && emb.len() != 768 + { + log::warn!( + "Skipping location with invalid embedding dimensions: {}", + emb.len() + ); + continue; + } let embedding_bytes = location .embedding diff --git a/src/database/mod.rs b/src/database/mod.rs index bbdf33f..0bfd3f1 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -11,7 +11,6 @@ use crate::otel::trace_db_call; pub mod calendar_dao; pub mod daily_summary_dao; -pub mod embeddings_dao; pub mod insights_dao; pub mod location_dao; pub mod models; @@ -20,7 +19,6 @@ pub mod search_dao; pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; -pub use embeddings_dao::{EmbeddingDao, InsertMessageEmbedding}; pub use insights_dao::{InsightDao, SqliteInsightDao}; pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; diff --git a/src/files.rs b/src/files.rs index 729e73e..8721568 100644 --- a/src/files.rs +++ b/src/files.rs @@ -233,12 +233,8 @@ pub async fn list_photos( if let (Some(photo_lat), Some(photo_lon)) = (exif.gps_latitude, exif.gps_longitude) { - let distance = haversine_distance( - lat, - lon, - photo_lat as f64, - photo_lon as f64, - ); + let distance = + haversine_distance(lat, lon, photo_lat as f64, photo_lon as f64); distance <= radius_km } else { false @@ -410,9 +406,13 @@ pub async fn list_photos( ) }) .map(|path: &PathBuf| { - let relative = path.strip_prefix(&app_state.base_path).unwrap_or_else(|_| panic!("Unable to strip base path {} from file path {}", + let relative = path.strip_prefix(&app_state.base_path).unwrap_or_else(|_| { + panic!( + "Unable to strip base path {} from file path {}", &app_state.base_path.path(), - path.display())); + path.display() + ) + }); relative.to_path_buf() }) .map(|f| f.to_str().unwrap().to_string()) @@ -791,6 +791,7 @@ pub struct RealFileSystem { } impl RealFileSystem { + #[allow(dead_code)] // Used in main.rs binary and tests pub(crate) fn new(base_path: String) -> RealFileSystem { RealFileSystem { base_path } } diff --git a/src/parsers/location_json_parser.rs b/src/parsers/location_json_parser.rs index 7ca6b87..9e8aa0e 100644 --- a/src/parsers/location_json_parser.rs +++ b/src/parsers/location_json_parser.rs @@ -34,6 +34,7 @@ struct LocationPoint { #[derive(Debug, Deserialize)] struct ActivityRecord { activity: Vec, + #[allow(dead_code)] // Part of JSON structure, may be used in future timestamp_ms: Option, } diff --git a/src/parsers/search_html_parser.rs b/src/parsers/search_html_parser.rs index 91440be..7688185 100644 --- a/src/parsers/search_html_parser.rs +++ b/src/parsers/search_html_parser.rs @@ -30,34 +30,37 @@ pub fn parse_search_html(path: &str) -> Result> { // Strategy 2: Look for outer-cell structure (older format) if records.is_empty() - && let Ok(outer_selector) = Selector::parse("div.outer-cell") { - for cell in document.select(&outer_selector) { - if let Some(record) = parse_outer_cell(&cell) { - records.push(record); - } + && let Ok(outer_selector) = Selector::parse("div.outer-cell") + { + for cell in document.select(&outer_selector) { + if let Some(record) = parse_outer_cell(&cell) { + records.push(record); } } + } // Strategy 3: Generic approach - look for links and timestamps if records.is_empty() - && let Ok(link_selector) = Selector::parse("a") { - for link in document.select(&link_selector) { - if let Some(href) = link.value().attr("href") { - // Check if it's a search URL - if (href.contains("google.com/search?q=") || href.contains("search?q=")) - && let Some(query) = extract_query_from_url(href) { - // Try to find nearby timestamp - let timestamp = find_nearby_timestamp(&link); + && let Ok(link_selector) = Selector::parse("a") + { + for link in document.select(&link_selector) { + if let Some(href) = link.value().attr("href") { + // Check if it's a search URL + if (href.contains("google.com/search?q=") || href.contains("search?q=")) + && let Some(query) = extract_query_from_url(href) + { + // Try to find nearby timestamp + let timestamp = find_nearby_timestamp(&link); - records.push(ParsedSearchRecord { - timestamp: timestamp.unwrap_or_else(|| Utc::now().timestamp()), - query, - search_engine: Some("Google".to_string()), - }); - } + records.push(ParsedSearchRecord { + timestamp: timestamp.unwrap_or_else(|| Utc::now().timestamp()), + query, + search_engine: Some("Google".to_string()), + }); } } } + } Ok(records) } @@ -118,11 +121,12 @@ fn extract_query_from_url(url: &str) -> Option { fn find_nearby_timestamp(element: &scraper::ElementRef) -> Option { // Look for timestamp in parent or sibling elements if let Some(parent) = element.parent() - && parent.value().as_element().is_some() { - let parent_ref = scraper::ElementRef::wrap(parent)?; - let text = parent_ref.text().collect::>().join(" "); - return parse_timestamp_from_text(&text); - } + && parent.value().as_element().is_some() + { + let parent_ref = scraper::ElementRef::wrap(parent)?; + let text = parent_ref.text().collect::>().join(" "); + return parse_timestamp_from_text(&text); + } None } @@ -135,9 +139,10 @@ fn parse_timestamp_from_text(text: &str) -> Option { if let Some(iso_match) = text .split_whitespace() .find(|s| s.contains('T') && s.contains('-')) - && let Ok(dt) = DateTime::parse_from_rfc3339(iso_match) { - return Some(dt.timestamp()); - } + && let Ok(dt) = DateTime::parse_from_rfc3339(iso_match) + { + return Some(dt.timestamp()); + } // Try common date patterns let patterns = [ @@ -149,9 +154,10 @@ fn parse_timestamp_from_text(text: &str) -> Option { for pattern in patterns { // Extract potential date string if let Some(date_part) = extract_date_substring(text) - && let Ok(dt) = NaiveDateTime::parse_from_str(&date_part, pattern) { - return Some(dt.and_utc().timestamp()); - } + && let Ok(dt) = NaiveDateTime::parse_from_str(&date_part, pattern) + { + return Some(dt.and_utc().timestamp()); + } } None -- 2.49.1 From 02ca60a5d08f8ff2a62ec9a1776fbd24b0734ffd Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 14:23:50 -0500 Subject: [PATCH 24/25] Remove individual messages embedding SQL --- .../down.sql | 3 --- .../up.sql | 19 ------------------- 2 files changed, 22 deletions(-) delete mode 100644 migrations/2026-01-04-000000_add_message_embeddings/down.sql delete mode 100644 migrations/2026-01-04-000000_add_message_embeddings/up.sql diff --git a/migrations/2026-01-04-000000_add_message_embeddings/down.sql b/migrations/2026-01-04-000000_add_message_embeddings/down.sql deleted file mode 100644 index c8b6965..0000000 --- a/migrations/2026-01-04-000000_add_message_embeddings/down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Drop tables in reverse order -DROP TABLE IF EXISTS vec_message_embeddings; -DROP TABLE IF EXISTS message_embeddings; diff --git a/migrations/2026-01-04-000000_add_message_embeddings/up.sql b/migrations/2026-01-04-000000_add_message_embeddings/up.sql deleted file mode 100644 index a2fff45..0000000 --- a/migrations/2026-01-04-000000_add_message_embeddings/up.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Table for storing message metadata and embeddings --- Embeddings stored as BLOB for proof-of-concept --- For production with many contacts, consider using sqlite-vec extension -CREATE TABLE message_embeddings ( - id INTEGER PRIMARY KEY NOT NULL, - contact TEXT NOT NULL, - body TEXT NOT NULL, - timestamp BIGINT NOT NULL, - is_sent BOOLEAN NOT NULL, - embedding BLOB NOT NULL, - created_at BIGINT NOT NULL, - model_version TEXT NOT NULL, - -- Prevent duplicate embeddings for the same message - UNIQUE(contact, body, timestamp) -); - --- Indexes for efficient queries -CREATE INDEX idx_message_embeddings_contact ON message_embeddings(contact); -CREATE INDEX idx_message_embeddings_timestamp ON message_embeddings(timestamp); -- 2.49.1 From e31d5716b6a1c10cf0f019409e12190d78ba7a69 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 14 Jan 2026 15:21:44 -0500 Subject: [PATCH 25/25] Additional cleanup and warning fixing --- src/ai/sms_client.rs | 16 ---------------- src/file_types.rs | 3 +++ src/state.rs | 5 ----- src/tags.rs | 4 ++++ src/video/mod.rs | 1 + 5 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index ea91ae1..23b1b0f 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use chrono::NaiveDate; use reqwest::Client; use serde::Deserialize; @@ -21,21 +20,6 @@ impl SmsApiClient { } } - pub async fn fetch_messages_for_date(&self, date: NaiveDate) -> Result> { - // Calculate date range (midnight to midnight in local time) - let start = date - .and_hms_opt(0, 0, 0) - .ok_or_else(|| anyhow::anyhow!("Invalid start time"))?; - let end = date - .and_hms_opt(23, 59, 59) - .ok_or_else(|| anyhow::anyhow!("Invalid end time"))?; - - let start_ts = start.and_utc().timestamp(); - let end_ts = end.and_utc().timestamp(); - - self.fetch_messages(start_ts, end_ts, None, None).await - } - /// Fetch messages for a specific contact within ±1 day of the given timestamp /// Falls back to all contacts if no messages found for the specific contact /// Messages are sorted by proximity to the center timestamp diff --git a/src/file_types.rs b/src/file_types.rs index 4db837c..c1249d0 100644 --- a/src/file_types.rs +++ b/src/file_types.rs @@ -35,16 +35,19 @@ pub fn is_media_file(path: &Path) -> bool { } /// Check if a DirEntry is an image file (for walkdir usage) +#[allow(dead_code)] pub fn direntry_is_image(entry: &DirEntry) -> bool { is_image_file(entry.path()) } /// Check if a DirEntry is a video file (for walkdir usage) +#[allow(dead_code)] pub fn direntry_is_video(entry: &DirEntry) -> bool { is_video_file(entry.path()) } /// Check if a DirEntry is a media file (for walkdir usage) +#[allow(dead_code)] pub fn direntry_is_media(entry: &DirEntry) -> bool { is_media_file(entry.path()) } diff --git a/src/state.rs b/src/state.rs index f744715..ac55245 100644 --- a/src/state.rs +++ b/src/state.rs @@ -20,7 +20,6 @@ pub struct AppState { pub ollama: OllamaClient, pub sms_client: SmsApiClient, pub insight_generator: InsightGenerator, - pub insight_dao: Arc>>, } impl AppState { @@ -34,7 +33,6 @@ impl AppState { ollama: OllamaClient, sms_client: SmsApiClient, insight_generator: InsightGenerator, - insight_dao: Arc>>, ) -> Self { let playlist_generator = PlaylistGenerator::new(); let video_playlist_manager = @@ -51,7 +49,6 @@ impl AppState { ollama, sms_client, insight_generator, - insight_dao, } } @@ -132,7 +129,6 @@ impl Default for AppState { ollama, sms_client, insight_generator, - insight_dao, ) } } @@ -201,7 +197,6 @@ impl AppState { ollama, sms_client, insight_generator, - insight_dao, ) } } diff --git a/src/tags.rs b/src/tags.rs index 0d00369..ef23ec6 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -260,9 +260,13 @@ pub struct InsertTaggedPhoto { #[derive(Queryable, Clone, Debug)] pub struct TaggedPhoto { + #[allow(dead_code)] // Part of API contract pub id: i32, + #[allow(dead_code)] // Part of API contract pub photo_name: String, + #[allow(dead_code)] // Part of API contract pub tag_id: i32, + #[allow(dead_code)] // Part of API contract pub created_time: i64, } diff --git a/src/video/mod.rs b/src/video/mod.rs index 9889243..1089301 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -10,6 +10,7 @@ use walkdir::WalkDir; pub mod actors; pub mod ffmpeg; +#[allow(dead_code)] pub async fn generate_video_gifs() { tokio::spawn(async { info!("Starting to make video gifs"); -- 2.49.1