diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5694b53..8023817 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "grafyn-frontend", - "version": "0.1.13", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "grafyn-frontend", - "version": "0.1.13", + "version": "0.1.15", "license": "GPL-3.0-only", "dependencies": { "@tauri-apps/api": "^1.6.0", diff --git a/frontend/src-tauri/src/commands/canvas.rs b/frontend/src-tauri/src/commands/canvas.rs index 56d2bda..15a11fa 100644 --- a/frontend/src-tauri/src/commands/canvas.rs +++ b/frontend/src-tauri/src/commands/canvas.rs @@ -9,12 +9,13 @@ use crate::models::canvas::{ use crate::models::note::{ChunkResult, NoteCreate, NoteStatus}; use crate::models::settings::UserSettings; use crate::models::twin::{ - ActionGap, ConstitutionItem, ConstitutionSetup, DecisionEpisodeCreate, PrimitiveDecisionAssessment, - ReflectionCardCreate, TraceEventType, TwinContextRecord, + ActionGap, ConstitutionItem, ConstitutionSetup, DecisionEpisode, DecisionEpisodeCreate, + PrimitiveDecisionAssessment, ReflectionCardCreate, TraceEventType, TwinContextRecord, }; -use crate::services::openrouter::ChatMessage; +use crate::services::ollama::OllamaService; +use crate::services::openrouter::{ChatMessage, OpenRouterService}; use crate::services::retrieval::RetrievalResult; -use crate::services::twin_store::TwinStore; +use crate::services::twin_store::{parse_twin_prediction, TwinStore}; use crate::AppState; use chrono::Utc; use futures::StreamExt; @@ -44,6 +45,13 @@ const LLM_NODE_HEIGHT: f64 = 200.0; const LLM_NODE_Y_STEP: f64 = 300.0; // height(200) + 100px gap for content overflow const LLM_NODE_X_GAP: f64 = 80.0; +// Twin context assembly +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, @@ -60,6 +68,11 @@ struct ResolvedPromptContext { constitution_items: Vec, action_gaps: Vec, system_prompt: Option, + /// Raw twin system prompt before any user system prompt is merged in; + /// reused verbatim by the sealed-prediction call. + twin_context_prompt: Option, + context_version: Option, + decision_case_ids: Vec, } type StreamedResponseUpdate = (String, String, ResponseStatus, Option); @@ -304,6 +317,15 @@ pub async fn send_prompt( initial_leaning: decision_metadata.initial_leaning, review_date: decision_metadata.review_date, primitive_assessment: PrimitiveDecisionAssessment::default(), + // Stamped on every decision episode — including non-Twin + // context tiles — so attribution survives a failed or absent + // hidden prediction call. + context_version: Some( + resolved_context + .context_version + .clone() + .unwrap_or_else(|| TWIN_CONTEXT_VERSION.to_string()), + ), }) .map_err(|error| error.to_string())?; } @@ -328,6 +350,8 @@ pub async fn send_prompt( "candidate_twin_record_ids": tile.candidate_twin_records.iter().map(|record| record.id.clone()).collect::>(), "constitution_item_ids": resolved_context.constitution_items.iter().map(|item| item.id.clone()).collect::>(), "action_gap_ids": resolved_context.action_gaps.iter().map(|gap| gap.id.clone()).collect::>(), + "context_version": resolved_context.context_version.clone(), + "decision_case_ids": resolved_context.decision_case_ids.clone(), "web_search": tile.web_search, "web_search_max_results": tile.web_search_max_results, }), @@ -582,6 +606,47 @@ pub async fn send_prompt( ); }); + // Sealed twin prediction: one hidden, non-streaming call per decision + // episode with at least two options. Fire-and-forget in its own task — + // it never emits canvas-stream events and cannot block or fail the + // visible flow above. + if let Some(episode_id) = tile.decision_episode_id.clone() { + let metadata = tile.decision_metadata.clone().unwrap_or_default(); + if metadata.options.len() >= 2 { + let prediction_model = match model_route.provider { + ModelProviderRoute::Ollama => tile.models.first().cloned().unwrap_or_default(), + ModelProviderRoute::OpenRouter => { + let settings = state.settings_service.read().await; + settings.get().llm_model.clone() + } + }; + let decision = if metadata.decision.trim().is_empty() { + tile.prompt.clone() + } else { + metadata.decision.clone() + }; + let context_version = resolved_context + .context_version + .clone() + .unwrap_or_else(|| TWIN_CONTEXT_VERSION.to_string()); + tauri::async_runtime::spawn(run_sealed_twin_prediction( + state.twin_store.clone(), + state.openrouter.clone(), + state.ollama.clone(), + model_route.provider.clone(), + prediction_model, + episode_id, + tile.prompt.clone(), + decision, + metadata.options.clone(), + metadata.stakes.clone(), + resolved_context.twin_context_prompt.clone(), + context_version, + tile.decision_metadata.clone(), + )); + } + } + Ok(tile_id) } @@ -2269,10 +2334,9 @@ async fn resolve_prompt_context( 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_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 { @@ -2289,6 +2353,9 @@ async fn resolve_prompt_context( 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(), }); } @@ -2376,6 +2443,9 @@ async fn resolve_prompt_context( 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)"); @@ -2397,6 +2467,9 @@ async fn resolve_prompt_context( 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(), }) } } @@ -2408,10 +2481,9 @@ async fn resolve_twin_prompt_context( 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 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; @@ -2484,7 +2556,14 @@ async fn resolve_twin_prompt_context( 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) = { + 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() @@ -2496,33 +2575,62 @@ async fn resolve_twin_prompt_context( let (constitution_items, action_gaps) = twin_store .select_constitution_context(&constitution_query) .map_err(|error| error.to_string())?; - (setup, approved, candidate, constitution_items, action_gaps) + 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, - ¬e_contexts, - &approved_twin_records, - &candidate_twin_records, - &constitution_items, - &action_gaps, + &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, + _ => twin_prompt.clone(), }; Ok(ResolvedPromptContext { messages, context_notes, - approved_twin_records, - candidate_twin_records, - constitution_items, - action_gaps, + 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, }) } @@ -2579,6 +2687,9 @@ async fn resolve_note_level_context( 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(), }) } @@ -2818,8 +2929,328 @@ fn build_note_context_prompt(notes: &[(String, String, String)]) -> String { 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\": \"