Prefs: owner defaults (model, backend, persona_id, reel span/length)
Closes #127. Grows user_ai_prefs with five nullable owner-default columns and makes them the server-side fallback layer: request param -> stored pref -> hard default, at insight generation (agentic + standard), all three insight-chat turn variants, and reel creation. - PUT /prefs accepts the fields with the existing three-state partial update semantics (TriState generalized to TriState<T> for the numeric reel_max_segments); validation (canonical BackendKind::parse, MemoriesSpan::from_str) runs before any write. - GET /prefs returns the new fields. - capture_prefs reads the existing row and carries the AI owner-defaults through unchanged (the upsert overwrites all columns), mirroring only the effective reel span/segments. - MemoriesSpan gains Display + FromStr so stored spans round-trip losslessly. 1463/0 tests (+27), clippy clean.
This commit is contained in:
1 parent
aa3c58f051
commit
a699c940b8
10 files changed
+863
-85
No files matched your search
+174
-15
@@ -18,6 +18,7 @@ pub mod selector;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{LazyLock, Mutex, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -71,13 +72,38 @@ pub fn normalize_library_key(libs: &[Library], param: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reel span / segment-cap resolution: explicit request param → stored
|
||||
/// owner pref → hard default. A stored span that no longer parses (e.g.
|
||||
/// hand-edited) is skipped rather than erroring — the hard default still
|
||||
/// applies. Non-positive stored segment caps are ignored the same way.
|
||||
fn resolve_reel_params(
|
||||
req_span: Option<MemoriesSpan>,
|
||||
req_max_segments: Option<usize>,
|
||||
prefs: Option<&crate::database::models::UserAiPrefs>,
|
||||
) -> (MemoriesSpan, usize) {
|
||||
let pref_span = prefs
|
||||
.and_then(|p| p.reel_span.as_deref())
|
||||
.and_then(|s| MemoriesSpan::from_str(s).ok());
|
||||
let pref_max_segments = prefs
|
||||
.and_then(|p| p.reel_max_segments)
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| v as usize);
|
||||
let span = req_span.or(pref_span).unwrap_or(MemoriesSpan::Day);
|
||||
let max_segments = req_max_segments
|
||||
.or(pref_max_segments)
|
||||
.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
|
||||
(span, max_segments)
|
||||
}
|
||||
|
||||
/// Best-effort: mirror the latest client reel params into `user_ai_prefs`
|
||||
/// so the nightly pre-gen scheduler can pick them up. Never fails the
|
||||
/// caller regardless of DB errors.
|
||||
fn capture_prefs(
|
||||
app_state: &AppState,
|
||||
req: &web::Json<CreateReelRequest>,
|
||||
req: &CreateReelRequest,
|
||||
library_param: Option<&str>,
|
||||
effective_span: MemoriesSpan,
|
||||
effective_max_segments: usize,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
use crate::database::models::UpsertUserAiPrefs;
|
||||
let now = std::time::SystemTime::now()
|
||||
@@ -93,6 +119,21 @@ fn capture_prefs(
|
||||
};
|
||||
let mut dao = app_state.user_ai_prefs_dao.lock().expect("lock");
|
||||
let ctx = opentelemetry::Context::new();
|
||||
// The row passively mirrors the latest request's EFFECTIVE reel params
|
||||
// (after the request → pref → default resolution done by the caller), so
|
||||
// the stored reel_span / reel_max_segments track what was last used —
|
||||
// idempotent when the pref itself supplied the effective value.
|
||||
//
|
||||
// The owner-default fields (model/backend/persona) are owned by PUT /prefs,
|
||||
// not by reel requests. The upsert overwrites every column, so we must
|
||||
// carry the existing values through unchanged — writing None here would
|
||||
// silently wipe the owner's stored defaults on every reel creation. If the
|
||||
// read fails we bail without writing: this is best-effort, and clobbering
|
||||
// owner defaults on a transient error is worse than skipping a mirror.
|
||||
let existing = match dao.get_prefs(&ctx) {
|
||||
Ok(row) => row,
|
||||
Err(e) => return Err(anyhow::anyhow!("failed to read prefs before mirror: {e}")),
|
||||
};
|
||||
dao.upsert_prefs(
|
||||
&ctx,
|
||||
&UpsertUserAiPrefs {
|
||||
@@ -104,6 +145,11 @@ fn capture_prefs(
|
||||
library: Some(library),
|
||||
updated_at: now as i64,
|
||||
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
|
||||
default_model: existing.as_ref().and_then(|e| e.default_model.clone()),
|
||||
default_backend: existing.as_ref().and_then(|e| e.default_backend.clone()),
|
||||
default_persona_id: existing.as_ref().and_then(|e| e.default_persona_id.clone()),
|
||||
reel_span: Some(effective_span.to_string()),
|
||||
reel_max_segments: Some(effective_max_segments as i32),
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}"))
|
||||
@@ -415,20 +461,21 @@ pub async fn create_reel_handler(
|
||||
}));
|
||||
}
|
||||
|
||||
let span = req.span.unwrap_or(MemoriesSpan::Day);
|
||||
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
|
||||
// Grouping zone: explicit request param → stored pref → app default
|
||||
// (ADR-0001). The legacy device-offset param is still mirrored into the
|
||||
// frozen prefs column by `capture_prefs` for rollback but no longer
|
||||
// drives selection.
|
||||
// Owner defaults: explicit request param → stored pref → hard default.
|
||||
// The stored row is read once and reused for the span, the segment cap,
|
||||
// and the display zone. The legacy device-offset param is still mirrored
|
||||
// into the frozen prefs column by `capture_prefs` for rollback but no
|
||||
// longer drives selection.
|
||||
let stored_prefs = app_state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten());
|
||||
let (span, max_segments) =
|
||||
resolve_reel_params(req.span, req.max_segments, stored_prefs.as_ref());
|
||||
let display_zone_name = {
|
||||
let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty());
|
||||
let stored = app_state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
|
||||
.and_then(|p| p.display_tz);
|
||||
let stored = stored_prefs.as_ref().and_then(|p| p.display_tz.clone());
|
||||
crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref()))
|
||||
};
|
||||
let selector = ReelSelector::Memories {
|
||||
@@ -489,7 +536,7 @@ pub async fn create_reel_handler(
|
||||
},
|
||||
);
|
||||
// Capture params for passive prefs mirror (best-effort, never fails).
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref());
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
|
||||
return HttpResponse::Accepted().json(ReelJobCreatedResponse {
|
||||
job_id: job_id.to_string(),
|
||||
status: ReelJobStatus::Done,
|
||||
@@ -549,7 +596,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, &req, req.library.as_deref());
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
|
||||
|
||||
HttpResponse::Accepted().json(ReelJobCreatedResponse {
|
||||
job_id: job_id.to_string(),
|
||||
@@ -1333,9 +1380,121 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::models::UserAiPrefs;
|
||||
use crate::libraries::Library;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn prefs_row(span: Option<&str>, max_segments: Option<i32>) -> UserAiPrefs {
|
||||
UserAiPrefs {
|
||||
id: 1,
|
||||
voice: None,
|
||||
tz_offset_minutes: None,
|
||||
library: None,
|
||||
updated_at: 0,
|
||||
display_tz: None,
|
||||
default_model: None,
|
||||
default_backend: None,
|
||||
default_persona_id: None,
|
||||
reel_span: span.map(str::to_string),
|
||||
reel_max_segments: max_segments,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_request_wins_over_pref() {
|
||||
let prefs = prefs_row(Some("month"), Some(10));
|
||||
let (span, max) = resolve_reel_params(Some(MemoriesSpan::Day), Some(5), Some(&prefs));
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_pref_fills_omitted_request() {
|
||||
let prefs = prefs_row(Some("week"), Some(24));
|
||||
let (span, max) = resolve_reel_params(None, None, Some(&prefs));
|
||||
assert_eq!(span, MemoriesSpan::Week);
|
||||
assert_eq!(max, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_no_pref_falls_to_hard_defaults() {
|
||||
let (span, max) = resolve_reel_params(None, None, None);
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_invalid_or_nonpositive_pref_skipped() {
|
||||
// Hand-edited / corrupt stored values fall through to hard defaults
|
||||
// instead of erroring the request.
|
||||
let bad_span = prefs_row(Some("decade"), Some(0));
|
||||
let (span, max) = resolve_reel_params(None, None, Some(&bad_span));
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
|
||||
}
|
||||
|
||||
/// Regression: reel creation is a PASSIVE mirror. The upsert overwrites
|
||||
/// every column, so `capture_prefs` must carry the PUT /prefs-owned
|
||||
/// default_model / default_backend / default_persona_id through unchanged
|
||||
/// — a reel request has no business wiping the owner's AI defaults.
|
||||
#[actix_rt::test]
|
||||
async fn reel_capture_preserves_owner_defaults() {
|
||||
use crate::database::models::UpsertUserAiPrefs;
|
||||
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
|
||||
use crate::state::AppState;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
|
||||
let mut state = AppState::test_state();
|
||||
state.user_ai_prefs_dao = Arc::new(Mutex::new(Box::new(
|
||||
SqliteUserAiPrefsDao::from_connection(conn.clone()),
|
||||
)));
|
||||
{
|
||||
let mut dao = SqliteUserAiPrefsDao::from_connection(conn.clone());
|
||||
dao.upsert_prefs(
|
||||
&opentelemetry::Context::new(),
|
||||
&UpsertUserAiPrefs {
|
||||
voice: None,
|
||||
tz_offset_minutes: None,
|
||||
library: None,
|
||||
updated_at: 0,
|
||||
display_tz: None,
|
||||
default_model: Some("llama3.2:3b".into()),
|
||||
default_backend: Some("hybrid".into()),
|
||||
default_persona_id: Some("cabin".into()),
|
||||
reel_span: None,
|
||||
reel_max_segments: None,
|
||||
},
|
||||
)
|
||||
.expect("seed owner defaults");
|
||||
}
|
||||
|
||||
let req = CreateReelRequest {
|
||||
span: Some(MemoriesSpan::Week),
|
||||
timezone_offset_minutes: Some(-240),
|
||||
display_tz: None,
|
||||
library: None,
|
||||
voice: Some("grandma".into()),
|
||||
max_segments: Some(12),
|
||||
};
|
||||
capture_prefs(&state, &req, None, MemoriesSpan::Week, 12).expect("capture");
|
||||
|
||||
let row = state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.get_prefs(&opentelemetry::Context::new())
|
||||
.expect("read")
|
||||
.expect("row");
|
||||
// The mirrored reel params landed...
|
||||
assert_eq!(row.reel_span.as_deref(), Some("week"));
|
||||
assert_eq!(row.reel_max_segments, Some(12));
|
||||
// ...and the owner defaults survived untouched.
|
||||
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
|
||||
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
|
||||
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
|
||||
}
|
||||
|
||||
fn photo(p: &str, lib: i32) -> SegmentMedia {
|
||||
SegmentMedia::Photo {
|
||||
rel_path: p.to_string(),
|
||||
|
||||
Reference in new issue
Block a user