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:
1 parent
b30c8c16d0
commit
f707353807
26 files changed
+1825
-153
No files matched your search
+48
-43
@@ -120,7 +120,7 @@ pub async fn generation_status_handler(
|
||||
}
|
||||
|
||||
if let Some(ref fp) = query.path {
|
||||
let library = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -218,10 +218,11 @@ pub async fn cancel_generation_handler(
|
||||
}
|
||||
|
||||
if let Some(ref fp) = request.file_path {
|
||||
let library = libraries::resolve_library_param(&app_state, request.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
let library =
|
||||
libraries::resolve_library_param_state(&app_state, request.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
let normalized = normalize_path(fp);
|
||||
|
||||
// Get active job ids first, then cancel in DB, then abort tasks
|
||||
@@ -580,7 +581,7 @@ pub async fn get_insight_handler(
|
||||
|
||||
// Expand to rel_paths sharing content so an insight generated under
|
||||
// library 1 still shows when the same photo is viewed from library 2.
|
||||
let library = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -1218,15 +1219,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 library = match libraries::resolve_library_param(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Service-token claims (sub: "service:apollo") fall through to
|
||||
// user_id=1 — the operator convention. Mobile/web clients have a
|
||||
@@ -1344,15 +1346,16 @@ pub async fn chat_rewind_handler(
|
||||
request: web::Json<ChatRewindHttpRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let library = match libraries::resolve_library_param(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match app_state
|
||||
.insight_chat
|
||||
@@ -1393,7 +1396,7 @@ pub async fn chat_history_handler(
|
||||
// cross-library lookup when the scoped one misses, so a photo
|
||||
// with no insight in this library but one in another still
|
||||
// surfaces (the "show this photo's primary insight" merge case).
|
||||
let library = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -1444,15 +1447,16 @@ pub async fn chat_stream_handler(
|
||||
request: web::Json<ChatTurnHttpRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let library = match libraries::resolve_library_param(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Service-token sub falls through to user_id=1 (see chat_turn_handler).
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
@@ -1589,15 +1593,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 library = match libraries::resolve_library_param(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
|
||||
|
||||
@@ -4497,6 +4497,108 @@ Return ONLY the summary, nothing else."#,
|
||||
))
|
||||
}
|
||||
|
||||
/// A read-only agentic tool loop: chat with tools until the model stops
|
||||
/// calling them, then return the final content.
|
||||
///
|
||||
/// This is the loop body extracted from
|
||||
/// `generate_agentic_insight_for_photo` (lines 4316-4377) so it can be
|
||||
/// reused by the reel-scripter without the photo-specific context
|
||||
/// (image_base64, file_path, persona_id). The photo insight loop still
|
||||
/// has its own copy because it threads image/file context through
|
||||
/// `execute_tool`.
|
||||
///
|
||||
/// Calls `execute_tool` with empty file/image context; enabled tools
|
||||
/// never read those fields.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn run_readonly_tool_loop(
|
||||
&self,
|
||||
backend: &ResolvedBackend,
|
||||
mut messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
max_iter: usize,
|
||||
) -> Result<String> {
|
||||
let mut final_content = String::new();
|
||||
|
||||
for iteration in 0..max_iter {
|
||||
log::info!("Agentic iteration {}/{}", iteration + 1, max_iter);
|
||||
|
||||
let (response, _prompt_tokens, _eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
// Sanitize tool call arguments before pushing back into history.
|
||||
// Some models occasionally return non-object arguments (bool,
|
||||
// string, null) which Ollama rejects when they are re-sent in
|
||||
// a subsequent request.
|
||||
let mut response = response;
|
||||
if let Some(ref mut tool_calls) = response.tool_calls {
|
||||
for tc in tool_calls.iter_mut() {
|
||||
if !tc.function.arguments.is_object() {
|
||||
log::warn!(
|
||||
"Tool '{}' returned non-object arguments ({:?}), normalising to {{}}",
|
||||
tc.function.name,
|
||||
tc.function.arguments
|
||||
);
|
||||
tc.function.arguments = serde_json::Value::Object(Default::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(response.clone());
|
||||
|
||||
if let Some(ref tool_calls) = response.tool_calls
|
||||
&& !tool_calls.is_empty()
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
log::info!(
|
||||
"Agentic tool call [{}]: {} {}",
|
||||
iteration,
|
||||
tool_call.function.name,
|
||||
tool_call.function.arguments
|
||||
);
|
||||
let result = self
|
||||
.execute_tool(
|
||||
&tool_call.function.name,
|
||||
&tool_call.function.arguments,
|
||||
backend,
|
||||
&None,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
&opentelemetry::Context::new(),
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// No tool calls — this is the final answer
|
||||
final_content = response.content;
|
||||
break;
|
||||
}
|
||||
|
||||
// If loop exhausted without final answer, ask for one
|
||||
if final_content.is_empty() {
|
||||
log::info!(
|
||||
"Agentic loop exhausted after {} iterations, requesting final answer",
|
||||
max_iter
|
||||
);
|
||||
messages.push(ChatMessage::user(
|
||||
"Based on the context gathered, please write the final answer. Return ONLY the JSON object, no prose or code fences.",
|
||||
));
|
||||
let (final_response, _, _) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), vec![])
|
||||
.await?;
|
||||
final_content = final_response.content.clone();
|
||||
messages.push(final_response);
|
||||
}
|
||||
|
||||
Ok(final_content)
|
||||
}
|
||||
|
||||
/// Reverse geocode GPS coordinates to human-readable place names
|
||||
async fn reverse_geocode(&self, lat: f64, lon: f64) -> Option<String> {
|
||||
let url = format!(
|
||||
|
||||
+1
-1
@@ -1020,7 +1020,7 @@ pub async fn create_voice_from_library_handler(
|
||||
let voice_name =
|
||||
append_ref_window(&voice_name, ref_start, ref_duration.round().max(1.0) as u32);
|
||||
|
||||
let library = match libraries::resolve_library_param(&app_state, req.library.as_deref()) {
|
||||
let library = match libraries::resolve_library_param_state(&app_state, req.library.as_deref()) {
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
|
||||
Reference in new issue
Block a user