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
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user