use anyhow::{Result, anyhow, bail}; use chrono::{TimeZone, Utc}; use opentelemetry::trace::Status; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; use crate::ai::backend::{BackendKind, ResolvedBackend, SamplingOverrides}; use crate::ai::insight_generator::InsightGenerator; use crate::ai::llm_client::{ BranchLeafInfo, ChatHistoryStore, ChatMessage, LlmStreamEvent, StoredChatNode, Tool, }; use crate::ai::turn_registry::TurnEntry; use crate::ai::turn_registry::TurnRegistry; use crate::database::InsightDao; use crate::database::models::InsertPhotoInsight; use crate::utils::{normalize_path, retry_with_backoff}; use futures::stream::{BoxStream, StreamExt}; use tracing::Instrument; use tracing_opentelemetry::OpenTelemetrySpanExt; use uuid::Uuid; pub const DEFAULT_MAX_ITERATIONS: usize = 6; /// Assumed context window when the request doesn't specify `num_ctx` and /// the backend can't report the window it serves (llama-swap can; see /// `effective_num_ctx`). Real conversations rarely pass ~16k tokens, so 32k /// keeps the truncation pass from gutting history the server could hold /// (which also destroys the server's KV-cache prefix reuse). Override /// per-deploy with AGENTIC_CHAT_DEFAULT_NUM_CTX. const DEFAULT_NUM_CTX: i32 = 32768; /// Headroom reserved for the model's response, deducted from the context /// budget when deciding whether to truncate the replayed history. pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048; /// Cheap byte-to-token approximation used by the truncation pass. The real /// tokenization is model-specific; this avoids carrying tiktoken just for a /// soft bound. pub(crate) const BYTES_PER_TOKEN: usize = 4; /// Flat token cost charged per inlined image in the truncation budget. A /// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on /// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of /// characters) must NOT be counted as text bytes — doing so dwarfs the entire /// text budget and forces spurious truncation on every turn. const IMAGE_TOKENS_EACH: usize = 1300; /// User prompt injected when the agentic loop exhausts its iteration budget /// without producing a tool-free reply. Internal scaffolding only — it is /// stripped from the transcript before persistence (see /// [`push_synthetic_final_prompt`] / [`remove_synthetic_final_prompt`]). const SYNTHETIC_FINAL_ANSWER_PROMPT: &str = "Please write your final answer now without calling any more tools."; pub type ChatLockMap = Arc>>>>; #[derive(Debug)] pub struct ChatTurnRequest { pub library_id: i32, /// Author's user_id, extracted from Claims at the handler. Tagged /// onto every entity_fact row written this turn so the composite FK /// (user_id, persona_id) → personas holds and so cross-user reads /// stay isolated. Service token claims that don't parse as i32 /// fall through to user_id=1 (operator convention). pub user_id: i32, pub file_path: String, pub user_message: String, /// Override the model id. Local mode: an Ollama model name. Hybrid: /// an OpenRouter id. None defers to the stored insight's `model_version`. pub model: Option, /// Override the backend used for this turn. None defers to the stored /// insight's `backend`. Switching `local -> hybrid` is rejected in v1. pub backend: Option, pub num_ctx: Option, pub temperature: Option, pub top_p: Option, pub top_k: Option, pub min_p: Option, /// Reasoning toggle for thinking-capable models. Forwarded to the /// llama.cpp backend as `chat_template_kwargs.enable_thinking`; ignored /// by other backends. None defers to the model/template default. pub enable_thinking: Option, pub max_iterations: Option, /// Per-turn system-prompt override. In append mode (default), applied /// ephemerally — original system message restored before persistence. /// In amend mode, persisted into the new insight row's system message. /// None / empty = no change. pub system_prompt: Option, /// Active persona id for this turn. Tools that write to /// `entity_facts` tag the new rows with it; `recall_facts_for_photo` /// scopes its read to it. None defaults to `"default"`. pub persona_id: Option, /// When true, write a new insight row (regenerating title) instead of /// updating training_messages on the existing row. pub amend: bool, /// When true, force the bootstrap path even if an insight already exists: /// flip prior rows to `is_current=false` and create a new insight row /// from `system_prompt` + `user_message` + photo. Takes precedence over /// `amend`. With no existing insight, collapses to a normal bootstrap /// (the row-flip step is a no-op). pub regenerate: bool, } #[derive(Debug)] pub struct ChatTurnResult { pub assistant_message: String, pub tool_calls_made: usize, pub iterations_used: usize, pub truncated: bool, pub prompt_eval_count: Option, pub eval_count: Option, /// Set when `amend=true` and the new insight row was inserted. pub amended_insight_id: Option, /// Backend used for this turn — useful when the client overrode the /// stored value. pub backend_used: String, /// Model identifier the chat backend ran with. pub model_used: String, } #[derive(Clone)] pub struct InsightChatService { generator: Arc, insight_dao: Arc>>, chat_locks: ChatLockMap, /// Local vision descriptions of transcript images, keyed by a hash of /// the image data, so a local-generated chat continued on a text-only /// (hybrid) model describes its photo once rather than every turn. image_descriptions: Arc>>, } /// Cap on cached image descriptions; the cache is cleared when it fills. /// One entry per photo chatted about since the server started. const IMAGE_DESCRIPTION_CACHE_MAX: usize = 256; impl InsightChatService { pub fn new( generator: Arc, insight_dao: Arc>>, chat_locks: ChatLockMap, ) -> Self { Self { generator, insight_dao, chat_locks, image_descriptions: Arc::new(Mutex::new(HashMap::new())), } } /// Prepare a replayed transcript for a backend that can't take images. /// /// A chat generated on the local backend carries the photo itself in its /// first user message. Continuing it on hybrid would send that photo to /// OpenRouter — which the OpenRouter client refuses — so each image is /// described by the local vision model and the text takes its place in /// the copy sent to the model. The returned stash puts the images back /// before the transcript is saved, so the stored chat keeps its photo and /// a later local turn still sees it. /// /// A no-op for backends that take images inline. If the local model /// can't describe an image the turn fails: going ahead without the /// photo would quietly change what the chat is about. async fn inline_images_as_text( &self, backend: &ResolvedBackend, messages: &mut [ChatMessage], ) -> Result { if backend.images_inline { return Ok(ImageStash::default()); } let mut descriptions: HashMap = HashMap::new(); for image in transcript_images(messages) { let key = image_cache_key(&image); let cached = self .image_descriptions .lock() .ok() .and_then(|c| c.get(&key).cloned()); let description = match cached { Some(d) => d, None => { let d = backend.local().describe_image(&image).await.map_err(|e| { anyhow!( "couldn't describe the photo with the local vision model, so this \ chat can't continue on a text-only model: {e}" ) })?; if let Ok(mut cache) = self.image_descriptions.lock() { if cache.len() >= IMAGE_DESCRIPTION_CACHE_MAX { cache.clear(); } cache.insert(key, d.clone()); } d } }; descriptions.insert(image, description); } Ok(apply_image_descriptions(messages, &descriptions)) } /// Accessor for the insight DAO (used by async job completion). pub fn insight_dao(&self) -> &Arc>> { &self.insight_dao } /// Load the rendered transcript for chat-UI display. Deserializes the /// `training_messages` tree, traverses to `active_leaf_id`, and renders /// the path from root to that leaf. Backward-compatible: if the stored /// value is a flat array (old format), converts to a tree automatically. /// /// `library_id` scopes the lookup to one library — without it, a /// regenerate on lib1 can be shadowed on the next refresh by an /// untouched `is_current=true` row in lib2 for the same rel_path. pub fn load_history( &self, library_id: i32, file_path: &str, branch_id: Option, ) -> Result { let normalized = normalize_path(file_path); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let insight = match dao .get_current_insight_for_library(library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? { Some(i) => i, None => dao .get_insight(&normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? .ok_or_else(|| anyhow!("no insight found for path"))?, }; let raw = insight .training_messages .as_ref() .ok_or_else(|| anyhow!("insight has no chat history (pre-agentic insight)"))?; // Backward-compatible deserialization: flat array (old) vs tree (new). let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(raw) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(raw) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; // Use branch_id if provided, otherwise use active_leaf_id. let target_leaf = branch_id.unwrap_or(store.active_leaf_id); let path = store .path_to_leaf(target_leaf) .ok_or_else(|| anyhow!("branch_id {} not found in tree", target_leaf))?; let (rendered, turn_count, _node_ids, fork_info) = render_tree_path(&store, &path); Ok(HistoryView { messages: rendered, turn_count, model_version: insight.model_version, backend: insight.backend, active_leaf_id: store.active_leaf_id, viewing_branch_id: target_leaf, fork_info, }) } #[tracing::instrument(name = "ai.insight.chat_turn", skip_all)] pub async fn chat_turn(&self, req: ChatTurnRequest) -> Result { let span = tracing::Span::current(); span.set_attribute("file_path", req.file_path.clone()); span.set_attribute("library_id", req.library_id as i64); span.set_attribute("amend", req.amend); if req.user_message.trim().is_empty() { bail!("user_message must not be empty"); } if req.user_message.len() > 8192 { bail!("user_message exceeds 8192 chars"); } let active_persona = req .persona_id .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "default".to_string()); span.set_attribute("persona_id", active_persona.clone()); let normalized = normalize_path(&req.file_path); // 1. Acquire the per-(library, file) async mutex. Two concurrent // chat turns on the same insight would race on the JSON blob — // the lock serialises them. let lock_key = (req.library_id, normalized.clone()); let entry_lock = { let mut locks = self.chat_locks.lock().await; locks .entry(lock_key.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = entry_lock.lock().await; // 2. Load the current insight + history. let insight = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.get_current_insight_for_library(req.library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? .ok_or_else(|| anyhow!("no insight found for path"))? }; let raw_history = insight .training_messages .as_ref() .ok_or_else(|| { anyhow!("insight has no chat history; regenerate this insight in agentic mode") })? .clone(); // Backward-compatible: flat array (old) vs tree (new). let mut store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(&raw_history) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(&raw_history) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; // Build messages from the path to the active leaf. let path = store .path_to_leaf(store.active_leaf_id) .ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?; let mut messages: Vec = path.iter().map(|n| n.message.clone()).collect(); // 3. Resolve effective backend. Reject the unsupported switch. let stored_backend = insight.backend.clone(); let effective_backend = req .backend .as_deref() .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| stored_backend.clone()); validate_cross_replay(&stored_backend, &effective_backend)?; let kind = BackendKind::parse(&effective_backend)?; span.set_attribute("backend", kind.as_str()); let max_iterations = req .max_iterations .unwrap_or(DEFAULT_MAX_ITERATIONS) .clamp(1, env_max_iterations()); span.set_attribute("max_iterations", max_iterations as i64); let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { model: continuation_model( req.model.clone(), &stored_model, &stored_backend, kind.as_str(), ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; // Text-only backend (hybrid) on a transcript holding the photo: // swap it for a local description. Restored before saving. let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); span.set_attribute("model", model_used.clone()); // 5. Decide vision + tool set. In hybrid (describe-then-inline) mode // we omit `describe_photo`. Otherwise trust the stored history: // if the first user message carries images, describe_photo stays. let local_first_user_has_image = messages .iter() .find(|m| m.role == "user") .and_then(|m| m.images.as_ref()) .map(|imgs| !imgs.is_empty()) .unwrap_or(false); let offer_describe_tool = backend.images_inline && local_first_user_has_image; let gate_opts = self.generator.current_gate_opts_for_persona( offer_describe_tool, Some((req.user_id, &active_persona)), ); let tools = InsightGenerator::build_tool_definitions(gate_opts); let image_base64: Option = if offer_describe_tool { self.generator.load_image_as_base64(&normalized).ok() } else { None }; // 6. Apply truncation budget (see `apply_context_budget`), sized to // the window the model is actually served with. let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let truncated = fitter.trim(&mut messages); if truncated { span.set_attribute("history_truncated", true); } // Everything in `messages` at this point is replayed history that is // ALREADY in the tree; only what the turn appends past this mark // becomes new nodes. Captured after the budget pass, not from // `path.len()`: truncation drains from the middle, so `path.len()` // over-counts and the slice below would either swallow the new user // turn or panic on an out-of-range start index. let history_len = messages.len(); // 7. Append the new user turn. messages.push(ChatMessage::user(req.user_message.clone())); // Apply per-turn system-prompt override BEFORE the budget annotation // so the budget note attaches to the override, not the original. // The stash is consumed below before persistence (append mode) or // dropped (amend mode, where the override stays in place). let override_stash = apply_system_prompt_override(&mut messages, req.system_prompt.as_deref()); // Temporarily annotate the system message with this turn's iteration // budget so the model knows how many tool-calling rounds it has. We // restore the original content before persistence so the note doesn't // accumulate across turns. let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); // 8. Agentic loop — same shape as insight_generator's, but capped // tighter and dispatching tools through the shared executor. // Each LLM / tool await runs inside `loop_span` so their spans // nest under it. let loop_span = tracing::info_span!("ai.chat.loop"); let mut tool_calls_made = 0usize; let mut iterations_used = 0usize; let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; let mut final_content = String::new(); for iteration in 0..max_iterations { iterations_used = iteration + 1; log::info!("Chat iteration {}/{}", iterations_used, max_iterations); let (response, prompt_tokens, eval_tokens) = fitter .chat(backend.chat(), &messages, tools.clone()) .instrument(loop_span.clone()) .await?; last_prompt_eval_count = prompt_tokens; last_eval_count = eval_tokens; let mut response = response; if let Some(ref mut tcs) = response.tool_calls { for tc in tcs.iter_mut() { if !tc.function.arguments.is_object() { 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 { tool_calls_made += 1; log::info!( "Chat tool call [{}]: {} {:?}", iteration, tool_call.function.name, tool_call.function.arguments ); let result = self .generator .execute_tool( &tool_call.function.name, &tool_call.function.arguments, &backend, &image_base64, &normalized, req.user_id, &active_persona, None, ) .instrument(loop_span.clone()) .await; messages.push(ChatMessage::tool_result(result)); } continue; } final_content = response.content; break; } if final_content.is_empty() { log::info!( "Chat loop exhausted after {} iterations, requesting final answer", iterations_used ); let synthetic_idx = push_synthetic_final_prompt(&mut messages); let (final_response, prompt_tokens, eval_tokens) = fitter .chat(backend.chat(), &messages, vec![]) .instrument(loop_span.clone()) .await?; last_prompt_eval_count = prompt_tokens; last_eval_count = eval_tokens; final_content = final_response.content.clone(); messages.push(final_response); // Drop the synthetic prompt before persistence — internal // scaffolding only (mirrors both streaming variants). remove_synthetic_final_prompt(&mut messages, synthetic_idx); } // Strip any leaked reasoning block from the content we // return / persist as the reply (the raw transcript keeps it). let final_content = crate::ai::llm_client::strip_think_blocks(&final_content); loop_span.set_status(Status::Ok); drop(loop_span); // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); // Put the photo back so the stored transcript keeps it. image_stash.restore(&mut messages); // Append mode: undo the per-turn system-prompt override so the // stored transcript keeps the original baked persona. Amend mode: // keep the override in place — it becomes the new insight row's // system message. if !req.amend { restore_system_prompt_override(&mut messages, override_stash); } // 9. Persist. Append mode rewrites the JSON blob in place; amend // mode regenerates the title and inserts a new insight row, // relying on store_insight to flip prior rows' is_current=false. // Append new messages (this turn's user + assistant exchanges) as // tree nodes chained from the previous active_leaf_id. let new_messages = messages[history_len..].to_vec(); let mut parent_id = Some(store.active_leaf_id); for msg in &new_messages { let new_id = store.append_node(parent_id, msg.clone()); parent_id = Some(new_id); } // Update active_leaf_id to the last node appended this turn. if let Some(last_id) = parent_id { store.active_leaf_id = last_id; } let json = serde_json::to_string(&store) .map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?; let mut amended_insight_id: Option = None; if req.amend { let title_prompt = format!( "Create a short title (maximum 8 words) for the following journal entry:\n\n{}\n\n\ Capture the key moment or theme. Return ONLY the title, nothing else.", final_content ); let title_raw = backend .chat() .generate( &title_prompt, Some( "You are my long term memory assistant. Use only the information provided. Do not invent details.", ), None, ) .await?; let title = title_raw.trim().trim_matches('"').to_string(); // Amended rows intentionally do not inherit the parent's // `fewshot_source_ids`. The parent's few-shot influence is still // present in this row's content; if you want strict lineage // tracking for training-set filtering, fetch the parent here and // copy its value forward. let new_row = InsertPhotoInsight { library_id: req.library_id, file_path: normalized.clone(), title, summary: final_content.clone(), generated_at: Utc::now().timestamp(), model_version: model_used.clone(), is_current: true, training_messages: Some(json), backend: kind.as_str().to_string(), fewshot_source_ids: None, content_hash: None, num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, system_prompt: req.system_prompt.clone(), persona_id: req.persona_id.clone(), prompt_eval_count: None, eval_count: None, }; let dao = self.insight_dao.clone(); let stored = retry_with_backoff("store_insight", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.store_insight(new_row.clone()) }) .map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?; amended_insight_id = Some(stored.id); } else { let dao = self.insight_dao.clone(); let rows = retry_with_backoff("update_training_messages", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.update_training_messages(req.library_id, &normalized, &json) }) .map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?; if rows == 0 { log::warn!( "update_training_messages updated 0 rows for {} (lib {}), \ concurrent regenerate likely flipped is_current", normalized, req.library_id ); } } Ok(ChatTurnResult { assistant_message: final_content, tool_calls_made, iterations_used, truncated, prompt_eval_count: last_prompt_eval_count, eval_count: last_eval_count, amended_insight_id, backend_used: kind.as_str().to_string(), model_used, }) } /// Rewind the conversation to the rendered message at /// `discard_from_rendered_index` by setting `active_leaf_id` to the /// node just before the discarded message. Unlike the legacy flat-array /// approach, this preserves all fork branches — the discarded path /// remains accessible as an alternate branch. /// /// The initial user turn cannot be discarded; attempting to do so /// returns an error. /// /// Holds the per-file chat mutex so it serialises with `chat_turn`. pub async fn rewind_history( &self, library_id: i32, file_path: &str, discard_from_rendered_index: usize, ) -> Result<()> { if discard_from_rendered_index == 0 { bail!("cannot discard the initial user message"); } let normalized = normalize_path(file_path); let lock_key = (library_id, normalized.clone()); let entry_lock = { let mut locks = self.chat_locks.lock().await; locks .entry(lock_key.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = entry_lock.lock().await; let insight = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.get_current_insight_for_library(library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? .ok_or_else(|| anyhow!("no insight found for path"))? }; let raw_history = insight .training_messages .as_ref() .ok_or_else(|| anyhow!("insight has no chat history"))?; let mut store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(raw_history) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(raw_history) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; let path = store .path_to_leaf(store.active_leaf_id) .ok_or_else(|| anyhow!("active_leaf_id not found in tree"))?; let (_rendered, _turn_count, node_ids, _fork_info) = render_tree_path(&store, &path); // The last kept rendered message is at index `discard_from_rendered_index - 1`. let last_kept_idx = discard_from_rendered_index - 1; let new_active_leaf_id = *node_ids .get(last_kept_idx) .ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?; store.active_leaf_id = new_active_leaf_id; let json = serde_json::to_string(&store) .map_err(|e| anyhow!("failed to serialize tree: {}", e))?; let dao = self.insight_dao.clone(); let rows = retry_with_backoff("update_training_messages", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.update_training_messages(library_id, &normalized, &json) }) .map_err(|e| anyhow!("failed to persist rewound history: {:?}", e))?; if rows == 0 { log::warn!( "update_training_messages (rewind) updated 0 rows for {} (lib {}), \ concurrent regenerate likely flipped is_current", normalized, library_id ); } Ok(()) } /// Switch the active branch to the given leaf ID. The new branch becomes /// the active conversation path; the previous active branch becomes a /// regular fork. If the leaf ID doesn't exist, returns an error. pub async fn switch_branch( &self, library_id: i32, file_path: &str, leaf_id: u64, ) -> Result<()> { let normalized = normalize_path(file_path); let lock_key = (library_id, normalized.clone()); let entry_lock = { let mut locks = self.chat_locks.lock().await; locks .entry(lock_key.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = entry_lock.lock().await; let insight = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.get_current_insight_for_library(library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? .ok_or_else(|| anyhow!("no insight found for path"))? }; let raw_history = insight .training_messages .as_ref() .ok_or_else(|| anyhow!("insight has no chat history"))?; let mut store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(raw_history) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(raw_history) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; // Validate the leaf_id exists and is a leaf. if !store.nodes.iter().any(|n| n.id == leaf_id) { bail!("branch_id {} not found in tree", leaf_id); } if !store.children_of(leaf_id).is_empty() { bail!("branch_id {} is not a leaf node", leaf_id); } store.active_leaf_id = leaf_id; let json = serde_json::to_string(&store) .map_err(|e| anyhow!("failed to serialize tree: {}", e))?; let dao = self.insight_dao.clone(); let rows = retry_with_backoff("update_training_messages", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.update_training_messages(library_id, &normalized, &json) }) .map_err(|e| anyhow!("failed to persist branch switch: {:?}", e))?; if rows == 0 { log::warn!( "update_training_messages (switch_branch) updated 0 rows for {} (lib {}), \ concurrent regenerate likely flipped is_current", normalized, library_id ); } Ok(()) } /// Return branch metadata and the active leaf ID for a photo's /// conversation tree. /// /// With `node_id = None` this lists every leaf in the tree. With /// `node_id = Some(fork)` (from `ForkInfo.node_id`) it lists only the /// sibling branches diverging at that node, position-ranked; options in /// the same subtree as `viewing_leaf` anchor to that leaf so the client /// can recognise "the branch I'm on". pub fn get_branches( &self, library_id: i32, file_path: &str, node_id: Option, viewing_leaf: Option, ) -> Result<(Vec, u64)> { let normalized = normalize_path(file_path); let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); let insight = dao .get_current_insight_for_library(library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? .ok_or_else(|| anyhow!("no insight found for path"))?; let raw = insight .training_messages .as_ref() .ok_or_else(|| anyhow!("insight has no chat history"))?; let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(raw) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(raw) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; let list = match node_id { Some(fork_node) => { if !store.nodes.iter().any(|n| n.id == fork_node) { bail!("node_id {} not found in tree", fork_node); } store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id)) } None => store.leaves_with_info(), }; Ok((list, store.active_leaf_id)) } /// Streaming variant of `chat_turn`. Emits user-facing events as the /// conversation progresses: iteration starts, tool dispatch + result, /// text deltas from the final assistant reply, and a terminal `Done` /// frame. Persistence happens inside the stream after the loop ends. /// /// The stream takes ownership of the service via `Arc` (passed by /// the caller) so it can live past the handler's await boundary. /// /// Must be called from inside the request's span: the turn span is /// created here, eagerly, so it parents on the caller. The returned /// stream is polled by the HTTP body writer, outside any span. pub fn chat_turn_stream( self: Arc, req: ChatTurnRequest, ) -> BoxStream<'static, ChatStreamEvent> { let span = tracing::info_span!( "ai.chat.turn.stream", file_path = %req.file_path, library_id = req.library_id, ); let svc = self; let s = async_stream::stream! { match svc.chat_turn_stream_inner(req, span, Ok).await { Ok(mut rx) => { while let Some(ev) = rx.recv().await { yield ev; } } Err(e) => { yield ChatStreamEvent::Error(format!("{}", e)); } } }; Box::pin(s) } /// Internal: drives the streaming loop on a background task, returning /// a receiver the caller drains. Keeping the work on a spawned task /// decouples the HTTP request lifetime from the chat execution, which /// matters because the chat may run longer than any single network hop /// and we want clean cancellation semantics via the channel close. async fn chat_turn_stream_inner( self: Arc, req: ChatTurnRequest, span: tracing::Span, _ev_mapper: F, ) -> Result> where F: Fn(ChatStreamEvent) -> Result + Send + 'static, { let (tx, rx) = tokio::sync::mpsc::channel::(64); let svc = self.clone(); // Carry the turn span into the task explicitly — tracing context is // thread-local, so without `.instrument` every LLM / DB span inside // the turn would orphan onto a fresh trace. tokio::spawn( async move { let result = svc.run_streaming_turn(req, tx.clone()).await; let span = tracing::Span::current(); if let Err(e) = result { span.set_status(Status::error(format!("{e}"))); let _ = tx.send(ChatStreamEvent::Error(format!("{}", e))).await; } else { span.set_status(Status::Ok); } } .instrument(span), ); Ok(rx) } /// Async turn dispatch: creates a TurnEntry in the registry, spawns the /// agentic loop on a Tokio task, and returns the turn_id immediately. /// Events are buffered in the TurnEntry for SSE replay. pub async fn chat_turn_async( self: Arc, registry: Arc, req: ChatTurnRequest, ) -> String { let turn_id = Uuid::new_v4().to_string(); let entry = Arc::new(TurnEntry::new( turn_id.clone(), req.file_path.clone(), req.library_id, )); registry.insert(entry.clone()).await; let svc = self.clone(); let entry_clone = entry.clone(); // Span covering the whole spawned turn execution. Created before the // spawn so it parents on the dispatching request (same trace as the // 202), and carried into the task with `.instrument`. It outlives the // dispatch span, which ends at the 202 response. let turn_span = tracing::info_span!( "ai.chat.turn.execute", turn_id = %turn_id, library_id = req.library_id, ); let handle = tokio::spawn( async move { let span = tracing::Span::current(); let result = svc .run_streaming_turn_with_entry(req, entry_clone.clone(), &span) .await; if let Err(ref e) = result { span.set_attribute("status", "error"); span.set_status(Status::error(format!("{e}"))); // Push the terminal event BEFORE flipping status: a replay // reader treats a terminal status with no buffered tail as // "closed", so the Error must be in the buffer first. let _ = entry_clone .push_event(ChatStreamEvent::Error(format!("{}", e))) .await; entry_clone.set_terminal_status(crate::ai::turn_registry::TurnStatus::Error); } else { span.set_attribute("status", "done"); span.set_status(Status::Ok); } } .instrument(turn_span), ); // Install the abort handle so DELETE can actually stop the task. entry.set_abort_handle(handle.abort_handle()); turn_id } /// Variant of `run_streaming_turn` that pushes events to a `TurnEntry` /// buffer instead of an `mpsc::Sender`. async fn run_streaming_turn_with_entry( self: Arc, req: ChatTurnRequest, entry: Arc, turn_span: &tracing::Span, ) -> Result<()> { if req.user_message.trim().is_empty() { bail!("user_message must not be empty"); } if req.user_message.len() > 8192 { bail!("user_message exceeds 8192 chars"); } let normalized = normalize_path(&req.file_path); let lock_key = (req.library_id, normalized.clone()); let entry_lock = { let mut locks = self.chat_locks.lock().await; locks .entry(lock_key.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = entry_lock.lock().await; // Look up existing insight scoped to this turn's library_id. let existing_insight = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.get_current_insight_for_library(req.library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? }; if req.regenerate || existing_insight.is_none() { return self .run_bootstrap_streaming_with_entry(req, normalized, entry, turn_span) .await; } let insight = existing_insight.expect("just checked Some above"); self.run_continuation_streaming_with_entry(req, normalized, insight, entry, turn_span) .await } /// Continuation path with TurnEntry buffer. async fn run_continuation_streaming_with_entry( &self, req: ChatTurnRequest, normalized: String, insight: crate::database::models::PhotoInsight, entry: Arc, turn_span: &tracing::Span, ) -> Result<()> { let active_persona = req .persona_id .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "default".to_string()); let raw_history = insight.training_messages.as_ref().ok_or_else(|| { anyhow!("insight has no chat history; regenerate this insight in agentic mode") })?; // Backward-compatible: flat array (old) vs tree (new). let mut store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::>(raw_history) { ChatHistoryStore::from_flat_array(arr) } else { serde_json::from_str(raw_history) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? }; let path = store .path_to_leaf(store.active_leaf_id) .ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?; let mut messages: Vec = path.iter().map(|n| n.message.clone()).collect(); let stored_backend = insight.backend.clone(); let effective_backend = req .backend .as_deref() .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| stored_backend.clone()); let kind = BackendKind::parse(&effective_backend)?; validate_cross_replay(&stored_backend, kind.as_str())?; let max_iterations = req .max_iterations .unwrap_or(DEFAULT_MAX_ITERATIONS) .clamp(1, env_max_iterations()); let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { model: continuation_model( req.model.clone(), &stored_model, &stored_backend, kind.as_str(), ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; // Text-only backend (hybrid) on a transcript holding the photo: // swap it for a local description. Restored before saving. let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); // llm.* attributes on the turn-level span (issue #130): clients can't // know local-vs-hybrid, but the turn knows its resolved backend. turn_span.set_attribute("llm.backend", kind.as_str()); turn_span.set_attribute("llm.model", model_used.clone()); let local_first_user_has_image = messages .iter() .find(|m| m.role == "user") .and_then(|m| m.images.as_ref()) .map(|imgs| !imgs.is_empty()) .unwrap_or(false); let offer_describe_tool = backend.images_inline && local_first_user_has_image; let gate_opts = self.generator.current_gate_opts_for_persona( offer_describe_tool, Some((req.user_id, &active_persona)), ); let tools = InsightGenerator::build_tool_definitions(gate_opts); let image_base64: Option = if offer_describe_tool { self.generator.load_image_as_base64(&normalized).ok() } else { None }; let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let truncated = fitter.trim(&mut messages); if truncated { let _ = entry.push_event(ChatStreamEvent::Truncated).await; } // See the note in `chat_turn`: the new-node boundary must be read // after the budget pass, because truncation drains replayed history // out of the middle of `messages`. let history_len = messages.len(); messages.push(ChatMessage::user(req.user_message.clone())); let override_stash = apply_system_prompt_override(&mut messages, req.system_prompt.as_deref()); let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); let outcome = self .run_streaming_agentic_loop_with_entry( &backend, &mut messages, tools, &image_base64, &normalized, req.user_id, &active_persona, max_iterations, &entry, &mut fitter, ) .await?; let AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content, cancelled, } = outcome; // Turn was cancelled mid-flight: the DELETE handler already pushed the // terminal event and flipped status. Don't persist a partial turn or // push a second terminal event. if cancelled { return Ok(()); } restore_system_content(&mut messages, original_system_content); // Put the photo back so the stored transcript keeps it. image_stash.restore(&mut messages); if !req.amend { restore_system_prompt_override(&mut messages, override_stash); } // Append new messages as tree nodes. let new_messages = messages[history_len..].to_vec(); let mut parent_id = Some(store.active_leaf_id); for msg in &new_messages { let new_id = store.append_node(parent_id, msg.clone()); parent_id = Some(new_id); } if let Some(last_id) = parent_id { store.active_leaf_id = last_id; } let json = serde_json::to_string(&store) .map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?; let mut amended_insight_id: Option = None; if req.amend { let (title, body) = crate::ai::insight_generator::parse_title_body(&final_content); let final_content = body; let new_row = InsertPhotoInsight { library_id: req.library_id, file_path: normalized.clone(), title, summary: final_content.clone(), generated_at: Utc::now().timestamp(), model_version: model_used.clone(), is_current: true, training_messages: Some(json), backend: kind.as_str().to_string(), fewshot_source_ids: None, content_hash: None, num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, system_prompt: req.system_prompt.clone(), persona_id: req.persona_id.clone(), prompt_eval_count: None, eval_count: None, }; let dao = self.insight_dao.clone(); let stored = retry_with_backoff("store_insight", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.store_insight(new_row.clone()) }) .map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?; amended_insight_id = Some(stored.id); } else { let dao = self.insight_dao.clone(); let rows = retry_with_backoff("update_training_messages", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.update_training_messages(req.library_id, &normalized, &json) }) .map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?; if rows == 0 { log::warn!( "update_training_messages (stream) updated 0 rows for {} (lib {}), \ concurrent regenerate likely flipped is_current", normalized, req.library_id ); } } let _ = entry .push_event(ChatStreamEvent::Done { tool_calls_made, iterations_used, truncated, prompt_tokens: last_prompt_eval_count, eval_tokens: last_eval_count, num_ctx: req.num_ctx, amended_insight_id, backend_used: kind.as_str().to_string(), model_used, cancelled: false, }) .await; entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Done); Ok(()) } /// Bootstrap path with TurnEntry buffer. async fn run_bootstrap_streaming_with_entry( &self, req: ChatTurnRequest, normalized: String, entry: Arc, turn_span: &tracing::Span, ) -> Result<()> { let active_persona = req .persona_id .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "default".to_string()); let effective_backend = resolve_bootstrap_backend(req.backend.as_deref())?; let kind = BackendKind::parse(&effective_backend)?; let max_iterations = req .max_iterations .unwrap_or(DEFAULT_MAX_ITERATIONS) .clamp(1, env_max_iterations()); let overrides = SamplingOverrides { model: req.model.clone().filter(|m| !m.is_empty()), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; let model_used = backend.model().to_string(); // llm.* attributes on the turn-level span (issue #130). turn_span.set_attribute("llm.backend", kind.as_str()); turn_span.set_attribute("llm.model", model_used.clone()); let image_base64: Option = self.generator.load_image_as_base64(&normalized).ok(); let exif = self.generator.fetch_exif(&normalized); let date_taken_str = resolve_date_taken_for_context( &exif, &normalized, self.generator.display_zone(), crate::camera_tz::server_zone(), ); let gps = exif .as_ref() .and_then(|e| match (e.gps_latitude, e.gps_longitude) { (Some(lat), Some(lon)) => Some((lat as f64, lon as f64)), _ => None, }); let visual_block = if !backend.images_inline { match image_base64.as_deref() { Some(b64) => match backend.local().describe_image(b64).await { Ok(desc) => { format!("Visual description (from local vision model):\n{}\n", desc) } Err(e) => { log::warn!("{} bootstrap: describe_image failed: {}", kind.as_str(), e); String::new() } }, None => String::new(), } } else { String::new() }; let offer_describe_tool = backend.images_inline && image_base64.is_some(); let gate_opts = self.generator.current_gate_opts_for_persona( offer_describe_tool, Some((req.user_id, &active_persona)), ); let tools = InsightGenerator::build_tool_definitions(gate_opts); // Server-side persona resolution: explicit client system_prompt wins; // else the active persona's stored prompt; else the neutral default. let persona_prompt = self .generator .persona_system_prompt(req.user_id, &active_persona); let persona = resolve_bootstrap_system_prompt(req.system_prompt.as_deref(), persona_prompt); let system_content = build_bootstrap_system_message( &persona, &normalized, date_taken_str.as_deref(), gps, &visual_block, ); let system_msg = ChatMessage::system(system_content); let mut user_msg = ChatMessage::user(req.user_message.clone()); if backend.images_inline && let Some(ref img) = image_base64 { user_msg.images = Some(vec![img.clone()]); } let mut messages = vec![system_msg, user_msg]; let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let outcome = self .run_streaming_agentic_loop_with_entry( &backend, &mut messages, tools, &image_base64, &normalized, req.user_id, &active_persona, max_iterations, &entry, &mut fitter, ) .await?; let AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content, cancelled, } = outcome; // Turn was cancelled mid-flight: the DELETE handler already pushed the // terminal event and flipped status. Don't persist a partial turn or // push a second terminal event. if cancelled { return Ok(()); } let (title, body) = crate::ai::insight_generator::parse_title_body(&final_content); let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; let new_row = InsertPhotoInsight { library_id: req.library_id, file_path: normalized.clone(), title, summary: body, generated_at: Utc::now().timestamp(), model_version: model_used.clone(), is_current: true, training_messages: Some(json), backend: kind.as_str().to_string(), fewshot_source_ids: None, content_hash: None, num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, system_prompt: req.system_prompt.clone(), persona_id: req.persona_id.clone(), prompt_eval_count: None, eval_count: None, }; let dao = self.insight_dao.clone(); let stored = retry_with_backoff("store_insight", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.store_insight(new_row.clone()) }) .map_err(|e| anyhow!("failed to store bootstrap insight: {:?}", e))?; let _ = entry .push_event(ChatStreamEvent::Done { tool_calls_made, iterations_used, truncated: false, prompt_tokens: last_prompt_eval_count, eval_tokens: last_eval_count, num_ctx: req.num_ctx, amended_insight_id: Some(stored.id), backend_used: kind.as_str().to_string(), model_used, cancelled: false, }) .await; entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Done); Ok(()) } /// Agentic loop variant that pushes events to a `TurnEntry` buffer. /// Same as `run_streaming_agentic_loop` but emits events to a /// `TurnEntry` for SSE replay. Thin wrapper around the free function /// `run_streaming_agentic_loop_with_entry` so the persona chat /// (and any future chat-without-file surface) can reuse the loop /// body without instantiating an `InsightChatService`. pub async fn run_streaming_agentic_loop_with_entry( &self, backend: &ResolvedBackend, messages: &mut Vec, tools: Vec, image_base64: &Option, normalized: &str, user_id: i32, active_persona: &str, max_iterations: usize, entry: &Arc, fitter: &mut ContextFitter, ) -> Result { crate::ai::insight_chat::run_streaming_agentic_loop_with_entry( &self.generator, backend, messages, tools, image_base64, normalized, user_id, active_persona, None, max_iterations, entry, fitter, ) .await } async fn run_streaming_turn( self: Arc, req: ChatTurnRequest, tx: tokio::sync::mpsc::Sender, ) -> Result<()> { if req.user_message.trim().is_empty() { bail!("user_message must not be empty"); } if req.user_message.len() > 8192 { bail!("user_message exceeds 8192 chars"); } let normalized = normalize_path(&req.file_path); let lock_key = (req.library_id, normalized.clone()); let entry_lock = { let mut locks = self.chat_locks.lock().await; locks .entry(lock_key.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = entry_lock.lock().await; // Look up existing insight scoped to this turn's library_id. // Path-only lookup would let an `is_current=true` row in // another library route us into the continuation path against // a transcript we'd then update_training_messages on — corrupting // the other library's curated insight. Library-scoped lookup // means a fresh chat on a photo that has no insight in this // library bootstraps cleanly, even when another library has // an insight for the same rel_path. let existing_insight = { let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); dao.get_current_insight_for_library(req.library_id, &normalized) .map_err(|e| anyhow!("failed to load insight: {:?}", e))? }; if req.regenerate || existing_insight.is_none() { return self.run_bootstrap_streaming(req, normalized, tx).await; } let insight = existing_insight.expect("just checked Some above"); self.run_continuation_streaming(req, normalized, insight, tx) .await } /// Continuation path: photo has an existing agentic insight with /// `training_messages` populated. Replay the transcript, append a new /// turn, run the agentic loop, persist (UPDATE for append; INSERT new /// row for amend). async fn run_continuation_streaming( &self, req: ChatTurnRequest, normalized: String, insight: crate::database::models::PhotoInsight, tx: tokio::sync::mpsc::Sender, ) -> Result<()> { let active_persona = req .persona_id .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "default".to_string()); let raw_history = insight.training_messages.as_ref().ok_or_else(|| { anyhow!("insight has no chat history; regenerate this insight in agentic mode") })?; let mut messages: Vec = serde_json::from_str(raw_history) .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?; // Backend selection — defer to stored insight's backend unless the // request supplies an override. let stored_backend = insight.backend.clone(); let effective_backend = req .backend .as_deref() .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| stored_backend.clone()); let kind = BackendKind::parse(&effective_backend)?; validate_cross_replay(&stored_backend, kind.as_str())?; let max_iterations = req .max_iterations .unwrap_or(DEFAULT_MAX_ITERATIONS) .clamp(1, env_max_iterations()); let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { model: continuation_model( req.model.clone(), &stored_model, &stored_backend, kind.as_str(), ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; // Text-only backend (hybrid) on a transcript holding the photo: // swap it for a local description. Restored before saving. let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); // Tool set — images_inline mode + first user turn carries an image → // offer describe_photo. Describe-then-inline mode (hybrid only): // visual description was inlined at bootstrap, no describe tool needed. let local_first_user_has_image = messages .iter() .find(|m| m.role == "user") .and_then(|m| m.images.as_ref()) .map(|imgs| !imgs.is_empty()) .unwrap_or(false); let offer_describe_tool = backend.images_inline && local_first_user_has_image; let gate_opts = self.generator.current_gate_opts_for_persona( offer_describe_tool, Some((req.user_id, &active_persona)), ); let tools = InsightGenerator::build_tool_definitions(gate_opts); let image_base64: Option = if offer_describe_tool { self.generator.load_image_as_base64(&normalized).ok() } else { None }; // Truncate before appending the new user turn. let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let truncated = fitter.trim(&mut messages); if truncated { let _ = tx.send(ChatStreamEvent::Truncated).await; } messages.push(ChatMessage::user(req.user_message.clone())); // Mirror chat_turn: per-turn override goes on first, budget note next. let override_stash = apply_system_prompt_override(&mut messages, req.system_prompt.as_deref()); let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); let outcome = self .run_streaming_agentic_loop( &backend, &mut messages, tools, &image_base64, &normalized, req.user_id, &active_persona, max_iterations, &tx, &mut fitter, ) .await?; let AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content, // The mpsc (legacy) path has no cancellation channel. cancelled: _, } = outcome; // Drop the per-turn iteration-budget note before persisting so it // doesn't snowball on subsequent turns. restore_system_content(&mut messages, original_system_content); // Put the photo back so the stored transcript keeps it. image_stash.restore(&mut messages); // Append mode: undo the per-turn system-prompt override. // Amend mode: keep it — it becomes the new row's system message. if !req.amend { restore_system_prompt_override(&mut messages, override_stash); } let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; let mut amended_insight_id: Option = None; if req.amend { let (title, body) = crate::ai::insight_generator::parse_title_body(&final_content); let final_content = body; // Amended rows intentionally do not inherit the parent's // `fewshot_source_ids`. The parent's few-shot influence is still // present in this row's content; if you want strict lineage // tracking for training-set filtering, fetch the parent here and // copy its value forward. let new_row = InsertPhotoInsight { library_id: req.library_id, file_path: normalized.clone(), title, summary: final_content.clone(), generated_at: Utc::now().timestamp(), model_version: model_used.clone(), is_current: true, training_messages: Some(json), backend: kind.as_str().to_string(), fewshot_source_ids: None, content_hash: None, num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, system_prompt: req.system_prompt.clone(), persona_id: req.persona_id.clone(), prompt_eval_count: None, eval_count: None, }; let dao = self.insight_dao.clone(); let stored = retry_with_backoff("store_insight", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.store_insight(new_row.clone()) }) .map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?; amended_insight_id = Some(stored.id); } else { let dao = self.insight_dao.clone(); let rows = retry_with_backoff("update_training_messages", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.update_training_messages(req.library_id, &normalized, &json) }) .map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?; if rows == 0 { log::warn!( "update_training_messages (stream) updated 0 rows for {} (lib {}), \ concurrent regenerate likely flipped is_current", normalized, req.library_id ); } } let _ = tx .send(ChatStreamEvent::Done { tool_calls_made, iterations_used, truncated, prompt_tokens: last_prompt_eval_count, eval_tokens: last_eval_count, num_ctx: req.num_ctx, amended_insight_id, backend_used: kind.as_str().to_string(), model_used, cancelled: false, }) .await; Ok(()) } /// Bootstrap path: no insight row yet (or `regenerate=true`). Build a /// fresh transcript from `req.system_prompt` + `req.user_message` + /// the photo, run the agentic loop, generate a title, and INSERT a /// new insight row. `store_insight` flips any prior rows for the same /// `(library_id, file_path)` to `is_current=false` — that's how /// `regenerate` shadows the previous insight. async fn run_bootstrap_streaming( &self, req: ChatTurnRequest, normalized: String, tx: tokio::sync::mpsc::Sender, ) -> Result<()> { let active_persona = req .persona_id .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "default".to_string()); let effective_backend = resolve_bootstrap_backend(req.backend.as_deref())?; let kind = BackendKind::parse(&effective_backend)?; let max_iterations = req .max_iterations .unwrap_or(DEFAULT_MAX_ITERATIONS) .clamp(1, env_max_iterations()); let overrides = SamplingOverrides { model: req.model.clone().filter(|m| !m.is_empty()), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; let model_used = backend.model().to_string(); // Load image bytes once. RAW preview fallback is handled inside // load_image_as_base64. Errors degrade silently — a chat that // discusses metadata-only is still useful. let image_base64: Option = self.generator.load_image_as_base64(&normalized).ok(); // EXIF lookup once — date_taken and GPS go into the photo // context block in the system message. Without these the model // hallucinates dates / GPS-keyed tool args (`get_sms_messages` // would otherwise default to today's date and miss every // historical photo). let exif = self.generator.fetch_exif(&normalized); let date_taken_str = resolve_date_taken_for_context( &exif, &normalized, self.generator.display_zone(), crate::camera_tz::server_zone(), ); let gps = exif .as_ref() .and_then(|e| match (e.gps_latitude, e.gps_longitude) { (Some(lat), Some(lon)) => Some((lat as f64, lon as f64)), _ => None, }); // Describe-then-inline (hybrid only): pre-describe the image so a // text-only chat model gets the visual description inline. // images_inline backends send images directly to the chat model. let visual_block = if !backend.images_inline { match image_base64.as_deref() { Some(b64) => match backend.local().describe_image(b64).await { Ok(desc) => { format!("Visual description (from local vision model):\n{}\n", desc) } Err(e) => { log::warn!("{} bootstrap: describe_image failed: {}", kind.as_str(), e); String::new() } }, None => String::new(), } } else { String::new() }; // Tool gates. images_inline + image present → expose describe_photo so // the chat model can re-look at the photo on demand. Non-inline: // already inlined, no tool needed. let offer_describe_tool = backend.images_inline && image_base64.is_some(); let gate_opts = self.generator.current_gate_opts_for_persona( offer_describe_tool, Some((req.user_id, &active_persona)), ); let tools = InsightGenerator::build_tool_definitions(gate_opts); // System message = persona + photo context block. Photo context // is in the system message — not the user turn — so the user's // bubble in the rendered transcript shows only what they typed. // Several agentic tools (recall_facts_for_photo, get_file_tags, // get_faces_in_photo, etc.) take a `file_path` arg the model // can't know without being told. `Date taken:` and `GPS:` give // get_sms_messages / reverse_geocode / get_personal_place_at // the args they need. In hybrid mode the visual description // belongs here for the same reason. // Server-side persona resolution: explicit client system_prompt wins; // else the active persona's stored prompt; else the neutral default. let persona_prompt = self .generator .persona_system_prompt(req.user_id, &active_persona); let persona = resolve_bootstrap_system_prompt(req.system_prompt.as_deref(), persona_prompt); let system_content = build_bootstrap_system_message( &persona, &normalized, date_taken_str.as_deref(), gps, &visual_block, ); let system_msg = ChatMessage::system(system_content); let mut user_msg = ChatMessage::user(req.user_message.clone()); if backend.images_inline && let Some(ref img) = image_base64 { user_msg.images = Some(vec![img.clone()]); } let mut messages = vec![system_msg, user_msg]; let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let outcome = self .run_streaming_agentic_loop( &backend, &mut messages, tools, &image_base64, &normalized, req.user_id, &active_persona, max_iterations, &tx, &mut fitter, ) .await?; let AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content, // The mpsc (legacy) path has no cancellation channel. cancelled: _, } = outcome; let (title, body) = crate::ai::insight_generator::parse_title_body(&final_content); let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; let new_row = InsertPhotoInsight { library_id: req.library_id, file_path: normalized.clone(), title, summary: body, generated_at: Utc::now().timestamp(), model_version: model_used.clone(), is_current: true, training_messages: Some(json), backend: kind.as_str().to_string(), fewshot_source_ids: None, content_hash: None, num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, system_prompt: req.system_prompt.clone(), persona_id: req.persona_id.clone(), prompt_eval_count: None, eval_count: None, }; let dao = self.insight_dao.clone(); let stored = retry_with_backoff("store_insight", 3, || { let mut d = dao.lock().expect("Unable to lock InsightDao"); d.store_insight(new_row.clone()) }) .map_err(|e| anyhow!("failed to store bootstrap insight: {:?}", e))?; // amended_insight_id semantics broaden on bootstrap/regenerate: // populated whenever this turn produced a new insight row, so // clients don't need a separate field to learn the new id. let _ = tx .send(ChatStreamEvent::Done { tool_calls_made, iterations_used, truncated: false, prompt_tokens: last_prompt_eval_count, eval_tokens: last_eval_count, num_ctx: req.num_ctx, amended_insight_id: Some(stored.id), backend_used: kind.as_str().to_string(), model_used, cancelled: false, }) .await; Ok(()) } /// Drive the agentic loop with streaming SSE events. Shared between /// bootstrap and continuation. Mutates `messages` in place (response /// turns + tool results are appended) and returns counters + the /// final assistant content. async fn run_streaming_agentic_loop( &self, backend: &ResolvedBackend, messages: &mut Vec, tools: Vec, image_base64: &Option, normalized: &str, user_id: i32, active_persona: &str, max_iterations: usize, tx: &tokio::sync::mpsc::Sender, fitter: &mut ContextFitter, ) -> Result { let mut tool_calls_made = 0usize; let mut iterations_used = 0usize; let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; let mut final_content = String::new(); for iteration in 0..max_iterations { iterations_used = iteration + 1; let _ = tx .send(ChatStreamEvent::IterationStart { n: iterations_used, max: max_iterations, }) .await; let mut stream = fitter .chat_stream(backend.chat(), messages, tools.clone()) .await?; let mut final_message: Option = None; while let Some(ev) = stream.next().await { let ev = ev?; match ev { LlmStreamEvent::TextDelta(delta) => { let _ = tx.send(ChatStreamEvent::TextDelta(delta)).await; } LlmStreamEvent::Done { message, prompt_eval_count, eval_count, } => { last_prompt_eval_count = prompt_eval_count; last_eval_count = eval_count; final_message = Some(message); break; } } } let mut response = final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?; // Normalize non-object tool arguments (some models occasionally // return null/string/bool which Ollama rejects on the next turn). if let Some(ref mut tcs) = response.tool_calls { for tc in tcs.iter_mut() { if !tc.function.arguments.is_object() { 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 { tool_calls_made += 1; let call_index = tool_calls_made - 1; let _ = tx .send(ChatStreamEvent::ToolCall { index: call_index, name: tool_call.function.name.clone(), arguments: tool_call.function.arguments.clone(), }) .await; let result = self .generator .execute_tool( &tool_call.function.name, &tool_call.function.arguments, backend, image_base64, normalized, user_id, active_persona, None, ) .await; let (result_preview, result_truncated) = truncate_tool_result(&result); let _ = tx .send(ChatStreamEvent::ToolResult { index: call_index, name: tool_call.function.name.clone(), result: result_preview, result_truncated, }) .await; messages.push(ChatMessage::tool_result(result)); } continue; } final_content = response.content; break; } // No-tools fallback: loop exhausted iterations without producing a // tool-free reply. Ask once more, with no tools attached. if final_content.is_empty() { // Index of the synthetic "please write your final answer" // user message — we strip it from history after the model // responds, so it never appears in the rendered transcript // and load_history's user-turn handler doesn't reset // pending_tools at this position (wiping the prior tool // calls from the final assistant render). let synthetic_idx = push_synthetic_final_prompt(messages); let mut stream = fitter.chat_stream(backend.chat(), messages, vec![]).await?; let mut final_message: Option = None; while let Some(ev) = stream.next().await { let ev = ev?; match ev { LlmStreamEvent::TextDelta(delta) => { let _ = tx.send(ChatStreamEvent::TextDelta(delta)).await; } LlmStreamEvent::Done { message, prompt_eval_count, eval_count, } => { last_prompt_eval_count = prompt_eval_count; last_eval_count = eval_count; final_message = Some(message); break; } } } let final_response = final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?; final_content = final_response.content.clone(); messages.push(final_response); // Drop the synthetic prompt — internal scaffolding only. The // model's final_response (now at the end) was generated with // it in context and reads coherently without it on replay. remove_synthetic_final_prompt(messages, synthetic_idx); } Ok(AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, // Strip any leaked reasoning block from the content the // caller persists as title/summary (the raw transcript keeps it). final_content: crate::ai::llm_client::strip_think_blocks(&final_content), cancelled: false, }) } } /// Default system prompt for bootstrap when the client didn't supply one /// (or supplied an empty string). Apollo's frontend always sends a persona /// prompt today, so this is a fallback for clients that don't. const BOOTSTRAP_DEFAULT_SYSTEM_PROMPT: &str = "You are a helpful AI assistant analyzing the user's photo. \ Use the available tools to gather context and answer their questions \ in a conversational tone."; /// Pick the system prompt for bootstrap. Precedence: trimmed-non-empty /// `supplied` (the client's explicit `system_prompt`) wins; else /// `persona_prompt` (the active persona's stored prompt, resolved /// server-side from the persona store); else /// [`BOOTSTRAP_DEFAULT_SYSTEM_PROMPT`]. Returns an owned `String` because /// the bootstrap caller persists it on the new insight row. fn resolve_bootstrap_system_prompt( supplied: Option<&str>, persona_prompt: Option, ) -> String { supplied .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string) .or_else(|| persona_prompt.filter(|s| !s.trim().is_empty())) .unwrap_or_else(|| BOOTSTRAP_DEFAULT_SYSTEM_PROMPT.to_string()) } /// Compose the bootstrap system message: the persona on top, followed /// by a photo-context block carrying the file path, date taken (when /// known), GPS (when present), and — in hybrid mode — the local-vision /// visual description. Lives in the system message — not the user /// turn — so the rendered transcript shows only what the user typed. fn build_bootstrap_system_message( persona: &str, normalized_path: &str, date_taken: Option<&str>, gps: Option<(f64, f64)>, visual_block: &str, ) -> String { let mut out = persona.trim_end().to_string(); out.push_str("\n\n--- PHOTO CONTEXT ---\n"); out.push_str(&format!("Photo file path: {}\n", normalized_path)); out.push_str(&format!( "Date taken: {}\n", date_taken.unwrap_or("unknown") )); if let Some((lat, lon)) = gps { // Four decimal places ≈ 11 m of precision — plenty for any // place-lookup tool, and keeps the prompt short. out.push_str(&format!("GPS: {:.4}, {:.4}\n", lat, lon)); } if !visual_block.is_empty() { // visual_block already ends with a newline; no extra separator // needed. out.push_str(visual_block); } out } /// Resolve a human-readable `YYYY-MM-DD` date string for the photo /// context block (ADR-0001). Waterfall: the row's display wall (new /// contract when converted, per-source convention while unconverted) → /// filename pattern (wall patterns as-is, unix-timestamp patterns in the /// display zone) → `None`. The fs-time fallback that insight generation /// uses is intentionally NOT applied here — for chat we'd rather show /// "unknown" than a misleading inode mtime as the photo's date. fn resolve_date_taken_for_context( exif: &Option, file_path: &str, display_zone: chrono_tz::Tz, server_zone: Option, ) -> Option { if let Some(e) = exif && let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone) { return Some(wall.format("%Y-%m-%d").to_string()); } crate::memories::extract_date_from_filename(file_path).and_then(|fd| { let wall = match &fd { crate::memories::FilenameDate::Wall(w) => *w, crate::memories::FilenameDate::Utc(dt) => display_zone .timestamp_opt(dt.timestamp(), 0) .single()? .naive_local(), }; Some(wall.format("%Y-%m-%d").to_string()) }) } /// Validate the backend a chat continuation is switching to. /// /// Every transition is allowed now. `local → hybrid` used to be refused /// because a local transcript carries the raw photo; the turn now replaces /// it with a local vision description before anything reaches OpenRouter /// (`InsightChatService::inline_images_as_text`), and the OpenRouter client /// refuses image data outright as a backstop. `hybrid → local` replays the /// inlined description as text. `stored` is kept for the call sites' /// symmetry and future rules. /// /// Whether "local" routes through Ollama or llama-swap is decided at /// startup by `LLM_BACKEND`; both share the same transcript shape from /// the chat-replay perspective. fn validate_cross_replay(_stored: &str, effective: &str) -> Result<()> { if !matches!(effective, "local" | "hybrid") { bail!( "unknown backend '{}'; expected 'local' or 'hybrid'", effective ); } Ok(()) } /// The model to continue a chat with when the request names none: the one /// the insight was generated with, but only on the same backend. After a /// switch, the stored id belongs to the other backend (a llama-swap slot is /// not an OpenRouter model), so the new backend's default is used instead. fn continuation_model( requested: Option, stored_model: &str, stored_backend: &str, effective_backend: &str, ) -> Option { requested .or_else(|| (stored_backend == effective_backend).then(|| stored_model.to_string())) .filter(|m| !m.is_empty()) } /// Every distinct image in a transcript, in order of first appearance. fn transcript_images(messages: &[ChatMessage]) -> Vec { let mut seen = std::collections::HashSet::new(); messages .iter() .flat_map(|m| m.images.iter().flatten()) .filter(|img| seen.insert((*img).clone())) .cloned() .collect() } fn image_cache_key(image: &str) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); image.hash(&mut h); h.finish() } /// What `apply_image_descriptions` took out of a transcript, so it can be /// put back before the transcript is saved. #[derive(Debug, Default)] struct ImageStash { entries: Vec, } #[derive(Debug)] struct StashedImages { index: usize, original_content: String, rewritten_content: String, images: Option>, } impl ImageStash { /// Put the original content and images back. Each entry is matched by /// index *and* by still carrying the rewritten text, so a turn that /// reshaped the transcript can't get the wrong message restored. fn restore(self, messages: &mut [ChatMessage]) { for e in self.entries { if let Some(m) = messages.get_mut(e.index) && m.content == e.rewritten_content { m.content = e.original_content; m.images = e.images; } } } } /// Replace each image in `messages` with its description, appended to the /// message text. Returns what was taken out, for `ImageStash::restore`. fn apply_image_descriptions( messages: &mut [ChatMessage], descriptions: &HashMap, ) -> ImageStash { let mut stash = ImageStash::default(); for (idx, m) in messages.iter_mut().enumerate() { let Some(images) = m.images.as_ref().filter(|i| !i.is_empty()) else { continue; }; let mut content = m.content.clone(); for img in images { if let Some(desc) = descriptions.get(img) { content.push_str(&format!( "\n\nVisual description of the photo (from the local vision model):\n{desc}" )); } } stash.entries.push(StashedImages { index: idx, original_content: std::mem::replace(&mut m.content, content.clone()), rewritten_content: content, images: m.images.take(), }); } stash } /// Pick the backend label for bootstrap. Bootstrap has no stored insight /// to defer to (that's continuation's behaviour), so the default is /// `"local"`. Returns an error if the supplied label is non-empty but /// not one of the recognised values — same surface as continuation's /// validation. fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result { let lower = supplied .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "local".to_string()); if !matches!(lower.as_str(), "local" | "hybrid") { bail!("unknown backend '{}'; expected 'local' or 'hybrid'", lower); } Ok(lower) } /// Outcome of one streaming agentic loop pass. Shared between bootstrap /// and continuation. `pub` so the persona chat surface (and any future /// chat-without-file surface) can read the result of /// `run_streaming_agentic_loop_with_entry` without reaching back into /// private fields. pub struct AgenticLoopOutcome { pub tool_calls_made: usize, pub iterations_used: usize, pub last_prompt_eval_count: Option, pub last_eval_count: Option, pub final_content: String, /// True when the loop exited early because the turn was cancelled /// (status flipped out of `Running`). Callers skip persistence and the /// terminal `Done` push — the cancel handler owns the terminal event. pub cancelled: bool, } /// Events emitted by `chat_turn_stream`. One stream per turn; ends after /// `Done` or `Error`. #[derive(Debug, Clone)] pub enum ChatStreamEvent { /// Starting iteration `n` of up to `max` (1-based). IterationStart { n: usize, max: usize }, /// History was trimmed to fit the context budget before the turn ran. /// Emitted at most once, before any tool or text events. Truncated, /// Incremental content from the final assistant reply. Concatenate to /// reconstruct the reply body. Tool-dispatch turns don't produce these. TextDelta(String), /// The model requested this tool call. Emitted just before execution. /// `index` is a monotonically-increasing counter across the turn so the /// client can pair `ToolCall` with its matching `ToolResult`. ToolCall { index: usize, name: String, arguments: serde_json::Value, }, /// The tool finished; `result` is the (possibly truncated) output. ToolResult { index: usize, name: String, result: String, result_truncated: bool, }, /// Terminal success event with counters + persistence result. Done { tool_calls_made: usize, iterations_used: usize, truncated: bool, /// Renamed from `prompt_eval_count` to match the wire-name Apollo's /// frontend (and the mobile client) consume on the `done` SSE /// payload. prompt_tokens: Option, /// Renamed from `eval_count`. Same rationale. eval_tokens: Option, /// The configured context-window ceiling that ran this turn — echoes /// the request's `num_ctx` (or the server default when none was /// supplied). Lets clients render `prompt_tokens / num_ctx` without /// remembering what they asked for. num_ctx: Option, /// Populated when this turn produced a NEW insight row — bootstrap /// (no prior insight), regenerate (old row flipped to is_current= /// false), or amend. Null on append. Clients should target this id /// for any subsequent operation that previously needed a known /// insight (e.g., a follow-up `/insights/chat` against the just- /// created row). amended_insight_id: Option, backend_used: String, model_used: String, /// True only for the synthetic terminal event emitted by the cancel /// handler, so clients can distinguish a user-cancelled turn from a /// natural completion. Always false on the normal success path. cancelled: bool, }, /// Terminal failure event. No further events follow. Error(String), } /// Is this raw message visible in the rendered transcript? Must match /// `load_history`'s filter exactly — `find_raw_cut` depends on it to map /// rendered indices back to raw positions. #[allow(dead_code)] fn is_rendered(m: &ChatMessage) -> bool { match m.role.as_str() { "user" => true, "assistant" => { let has_tool_calls = m .tool_calls .as_ref() .map(|c| !c.is_empty()) .unwrap_or(false); !(has_tool_calls && m.content.trim().is_empty()) } _ => false, } } /// Given a rendered index to start discarding from, find the raw index at /// which to truncate. The cut position is the raw length after all prior /// rendered messages — which also strips any tool-call scaffolding that /// immediately precedes the discarded rendered message. /// /// Discarding *at* the end (`discard == rendered_count`) is a no-op success: /// returns `Some(messages.len())`. The mobile client hits this when /// regenerating after a failed turn — its optimistic user bubble lives at /// the index just past the server's persisted history. Strictly past the end /// (`discard > rendered_count`) returns `None`. #[allow(dead_code)] pub(crate) fn find_raw_cut( messages: &[ChatMessage], discard_from_rendered_index: usize, ) -> Option { let mut rendered_count = 0usize; let mut last_kept_raw_end = 0usize; for (i, m) in messages.iter().enumerate() { if !is_rendered(m) { continue; } if rendered_count == discard_from_rendered_index { return Some(last_kept_raw_end); } rendered_count += 1; last_kept_raw_end = i + 1; } if discard_from_rendered_index == rendered_count { return Some(messages.len()); } None } /// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code /// free of static globals and lets the operator change the cap by env without /// a restart in test harnesses (the running server still caches via Default). pub fn env_max_iterations() -> usize { std::env::var("AGENTIC_CHAT_MAX_ITERATIONS") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_MAX_ITERATIONS) .max(1) } /// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context /// window for the truncation budget when the request omits `num_ctx`. Same /// no-static-global rationale as `env_max_iterations` above. pub(crate) fn env_default_num_ctx() -> i32 { std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_NUM_CTX) .max(RESPONSE_HEADROOM_TOKENS as i32 + 1024) } /// Context window (tokens) to budget a chat turn against: the smaller of what /// the request asked for and what the server actually serves. llama-swap /// models fix their window at launch (`-c` in config.yaml) and ignore the /// per-request `num_ctx`, so budgeting against a bigger requested value just /// keeps more history than the server can hold. pub(crate) async fn effective_num_ctx( client: &dyn crate::ai::llm_client::LlmClient, requested: Option, ) -> usize { let requested = requested.filter(|n| *n > 0).map(|n| n as usize); match (requested, client.context_window().await) { (Some(r), Some(served)) => r.min(served), (None, Some(served)) => served, (Some(r), None) => r, (None, None) => env_default_num_ctx() as usize, } } /// Keeps each LLM call of a chat turn inside the context budget. Every call /// goes through [`ContextFitter::chat`] / [`ContextFitter::chat_stream`], /// which send a trimmed copy of `messages` (the caller's vector — and so what /// gets persisted — is untouched) and, if the server still rejects the prompt /// as too long (the byte-per-token estimate is only a heuristic), shrink the /// budget from the server's own token counts and retry once. The shrunk /// budget sticks for the rest of the turn. pub struct ContextFitter { budget_bytes: usize, } impl ContextFitter { pub(crate) fn from_num_ctx(num_ctx: usize) -> Self { let budget_tokens = num_ctx.saturating_sub(RESPONSE_HEADROOM_TOKENS); Self { budget_bytes: budget_tokens.saturating_mul(BYTES_PER_TOKEN), } } /// Budget for `client`'s model; see [`effective_num_ctx`]. pub(crate) async fn for_client( client: &dyn crate::ai::llm_client::LlmClient, requested: Option, ) -> Self { Self::from_num_ctx(effective_num_ctx(client, requested).await) } /// Trim `messages` in place to the budget. Used once on the replayed /// history before the turn starts; returns true when anything was dropped. pub(crate) fn trim(&self, messages: &mut Vec) -> bool { apply_context_budget(messages, self.budget_bytes) } fn fit(&self, messages: &[ChatMessage]) -> Vec { let mut fitted = messages.to_vec(); apply_context_budget(&mut fitted, self.budget_bytes); fitted } /// Shrink the budget after a context-overflow error. Returns false (and /// leaves the budget alone) when `err` isn't one. fn shrink_after(&mut self, err: &anyhow::Error, sent: &[ChatMessage]) -> bool { let Some(counts) = parse_context_overflow(&format!("{err:#}")) else { return false; }; let sent_bytes = estimate_bytes(sent).min(self.budget_bytes); let shrunk = match counts { // Scale what we sent by the server's measured overshoot, with 10% // slack since trimming lands on message boundaries. Some((prompt_tokens, n_ctx)) if prompt_tokens > 0 => { let target = n_ctx.saturating_sub(RESPONSE_HEADROOM_TOKENS); sent_bytes.saturating_mul(target) / prompt_tokens * 9 / 10 } _ => sent_bytes / 2, }; tracing::warn!( old_budget_bytes = self.budget_bytes, new_budget_bytes = shrunk, "chat prompt exceeded the model's context window; trimming and retrying" ); self.budget_bytes = shrunk; true } pub(crate) async fn chat( &mut self, client: &dyn crate::ai::llm_client::LlmClient, messages: &[ChatMessage], tools: Vec, ) -> Result<(ChatMessage, Option, Option)> { let sent = self.fit(messages); match client.chat_with_tools(sent.clone(), tools.clone()).await { Err(e) if self.shrink_after(&e, &sent) => { client.chat_with_tools(self.fit(messages), tools).await } result => result, } } pub(crate) async fn chat_stream( &mut self, client: &dyn crate::ai::llm_client::LlmClient, messages: &[ChatMessage], tools: Vec, ) -> Result>> { let sent = self.fit(messages); match client .chat_with_tools_stream(sent.clone(), tools.clone()) .await { Err(e) if self.shrink_after(&e, &sent) => { client .chat_with_tools_stream(self.fit(messages), tools) .await } result => result, } } } /// Recognise a "prompt longer than the context window" error from any /// backend. `None` = some other error. `Some(Some((prompt_tokens, n_ctx)))` /// when the body carries llama-server's counts /// (`exceed_context_size_error` with `n_prompt_tokens` / `n_ctx`), /// `Some(None)` when it's an overflow without usable numbers. fn parse_context_overflow(err: &str) -> Option> { let lower = err.to_lowercase(); let is_overflow = lower.contains("exceed_context_size") || lower.contains("context_length_exceeded") || lower.contains("maximum context length") || (lower.contains("exceed") && lower.contains("context")); if !is_overflow { return None; } let num = |key: &str| -> Option { let re = regex::Regex::new(&format!(r#""{key}"\s*:\s*(\d+)"#)).ok()?; re.captures(err)?.get(1)?.as_str().parse().ok() }; Some(num("n_prompt_tokens").zip(num("n_ctx"))) } /// Append a per-turn iteration-budget reminder to the replayed system /// message so the model knows how many tool-calling rounds this turn gets. /// Returns the original `content` so the caller can restore it before /// persistence — otherwise the note would accumulate across turns. /// /// No-op (returns `None`) when `messages` has no leading system message. fn annotate_system_with_budget( messages: &mut [ChatMessage], max_iterations: usize, ) -> Option { let first = messages.first_mut()?; if first.role != "system" { return None; } let original = first.content.clone(); first.content = format!( "{}\n\n(Budget for this chat turn: up to {} tool-calling iterations. Produce your final reply before the budget is exhausted.)", first.content, max_iterations ); Some(original) } /// Restore a system-message content previously captured by /// [`annotate_system_with_budget`]. No-op when `original` is `None` or the /// first message isn't a system message. fn restore_system_content(messages: &mut [ChatMessage], original: Option) { let Some(original) = original else { return }; if let Some(first) = messages.first_mut() && first.role == "system" { first.content = original; } } /// Append the synthetic "write your final answer" user prompt, returning the /// Free-function form of `InsightChatService::run_streaming_agentic_loop_with_entry`. /// Persona chat (and any future chat-without-file surface) doesn't need an /// `InsightChatService` — it just needs the agent loop. This is the same /// body lifted out of the `InsightChatService` impl, taking /// `&InsightGenerator` so any caller in the crate can drive it. /// /// Public so the persona chat session can call into it directly. pub async fn run_streaming_agentic_loop_with_entry( generator: &crate::ai::insight_generator::InsightGenerator, backend: &ResolvedBackend, messages: &mut Vec, tools: Vec, image_base64: &Option, normalized: &str, user_id: i32, active_persona: &str, thread_contact: Option, max_iterations: usize, entry: &Arc, fitter: &mut ContextFitter, ) -> Result { let mut tool_calls_made = 0usize; let mut iterations_used = 0usize; let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; let mut final_content = String::new(); // Only tools in this turn's catalog may run. The catalog is how every // gate (per-persona corrections, practice mode's narrowed set, absent // backing tables) is expressed, and `execute_tool` dispatches on name // alone — without this a model that names a tool it was never offered // (one it saw in an earlier turn, or simply guessed) would still run it. let offered: std::collections::HashSet = tools.iter().map(|t| t.function.name.clone()).collect(); for iteration in 0..max_iterations { if !entry.is_running() { return Ok(AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content, cancelled: true, }); } iterations_used = iteration + 1; let _ = entry .push_event(ChatStreamEvent::IterationStart { n: iterations_used, max: max_iterations, }) .await; let mut stream = fitter .chat_stream(backend.chat(), messages, tools.clone()) .await?; let mut final_message: Option = None; while let Some(ev) = stream.next().await { let ev = ev?; match ev { LlmStreamEvent::TextDelta(delta) => { let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; } LlmStreamEvent::Done { message, prompt_eval_count, eval_count, } => { last_prompt_eval_count = prompt_eval_count; last_eval_count = eval_count; final_message = Some(message); break; } } } let mut response = final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?; if let Some(ref mut tcs) = response.tool_calls { for tc in tcs.iter_mut() { if !tc.function.arguments.is_object() { 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 { tool_calls_made += 1; let call_index = tool_calls_made - 1; let _ = entry .push_event(ChatStreamEvent::ToolCall { index: call_index, name: tool_call.function.name.clone(), arguments: tool_call.function.arguments.clone(), }) .await; let result = if offered.contains(&tool_call.function.name) { generator .execute_tool( &tool_call.function.name, &tool_call.function.arguments, backend, image_base64, normalized, user_id, active_persona, thread_contact, ) .await } else { log::warn!( "refusing tool '{}': not offered this turn", tool_call.function.name ); format!( "Error: tool '{}' is not available here.", tool_call.function.name ) }; let (result_preview, result_truncated) = truncate_tool_result(&result); let _ = entry .push_event(ChatStreamEvent::ToolResult { index: call_index, name: tool_call.function.name.clone(), result: result_preview, result_truncated, }) .await; messages.push(ChatMessage::tool_result(result)); } continue; } final_content = response.content; break; } if final_content.is_empty() { let synthetic_idx = push_synthetic_final_prompt(messages); let mut stream = fitter.chat_stream(backend.chat(), messages, vec![]).await?; let mut final_message: Option = None; while let Some(ev) = stream.next().await { let ev = ev?; match ev { LlmStreamEvent::TextDelta(delta) => { let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; } LlmStreamEvent::Done { message, prompt_eval_count, eval_count, } => { last_prompt_eval_count = prompt_eval_count; last_eval_count = eval_count; final_message = Some(message); break; } } } let final_response = final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?; final_content = final_response.content.clone(); messages.push(final_response); remove_synthetic_final_prompt(messages, synthetic_idx); } Ok(AgenticLoopOutcome { tool_calls_made, iterations_used, last_prompt_eval_count, last_eval_count, final_content: crate::ai::llm_client::strip_think_blocks(&final_content), cancelled: false, }) } /// index the caller must later hand to [`remove_synthetic_final_prompt`]. /// Used when the agentic loop exhausts its budget: the model gets one more /// (tool-free) request, but the nudge itself must never persist — it would /// render as a user bubble in the transcript and reset `load_history`'s /// pending-tools tracking at that position. fn push_synthetic_final_prompt(messages: &mut Vec) -> usize { let idx = messages.len(); messages.push(ChatMessage::user(SYNTHETIC_FINAL_ANSWER_PROMPT)); idx } /// Remove the synthetic prompt inserted by [`push_synthetic_final_prompt`]. /// Defensive no-op when the message at `idx` isn't the synthetic prompt — /// guards against index drift if the surrounding code is reordered. fn remove_synthetic_final_prompt(messages: &mut Vec, idx: usize) { if messages .get(idx) .is_some_and(|m| m.role == "user" && m.content == SYNTHETIC_FINAL_ANSWER_PROMPT) { messages.remove(idx); } } /// Receipt produced by [`apply_system_prompt_override`] so the caller can /// undo the override before persistence. Two variants because we either /// replaced an existing system message (need its original content) or /// prepended a synthetic one (need to pop it). #[derive(Debug)] pub(crate) enum SystemPromptStash { Replaced { original: String }, Prepended, } /// Apply a per-turn `system_prompt` override to `messages` so the model /// sees the requested persona for this turn. Returns a stash the caller /// must pass to [`restore_system_prompt_override`] before persisting the /// transcript — without that step, append-mode chat would silently /// rewrite the stored persona. /// /// No-op (returns `None`) when `override_prompt` is `None` or empty. pub(crate) fn apply_system_prompt_override( messages: &mut Vec, override_prompt: Option<&str>, ) -> Option { let prompt = override_prompt .map(str::trim) .filter(|s| !s.is_empty())? .to_string(); if let Some(first) = messages.first_mut() && first.role == "system" { let original = std::mem::replace(&mut first.content, prompt); return Some(SystemPromptStash::Replaced { original }); } messages.insert(0, ChatMessage::system(prompt)); Some(SystemPromptStash::Prepended) } /// Undo an override previously applied by [`apply_system_prompt_override`]. /// No-op when `stash` is `None`. pub(crate) fn restore_system_prompt_override( messages: &mut Vec, stash: Option, ) { let Some(stash) = stash else { return }; match stash { SystemPromptStash::Replaced { original } => { if let Some(first) = messages.first_mut() && first.role == "system" { first.content = original; } } SystemPromptStash::Prepended => { if matches!(messages.first(), Some(m) if m.role == "system") { messages.remove(0); } } } } /// Fork indicator for a rendered message: position and total branches at /// the nearest divergence point upstream in the conversation tree. #[derive(Debug, Clone, serde::Serialize)] pub struct ForkInfo { pub position: usize, pub total: usize, /// The divergence node itself (the parent whose children fork). Pass as /// `node_id` to the branches endpoint to list the sibling branches at /// this exact point rather than every leaf in the tree. pub node_id: u64, } /// Render a path of tree nodes into the UI-friendly `RenderedMessage` format, /// matching the same filtering logic as the legacy flat-array `load_history`. /// Returns `(rendered_messages, turn_count, node_ids, fork_info)` where /// `node_ids[i]` is the tree node ID that produced `rendered_messages[i]` and /// `fork_info[i]` carries the nearest divergence point at or upstream of it. /// /// Fork detection walks *every* node in the path — including the non-rendered /// tool-dispatch/tool/system nodes — so a fork whose diverging child is a /// tool-dispatch assistant (empty content + tool_calls) is still attributed to /// the next rendered message. Detecting forks only on rendered nodes would miss /// regenerations where the model replied with a tool call. pub(crate) fn render_tree_path( store: &ChatHistoryStore, path: &[&StoredChatNode], ) -> (Vec, usize, Vec, Vec>) { let mut rendered = Vec::new(); let mut user_turns_seen = 0usize; let mut assistant_turns_seen = 0usize; let mut pending_tools: Vec = Vec::new(); let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> = std::collections::VecDeque::new(); let mut node_ids: Vec = Vec::new(); let mut fork_info: Vec> = Vec::new(); // Nearest divergence seen so far, carried forward across non-rendered // nodes (e.g. tool-dispatch) so the fork is attributed to the next // rendered message. Cleared after the first rendered message consumes it, // so the chip appears only at the actual divergence point. let mut last_fork: Option = None; for node in path { // Update the carried fork BEFORE rendering: a non-rendered fork child // (e.g. a tool-dispatch assistant) must colour the rendered message // that follows it. // (fork_at_node only fires for nodes with a parent, so the pair // pattern below can't drop a real fork.) if let (Some(node_id), Some((position, total))) = (node.parent_id, store.fork_at_node(node.id)) { last_fork = Some(ForkInfo { position, total, node_id, }); } let msg = &node.message; match msg.role.as_str() { "system" => continue, "tool" => { if let Some((name, arguments)) = pending_calls.pop_front() { let (result, result_truncated) = truncate_tool_result(&msg.content); pending_tools.push(ToolInvocation { name, arguments, result, result_truncated, }); } } "assistant" => { let has_tool_calls = msg .tool_calls .as_ref() .map(|c| !c.is_empty()) .unwrap_or(false); if has_tool_calls && msg.content.trim().is_empty() { if let Some(ref tcs) = msg.tool_calls { for tc in tcs { pending_calls.push_back(( tc.function.name.clone(), tc.function.arguments.clone(), )); } } continue; } assistant_turns_seen += 1; let tools = std::mem::take(&mut pending_tools); pending_calls.clear(); rendered.push(RenderedMessage { role: "assistant".to_string(), content: msg.content.clone(), is_initial: false, tools, }); node_ids.push(node.id); fork_info.push(last_fork.take()); } "user" => { let is_initial = user_turns_seen == 0; user_turns_seen += 1; pending_tools.clear(); pending_calls.clear(); rendered.push(RenderedMessage { role: "user".to_string(), content: msg.content.clone(), is_initial, tools: Vec::new(), }); node_ids.push(node.id); fork_info.push(last_fork.take()); } _ => continue, } } (rendered, assistant_turns_seen, node_ids, fork_info) } /// View returned to clients for chat-UI rendering. #[derive(Debug)] pub struct HistoryView { pub messages: Vec, pub turn_count: usize, pub model_version: String, pub backend: String, /// ID of the leaf node the current view is anchored to. pub active_leaf_id: u64, /// ID of the branch leaf being viewed. Equals `active_leaf_id` when /// viewing the active branch, or the `branch_id` parameter when /// viewing an alternate branch. pub viewing_branch_id: u64, /// Fork info for each rendered message. `None` means no divergence at /// or before that point in the tree. pub fork_info: Vec>, } #[derive(Debug)] pub struct RenderedMessage { pub role: String, pub content: String, pub is_initial: bool, /// Tools invoked during this turn (only populated for assistant replies). /// Empty for user messages and for assistant replies that didn't involve /// tool calls. pub tools: Vec, } #[derive(Debug, Clone)] pub struct ToolInvocation { pub name: String, pub arguments: serde_json::Value, pub result: String, /// True when `result` was trimmed for payload size. Full value remains /// available in the raw training_messages blob. pub result_truncated: bool, } /// Soft cap for tool-result bodies returned via the history API. Keeps /// payloads small for the mobile client — verbose SMS / geocoding responses /// don't need to ship in full for inspection. pub(crate) const TOOL_RESULT_PREVIEW_MAX: usize = 2000; pub(crate) fn truncate_tool_result(s: &str) -> (String, bool) { if s.len() <= TOOL_RESULT_PREVIEW_MAX { (s.to_string(), false) } else { // Cut on a char boundary. let mut cut = TOOL_RESULT_PREVIEW_MAX; while !s.is_char_boundary(cut) && cut > 0 { cut -= 1; } (s[..cut].to_string(), true) } } /// Trim history to fit within `budget_bytes` of serialized JSON. Always /// preserves the system message and the first user message (with its base64 /// images intact, since dropping those would invalidate the model's prior /// visual reasoning) plus that first exchange's final reply, i.e. the /// insight itself. /// /// Drops, oldest first, in three phases until the budget is met: /// 1. tool-call scaffolding (an assistant `tool_calls` turn + its tool /// results) inside *completed* exchanges — cheapest to lose, the final /// reply that used them stays; /// 2. whole completed exchanges (user turn through its final reply) after /// the first. Dropping a whole exchange keeps user/assistant alternation /// intact, which strict chat templates (Gemma) enforce; /// 3. tool-call scaffolding in the in-progress exchange (the current turn's /// own tool results, which grow during the agentic loop). /// /// An exchange is "completed" when it contains an assistant reply without /// tool calls. The current turn never is while the loop runs, so the user's /// question is never dropped — even after `push_synthetic_final_prompt` /// appends a later user message. /// /// Returns true when at least one message was dropped. pub(crate) fn apply_context_budget(messages: &mut Vec, budget_bytes: usize) -> bool { if budget_bytes == 0 || estimate_bytes(messages) <= budget_bytes { return false; } let mut dropped_any = false; while estimate_bytes(messages) > budget_bytes { let Some(range) = next_budget_drop(messages) else { break; }; messages.drain(range); dropped_any = true; } dropped_any } /// Pick the next span of `messages` for [`apply_context_budget`] to drop, /// following its phase order. `None` when nothing droppable remains. fn next_budget_drop(messages: &[ChatMessage]) -> Option> { let user_idxs: Vec = messages .iter() .enumerate() .filter(|(_, m)| m.role == "user") .map(|(i, _)| i) .collect(); let first_user = *user_idxs.first()?; // Exchanges: each user turn through the message before the next one. let exchanges: Vec> = user_idxs .iter() .enumerate() .map(|(n, &u)| u..user_idxs.get(n + 1).copied().unwrap_or(messages.len())) .collect(); let completed = |r: &std::ops::Range| { messages[r.clone()] .iter() .any(|m| m.role == "assistant" && !has_tool_calls(m)) }; // Oldest tool-call turn (+ trailing tool results) inside `r`. let tool_pair_in = |r: &std::ops::Range| { let start = (r.start + 1..r.end) .find(|&i| messages[i].role == "assistant" && has_tool_calls(&messages[i]))?; let mut end = start + 1; while end < r.end && messages[end].role == "tool" { end += 1; } Some(start..end) }; // Phase 1: tool scaffolding in completed exchanges. if let Some(r) = exchanges .iter() .filter(|r| completed(r)) .find_map(tool_pair_in) { return Some(r); } // Phase 2: whole completed exchanges after the first. if let Some(r) = exchanges .iter() .find(|r| r.start != first_user && completed(r)) { return Some(r.clone()); } // Phase 3: tool scaffolding in the in-progress exchange(s). exchanges.iter().find_map(tool_pair_in) } fn has_tool_calls(m: &ChatMessage) -> bool { m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) } /// Estimate the serialized byte size of `messages` for the truncation budget, /// EXCLUDING inlined base64 image payloads. Images are charged a flat /// `IMAGE_TOKENS_EACH` instead: their base64 is hundreds of KB of characters /// that have no relation to the text token pressure we're budgeting against, /// and counting them verbatim makes a single photo exceed the entire budget, /// spuriously trimming all history on every turn. fn estimate_bytes(messages: &[ChatMessage]) -> usize { let mut image_count = 0usize; // Clone with image payloads stripped so they don't inflate the byte count. // We still account for the (small) non-image fields verbatim. let stripped: Vec = messages .iter() .map(|m| { if let Some(imgs) = m.images.as_ref() { image_count += imgs.len(); } ChatMessage { images: None, ..m.clone() } }) .collect(); let text_bytes = serde_json::to_string(&stripped) .map(|s| s.len()) .unwrap_or(0); text_bytes + image_count * IMAGE_TOKENS_EACH * BYTES_PER_TOKEN } #[cfg(test)] mod tests { use super::*; use crate::ai::llm_client::{ToolCall, ToolCallFunction}; fn assistant_with_tool_call(name: &str) -> ChatMessage { ChatMessage { role: "assistant".to_string(), content: String::new(), tool_calls: Some(vec![ToolCall { id: None, function: ToolCallFunction { name: name.to_string(), arguments: serde_json::Value::Object(Default::default()), }, }]), images: None, } } fn assistant_text(text: &str) -> ChatMessage { ChatMessage { role: "assistant".to_string(), content: text.to_string(), tool_calls: None, images: None, } } #[test] fn truncation_preserves_system_and_first_user() { let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("first user with lots of context".repeat(50)), assistant_with_tool_call("get_x"), ChatMessage::tool_result("x result ".repeat(200)), assistant_with_tool_call("get_y"), ChatMessage::tool_result("y result ".repeat(200)), assistant_text("final answer"), ]; let original_len = msgs.len(); let dropped = apply_context_budget(&mut msgs, 500); assert!(dropped, "should drop something at this small budget"); assert!(msgs.len() < original_len); // First two messages preserved. assert_eq!(msgs[0].role, "system"); assert_eq!(msgs[1].role, "user"); } #[test] fn truncation_no_op_when_under_budget() { let mut msgs = vec![ChatMessage::system("s"), ChatMessage::user("u")]; let dropped = apply_context_budget(&mut msgs, 1_000_000); assert!(!dropped); assert_eq!(msgs.len(), 2); } #[test] fn image_payload_excluded_from_budget() { // First user message carries a ~400KB base64 image but only a little // text. Counting the base64 verbatim (old behavior) dwarfs the budget // and forces all tool history to be dropped on every turn. The image // must instead be charged a flat per-image cost so a short // conversation comfortably fits. let mut user = ChatMessage::user("describe this"); user.images = Some(vec!["A".repeat(400_000)]); let mut msgs = vec![ ChatMessage::system("sys"), user, assistant_with_tool_call("get_x"), ChatMessage::tool_result("small x result"), assistant_text("here is the answer"), ]; // Default budget: (32768 - 2048) * 4 bytes ≈ 120KB. The text easily // fits; only the (excluded) image bytes could blow it. let budget_bytes = (DEFAULT_NUM_CTX as usize - RESPONSE_HEADROOM_TOKENS) * BYTES_PER_TOKEN; let original_len = msgs.len(); let dropped = apply_context_budget(&mut msgs, budget_bytes); assert!( !dropped, "short conversation with one image must not truncate" ); assert_eq!(msgs.len(), original_len, "no messages should be dropped"); // Sanity: the flat image charge is accounted for but stays well under budget. assert!(estimate_bytes(&msgs) <= budget_bytes); } #[test] fn truncation_returns_false_with_no_droppable_pairs() { // Only system + user, no tool-call turns to drop. let mut msgs = vec![ChatMessage::system("s"), ChatMessage::user("u")]; let dropped = apply_context_budget(&mut msgs, 1); assert!(!dropped); } fn roles(msgs: &[ChatMessage]) -> Vec<&str> { msgs.iter().map(|m| m.role.as_str()).collect() } #[test] fn truncation_drops_old_plain_exchanges_when_no_tool_pairs_left() { // Long chat of plain Q&A: nothing but whole exchanges to drop. The // old pass could only drop tool pairs, so this never shrank. let filler = "x".repeat(2_000); let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("first"), assistant_text("the insight"), ChatMessage::user(format!("q1 {filler}")), assistant_text(&format!("a1 {filler}")), ChatMessage::user(format!("q2 {filler}")), assistant_text(&format!("a2 {filler}")), ChatMessage::user("current question"), ]; let budget = estimate_bytes(&msgs) - 1_000; assert!(apply_context_budget(&mut msgs, budget)); assert!(estimate_bytes(&msgs) <= budget); // Oldest exchange (q1/a1) went; the insight exchange, q2/a2 and the // current question stay, and roles still alternate. assert_eq!( roles(&msgs), ["system", "user", "assistant", "user", "assistant", "user"] ); assert_eq!(msgs[2].content, "the insight"); assert!(msgs[3].content.starts_with("q2")); assert_eq!(msgs[5].content, "current question"); } #[test] fn truncation_drops_tool_scaffolding_before_whole_exchanges() { let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("first"), assistant_text("the insight"), ChatMessage::user("q1"), assistant_with_tool_call("lookup"), ChatMessage::tool_result("r".repeat(4_000)), assistant_text("a1"), ChatMessage::user("current question"), ]; let budget = estimate_bytes(&msgs) - 1_000; assert!(apply_context_budget(&mut msgs, budget)); // Only the tool pair was needed; q1/a1 survive. assert_eq!( roles(&msgs), ["system", "user", "assistant", "user", "assistant", "user"] ); assert_eq!(msgs[4].content, "a1"); } #[test] fn truncation_never_drops_the_in_progress_question() { // Mid-loop with the synthetic final prompt appended: the real // question's exchange has no final reply yet, so phase 2 must skip // it; only its tool results (phase 3) can go. let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("first"), assistant_text("the insight"), ChatMessage::user("real question"), assistant_with_tool_call("lookup"), ChatMessage::tool_result("r".repeat(8_000)), ChatMessage::user("synthetic: answer now"), ]; assert!(apply_context_budget(&mut msgs, 600)); assert!(msgs.iter().any(|m| m.content == "real question")); assert!(msgs.iter().all(|m| m.role != "tool")); } #[test] fn parse_context_overflow_reads_llama_server_counts() { let err = r#"llama-swap chat request failed: 400 Bad Request — {"error":{"code":400,"message":"the request exceeds the available context size, try increasing it","type":"exceed_context_size_error","n_prompt_tokens":40312,"n_ctx":32768}}"#; assert_eq!(parse_context_overflow(err), Some(Some((40312, 32768)))); assert_eq!( parse_context_overflow("400 — This model's maximum context length is 128000 tokens"), Some(None) ); assert_eq!(parse_context_overflow("500 Internal Server Error"), None); } /// Scripted client for `ContextFitter`: fails the first `fail_first` /// calls with `error`, records the size of every prompt it's sent. struct ScriptedClient { window: Option, fail_first: usize, error: String, sent: Mutex>, } #[async_trait::async_trait] impl crate::ai::llm_client::LlmClient for ScriptedClient { async fn generate( &self, _: &str, _: Option<&str>, _: Option>, ) -> Result { unimplemented!() } async fn chat_with_tools( &self, messages: Vec, _: Vec, ) -> Result<(ChatMessage, Option, Option)> { let mut sent = self.sent.lock().unwrap(); sent.push(messages.len()); if sent.len() <= self.fail_first { bail!("{}", self.error); } Ok((assistant_text("ok"), None, None)) } async fn chat_with_tools_stream( &self, _: Vec, _: Vec, ) -> Result>> { unimplemented!() } async fn generate_embeddings(&self, _: &[&str]) -> Result>> { unimplemented!() } async fn describe_image(&self, _: &str) -> Result { unimplemented!() } async fn list_models(&self) -> Result> { unimplemented!() } async fn model_capabilities( &self, _: &str, ) -> Result { unimplemented!() } fn primary_model(&self) -> &str { "scripted" } async fn context_window(&self) -> Option { self.window } } fn scripted(window: Option, fail_first: usize, error: &str) -> ScriptedClient { ScriptedClient { window, fail_first, error: error.to_string(), sent: Mutex::new(Vec::new()), } } #[tokio::test] async fn effective_num_ctx_caps_request_at_served_window() { let served_32k = scripted(Some(32_768), 0, ""); assert_eq!(effective_num_ctx(&served_32k, Some(65_536)).await, 32_768); assert_eq!(effective_num_ctx(&served_32k, Some(8_192)).await, 8_192); assert_eq!(effective_num_ctx(&served_32k, None).await, 32_768); let unknown = scripted(None, 0, ""); assert_eq!(effective_num_ctx(&unknown, Some(65_536)).await, 65_536); } #[tokio::test] async fn fitter_retries_overflow_with_a_smaller_prompt() { let filler = "x".repeat(2_000); let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("first"), assistant_text("insight"), ]; for i in 0..10 { msgs.push(ChatMessage::user(format!("q{i} {filler}"))); msgs.push(assistant_text(&format!("a{i} {filler}"))); } msgs.push(ChatMessage::user("current")); let client = scripted( None, 1, r#"400 — {"error":{"type":"exceed_context_size_error","n_prompt_tokens":20000,"n_ctx":10000}}"#, ); let mut fitter = ContextFitter::from_num_ctx(1_000_000); let (reply, _, _) = fitter.chat(&client, &msgs, vec![]).await.unwrap(); assert_eq!(reply.content, "ok"); let sent = client.sent.lock().unwrap().clone(); assert_eq!(sent.len(), 2, "one retry"); assert_eq!(sent[0], msgs.len(), "first attempt fit the generous budget"); assert!(sent[1] < sent[0], "retry sent a trimmed prompt"); } #[tokio::test] async fn fitter_does_not_retry_other_errors() { let client = scripted(None, 5, "502 Bad Gateway"); let mut fitter = ContextFitter::from_num_ctx(32_768); let msgs = vec![ChatMessage::system("s"), ChatMessage::user("u")]; assert!(fitter.chat(&client, &msgs, vec![]).await.is_err()); assert_eq!(client.sent.lock().unwrap().len(), 1); } #[test] fn render_tree_path_detects_fork_at_tool_dispatch_child() { // Regression: a regeneration where the model replies with a tool call // forks at the user turn, but the diverging children are non-rendered // tool-dispatch assistant nodes. The indicator must still surface on // the rendered final reply that follows — earlier code only inspected // rendered nodes and silently dropped the fork, orphaning the branch. let mut store = ChatHistoryStore::empty(); let u = store.append_node(None, ChatMessage::user("q1")); // Original branch: tool-dispatch → tool result → final reply. let d1 = store.append_node(Some(u), assistant_with_tool_call("lookup")); let t1 = store.append_node(Some(d1), ChatMessage::tool_result("data1")); let _a1 = store.append_node(Some(t1), assistant_text("answer 1")); // Regenerated branch: also a tool reply, forking at the user turn. let d2 = store.append_node(Some(u), assistant_with_tool_call("lookup")); let t2 = store.append_node(Some(d2), ChatMessage::tool_result("data2")); let a2 = store.append_node(Some(t2), assistant_text("answer 2")); store.active_leaf_id = a2; let path = store.path_to_leaf(a2).expect("path to active leaf"); let (rendered, _turns, node_ids, fork_info) = render_tree_path(&store, &path); assert_eq!(rendered.len(), 2, "user turn + final reply are rendered"); assert_eq!(rendered[1].content, "answer 2"); assert_eq!(node_ids, vec![u, a2]); assert!( fork_info[0].is_none(), "user turn sits before the divergence" ); let f = fork_info[1] .as_ref() .expect("fork indicator carried onto the rendered reply"); assert_eq!((f.position, f.total), (2, 2)); assert_eq!(f.node_id, u, "divergence node is the forked user turn"); } #[test] fn render_tree_path_detects_fork_at_rendered_child() { // Non-tool case: two direct assistant replies to the same user turn. // The diverging child is itself rendered, so the indicator lands on it. let mut store = ChatHistoryStore::empty(); let u = store.append_node(None, ChatMessage::user("q1")); let _a1 = store.append_node(Some(u), assistant_text("answer 1")); let a2 = store.append_node(Some(u), assistant_text("answer 2")); store.active_leaf_id = a2; let path = store.path_to_leaf(a2).expect("path to active leaf"); let (rendered, _turns, _node_ids, fork_info) = render_tree_path(&store, &path); assert_eq!(rendered.len(), 2); let f = fork_info[1] .as_ref() .expect("fork indicator on the divergent rendered reply"); assert_eq!((f.position, f.total), (2, 2)); assert_eq!(f.node_id, u, "divergence node is the forked user turn"); } #[test] fn render_tree_path_nested_forks_only_emits_at_divergence() { // Regression: with .take() instead of .clone(), fork_info should only // appear at the actual divergence point, not propagate downstream. // Structure: // u (user "q1") // ├── a1 (assistant "answer 1") // └── a2 (assistant "answer 2") // ├── q2 (user "q2") // └── r2 (assistant "reply 2") <- active leaf // // Expected fork_info: [None, Some, None, None] // (only a2 has fork info since it's the divergence from a1) let mut store = ChatHistoryStore::empty(); let u = store.append_node(None, ChatMessage::user("q1")); let _a1 = store.append_node(Some(u), assistant_text("answer 1")); let a2 = store.append_node(Some(u), assistant_text("answer 2")); let q2 = store.append_node(Some(a2), ChatMessage::user("q2")); let r2 = store.append_node(Some(q2), assistant_text("reply 2")); store.active_leaf_id = r2; let path = store.path_to_leaf(r2).expect("path to active leaf"); let (rendered, _turns, _node_ids, fork_info) = render_tree_path(&store, &path); assert_eq!(rendered.len(), 4, "user + asst + user + asst"); assert!(fork_info[0].is_none(), "initial user has no fork"); assert!(fork_info[1].is_some(), "a2 is the fork point"); assert!(fork_info[2].is_none(), "q2 should NOT carry fork forward"); assert!(fork_info[3].is_none(), "r2 should NOT carry fork forward"); } #[test] fn rewind_strips_assistant_and_tool_scaffolding() { // Rendered: [user1, asst1, user2, asst2] → cut at rendered index 3 // (the final asst2) should drop the tool-call scaffolding + asst2, // leaving raw up through user2. let msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("q1"), assistant_text("a1"), ChatMessage::user("q2"), assistant_with_tool_call("lookup"), ChatMessage::tool_result("data"), assistant_text("a2 final"), ]; let cut = find_raw_cut(&msgs, 3).expect("cut found"); // raw[0..cut] should end at user("q2") — indices 0..=3. assert_eq!(cut, 4); assert_eq!(msgs[cut - 1].role, "user"); assert_eq!(msgs[cut - 1].content, "q2"); } #[test] fn rewind_at_second_rendered_cuts_after_first_user() { // Rendered index 1 = the first assistant reply → dropping it should // leave just the initial user message. let msgs = vec![ ChatMessage::system("s"), ChatMessage::user("q1"), assistant_with_tool_call("tool"), ChatMessage::tool_result("r"), assistant_text("a1"), ]; let cut = find_raw_cut(&msgs, 1).expect("cut found"); assert_eq!(cut, 2); // sys + user("q1") } #[test] fn rewind_beyond_range_returns_none() { let msgs = vec![ChatMessage::user("q1"), assistant_text("a1")]; assert!(find_raw_cut(&msgs, 5).is_none()); } #[test] fn rewind_at_end_is_noop_success() { // Mobile client retries after a failed turn that never persisted — // its optimistic user bubble's index equals the server's rendered // count. Should resolve to "no cut" rather than an out-of-range error. let msgs = vec![ ChatMessage::system("s"), ChatMessage::user("q1"), assistant_text("a1"), ]; let cut = find_raw_cut(&msgs, 2).expect("boundary cut should succeed"); assert_eq!(cut, msgs.len()); } #[test] fn apply_override_replaces_existing_system_message() { let mut msgs = vec![ ChatMessage::system("original persona"), ChatMessage::user("hi"), ]; let stash = apply_system_prompt_override(&mut msgs, Some("new persona")); assert_eq!(msgs[0].content, "new persona"); match stash { Some(SystemPromptStash::Replaced { original }) => { assert_eq!(original, "original persona"); } other => panic!("expected Replaced, got {:?}", other), } } #[test] fn apply_override_prepends_synthetic_when_missing() { let mut msgs = vec![ChatMessage::user("hi")]; let stash = apply_system_prompt_override(&mut msgs, Some("new persona")); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].role, "system"); assert_eq!(msgs[0].content, "new persona"); assert!(matches!(stash, Some(SystemPromptStash::Prepended))); } #[test] fn apply_override_no_op_when_none() { let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("hi")]; let stash = apply_system_prompt_override(&mut msgs, None); assert!(stash.is_none()); assert_eq!(msgs[0].content, "sys"); } #[test] fn apply_override_no_op_for_empty_string() { let mut msgs = vec![ChatMessage::system("sys")]; let stash = apply_system_prompt_override(&mut msgs, Some("")); assert!(stash.is_none()); assert_eq!(msgs[0].content, "sys"); } #[test] fn restore_override_replaces_back() { let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")]; restore_system_prompt_override( &mut msgs, Some(SystemPromptStash::Replaced { original: "original".to_string(), }), ); assert_eq!(msgs[0].content, "original"); assert_eq!(msgs.len(), 2); } #[test] fn restore_override_pops_synthetic() { let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")]; restore_system_prompt_override(&mut msgs, Some(SystemPromptStash::Prepended)); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].role, "user"); } #[test] fn override_round_trip_preserves_original_system_message() { let mut msgs = vec![ ChatMessage::system("original persona"), ChatMessage::user("first user"), assistant_text("first reply"), ]; let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral persona")); assert_eq!(msgs[0].content, "ephemeral persona"); restore_system_prompt_override(&mut msgs, stash); assert_eq!(msgs[0].content, "original persona"); assert_eq!(msgs.len(), 3); assert_eq!(msgs[1].role, "user"); assert_eq!(msgs[2].role, "assistant"); } #[test] fn override_with_synthetic_round_trip_drops_extra_message() { let mut msgs = vec![ChatMessage::user("first user")]; let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral")); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].role, "system"); restore_system_prompt_override(&mut msgs, stash); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].role, "user"); } // ── Bootstrap prompt / backend resolution ───────────────────────── // Resolution helpers run on every bootstrap turn — they pick the // persisted system prompt and the chosen backend label. Bugs here // would silently swap the persona or miscategorise a backend. #[test] fn bootstrap_system_prompt_falls_back_to_default_for_none() { let out = resolve_bootstrap_system_prompt(None, None); assert_eq!(out, BOOTSTRAP_DEFAULT_SYSTEM_PROMPT); } #[test] fn bootstrap_system_prompt_falls_back_to_default_for_empty_string() { // Apollo currently sends `''` when no persona is selected. let out = resolve_bootstrap_system_prompt(Some(""), None); assert_eq!(out, BOOTSTRAP_DEFAULT_SYSTEM_PROMPT); } #[test] fn bootstrap_system_prompt_falls_back_to_default_for_whitespace() { let out = resolve_bootstrap_system_prompt(Some(" \n\t "), None); assert_eq!(out, BOOTSTRAP_DEFAULT_SYSTEM_PROMPT); } #[test] fn bootstrap_system_prompt_uses_supplied_when_non_empty() { let out = resolve_bootstrap_system_prompt(Some("you are a journal"), None); assert_eq!(out, "you are a journal"); } #[test] fn bootstrap_system_prompt_does_not_strip_inner_whitespace() { // Trim only happens at the edges — interior newlines and spacing // (which Apollo's persona uses for tool listings) must survive. let prompt = "line one\nline two\n bullet"; let out = resolve_bootstrap_system_prompt(Some(prompt), None); assert_eq!(out, prompt); } #[test] fn bootstrap_system_prompt_explicit_wins_over_persona_store() { let out = resolve_bootstrap_system_prompt( Some("explicit prompt"), Some("stored persona prompt".to_string()), ); assert_eq!(out, "explicit prompt"); } #[test] fn bootstrap_system_prompt_uses_persona_store_when_no_explicit() { // Request carried persona_id but no system_prompt — the persona's // stored prompt must be used, not the neutral default. let out = resolve_bootstrap_system_prompt(None, Some("stored persona prompt".to_string())); assert_eq!(out, "stored persona prompt"); // Empty explicit prompt behaves like None. let out = resolve_bootstrap_system_prompt(Some(""), Some("stored persona prompt".to_string())); assert_eq!(out, "stored persona prompt"); } #[test] fn bootstrap_system_prompt_blank_persona_prompt_falls_to_default() { let out = resolve_bootstrap_system_prompt(None, Some(" ".to_string())); assert_eq!(out, BOOTSTRAP_DEFAULT_SYSTEM_PROMPT); } // ── Synthetic final-answer prompt scaffolding ────────────────────── #[test] fn synthetic_final_prompt_round_trip_leaves_no_scaffolding() { // Exhausted-loop fallback: nudge pushed, model reply appended, nudge // removed — the persisted transcript must contain the reply but not // the synthetic user prompt (all three loop variants rely on this). let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("q"), assistant_with_tool_call("lookup"), ChatMessage::tool_result("data"), ]; let idx = push_synthetic_final_prompt(&mut msgs); assert_eq!(msgs[idx].content, SYNTHETIC_FINAL_ANSWER_PROMPT); msgs.push(assistant_text("final answer")); remove_synthetic_final_prompt(&mut msgs, idx); assert_eq!(msgs.len(), 5); assert!( msgs.iter() .all(|m| m.content != SYNTHETIC_FINAL_ANSWER_PROMPT), "synthetic prompt must not persist" ); assert_eq!(msgs.last().unwrap().content, "final answer"); } #[test] fn remove_synthetic_final_prompt_is_noop_on_index_mismatch() { // Defensive guard: if the message at idx isn't the synthetic prompt // (index drift), nothing is removed. let mut msgs = vec![ChatMessage::user("q"), assistant_text("a")]; remove_synthetic_final_prompt(&mut msgs, 0); remove_synthetic_final_prompt(&mut msgs, 5); assert_eq!(msgs.len(), 2); } #[test] fn bootstrap_backend_defaults_to_local_when_none() { let out = resolve_bootstrap_backend(None).unwrap(); assert_eq!(out, "local"); } #[test] fn bootstrap_backend_defaults_to_local_when_empty() { let out = resolve_bootstrap_backend(Some("")).unwrap(); assert_eq!(out, "local"); } #[test] fn bootstrap_backend_accepts_local_and_hybrid_case_insensitively() { assert_eq!(resolve_bootstrap_backend(Some("LOCAL")).unwrap(), "local"); assert_eq!(resolve_bootstrap_backend(Some("Hybrid")).unwrap(), "hybrid"); assert_eq!( resolve_bootstrap_backend(Some(" local ")).unwrap(), "local" ); } #[test] fn bootstrap_backend_rejects_unknown_label() { // `llamacpp` is no longer a per-request backend value — it's chosen // at deploy time via `LLM_BACKEND`. for label in &["openrouter", "llamacpp", "ollama"] { let err = resolve_bootstrap_backend(Some(label)).unwrap_err(); let msg = format!("{}", err); assert!(msg.contains("unknown backend"), "label={}", label); } } #[test] fn cross_replay_allows_every_known_transition() { assert!(validate_cross_replay("local", "local").is_ok()); assert!(validate_cross_replay("hybrid", "hybrid").is_ok()); // Hybrid → local replays the inlined description as plain text. assert!(validate_cross_replay("hybrid", "local").is_ok()); // Local → hybrid swaps the photo for a local description first. assert!(validate_cross_replay("local", "hybrid").is_ok()); } #[test] fn continuation_model_drops_the_stored_model_across_a_switch() { // Same backend: keep the model the insight was generated with. assert_eq!( continuation_model(None, "gemma4-31", "local", "local").as_deref(), Some("gemma4-31") ); // Switched: a llama-swap slot name is not an OpenRouter model id. assert_eq!( continuation_model(None, "gemma4-31", "local", "hybrid"), None ); // An explicit request always wins. assert_eq!( continuation_model(Some("x/y".into()), "gemma4-31", "local", "hybrid").as_deref(), Some("x/y") ); assert_eq!(continuation_model(None, "", "local", "local"), None); } fn with_image(text: &str, img: &str) -> ChatMessage { let mut m = ChatMessage::user(text.to_string()); m.images = Some(vec![img.to_string()]); m } #[test] fn transcript_images_lists_each_image_once() { let msgs = vec![ ChatMessage::system("sys"), with_image("describe", "IMG_A"), ChatMessage::user("more"), with_image("again", "IMG_A"), ]; assert_eq!(transcript_images(&msgs), vec!["IMG_A".to_string()]); } #[test] fn photo_is_swapped_for_text_then_restored_exactly() { let original = vec![ ChatMessage::system("sys"), with_image("What's in this photo?", "IMG_A"), assistant_text("A beach."), ChatMessage::user("who's there?"), ]; let mut msgs = original.clone(); let descriptions = HashMap::from([("IMG_A".to_string(), "Two people on sand.".to_string())]); let stash = apply_image_descriptions(&mut msgs, &descriptions); // Nothing the text-only model receives carries image data. assert!(msgs.iter().all(|m| m.images.is_none())); assert!(msgs[1].content.starts_with("What's in this photo?")); assert!(msgs[1].content.contains("Two people on sand.")); // The turn appends its exchange, then the stash is restored before // saving: the stored transcript keeps the photo, unchanged. msgs.push(assistant_text("Two friends.")); stash.restore(&mut msgs); assert_eq!( serde_json::to_string(&msgs[..original.len()]).unwrap(), serde_json::to_string(&original).unwrap() ); assert_eq!(msgs[1].images, Some(vec!["IMG_A".to_string()])); } #[test] fn restore_leaves_a_message_the_turn_changed() { let mut msgs = vec![with_image("q", "IMG_A")]; let descriptions = HashMap::from([("IMG_A".to_string(), "desc".to_string())]); let stash = apply_image_descriptions(&mut msgs, &descriptions); msgs[0].content = "something else entirely".into(); stash.restore(&mut msgs); assert_eq!(msgs[0].content, "something else entirely"); assert!(msgs[0].images.is_none()); } #[test] fn cross_replay_rejects_unknown_effective() { // Both "openrouter" and the former "llamacpp" value are unknown now. for label in &["openrouter", "llamacpp"] { let err = validate_cross_replay("local", label).unwrap_err(); assert!( format!("{}", err).contains("unknown backend"), "label={}", label ); } } #[test] fn bootstrap_system_message_includes_path_and_persona() { let out = build_bootstrap_system_message("you are helpful", "pics/IMG.jpg", None, None, ""); assert!(out.starts_with("you are helpful")); assert!(out.contains("--- PHOTO CONTEXT ---")); assert!(out.contains("Photo file path: pics/IMG.jpg")); // No date supplied → "unknown" so the model doesn't guess. assert!(out.contains("Date taken: unknown")); assert!(!out.contains("GPS:")); assert!(!out.contains("Visual description")); } #[test] fn bootstrap_system_message_includes_date_when_supplied() { let out = build_bootstrap_system_message("voice", "pics/IMG.jpg", Some("2014-11-08"), None, ""); assert!(out.contains("Date taken: 2014-11-08")); assert!(!out.contains("Date taken: unknown")); } #[test] fn bootstrap_system_message_includes_gps_when_present() { let out = build_bootstrap_system_message( "voice", "p.jpg", Some("2020-01-01"), Some((42.36123, -71.05789)), "", ); // Four decimals — enough for place lookup, short enough to // not bloat the system prompt. assert!(out.contains("GPS: 42.3612, -71.0579")); } #[test] fn bootstrap_system_message_omits_gps_when_none() { let out = build_bootstrap_system_message("voice", "p.jpg", Some("2020-01-01"), None, ""); assert!(!out.contains("GPS:")); } #[test] fn bootstrap_system_message_includes_visual_block_when_supplied() { let visual = "Visual description (from local vision model):\nA dog in a park.\n"; let out = build_bootstrap_system_message("voice", "p.jpg", Some("2020-01-01"), None, visual); assert!(out.contains("Photo file path: p.jpg")); assert!(out.contains("A dog in a park")); // Path before date before visual. let path_pos = out.find("Photo file path:").unwrap(); let date_pos = out.find("Date taken:").unwrap(); let visual_pos = out.find("A dog in a park").unwrap(); assert!(path_pos < date_pos); assert!(date_pos < visual_pos); } #[test] fn bootstrap_system_message_trims_persona_trailing_whitespace() { let out = build_bootstrap_system_message("voice \n\n\n", "p.jpg", None, None, ""); assert!(out.contains("voice\n\n--- PHOTO CONTEXT ---")); } #[test] fn date_taken_for_context_prefers_exif_over_filename() { // EXIF wins when both are present (matches the canonical // date_resolver waterfall — EXIF is more reliable than // import-named filenames). let exif = Some(crate::database::models::ImageExif { id: 0, library_id: 1, file_path: "Screenshot_2014-06-01.png".to_string(), camera_make: None, camera_model: None, lens_model: None, width: None, height: None, orientation: None, gps_latitude: None, gps_longitude: None, gps_altitude: None, focal_length: None, aperture: None, shutter_speed: None, iso: None, // 2021-08-15 12:00:00 UTC date_taken: Some(1_629_028_800), created_time: 0, last_modified: 0, content_hash: None, size_bytes: None, phash_64: None, dhash_64: None, duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: None, original_date_taken: None, original_date_taken_source: None, clip_embedding: None, clip_model_version: None, date_utc: Some(1_629_028_800), camera_offset_minutes: None, date_taken_tz_source: Some("utc_direct".to_string()), original_date_utc: None, original_camera_offset_minutes: None, original_date_taken_tz_source: None, }); let out = resolve_date_taken_for_context( &exif, "Screenshot_2014-06-01.png", chrono_tz::Tz::America__New_York, Some(chrono_tz::Tz::America__New_York), ); assert_eq!(out.as_deref(), Some("2021-08-15")); } #[test] fn date_taken_for_context_falls_back_to_filename_when_no_exif() { // memories::extract_date_from_filename requires date+time in // the filename — date-only patterns aren't matched. Use the // canonical screenshot pattern for the regression case. let out = resolve_date_taken_for_context( &None, "Screenshot_2014-06-01-20-44-50.png", chrono_tz::Tz::America__New_York, Some(chrono_tz::Tz::America__New_York), ); assert_eq!(out.as_deref(), Some("2014-06-01")); } #[test] fn date_taken_for_context_returns_none_when_neither_source() { let out = resolve_date_taken_for_context( &None, "DSC_5171.JPG", chrono_tz::Tz::America__New_York, Some(chrono_tz::Tz::America__New_York), ); assert!(out.is_none()); } /// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00. /// The AI is told the camera wall clock (instant + offset — a midnight /// rollover relative to the owner's display zone is expected and /// correct), while the SMS/location/calendar windows anchor on the /// true instant with zero offset math. #[test] fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() { use chrono::NaiveDate; // 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT. let instant = NaiveDate::from_ymd_opt(2024, 7, 5) .unwrap() .and_hms_opt(23, 30, 0) .unwrap() .and_utc(); let date_utc = instant.timestamp(); let row = crate::database::models::ImageExif { id: 0, library_id: 1, file_path: "tokyo.jpg".to_string(), camera_make: None, camera_model: None, lens_model: None, width: None, height: None, orientation: None, gps_latitude: None, gps_longitude: None, gps_altitude: None, focal_length: None, aperture: None, shutter_speed: None, iso: None, date_taken: Some(date_utc), created_time: 0, last_modified: 0, content_hash: None, size_bytes: None, phash_64: None, dhash_64: None, duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: Some("exif".to_string()), original_date_taken: None, original_date_taken_source: None, clip_embedding: None, clip_model_version: None, date_utc: Some(date_utc), camera_offset_minutes: Some(540), date_taken_tz_source: Some("offset_tag".to_string()), original_date_utc: None, original_camera_offset_minutes: None, original_date_taken_tz_source: None, }; let ny = chrono_tz::Tz::America__New_York; // The prompt/UI date is the camera wall: 2024-07-06 08:30 JST — // one day AFTER the owner's display-zone date (midnight rollover // is correct here, the AI must see what the camera saw). let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny)); assert_eq!(ai_date.as_deref(), Some("2024-07-06")); // The proximity windows (SMS / location / calendar) anchor on the // true instant — no +9 h, no -4 h, nothing. let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny)); assert_eq!(anchor, Some(date_utc)); } /// AC-6: an unconverted container video row — the AI context date is /// the display-zone rendering of the true instant (the raw UTC creation /// time), matching the metadata/UI date. No 4–5 h offset, no midnight /// rollover relative to the UTC-naive reading. #[test] fn unconverted_video_row_ai_date_matches_display_wall() { use chrono::NaiveDate; // The container's raw creation string is "2024-07-06 01:30:00" // (a true UTC instant). The old server parsed that naive string // as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must // be told the owner's evening — July 5 — not July 6 (the UTC // date the old code would have rendered). let legacy = NaiveDate::from_ymd_opt(2024, 7, 6) .unwrap() .and_hms_opt(5, 30, 0) .unwrap() .and_utc() .timestamp(); let row = Some(crate::database::models::ImageExif { id: 0, library_id: 1, file_path: "movie.mp4".to_string(), camera_make: None, camera_model: None, lens_model: None, width: None, height: None, orientation: None, gps_latitude: None, gps_longitude: None, gps_altitude: None, focal_length: None, aperture: None, shutter_speed: None, iso: None, date_taken: Some(legacy), created_time: 0, last_modified: 0, content_hash: None, size_bytes: None, phash_64: None, dhash_64: None, duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: Some("exiftool".to_string()), original_date_taken: None, original_date_taken_source: None, clip_embedding: None, clip_model_version: None, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, original_date_utc: None, original_camera_offset_minutes: None, original_date_taken_tz_source: None, }); let ny = chrono_tz::Tz::America__New_York; let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny)); assert_eq!(ai_date.as_deref(), Some("2024-07-05")); } }