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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user