diff --git a/src/reels/mod.rs b/src/reels/mod.rs index 1fc5b3b..7fc71b0 100644 --- a/src/reels/mod.rs +++ b/src/reels/mod.rs @@ -18,7 +18,7 @@ pub mod selector; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock, Mutex, Mutex as StdMutex}; +use std::sync::{LazyLock, Mutex, Mutex as StdMutex}; use std::time::{Duration, Instant}; use actix_files::NamedFile; @@ -30,7 +30,7 @@ use serde_json::json; use uuid::Uuid; use crate::data::Claims; -use crate::database::{ExifDao, InsightDao, PrecomputedReelDao, UserAiPrefsDao}; +use crate::database::{ExifDao, InsightDao}; use crate::libraries::{Library, resolve_library_param}; use crate::memories::MemoriesSpan; use crate::otel::extract_context_from_request; @@ -61,7 +61,6 @@ pub fn normalize_library_key(libs: &[Library], param: Option<&str>) -> String { /// caller regardless of DB errors. fn capture_prefs( app_state: &AppState, - prefs_dao: &web::Data>>>, req: &web::Json, library_param: Option<&str>, ) -> Result<(), anyhow::Error> { @@ -77,7 +76,7 @@ fn capture_prefs( } _ => "all".to_string(), }; - let mut dao = prefs_dao.lock().expect("lock"); + let mut dao = app_state.user_ai_prefs_dao.lock().expect("lock"); let ctx = opentelemetry::Context::new(); dao.upsert_prefs( &ctx, @@ -385,7 +384,6 @@ pub async fn create_reel_handler( app_state: web::Data, exif_dao: web::Data>>, insight_dao: web::Data>>, - prefs_dao: web::Data>>>, ) -> impl Responder { let span_context = extract_context_from_request(&http_request); @@ -455,8 +453,8 @@ pub async fn create_reel_handler( }, ); // Capture params for passive prefs mirror (best-effort, never fails). - let _ = capture_prefs(&app_state, &prefs_dao, &req, req.library.as_deref()); - HttpResponse::Accepted().json(ReelJobCreatedResponse { + let _ = capture_prefs(&app_state, &req, req.library.as_deref()); + return HttpResponse::Accepted().json(ReelJobCreatedResponse { job_id: job_id.to_string(), status: ReelJobStatus::Done, }); @@ -515,7 +513,7 @@ pub async fn create_reel_handler( with_job(job_id, |job| job.abort = Some(handle.abort_handle())); // Capture params for passive prefs mirror (best-effort, never fails). - let _ = capture_prefs(&app_state, &prefs_dao, &req, req.library.as_deref()); + let _ = capture_prefs(&app_state, &req, req.library.as_deref()); HttpResponse::Accepted().json(ReelJobCreatedResponse { job_id: job_id.to_string(), @@ -584,7 +582,6 @@ pub async fn precomputed_reel_handler( _claims: Claims, query: web::Query>, app_state: web::Data, - reel_dao: web::Data>>, ) -> impl Responder { let span = query.get("span").map(|s| s.as_str()).unwrap_or("day"); let library_key = normalize_library_key( @@ -605,7 +602,10 @@ pub async fn precomputed_reel_handler( let min_generated_at = now - (max_age_hours * 3600); let ctx = opentelemetry::Context::new(); - let mut dao = reel_dao.lock().expect("Unable to lock PrecomputedReelDao"); + let mut dao = app_state + .precomputed_reel_dao + .lock() + .expect("Unable to lock PrecomputedReelDao"); // Fast existence gate: is there a fresh row at all? if !dao @@ -923,19 +923,23 @@ fn pregen_week_dow() -> u32 { .unwrap_or(1) } -/// Pure: seconds until the next run of `run_hour` given the current local time. -/// Handles same-day vs wrap-around. Recomputed each loop iteration to absorb -/// DST shifts. +/// Pure: seconds until the next `run_hour:00:00` strictly after `now`. +/// +/// Minute/second-accurate (not just hour-granular): when `now` is already at or +/// past the target this wraps to the same hour tomorrow, so a batch that +/// finishes inside the run hour sleeps ~24h rather than busy-looping (waking, +/// re-running, and re-sleeping 0s) for the rest of that hour. The tradeoff is +/// that booting at or after `run_hour` waits until the next day. Recomputed each +/// loop iteration from `Local::now()` so DST shifts are absorbed. pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime, run_hour: u32) -> u64 { - let now_hour = now.hour(); - let diff = if now_hour > run_hour { - 24 - now_hour + run_hour - } else if now_hour == run_hour { - 0 + let now_secs = now.hour() * 3600 + now.minute() * 60 + now.second(); + let target_secs = run_hour * 3600; + let diff = if target_secs > now_secs { + target_secs - now_secs } else { - run_hour - now_hour + 86_400 - now_secs + target_secs }; - (diff * 3600) as u64 + diff as u64 } /// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back @@ -1367,21 +1371,25 @@ mod tests { } #[test] - fn secs_until_next_run_hour_same_hour_returns_zero() { + fn secs_until_next_run_hour_within_run_hour_wraps_to_tomorrow() { + // 03:30, run 3 → already past today's 03:00, so wait until tomorrow + // 03:00 (23h30m). Crucially NOT 0 — that would busy-loop the scheduler + // for the rest of the hour. let dt = chrono::Local .with_ymd_and_hms(2026, 6, 13, 3, 30, 0) .single() .expect("valid datetime"); - assert_eq!(secs_until_next_run_hour(dt, 3), 0); + assert_eq!(secs_until_next_run_hour(dt, 3), 23 * 3600 + 30 * 60); } #[test] - fn secs_until_next_run_hour_future_today_returns_remaining() { + fn secs_until_next_run_hour_future_today_counts_minutes() { + // 10:15 → 14:00 is 3h45m, not a whole-hour 4h (minutes count). let dt = chrono::Local - .with_ymd_and_hms(2026, 6, 13, 10, 0, 0) + .with_ymd_and_hms(2026, 6, 13, 10, 15, 0) .single() .expect("valid datetime"); - assert_eq!(secs_until_next_run_hour(dt, 14), 4 * 3600); + assert_eq!(secs_until_next_run_hour(dt, 14), 3 * 3600 + 45 * 60); } #[test] @@ -1401,19 +1409,19 @@ mod tests { .expect("valid datetime"); // 0:00, run at 3 → 3 hours assert_eq!(secs_until_next_run_hour(dt, 3), 3 * 3600); - // 0:00, run at 0 → 0 (immediate) - assert_eq!(secs_until_next_run_hour(dt, 0), 0); + // 0:00 exactly, run at 0 → wraps to next midnight (not 0, so no busy loop) + assert_eq!(secs_until_next_run_hour(dt, 0), 86_400); } #[test] - fn secs_until_next_run_hour_last_hour() { + fn secs_until_next_run_hour_just_before_target() { + // 23:30, run 0 → 30 minutes to midnight (minute-accurate, not 1h). let dt = chrono::Local .with_ymd_and_hms(2026, 6, 13, 23, 30, 0) .single() .expect("valid datetime"); - // 23:30, run at 23 → 0 (still in hour 23) - assert_eq!(secs_until_next_run_hour(dt, 23), 0); - // 23:30, run at 0 → 1 hour - assert_eq!(secs_until_next_run_hour(dt, 0), 3600); + assert_eq!(secs_until_next_run_hour(dt, 0), 30 * 60); + // 23:30, run 23 → already past today's 23:00, wait until tomorrow. + assert_eq!(secs_until_next_run_hour(dt, 23), 86_400 - 30 * 60); } } diff --git a/src/reels/script.rs b/src/reels/script.rs index 202a22c..858efd1 100644 --- a/src/reels/script.rs +++ b/src/reels/script.rs @@ -53,10 +53,12 @@ Be concrete and grounded in the details given; never invent names, places, or \ events that aren't supported. Keep each line to one or two short sentences that \ can be read aloud in a few seconds. Avoid generic filler like \"what a \ wonderful day\" — if you have little to go on, simply describe the moment \ -plainly.\n\nYou may call read-only tools (search_messages, get_file_tags, \ -reverse_geocode, get_current_datetime, recall_entities, recall_facts_for_photo, \ -recall_facts_for_entity) to ground each line in real context. Never invent \ -details. Return ONLY the JSON object, no prose or code fences."; +plainly.\n\nYou may call read-only tools (search_rag, search_messages, \ +get_sms_messages, get_calendar_events, get_location_history, reverse_geocode, \ +get_personal_place_at, recall_entities, get_current_datetime) to ground each \ +line in real context — e.g. reverse_geocode a moment's GPS to name the place, \ +or check the calendar/messages around its date. Never invent details. Return \ +ONLY the JSON object, no prose or code fences."; /// Maximum agentic tool iterations for pre-generation. Tunable via /// `REEL_PREGEN_MAX_TOOL_ITERS` (default 8). @@ -317,19 +319,21 @@ pub async fn generate_script_agentic( // then filter out write tools. let gate = generator.current_gate_opts_for_persona(false, None); let all_tools = InsightGenerator::build_tool_definitions(gate); + // Whole-reel calls have no single photo and no authenticated user, so the + // loop runs execute_tool with empty file/image context and user_id=0. Only + // tools that work without that context are useful here — photo/user-bound + // tools (get_file_tags, get_faces_in_photo, recall_facts_for_photo, + // recall_facts_for_entity) would just no-op or error, burning iterations, + // so they're excluded. let read_only_names: std::collections::HashSet<&str> = [ "search_rag", "search_messages", "get_sms_messages", "get_calendar_events", "get_location_history", - "get_file_tags", - "get_faces_in_photo", "reverse_geocode", "get_personal_place_at", "recall_entities", - "recall_facts_for_photo", - "recall_facts_for_entity", "get_current_datetime", ] .into_iter()