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:
Cameron Cordes committed 2026-09-27 19:48:45 -04:00
1 parent aa3c58f051
commit a699c940b8
10 files changed
+863 -85

No files matched your search

+172
View File
@@ -384,6 +384,36 @@ pub struct ServerModels {
pub default_model: String,
}
/// Apply the stored owner defaults to any request field the client omitted.
/// Resolution order: request param → stored pref → hard default (applied
/// downstream). The request always wins; the pref only fills gaps. One DAO
/// read per request; a failed read silently falls back to hard defaults.
fn apply_owner_defaults(
app_state: &AppState,
model: &mut Option<String>,
backend: &mut Option<String>,
persona_id: &mut Option<String>,
) {
if model.is_some() && backend.is_some() && persona_id.is_some() {
return; // nothing to fill — skip the DB read
}
let Some(mut dao) = app_state.user_ai_prefs_dao.lock().ok() else {
return;
};
let Ok(Some(prefs)) = dao.get_prefs(&opentelemetry::Context::new()) else {
return;
};
if model.is_none() {
*model = prefs.default_model;
}
if backend.is_none() {
*backend = prefs.default_backend;
}
if persona_id.is_none() {
*persona_id = prefs.default_persona_id;
}
}
/// POST /insights/generate - Generate insight for a specific photo (async)
#[post("/insights/generate")]
pub async fn generate_insight_handler(
@@ -396,6 +426,16 @@ pub async fn generate_insight_handler(
let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate", &parent_context);
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
let gen_type = InsightGenerationType::Standard;
@@ -714,6 +754,16 @@ pub async fn generate_agentic_insight_handler(
let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context);
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
let gen_type = InsightGenerationType::Agentic;
@@ -1231,6 +1281,16 @@ pub async fn chat_turn_handler(
let mut span = tracer.start_with_context("http.insights.chat", &parent_context);
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -1611,6 +1671,16 @@ pub async fn chat_stream_handler(
request: web::Json<ChatTurnHttpRequest>,
app_state: web::Data<AppState>,
) -> HttpResponse {
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -1758,6 +1828,16 @@ pub async fn turn_async_handler(
let mut span = tracer.start_with_context("http.insights.chat_turn_async", &parent_context);
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -2277,3 +2357,95 @@ mod turn_replay_tests {
assert_eq!(info.total_events_pushed, 2);
}
}
#[cfg(test)]
mod owner_defaults_tests {
use super::apply_owner_defaults;
use crate::database::models::UpsertUserAiPrefs;
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
use crate::state::AppState;
use std::sync::{Arc, Mutex};
fn prefs_state(prefs: Option<UpsertUserAiPrefs>) -> AppState {
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()),
)));
if let Some(p) = prefs {
let mut dao = SqliteUserAiPrefsDao::from_connection(conn);
dao.upsert_prefs(&opentelemetry::Context::new(), &p)
.expect("seed prefs");
}
state
}
fn seeded_defaults() -> UpsertUserAiPrefs {
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,
}
}
#[actix_rt::test]
async fn request_wins_over_stored_pref() {
let state = prefs_state(Some(seeded_defaults()));
let mut model = Some("explicit".to_string());
let mut backend = Some("local".to_string());
let mut persona = Some("explicit-p".to_string());
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("explicit"));
assert_eq!(backend.as_deref(), Some("local"));
assert_eq!(persona.as_deref(), Some("explicit-p"));
}
#[actix_rt::test]
async fn pref_fills_omitted_fields() {
let state = prefs_state(Some(seeded_defaults()));
let mut model = None;
let mut backend = None;
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
assert_eq!(backend.as_deref(), Some("hybrid"));
assert_eq!(persona.as_deref(), Some("cabin"));
}
#[actix_rt::test]
async fn no_pref_row_leaves_fields_none() {
let state = prefs_state(None);
let mut model = None;
let mut backend = None;
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
// Hard defaults apply downstream; the pref layer fills nothing.
assert!(model.is_none());
assert!(backend.is_none());
assert!(persona.is_none());
}
#[actix_rt::test]
async fn pref_fills_only_the_fields_it_has() {
let state = prefs_state(Some(UpsertUserAiPrefs {
default_backend: None,
default_persona_id: Some("journal".into()),
..seeded_defaults()
}));
let mut model = None;
let mut backend = Some("local".to_string());
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
// Explicit request backend is untouched; pref had none anyway.
assert_eq!(backend.as_deref(), Some("local"));
assert_eq!(persona.as_deref(), Some("journal"));
}
}