From 66b62acfd5072ce8dbfb46b07c495d4180545e4a Mon Sep 17 00:00:00 2001 From: WKJBryan Date: Fri, 10 Jul 2026 13:56:49 +0800 Subject: [PATCH 01/30] refactor: convert canvas.rs to canvas/mod.rs (prep for split) Pure rename ahead of splitting canvas.rs into focused submodules. No content changes. --- frontend/src-tauri/src/commands/{canvas.rs => canvas/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/src-tauri/src/commands/{canvas.rs => canvas/mod.rs} (100%) diff --git a/frontend/src-tauri/src/commands/canvas.rs b/frontend/src-tauri/src/commands/canvas/mod.rs similarity index 100% rename from frontend/src-tauri/src/commands/canvas.rs rename to frontend/src-tauri/src/commands/canvas/mod.rs From 75cd4ebbd0f5f4147e600330a96940977aa07fd7 Mon Sep 17 00:00:00 2001 From: WKJBryan Date: Fri, 10 Jul 2026 14:01:28 +0800 Subject: [PATCH 02/30] refactor: extract canvas::shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move model-routing helpers (ModelRoute/ModelProviderRoute, resolve_model_route, effective_model_ids, is_vault_context_prompt, source_tile_context_provider) and append_canvas_trace into canvas::shared — used by streaming, debate, and session command handlers alike. Pure move; tests moved verbatim with their code. --- frontend/src-tauri/src/commands/canvas/mod.rs | 191 +---------------- .../src-tauri/src/commands/canvas/shared.rs | 197 ++++++++++++++++++ 2 files changed, 203 insertions(+), 185 deletions(-) create mode 100644 frontend/src-tauri/src/commands/canvas/shared.rs diff --git a/frontend/src-tauri/src/commands/canvas/mod.rs b/frontend/src-tauri/src/commands/canvas/mod.rs index a9fa36e..d365069 100644 --- a/frontend/src-tauri/src/commands/canvas/mod.rs +++ b/frontend/src-tauri/src/commands/canvas/mod.rs @@ -26,6 +26,12 @@ use std::time::Duration; use tauri::State; use tokio::sync::RwLock; +mod shared; +use shared::{ + append_canvas_trace, effective_model_ids, is_vault_context_prompt, resolve_model_route, + source_tile_context_provider, ModelProviderRoute, ModelRoute, +}; + const COMPACT_HISTORY_RECENT_TURNS: usize = 2; const COMPACT_HISTORY_EXCERPT_CHARS: usize = 240; const EMPTY_MODEL_RESPONSE_ERROR: &str = "No response returned from model"; @@ -77,18 +83,6 @@ struct ResolvedPromptContext { type StreamedResponseUpdate = (String, String, ResponseStatus, Option); -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelRoute { - provider: ModelProviderRoute, - model_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModelProviderRoute { - OpenRouter, - Ollama, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RetrievalDecisionReason { NoResults, @@ -2129,94 +2123,6 @@ fn prompt_request_from_tile( } } -fn resolve_model_route( - prompt_type: &PromptType, - context_mode: &ContextMode, - twin_provider_override: Option<&str>, - settings: &UserSettings, -) -> Result { - let twin_provider = twin_provider_override - .map(|provider| provider.trim().to_ascii_lowercase()) - .filter(|provider| provider == "ollama" || provider == "openrouter") - .unwrap_or_else(|| settings.twin_llm_provider.to_ascii_lowercase()); - - if is_vault_context_prompt(prompt_type, context_mode) && twin_provider == "ollama" { - let model = settings.ollama_model.trim(); - if model.is_empty() { - return Err( - "Select an Ollama model for local vault/twin responses before sending vault context" - .to_string(), - ); - } - - return Ok(ModelRoute { - provider: ModelProviderRoute::Ollama, - model_ids: vec![model.to_string()], - }); - } - - Ok(ModelRoute { - provider: ModelProviderRoute::OpenRouter, - model_ids: Vec::new(), - }) -} - -fn source_tile_context_provider( - session: &CanvasSession, - source_tile_ids: &[String], -) -> (bool, Option) { - for tile_id in source_tile_ids { - if let Some(tile) = session.prompt_tiles.iter().find(|tile| &tile.id == tile_id) { - if is_vault_context_prompt(&tile.prompt_type, &tile.context_mode) { - return (true, tile.twin_llm_provider.clone()); - } - } - } - - (false, None) -} - -fn is_vault_context_prompt(prompt_type: &PromptType, context_mode: &ContextMode) -> bool { - prompt_type == &PromptType::Decision - || matches!( - context_mode, - ContextMode::KnowledgeSearch - | ContextMode::Semantic - | ContextMode::Twin - | ContextMode::FullHistory - | ContextMode::Compact - ) -} - -fn effective_model_ids(route: &ModelRoute, requested_model_ids: &[String]) -> Vec { - match route.provider { - ModelProviderRoute::Ollama => { - if requested_model_ids.is_empty() { - route.model_ids.clone() - } else { - requested_model_ids.to_vec() - } - } - ModelProviderRoute::OpenRouter => requested_model_ids.to_vec(), - } -} - -async fn append_canvas_trace( - twin_store_arc: Arc>, - session_id: &str, - event_type: TraceEventType, - payload: serde_json::Value, -) { - let mut twin_store = twin_store_arc.write().await; - if let Err(error) = twin_store.append_trace_event(session_id, event_type, payload) { - log::error!( - "Failed to append twin trace for session '{}': {}", - session_id, - error - ); - } -} - async fn append_model_result_traces( twin_store_arc: Arc>, session_id: &str, @@ -4635,91 +4541,6 @@ mod tests { assert_eq!(request.twin_llm_provider.as_deref(), Some("ollama")); } - #[test] - fn model_route_uses_ollama_for_all_vault_context_when_configured() { - let mut settings = crate::models::settings::UserSettings::default(); - settings.twin_llm_provider = "ollama".to_string(); - settings.ollama_model = "llama3.1:8b".to_string(); - - let decision_route = resolve_model_route( - &PromptType::Decision, - &ContextMode::KnowledgeSearch, - None, - &settings, - ) - .unwrap(); - let twin_route = - resolve_model_route(&PromptType::Standard, &ContextMode::Twin, None, &settings) - .unwrap(); - let normal_route = - resolve_model_route(&PromptType::Standard, &ContextMode::None, None, &settings) - .unwrap(); - - assert_eq!(decision_route.provider, ModelProviderRoute::Ollama); - assert_eq!(decision_route.model_ids, vec!["llama3.1:8b".to_string()]); - assert_eq!(twin_route.provider, ModelProviderRoute::Ollama); - assert_eq!(normal_route.provider, ModelProviderRoute::OpenRouter); - assert!(normal_route.model_ids.is_empty()); - } - - #[test] - fn effective_model_ids_honors_requested_ollama_models() { - let route = ModelRoute { - provider: ModelProviderRoute::Ollama, - model_ids: vec!["llama3.1:8b".to_string()], - }; - - let effective = effective_model_ids(&route, &["qwen3:14b".to_string()]); - - assert_eq!(effective, vec!["qwen3:14b".to_string()]); - } - - #[test] - fn effective_model_ids_falls_back_to_configured_ollama_model() { - let route = ModelRoute { - provider: ModelProviderRoute::Ollama, - model_ids: vec!["llama3.1:8b".to_string()], - }; - - let effective = effective_model_ids(&route, &[]); - - assert_eq!(effective, vec!["llama3.1:8b".to_string()]); - } - - #[test] - fn model_route_allows_openrouter_for_vault_context_override() { - let mut settings = crate::models::settings::UserSettings::default(); - settings.twin_llm_provider = "ollama".to_string(); - settings.ollama_model = "llama3.1:8b".to_string(); - - let route = resolve_model_route( - &PromptType::Decision, - &ContextMode::Twin, - Some("openrouter"), - &settings, - ) - .unwrap(); - - assert_eq!(route.provider, ModelProviderRoute::OpenRouter); - assert!(route.model_ids.is_empty()); - } - - #[test] - fn model_route_fails_closed_when_local_twin_model_is_missing() { - let mut settings = crate::models::settings::UserSettings::default(); - settings.twin_llm_provider = "ollama".to_string(); - - let error = resolve_model_route( - &PromptType::Decision, - &ContextMode::KnowledgeSearch, - None, - &settings, - ) - .unwrap_err(); - - assert!(error.contains("Select an Ollama model")); - } - #[test] fn test_classify_streamed_model_response_rejects_empty_content() { let update = diff --git a/frontend/src-tauri/src/commands/canvas/shared.rs b/frontend/src-tauri/src/commands/canvas/shared.rs new file mode 100644 index 0000000..da05237 --- /dev/null +++ b/frontend/src-tauri/src/commands/canvas/shared.rs @@ -0,0 +1,197 @@ +use crate::models::canvas::{CanvasSession, ContextMode, PromptType}; +use crate::models::settings::UserSettings; +use crate::models::twin::TraceEventType; +use crate::services::twin_store::TwinStore; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ModelRoute { + pub(super) provider: ModelProviderRoute, + pub(super) model_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ModelProviderRoute { + OpenRouter, + Ollama, +} + +pub(super) fn resolve_model_route( + prompt_type: &PromptType, + context_mode: &ContextMode, + twin_provider_override: Option<&str>, + settings: &UserSettings, +) -> Result { + let twin_provider = twin_provider_override + .map(|provider| provider.trim().to_ascii_lowercase()) + .filter(|provider| provider == "ollama" || provider == "openrouter") + .unwrap_or_else(|| settings.twin_llm_provider.to_ascii_lowercase()); + + if is_vault_context_prompt(prompt_type, context_mode) && twin_provider == "ollama" { + let model = settings.ollama_model.trim(); + if model.is_empty() { + return Err( + "Select an Ollama model for local vault/twin responses before sending vault context" + .to_string(), + ); + } + + return Ok(ModelRoute { + provider: ModelProviderRoute::Ollama, + model_ids: vec![model.to_string()], + }); + } + + Ok(ModelRoute { + provider: ModelProviderRoute::OpenRouter, + model_ids: Vec::new(), + }) +} + +pub(super) fn source_tile_context_provider( + session: &CanvasSession, + source_tile_ids: &[String], +) -> (bool, Option) { + for tile_id in source_tile_ids { + if let Some(tile) = session.prompt_tiles.iter().find(|tile| &tile.id == tile_id) { + if is_vault_context_prompt(&tile.prompt_type, &tile.context_mode) { + return (true, tile.twin_llm_provider.clone()); + } + } + } + + (false, None) +} + +pub(super) fn is_vault_context_prompt(prompt_type: &PromptType, context_mode: &ContextMode) -> bool { + prompt_type == &PromptType::Decision + || matches!( + context_mode, + ContextMode::KnowledgeSearch + | ContextMode::Semantic + | ContextMode::Twin + | ContextMode::FullHistory + | ContextMode::Compact + ) +} + +pub(super) fn effective_model_ids(route: &ModelRoute, requested_model_ids: &[String]) -> Vec { + match route.provider { + ModelProviderRoute::Ollama => { + if requested_model_ids.is_empty() { + route.model_ids.clone() + } else { + requested_model_ids.to_vec() + } + } + ModelProviderRoute::OpenRouter => requested_model_ids.to_vec(), + } +} + +pub(super) async fn append_canvas_trace( + twin_store_arc: Arc>, + session_id: &str, + event_type: TraceEventType, + payload: serde_json::Value, +) { + let mut twin_store = twin_store_arc.write().await; + if let Err(error) = twin_store.append_trace_event(session_id, event_type, payload) { + log::error!( + "Failed to append twin trace for session '{}': {}", + session_id, + error + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_route_uses_ollama_for_all_vault_context_when_configured() { + let mut settings = crate::models::settings::UserSettings::default(); + settings.twin_llm_provider = "ollama".to_string(); + settings.ollama_model = "llama3.1:8b".to_string(); + + let decision_route = resolve_model_route( + &PromptType::Decision, + &ContextMode::KnowledgeSearch, + None, + &settings, + ) + .unwrap(); + let twin_route = + resolve_model_route(&PromptType::Standard, &ContextMode::Twin, None, &settings) + .unwrap(); + let normal_route = + resolve_model_route(&PromptType::Standard, &ContextMode::None, None, &settings) + .unwrap(); + + assert_eq!(decision_route.provider, ModelProviderRoute::Ollama); + assert_eq!(decision_route.model_ids, vec!["llama3.1:8b".to_string()]); + assert_eq!(twin_route.provider, ModelProviderRoute::Ollama); + assert_eq!(normal_route.provider, ModelProviderRoute::OpenRouter); + assert!(normal_route.model_ids.is_empty()); + } + + #[test] + fn effective_model_ids_honors_requested_ollama_models() { + let route = ModelRoute { + provider: ModelProviderRoute::Ollama, + model_ids: vec!["llama3.1:8b".to_string()], + }; + + let effective = effective_model_ids(&route, &["qwen3:14b".to_string()]); + + assert_eq!(effective, vec!["qwen3:14b".to_string()]); + } + + #[test] + fn effective_model_ids_falls_back_to_configured_ollama_model() { + let route = ModelRoute { + provider: ModelProviderRoute::Ollama, + model_ids: vec!["llama3.1:8b".to_string()], + }; + + let effective = effective_model_ids(&route, &[]); + + assert_eq!(effective, vec!["llama3.1:8b".to_string()]); + } + + #[test] + fn model_route_allows_openrouter_for_vault_context_override() { + let mut settings = crate::models::settings::UserSettings::default(); + settings.twin_llm_provider = "ollama".to_string(); + settings.ollama_model = "llama3.1:8b".to_string(); + + let route = resolve_model_route( + &PromptType::Decision, + &ContextMode::Twin, + Some("openrouter"), + &settings, + ) + .unwrap(); + + assert_eq!(route.provider, ModelProviderRoute::OpenRouter); + assert!(route.model_ids.is_empty()); + } + + #[test] + fn model_route_fails_closed_when_local_twin_model_is_missing() { + let mut settings = crate::models::settings::UserSettings::default(); + settings.twin_llm_provider = "ollama".to_string(); + + let error = resolve_model_route( + &PromptType::Decision, + &ContextMode::KnowledgeSearch, + None, + &settings, + ) + .unwrap_err(); + + assert!(error.contains("Select an Ollama model")); + } +} From 8458c89765a14e5b8060be10489fda92b5086cac Mon Sep 17 00:00:00 2001 From: WKJBryan Date: Fri, 10 Jul 2026 14:21:59 +0800 Subject: [PATCH 03/30] refactor: extract canvas::context Move twin/context prompt-building (build_note_context_prompt, build_chunk_context_prompt, build_twin_context_prompt, resolve_twin_prompt_context, the twin Simulation identity gate, and the conversation-history reconstruction helpers) into canvas::context. Pure move; the twin gate logic and its tests move verbatim. Shared test builders (build_response/build_tile) used by both context's and streaming's test suites are factored into a small #[cfg(test)] canvas::test_support module rather than duplicated. --- .../src-tauri/src/commands/canvas/context.rs | 2279 ++++++++++++++++ frontend/src-tauri/src/commands/canvas/mod.rs | 2424 +---------------- .../src-tauri/src/commands/canvas/shared.rs | 11 +- .../src/commands/canvas/test_support.rs | 61 + 4 files changed, 2410 insertions(+), 2365 deletions(-) create mode 100644 frontend/src-tauri/src/commands/canvas/context.rs create mode 100644 frontend/src-tauri/src/commands/canvas/test_support.rs diff --git a/frontend/src-tauri/src/commands/canvas/context.rs b/frontend/src-tauri/src/commands/canvas/context.rs new file mode 100644 index 0000000..04b104b --- /dev/null +++ b/frontend/src-tauri/src/commands/canvas/context.rs @@ -0,0 +1,2279 @@ +use super::shared::ModelProviderRoute; +use crate::commands::run_retrieval; +use crate::models::canvas::{ + CanvasSession, ContextMode, DecisionPromptMetadata, PromptRequest, PromptType, TileContextNote, + TwinAnswerMode, +}; +use crate::models::note::ChunkResult; +use crate::models::twin::{ + ActionGap, ConstitutionItem, ConstitutionSetup, DecisionEpisode, TwinContextRecord, +}; +use crate::services::ollama::OllamaService; +use crate::services::openrouter::{ChatMessage, OpenRouterService}; +use crate::services::retrieval::RetrievalResult; +use crate::services::twin_store::{parse_twin_prediction, TwinStore}; +use crate::AppState; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; + +const COMPACT_HISTORY_RECENT_TURNS: usize = 2; +const COMPACT_HISTORY_EXCERPT_CHARS: usize = 240; +const MIN_RETRIEVAL_SCORE_FOR_NOTES: f32 = 5.0; +const MIN_CANVAS_QUERY_TOKEN_LEN: usize = 3; +const CANVAS_RETRIEVAL_STOPWORDS: &[&str] = &[ + "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for", "from", "how", "i", + "if", "in", "into", "is", "it", "its", "like", "make", "more", "my", "not", "of", "on", "or", + "our", "real", "so", "that", "the", "their", "them", "there", "these", "they", "this", "to", + "up", "use", "want", "was", "we", "what", "when", "where", "which", "who", "why", "with", + "works", "would", "you", "your", +]; + +// Twin context assembly +pub(super) const TWIN_CONTEXT_VERSION: &str = "ctx-v2-cases-lexical"; +const TWIN_CONTEXT_TOKEN_BUDGET: usize = 4000; +const MAX_TWIN_CASE_CONTEXT: usize = 5; +const TWIN_CASE_FIELD_MAX_CHARS: usize = 800; +const TWIN_CASE_CORRECTION_MAX_CHARS: usize = 500; + +#[derive(Debug, Clone)] +struct ConversationTurn { + prompt: String, + response: String, + model_id: String, +} + +#[derive(Debug, Clone)] +pub(super) struct ResolvedPromptContext { + pub(super) messages: Vec, + pub(super) context_notes: Vec, + pub(super) approved_twin_records: Vec, + pub(super) candidate_twin_records: Vec, + pub(super) constitution_items: Vec, + pub(super) action_gaps: Vec, + pub(super) system_prompt: Option, + /// Raw twin system prompt before any user system prompt is merged in; + /// reused verbatim by the sealed-prediction call. + pub(super) twin_context_prompt: Option, + pub(super) context_version: Option, + pub(super) decision_case_ids: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RetrievalDecisionReason { + NoResults, + WeakTopScore, + NoKeywordMatch, + NoSnippet, + NoLexicalOverlap, + UseRetrievedNotes, +} + +fn truncate_note_context_content(content: &str, max_chars: usize) -> String { + let mut truncated = String::new(); + let mut chars = content.chars(); + for _ in 0..max_chars { + match chars.next() { + Some(ch) => truncated.push(ch), + None => return truncated, + } + } + + if chars.next().is_some() { + truncated.push_str("..."); + } + + truncated +} + +fn normalize_canvas_query_tokens(text: &str) -> Vec { + let normalized: String = text + .chars() + .map(|ch| { + if ch.is_alphanumeric() { + ch.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect(); + + normalized + .split_whitespace() + .filter(|token| token.len() >= MIN_CANVAS_QUERY_TOKEN_LEN) + .filter(|token| !CANVAS_RETRIEVAL_STOPWORDS.contains(token)) + .map(str::to_string) + .collect() +} + +fn matching_canvas_query_tokens( + query_tokens: &[String], + title: &str, + snippet: &str, +) -> HashSet { + let candidate_tokens: HashSet = + normalize_canvas_query_tokens(&format!("{} {}", title, snippet)) + .into_iter() + .collect(); + + query_tokens + .iter() + .filter(|token| candidate_tokens.contains(token.as_str())) + .cloned() + .collect() +} + +fn should_use_retrieved_notes( + prompt: &str, + retrieval_results: &[RetrievalResult], +) -> RetrievalDecisionReason { + let Some(top_result) = retrieval_results.first() else { + return RetrievalDecisionReason::NoResults; + }; + + if top_result.score < MIN_RETRIEVAL_SCORE_FOR_NOTES { + return RetrievalDecisionReason::WeakTopScore; + } + + if !top_result + .relevance_reasons + .iter() + .any(|reason| reason == "keyword match") + { + return RetrievalDecisionReason::NoKeywordMatch; + } + + let query_tokens = normalize_canvas_query_tokens(prompt); + + let mut saw_snippet = false; + for result in retrieval_results { + if result.snippet.trim().is_empty() { + continue; + } + + saw_snippet = true; + let matched_tokens = + matching_canvas_query_tokens(&query_tokens, &result.note.title, &result.snippet); + let has_long_match = matched_tokens.iter().any(|token| token.len() >= 6); + + if matched_tokens.len() >= 2 || (has_long_match && !matched_tokens.is_empty()) { + return RetrievalDecisionReason::UseRetrievedNotes; + } + } + + if !saw_snippet { + RetrievalDecisionReason::NoSnippet + } else { + RetrievalDecisionReason::NoLexicalOverlap + } +} + +pub(super) async fn resolve_prompt_context( + state: &AppState, + session: &CanvasSession, + request: &PromptRequest, +) -> Result { + let messages = build_canvas_messages(session, request)?; + + if request.context_mode == ContextMode::Twin { + return resolve_twin_prompt_context(state, messages, session, request).await; + } + + if matches!( + request.context_mode, + ContextMode::KnowledgeSearch | ContextMode::Semantic + ) { + let pinned_ids = session.pinned_note_ids.clone(); + + // Quality gate: note-level retrieval to check if vault has relevant content + let retrieval_results = run_retrieval(state, &request.prompt, 5, &pinned_ids) + .await + .unwrap_or_default(); + + let retrieval_decision = should_use_retrieved_notes(&request.prompt, &retrieval_results); + if retrieval_decision != RetrievalDecisionReason::UseRetrievedNotes { + log::info!( + "Canvas knowledge search fallback for prompt {:?}: {:?}", + request.prompt, + retrieval_decision + ); + return Ok(ResolvedPromptContext { + messages, + context_notes: Vec::new(), + approved_twin_records: Vec::new(), + candidate_twin_records: Vec::new(), + constitution_items: Vec::new(), + action_gaps: Vec::new(), + system_prompt: request.system_prompt.clone(), + twin_context_prompt: None, + context_version: None, + decision_case_ids: Vec::new(), + }); + } + + // Check if chunk-level retrieval is enabled + let chunk_enabled = { + let retrieval = state.retrieval_service.read().await; + retrieval.get_config().chunk_retrieval_enabled + }; + + if chunk_enabled { + // Chunk-level context: retrieve relevant paragraphs within token budget + let chunks = { + let retrieval = state.retrieval_service.read().await; + let chunk_index = state.chunk_index.read().await; + let graph = state.graph_index.read().await; + let priority = state.priority_service.read().await; + let token_budget = retrieval.get_config().default_token_budget; + retrieval + .retrieve_chunks( + &chunk_index, + &graph, + &priority, + &request.prompt, + token_budget, + &pinned_ids, + ) + .unwrap_or_default() + }; + + if chunks.is_empty() { + log::info!("Chunk retrieval returned no results, falling back to note-level"); + return resolve_note_level_context( + state, + messages, + &retrieval_results, + &pinned_ids, + &request.system_prompt, + ) + .await; + } + + let total_tokens: usize = chunks.iter().map(|c| c.token_estimate).sum(); + let parent_count = chunks + .iter() + .map(|c| &c.parent_note_id) + .collect::>() + .len(); + log::info!( + "Canvas using chunk retrieval: {} chunks from {} notes (~{} tokens)", + chunks.len(), + parent_count, + total_tokens + ); + + // Build context notes from chunk parent notes (deduped) + let mut seen_notes: HashSet = HashSet::new(); + let context_notes: Vec = chunks + .iter() + .filter_map(|c| { + if seen_notes.insert(c.parent_note_id.clone()) { + Some(TileContextNote { + id: c.parent_note_id.clone(), + title: c.parent_title.clone(), + snippet: truncate_note_context_content(&c.text, 200), + score: c.search_score, + pinned: pinned_ids.contains(&c.parent_note_id), + }) + } else { + None + } + }) + .collect(); + + let note_prompt = build_chunk_context_prompt(&chunks); + let system_prompt = match &request.system_prompt { + Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", note_prompt, user_sp), + _ => note_prompt, + }; + + Ok(ResolvedPromptContext { + messages, + context_notes, + approved_twin_records: Vec::new(), + candidate_twin_records: Vec::new(), + constitution_items: Vec::new(), + action_gaps: Vec::new(), + system_prompt: Some(system_prompt), + twin_context_prompt: None, + context_version: None, + decision_case_ids: Vec::new(), + }) + } else { + log::info!("Canvas using note-level context (chunk retrieval disabled)"); + resolve_note_level_context( + state, + messages, + &retrieval_results, + &pinned_ids, + &request.system_prompt, + ) + .await + } + } else { + Ok(ResolvedPromptContext { + messages, + context_notes: Vec::new(), + approved_twin_records: Vec::new(), + candidate_twin_records: Vec::new(), + constitution_items: Vec::new(), + action_gaps: Vec::new(), + system_prompt: request.system_prompt.clone(), + twin_context_prompt: None, + context_version: None, + decision_case_ids: Vec::new(), + }) + } +} + +async fn resolve_twin_prompt_context( + state: &AppState, + messages: Vec, + session: &CanvasSession, + request: &PromptRequest, +) -> Result { + let pinned_ids = session.pinned_note_ids.clone(); + let retrieval_results = run_retrieval(state, &request.prompt, 5, &pinned_ids) + .await + .unwrap_or_default(); + + let should_use_notes = should_use_retrieved_notes(&request.prompt, &retrieval_results) + == RetrievalDecisionReason::UseRetrievedNotes; + let mut context_notes = Vec::new(); + let mut note_contexts = Vec::new(); + + if should_use_notes { + let chunk_enabled = { + let retrieval = state.retrieval_service.read().await; + retrieval.get_config().chunk_retrieval_enabled + }; + + if chunk_enabled { + let chunks = { + let retrieval = state.retrieval_service.read().await; + let chunk_index = state.chunk_index.read().await; + let graph = state.graph_index.read().await; + let priority = state.priority_service.read().await; + let token_budget = retrieval.get_config().default_token_budget; + retrieval + .retrieve_chunks( + &chunk_index, + &graph, + &priority, + &request.prompt, + token_budget, + &pinned_ids, + ) + .unwrap_or_default() + }; + + if !chunks.is_empty() { + let mut seen_notes: HashSet = HashSet::new(); + for chunk in &chunks { + note_contexts.push(( + chunk.parent_note_id.clone(), + chunk.parent_title.clone(), + chunk.text.clone(), + )); + if seen_notes.insert(chunk.parent_note_id.clone()) { + context_notes.push(TileContextNote { + id: chunk.parent_note_id.clone(), + title: chunk.parent_title.clone(), + snippet: truncate_note_context_content(&chunk.text, 200), + score: chunk.search_score, + pinned: pinned_ids.contains(&chunk.parent_note_id), + }); + } + } + } + } + + if note_contexts.is_empty() { + note_contexts = fetch_note_contexts(state, &retrieval_results).await; + let found_ids: HashSet<&str> = + note_contexts.iter().map(|(id, _, _)| id.as_str()).collect(); + for r in &retrieval_results { + if found_ids.contains(r.note.id.as_str()) { + context_notes.push(TileContextNote { + id: r.note.id.clone(), + title: r.note.title.clone(), + snippet: r.snippet.clone(), + score: r.score, + pinned: pinned_ids.contains(&r.note.id), + }); + } + } + } + } + + let constitution_query = + decision_context_query(&request.prompt, request.decision_metadata.as_ref()); + let ( + setup, + approved_twin_records, + candidate_twin_records, + constitution_items, + action_gaps, + decision_cases, + ) = { + let mut twin_store = state.twin_store.write().await; + let setup = twin_store + .get_constitution_setup() + .map_err(|error| error.to_string())?; + validate_twin_identity_for_answer_mode(&setup, &request.twin_answer_mode)?; + let (approved, candidate) = twin_store + .select_context_records(&request.prompt) + .map_err(|error| error.to_string())?; + let (constitution_items, action_gaps) = twin_store + .select_constitution_context(&constitution_query) + .map_err(|error| error.to_string())?; + let decision_cases = twin_store + .select_decision_cases(&constitution_query, None, MAX_TWIN_CASE_CONTEXT) + .map_err(|error| error.to_string())?; + ( + setup, + approved, + candidate, + constitution_items, + action_gaps, + decision_cases, + ) + }; + + let selection = apply_twin_context_budget( + decision_cases, + constitution_items, + approved_twin_records, + candidate_twin_records, + action_gaps, + note_contexts, + TWIN_CONTEXT_TOKEN_BUDGET, + ); + let decision_case_ids = selection + .cases + .iter() + .map(|episode| episode.id.clone()) + .collect::>(); + + let twin_prompt = build_twin_context_prompt( + &setup, + &selection.cases, + &selection.notes, + &selection.approved, + &selection.candidates, + &selection.constitution_items, + &selection.action_gaps, + &request.twin_answer_mode, + &request.prompt_type, + request.decision_metadata.as_ref(), + ); + let system_prompt = match &request.system_prompt { + Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", twin_prompt, user_sp), + _ => twin_prompt.clone(), + }; + + Ok(ResolvedPromptContext { + messages, + context_notes, + approved_twin_records: selection.approved, + candidate_twin_records: selection.candidates, + constitution_items: selection.constitution_items, + action_gaps: selection.action_gaps, + system_prompt: Some(system_prompt), + twin_context_prompt: Some(twin_prompt), + context_version: Some(TWIN_CONTEXT_VERSION.to_string()), + decision_case_ids, + }) +} + +/// Fetch full note content for retrieval results, returning (id, title, truncated_content) tuples. +/// Skips notes that can't be read from the store. Called by both the semantic and twin note-level paths. +async fn fetch_note_contexts( + state: &AppState, + results: &[RetrievalResult], +) -> Vec<(String, String, String)> { + let store = state.knowledge_store.read().await; + results + .iter() + .filter_map(|r| { + store.get_note(&r.note.id).ok().map(|note| { + let truncated = truncate_note_context_content(¬e.content, 1500); + (note.id.clone(), note.title.clone(), truncated) + }) + }) + .collect() +} + +/// Fall back to note-level context when chunk retrieval is disabled or returns nothing. +async fn resolve_note_level_context( + state: &AppState, + messages: Vec, + retrieval_results: &[RetrievalResult], + pinned_ids: &[String], + user_system_prompt: &Option, +) -> Result { + let note_contexts = fetch_note_contexts(state, retrieval_results).await; + + let context_notes: Vec = retrieval_results + .iter() + .map(|r| TileContextNote { + id: r.note.id.clone(), + title: r.note.title.clone(), + snippet: r.snippet.clone(), + score: r.score, + pinned: pinned_ids.contains(&r.note.id), + }) + .collect(); + + let note_prompt = build_note_context_prompt(¬e_contexts); + let system_prompt = match user_system_prompt { + Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", note_prompt, user_sp), + _ => note_prompt, + }; + + Ok(ResolvedPromptContext { + messages, + context_notes, + approved_twin_records: Vec::new(), + candidate_twin_records: Vec::new(), + constitution_items: Vec::new(), + action_gaps: Vec::new(), + system_prompt: Some(system_prompt), + twin_context_prompt: None, + context_version: None, + decision_case_ids: Vec::new(), + }) +} + +fn build_canvas_messages( + session: &CanvasSession, + request: &PromptRequest, +) -> Result, String> { + match request.context_mode { + ContextMode::FullHistory => build_full_history_messages(session, request), + ContextMode::Compact => build_compact_history_messages(session, request), + _ => Ok(vec![ChatMessage { + role: "user".to_string(), + content: request.prompt.clone(), + }]), + } +} + +fn build_full_history_messages( + session: &CanvasSession, + request: &PromptRequest, +) -> Result, String> { + let turns = build_selected_parent_chain(session, request)?; + let mut messages = Vec::with_capacity((turns.len() * 2) + 1); + + for turn in turns { + messages.push(ChatMessage { + role: "user".to_string(), + content: turn.prompt, + }); + messages.push(ChatMessage { + role: "assistant".to_string(), + content: turn.response, + }); + } + + messages.push(ChatMessage { + role: "user".to_string(), + content: request.prompt.clone(), + }); + + Ok(messages) +} + +fn build_compact_history_messages( + session: &CanvasSession, + request: &PromptRequest, +) -> Result, String> { + let turns = build_selected_parent_chain(session, request)?; + let mut messages = Vec::new(); + + if turns.len() > COMPACT_HISTORY_RECENT_TURNS { + let split_at = turns.len() - COMPACT_HISTORY_RECENT_TURNS; + let summary = build_compact_history_summary(&turns[..split_at]); + messages.push(ChatMessage { + role: "user".to_string(), + content: summary, + }); + + for turn in &turns[split_at..] { + messages.push(ChatMessage { + role: "user".to_string(), + content: turn.prompt.clone(), + }); + messages.push(ChatMessage { + role: "assistant".to_string(), + content: turn.response.clone(), + }); + } + } else { + for turn in turns { + messages.push(ChatMessage { + role: "user".to_string(), + content: turn.prompt, + }); + messages.push(ChatMessage { + role: "assistant".to_string(), + content: turn.response, + }); + } + } + + messages.push(ChatMessage { + role: "user".to_string(), + content: request.prompt.clone(), + }); + + Ok(messages) +} + +fn build_selected_parent_chain( + session: &CanvasSession, + request: &PromptRequest, +) -> Result, String> { + let mut tile_id = request + .parent_tile_id + .clone() + .ok_or_else(|| "Context mode requires a parent tile".to_string())?; + let mut model_id = request + .parent_model_id + .clone() + .ok_or_else(|| "Context mode requires a parent model".to_string())?; + let mut visited = HashSet::new(); + let mut turns = Vec::new(); + + loop { + let visit_key = format!("{}::{}", tile_id, model_id); + if !visited.insert(visit_key) { + return Err("Detected a cycle while reconstructing canvas history".to_string()); + } + + let tile = session + .prompt_tiles + .iter() + .find(|t| t.id == tile_id) + .ok_or_else(|| format!("Parent tile not found in session: {}", tile_id))?; + let response = tile.responses.get(&model_id).ok_or_else(|| { + format!( + "Parent response not found for tile {} and model {}", + tile_id, model_id + ) + })?; + + turns.push(ConversationTurn { + prompt: tile.prompt.clone(), + response: response.content.clone(), + model_id: model_id.clone(), + }); + + match (&tile.parent_tile_id, &tile.parent_model_id) { + (Some(next_tile_id), Some(next_model_id)) => { + tile_id = next_tile_id.clone(); + model_id = next_model_id.clone(); + } + (None, None) => break, + _ => { + return Err(format!( + "Incomplete parent linkage for tile {} while reconstructing history", + tile.id + )) + } + } + } + + turns.reverse(); + Ok(turns) +} + +fn build_compact_history_summary(turns: &[ConversationTurn]) -> String { + let mut summary = String::from("Conversation summary before the most recent turns:\n"); + + for (index, turn) in turns.iter().enumerate() { + summary.push_str(&format!( + "\nTurn {}:\nUser: {}\nAssistant ({}): {}\n", + index + 1, + truncate_for_compact_history(&turn.prompt), + turn.model_id, + truncate_for_compact_history(&turn.response), + )); + } + + summary +} + +fn truncate_for_compact_history(content: &str) -> String { + if content.chars().count() <= COMPACT_HISTORY_EXCERPT_CHARS { + return content.to_string(); + } + + let mut truncated = content + .chars() + .take(COMPACT_HISTORY_EXCERPT_CHARS) + .collect::(); + truncated.push_str("..."); + truncated +} + +/// Build a system prompt that includes retrieved note context. +/// Used by send_prompt when context_mode is Semantic. +fn build_chunk_context_prompt(chunks: &[ChunkResult]) -> String { + let mut prompt = String::from( + "You are a helpful knowledge assistant for the user's personal note-taking system (Grafyn). \ + Answer questions using the context from the user's notes below. \ + Reference specific notes by title when citing information. \ + If the notes don't contain relevant information, say so honestly.\n\n", + ); + + if chunks.is_empty() { + prompt.push_str("No relevant notes were found for this query.\n"); + return prompt; + } + + // Group chunks by parent note, preserving insertion order + let mut note_order: Vec = Vec::new(); + let mut note_map: HashMap)> = HashMap::new(); + + for chunk in chunks { + let entry = note_map + .entry(chunk.parent_note_id.clone()) + .or_insert_with(|| { + note_order.push(chunk.parent_note_id.clone()); + (chunk.parent_title.clone(), Vec::new()) + }); + entry.1.push(&chunk.text); + } + + prompt.push_str("## Relevant Notes\n\n"); + for note_id in ¬e_order { + if let Some((title, texts)) = note_map.get(note_id) { + prompt.push_str(&format!("### {} (id: {})\n", title, note_id)); + for text in texts { + prompt.push_str(text); + prompt.push_str("\n\n"); + } + } + } + + prompt +} + +fn build_note_context_prompt(notes: &[(String, String, String)]) -> String { + let mut prompt = String::from( + "You are a helpful knowledge assistant for the user's personal note-taking system (Grafyn). \ + Answer questions using the context from the user's notes below. \ + Reference specific notes by title when citing information. \ + If the notes don't contain relevant information, say so honestly.\n\n", + ); + + if notes.is_empty() { + prompt.push_str("No relevant notes were found for this query.\n"); + } else { + prompt.push_str("## Relevant Notes\n\n"); + for (id, title, content) in notes { + prompt.push_str(&format!("### {} (id: {})\n{}\n\n", title, id, content)); + } + } + + prompt +} + +/// Rough token estimate matching the chunk-index convention (words * 4/3). +fn estimate_tokens(text: &str) -> usize { + text.split_whitespace().count() * 4 / 3 + 1 +} + +/// Render one past decision episode as a verbatim behavioral case. +fn format_decision_case(episode: &DecisionEpisode) -> String { + let mut case = format!("### Past decision: {}\n", episode.decision.trim()); + if !episode.options.is_empty() { + case.push_str(&format!("- Options: {}\n", episode.options.join(" | "))); + } + if let Some(chosen) = episode.chosen_option.as_deref() { + case.push_str(&format!("- Chose: {}\n", chosen.trim())); + } + if let Some(leaning) = episode.initial_leaning.as_deref() { + if !leaning.trim().is_empty() { + case.push_str(&format!("- Initial leaning: {}\n", leaning.trim())); + } + } + if let Some(outcome) = episode.outcome.as_deref() { + if !outcome.trim().is_empty() { + case.push_str(&format!( + "- Outcome: {}\n", + truncate_note_context_content(outcome.trim(), TWIN_CASE_FIELD_MAX_CHARS) + )); + } + } + if let Some(lesson) = episode.lesson.as_deref() { + if !lesson.trim().is_empty() { + case.push_str(&format!( + "- Lesson (verbatim): {}\n", + truncate_note_context_content(lesson.trim(), TWIN_CASE_FIELD_MAX_CHARS) + )); + } + } + if let Some(note) = episode.correction_note.as_deref() { + if !note.trim().is_empty() { + case.push_str(&format!( + "- Correction note (recorded when an earlier sealed twin guess missed): {}\n", + truncate_note_context_content(note.trim(), TWIN_CASE_CORRECTION_MAX_CHARS) + )); + } + } + case.push('\n'); + case +} + +struct TwinContextSelection { + cases: Vec, + constitution_items: Vec, + approved: Vec, + candidates: Vec, + action_gaps: Vec, + notes: Vec<(String, String, String)>, +} + +/// Greedy-fill the variable twin context sections into a hard token budget, +/// in priority order: cases > constitution > approved records > candidate +/// records > action gaps > evidence notes. Fixed scaffolding (operating +/// contract, identity, answer instructions, decision metadata) sits outside +/// the budget. +#[allow(clippy::too_many_arguments)] +fn apply_twin_context_budget( + cases: Vec, + constitution_items: Vec, + approved: Vec, + candidates: Vec, + action_gaps: Vec, + notes: Vec<(String, String, String)>, + budget: usize, +) -> TwinContextSelection { + let mut remaining = budget as isize; + let mut take_within_budget = move |cost: usize| -> bool { + if remaining - cost as isize >= 0 { + remaining -= cost as isize; + true + } else { + false + } + }; + + let cases = cases + .into_iter() + .filter(|episode| take_within_budget(estimate_tokens(&format_decision_case(episode)))) + .collect(); + let constitution_items = constitution_items + .into_iter() + .filter(|item| take_within_budget(estimate_tokens(&format_constitution_item(item)))) + .collect(); + let approved = approved + .into_iter() + .filter(|record| take_within_budget(estimate_tokens(&format_twin_record(record)))) + .collect(); + let candidates = candidates + .into_iter() + .filter(|record| take_within_budget(estimate_tokens(&format_twin_record(record)))) + .collect(); + let action_gaps = action_gaps + .into_iter() + .filter(|gap| take_within_budget(estimate_tokens(&format_action_gap(gap)))) + .collect(); + let notes = notes + .into_iter() + .filter(|(_, title, content)| { + take_within_budget(estimate_tokens(title) + estimate_tokens(content)) + }) + .collect(); + + TwinContextSelection { + cases, + constitution_items, + approved, + candidates, + action_gaps, + notes, + } +} + +/// User message for the hidden sealed-prediction call. With a configured +/// Twin Identity the framing is immersed first person; the fallback is a +/// neutral decision-support instruction. Disclosure language lives in the +/// app UI, never in model-facing prompts. +fn build_twin_prediction_user_message( + setup: &ConstitutionSetup, + decision: &str, + options: &[String], + stakes: Option<&str>, +) -> String { + let immersed = has_twin_identity(setup); + let mut message = String::new(); + if immersed { + let name = setup + .twin_name + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); + message.push_str(&format!( + "I am {}. The decision in front of me: {}\n", + name, + decision.trim() + )); + } else { + message.push_str(&format!( + "Decision under consideration: {}\n", + decision.trim() + )); + } + if let Some(stakes) = stakes { + if !stakes.trim().is_empty() { + message.push_str(&format!("Stakes: {}\n", stakes.trim())); + } + } + message.push_str("My options:\n"); + for (index, option) in options.iter().enumerate() { + message.push_str(&format!("{}. {}\n", index + 1, option)); + } + if immersed { + message.push_str( + "\nWhich option do I choose? I answer with only this JSON object and nothing else:\n", + ); + } else { + message.push_str( + "\nGiven the context above, determine which option best fits this decision-maker's \ + documented values, constitution, and past decisions. Respond with only this JSON \ + object and nothing else:\n", + ); + } + message.push_str( + "{\"predicted_option\": \"