diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 4f15ef4..a6a50f1 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -28,6 +28,11 @@ use crate::otel::global_tracer; use crate::tags::TagDao; use crate::utils::{earliest_fs_time, normalize_path}; +/// Max location records rendered by `tool_get_location_history`. The DAO +/// query is range-bounded, not limited, so the tool caps the rendered list +/// and labels the truncation via `found_header`. +const LOCATION_HISTORY_DISPLAY_LIMIT: usize = 20; + /// Parse a "Title: ...\n\n" response into (title, body). /// Falls back to the first sentence as the title if the model didn't /// follow the format. @@ -218,15 +223,11 @@ impl InsightGenerator { /// be called once per chat turn / generation. `has_vision` is /// supplied by the caller because it depends on the model selected /// for this turn, not on persistent state. - pub fn current_gate_opts(&self, has_vision: bool) -> ToolGateOpts { - self.current_gate_opts_for_persona(has_vision, None) - } - - /// Same as `current_gate_opts` but resolves the per-persona - /// `allow_agent_corrections` flag too. Pass `Some((user_id, - /// persona_id))` when generating in a persona context (every chat - /// turn does); pass `None` for callers that don't have one yet - /// (cold paths, populate_knowledge bin), which defaults the gate + /// + /// Also resolves the per-persona `allow_agent_corrections` flag. + /// Pass `Some((user_id, persona_id))` when generating in a persona + /// context (every chat turn and agentic generation does); pass + /// `None` for callers that don't have one, which defaults the gate /// to closed — the conservative posture. pub fn current_gate_opts_for_persona( &self, @@ -277,6 +278,22 @@ impl InsightGenerator { } } + /// Resolve the stored system prompt for `(user_id, persona_id)` from + /// the persona store. Returns `None` when the persona row doesn't + /// exist or its prompt is blank — callers fall back to their own + /// default. Used for server-side persona resolution: a request that + /// carries `persona_id` but no explicit `system_prompt` should speak + /// in the persona's stored voice, not the neutral default. + pub(crate) fn persona_system_prompt(&self, user_id: i32, persona_id: &str) -> Option { + let cx = opentelemetry::Context::new(); + let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao"); + pdao.get_persona(&cx, user_id, persona_id) + .ok() + .flatten() + .map(|p| p.system_prompt) + .filter(|s| !s.trim().is_empty()) + } + /// Resolve `rel_path` against the configured libraries, returning the /// first root under which the file exists. Insights may be generated /// for any library — the generator itself doesn't know which — so we @@ -1673,6 +1690,10 @@ Return ONLY the summary, nothing else."#, self.tool_recall_facts_for_photo(arguments, user_id, persona_id, cx) .await } + "recall_facts_for_entity" => { + self.tool_recall_facts_for_entity(arguments, user_id, persona_id, cx) + .await + } "store_entity" => self.tool_store_entity(arguments, cx).await, "store_fact" => { self.tool_store_fact( @@ -2145,8 +2166,8 @@ Return ONLY the summary, nothing else."#, }) .collect(); format!( - "Found {} messages:\n{}", - messages.len(), + "{}\n{}", + Self::found_header(messages.len(), formatted.len(), "messages"), formatted.join("\n") ) } @@ -2171,7 +2192,8 @@ Return ONLY the summary, nothing else."#, let days_radius = args .get("days_radius") .and_then(|v| v.as_i64()) - .unwrap_or(7); + .unwrap_or(7) + .clamp(1, 30); let limit = args .get("limit") .and_then(|v| v.as_i64()) @@ -2250,7 +2272,8 @@ Return ONLY the summary, nothing else."#, let days_radius = args .get("days_radius") .and_then(|v| v.as_i64()) - .unwrap_or(14); + .unwrap_or(14) + .clamp(1, 30); let date = match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { Ok(d) => d, @@ -2279,7 +2302,7 @@ Return ONLY the summary, nothing else."#, Some(locs) if !locs.is_empty() => { let formatted: Vec = locs .iter() - .take(20) + .take(LOCATION_HISTORY_DISPLAY_LIMIT) .map(|loc| { let dt = DateTime::from_timestamp(loc.timestamp, 0) .map(|dt| { @@ -2305,8 +2328,8 @@ Return ONLY the summary, nothing else."#, }) .collect(); format!( - "Found {} location records:\n{}", - locs.len(), + "{}\n{}", + Self::found_header(locs.len(), formatted.len(), "location records"), formatted.join("\n") ) } @@ -2315,6 +2338,20 @@ Return ONLY the summary, nothing else."#, } } + /// Render a `Found N :` tool-result header, annotating when only + /// the first `shown` of `total` items are listed below it. Without the + /// annotation the model believes it saw everything ("Found 312 + /// messages:" followed by 60 lines) and reasons from a silently + /// truncated list. `summarize_tool_result` keeps parsing the leading + /// count either way. + fn found_header(total: usize, shown: usize, noun: &str) -> String { + if shown < total { + format!("Found {} {} (showing first {}):", total, noun, shown) + } else { + format!("Found {} {}:", total, noun) + } + } + /// Tool: get_file_tags — fetch tags for a file path async fn tool_get_file_tags( &self, @@ -2669,6 +2706,102 @@ Return ONLY the summary, nothing else."#, } } + /// Tool: recall_facts_for_entity — retrieve facts for one entity by id. + /// Persona scoping and the reviewed-only-facts filter mirror + /// `tool_recall_facts_for_photo` exactly: reads are always Single + /// (user_id, persona_id), and strict-mode personas see only + /// human-reviewed facts. + async fn tool_recall_facts_for_entity( + &self, + args: &serde_json::Value, + user_id: i32, + persona_id: &str, + cx: &opentelemetry::Context, + ) -> String { + use crate::database::PersonaFilter; + let persona_filter = PersonaFilter::Single { + user_id, + persona_id: persona_id.to_string(), + }; + let entity_id = match args.get("entity_id").and_then(|v| v.as_i64()) { + Some(id) => id as i32, + None => return "Error: missing required parameter 'entity_id'".to_string(), + }; + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(50) + .clamp(1, 100) as usize; + + log::info!( + "tool_recall_facts_for_entity: entity_id={}, limit={}", + entity_id, + limit + ); + + // Resolve the persona's reviewed-only-mode flag once — identical + // fallback semantics to recall_facts_for_photo (missing persona + // row → permissive active+reviewed default). + let reviewed_only = { + let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao"); + pdao.get_persona(cx, user_id, persona_id) + .ok() + .flatten() + .map(|p| p.reviewed_only_facts) + .unwrap_or(false) + }; + + let mut kdao = self + .knowledge_dao + .lock() + .expect("Unable to lock KnowledgeDao"); + + let entity = match kdao.get_entity_by_id(cx, entity_id) { + Ok(Some(e)) => e, + Ok(None) => return format!("No entity found with ID {}.", entity_id), + Err(e) => return format!("Error fetching entity: {:?}", e), + }; + + let mut output_lines = vec![format!("Entity: {} ({})", entity.name, entity.entity_type)]; + match kdao.get_facts_for_entity(cx, entity_id, &persona_filter) { + Ok(facts) => { + // Default scope: active + reviewed. Strict mode trims to + // reviewed only — same allow rule as recall_facts_for_photo. + let allow = |s: &str| -> bool { + if reviewed_only { + s == "reviewed" + } else { + s == "active" || s == "reviewed" + } + }; + for f in facts.iter().filter(|f| allow(&f.status)).take(limit) { + let obj = if let Some(ref v) = f.object_value { + v.clone() + } else if let Some(oid) = f.object_entity_id { + kdao.get_entity_by_id(cx, oid) + .ok() + .flatten() + .map(|e| format!("{} (entity ID: {})", e.name, e.id)) + .unwrap_or_else(|| format!("entity:{}", oid)) + } else { + "(unknown)".to_string() + }; + output_lines.push(format!(" - {} {}", f.predicate, obj)); + } + } + Err(e) => return format!("Error fetching facts: {:?}", e), + } + + if output_lines.len() == 1 { + format!( + "No active knowledge facts found for entity {} (ID: {}).", + entity.name, entity_id + ) + } else { + format!("Knowledge for this entity:\n{}", output_lines.join("\n")) + } + } + /// Tool: store_entity — upsert an entity into the knowledge memory. /// Embeddings go through the configured local backend (`LLM_BACKEND`), /// independent of the per-request chat backend in the caller. @@ -3063,7 +3196,7 @@ Return ONLY the summary, nothing else."#, /// Build the list of tool definitions for the agentic loop, gated by /// `opts`. Always-on tools: `search_messages`, `get_sms_messages`, /// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the - /// four knowledge-memory tools. Conditional: `describe_photo` (vision + /// five knowledge-memory tools. Conditional: `describe_photo` (vision /// model), `get_personal_place_at` (Apollo configured), `search_rag` /// (daily_summaries populated), `get_calendar_events` (calendar /// populated), `get_location_history` (location history populated). @@ -3280,6 +3413,22 @@ Return ONLY the summary, nothing else."#, }), )); + tools.push(Tool::function( + "recall_facts_for_entity", + "Retrieve all stored facts about one specific entity by its ID. Use to follow up on an entity \ + surfaced by `recall_entities` (or referenced by another fact's object) that is NOT linked to the \ + current photo — `recall_facts_for_photo` only covers photo-linked entities. \ + Example: `{entity_id: 7}` — everything known about entity 7.", + serde_json::json!({ + "type": "object", + "required": ["entity_id"], + "properties": { + "entity_id": { "type": "integer", "description": "ID of the entity to fetch facts for (from recall_entities, store_entity, or a fact's object reference)." }, + "limit": { "type": "integer", "description": "Max facts to return (default 50, max 100)." } + } + }), + )); + tools.push(Tool::function( "store_entity", "Upsert a person / place / event / thing into the knowledge memory. Returns the entity id (use it as \ @@ -3543,7 +3692,7 @@ Return ONLY the summary, nothing else."#, format!("{} personal place(s)", n) } } - "recall_entities" | "recall_facts_for_photo" => { + "recall_entities" | "recall_facts_for_photo" | "recall_facts_for_entity" => { let n = raw.lines().skip(1).filter(|l| !l.trim().is_empty()).count(); let kind = if tool_name == "recall_entities" { "entities" @@ -4033,8 +4182,11 @@ Return ONLY the summary, nothing else."#, // 10. Define tools. describe_photo offered only when the chat model // sees images directly (images_inline); in hybrid mode the visual - // description is already inlined as text. - let gate_opts = self.current_gate_opts(backend.images_inline); + // description is already inlined as text. Persona-aware so the + // persona's allow_agent_corrections gate opens here exactly like + // it does for chat turns (insight_chat does the same). + let gate_opts = + self.current_gate_opts_for_persona(backend.images_inline, Some((user_id, &persona_id))); let tools = Self::build_tool_definitions(gate_opts); // 11. Build initial messages. images_inline → attach base64 to the @@ -4145,7 +4297,10 @@ Return ONLY the summary, nothing else."#, .set_attribute(KeyValue::new("iterations_used", iterations_used as i64)); loop_cx.span().set_status(Status::Ok); - // 13. Parse title from the model's inline response. + // 13. Strip any leaked reasoning block (thinking + // models emit it ahead of the answer; the raw transcript in + // training_messages keeps it), then parse the title. + final_content = crate::ai::llm_client::strip_think_blocks(&final_content); let (title, body) = parse_title_body(&final_content); final_content = body; @@ -4341,6 +4496,7 @@ mod tests { assert!(names.contains(&"get_current_datetime")); assert!(names.contains(&"recall_entities")); assert!(names.contains(&"recall_facts_for_photo")); + assert!(names.contains(&"recall_facts_for_entity")); assert!(names.contains(&"store_entity")); assert!(names.contains(&"store_fact")); @@ -4550,6 +4706,37 @@ mod tests { ); } + #[test] + fn found_header_labels_truncation_honestly() { + // Truncated: total exceeds what's listed below the header. + assert_eq!( + InsightGenerator::found_header(312, 60, "messages"), + "Found 312 messages (showing first 60):" + ); + // Not truncated: plain header, no annotation noise. + assert_eq!( + InsightGenerator::found_header(7, 7, "messages"), + "Found 7 messages:" + ); + assert_eq!( + InsightGenerator::found_header(40, 20, "location records"), + "Found 40 location records (showing first 20):" + ); + } + + #[test] + fn summarize_parses_truncated_found_header() { + // The "(showing first K)" annotation must not break the few-shot + // trajectory summarizer's "Found N" count parsing. + assert_eq!( + InsightGenerator::summarize_tool_result( + "get_sms_messages", + "Found 312 messages (showing first 60):\n[2023-08-15 10:00] Sarah: hi" + ), + "312 messages" + ); + } + fn make_search_hit( id: i64, contact: &str, @@ -4869,11 +5056,9 @@ mod tests { // Replicate the resolve_backend local-client construction // (lines ~3686-3695 of this file). let mut lc = base.clone(); - if let Some(ref m) = overrides_model { - if !is_hybrid { - lc.primary_model = m.clone(); - lc.set_vision_model(m.clone()); - } + if !is_hybrid && let Some(ref m) = overrides_model { + lc.primary_model = m.clone(); + lc.set_vision_model(m.clone()); } // In hybrid mode the local client must keep its configured slots.