feat: nightly agentic pre-generation of memory reels

Implement end-to-end nightly pre-generation of memory reels with agentic
scripting that grounds narration in calendar, location, messages, and RAG.

Sections A-E from the plan:

A. Extract produce_reel pipeline core from run_reel_job with
   ScripterMode::Fast/Agentic and progress callbacks.

B. Agentic scripter: factor run_readonly_tool_loop from the insight
   generator, build read-only tool gate, prompt builder with GPS, and
   generate_script_agentic with fallback to fast path.

C. Precomputed reels ledger (SQLite table + DAO), GET /reels/precomputed
   handler with validity gate, GET /reels/by-key/{key}/video streaming,
   and normalize_library_key helper.

D. Nightly scheduler: spawn_pregen_scheduler with configurable hour,
   run_pregen_batch (day/week/month spans), pregen_one with dedup and
   disk-check, secs_until_next_run_hour time math.

E. user_ai_prefs passive mirror table + DAO for param capture in
   create_reel_handler and replay in the scheduler.

Also fixes resolve_library_param signature to take &[Library] and adds
resolve_library_param_state wrapper for AppState callers.

New files: migrations/2026-06-13-000000_add_precomputed_reels/,
  migrations/2026-06-13-000010_add_user_ai_prefs/,
  src/database/precomputed_reel_dao.rs,
  src/database/user_ai_prefs_dao.rs
This commit is contained in:
Cameron Cordes
2026-06-13 14:29:34 -04:00
parent b30c8c16d0
commit f707353807
26 changed files with 1825 additions and 153 deletions
+42 -37
View File
@@ -291,11 +291,11 @@ pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
}
/// Resolve a library request parameter (accepts numeric id as string or name)
/// against the configured libraries. Returns `Ok(None)` when the param is
/// against a list of libraries. Returns `Ok(None)` when the param is
/// absent, meaning "span all libraries". Returns `Err` when a value is
/// provided but does not match any library.
pub fn resolve_library_param<'a>(
state: &'a AppState,
libs: &'a [Library],
param: Option<&str>,
) -> Result<Option<&'a Library>, String> {
let Some(raw) = param.map(str::trim).filter(|s| !s.is_empty()) else {
@@ -303,18 +303,29 @@ pub fn resolve_library_param<'a>(
};
if let Ok(id) = raw.parse::<i32>() {
return state
.library_by_id(id)
return libs
.iter()
.find(|l| l.id == id)
.map(Some)
.ok_or_else(|| format!("unknown library id: {}", id));
}
state
.library_by_name(raw)
libs.iter()
.find(|l| l.name == raw)
.map(Some)
.ok_or_else(|| format!("unknown library name: {}", raw))
}
/// Resolve a library request parameter against the AppState's libraries.
/// Returns `Ok(None)` when the param is absent, meaning "span all libraries".
/// Returns `Err` when a value is provided but does not match any library.
pub fn resolve_library_param_state<'a>(
state: &'a AppState,
param: Option<&str>,
) -> Result<Option<&'a Library>, String> {
resolve_library_param(&state.libraries, param)
}
/// Health of a library at a point in time. Probed at the top of each
/// file-watcher tick. The `Stale` state is the "be conservative" signal:
/// destructive paths (ingest writes, future move-handoff and orphan GC in
@@ -662,12 +673,6 @@ mod tests {
assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg"));
}
fn state_with_libraries(libs: Vec<Library>) -> AppState {
let mut state = AppState::test_state();
state.libraries = libs;
state
}
fn sample_libraries() -> Vec<Library> {
vec![
Library {
@@ -687,52 +692,52 @@ mod tests {
]
}
#[actix_rt::test]
async fn resolve_library_param_absent_is_union() {
let state = state_with_libraries(sample_libraries());
assert!(matches!(resolve_library_param(&state, None), Ok(None)));
#[test]
fn resolve_library_param_absent_is_union() {
let libs = sample_libraries();
assert!(matches!(resolve_library_param(&libs, None), Ok(None)));
}
#[actix_rt::test]
async fn resolve_library_param_empty_or_whitespace_is_union() {
let state = state_with_libraries(sample_libraries());
assert!(matches!(resolve_library_param(&state, Some("")), Ok(None)));
#[test]
fn resolve_library_param_empty_or_whitespace_is_union() {
let libs = sample_libraries();
assert!(matches!(resolve_library_param(&libs, Some("")), Ok(None)));
assert!(matches!(
resolve_library_param(&state, Some(" ")),
resolve_library_param(&libs, Some(" ")),
Ok(None)
));
}
#[actix_rt::test]
async fn resolve_library_param_numeric_id_matches() {
let state = state_with_libraries(sample_libraries());
let lib = resolve_library_param(&state, Some("7"))
#[test]
fn resolve_library_param_numeric_id_matches() {
let libs = sample_libraries();
let lib = resolve_library_param(&libs, Some("7"))
.expect("valid id")
.expect("some library");
assert_eq!(lib.id, 7);
assert_eq!(lib.name, "archive");
}
#[actix_rt::test]
async fn resolve_library_param_name_matches() {
let state = state_with_libraries(sample_libraries());
let lib = resolve_library_param(&state, Some("main"))
#[test]
fn resolve_library_param_name_matches() {
let libs = sample_libraries();
let lib = resolve_library_param(&libs, Some("main"))
.expect("valid name")
.expect("some library");
assert_eq!(lib.id, 1);
}
#[actix_rt::test]
async fn resolve_library_param_unknown_id_errs() {
let state = state_with_libraries(sample_libraries());
let err = resolve_library_param(&state, Some("999")).unwrap_err();
#[test]
fn resolve_library_param_unknown_id_errs() {
let libs = sample_libraries();
let err = resolve_library_param(&libs, Some("999")).unwrap_err();
assert!(err.contains("unknown library id"));
}
#[actix_rt::test]
async fn resolve_library_param_unknown_name_errs() {
let state = state_with_libraries(sample_libraries());
let err = resolve_library_param(&state, Some("missing")).unwrap_err();
#[test]
fn resolve_library_param_unknown_name_errs() {
let libs = sample_libraries();
let err = resolve_library_param(&libs, Some("missing")).unwrap_err();
assert!(err.contains("unknown library name"));
}