// Tauri commands wrapping kon_storage task CRUD. // Pattern mirrors transcripts.rs — TaskDto is the camelCase frontend shape, // storage functions are aliased with db_ prefix to avoid name collisions. use serde::{Deserialize, Serialize}; use uuid::Uuid; use kon_llm::prompts::FeedbackExample as LlmFeedbackExample; use kon_storage::{ complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task, delete_task as db_delete_task, get_task_by_id as db_get_task, insert_subtask as db_insert_subtask, insert_task as db_insert_task, list_feedback_examples as db_list_feedback_examples, list_recent_completions as db_list_recent_completions, list_subtasks as db_list_subtasks, list_tasks as db_list_tasks, set_task_energy as db_set_task_energy, uncomplete_task as db_uncomplete_task, update_task as db_update_task, DailyCompletionCount, FeedbackRow, FeedbackTargetType, TaskRow, }; use crate::AppState; /// Frontend-facing task shape. Matches the in-memory object in page.svelte.js. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TaskDto { pub id: String, pub text: String, pub bucket: String, pub list_id: Option, pub effort: Option, pub notes: String, pub done: bool, pub done_at: Option, pub created_at: String, pub source_transcript_id: Option, pub parent_task_id: Option, pub energy: Option, } impl From for TaskDto { fn from(r: TaskRow) -> Self { Self { id: r.id, text: r.text, bucket: r.bucket, list_id: r.list_id, effort: r.effort, notes: r.notes, done: r.done, done_at: r.done_at, created_at: r.created_at, source_transcript_id: r.source_transcript_id, parent_task_id: r.parent_task_id, energy: r.energy, } } } /// Accepted energy tag values. Kept as a const so frontend and storage /// validate against the same list. Migration v11 enforces the same /// set via a CHECK constraint. const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"]; fn validate_energy(raw: Option<&str>) -> Result, String> { match raw { None => Ok(None), Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)), Some(other) => Err(format!( "energy must be one of {:?} or null, got {:?}", ENERGY_LEVELS, other )), } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateTaskRequest { pub id: String, pub text: String, pub bucket: String, #[serde(default)] pub source_transcript_id: Option, #[serde(default)] pub list_id: Option, #[serde(default)] pub effort: Option, #[serde(default)] pub energy: Option, } #[tauri::command] pub async fn create_task_cmd( state: tauri::State<'_, AppState>, request: CreateTaskRequest, ) -> Result { let energy = validate_energy(request.energy.as_deref())?; db_insert_task( &state.db, &request.id, &request.text, &request.bucket, request.source_transcript_id.as_deref(), request.list_id.as_deref(), request.effort.as_deref(), energy, ) .await .map_err(|e| e.to_string())?; // Fetch the freshly-inserted row so the frontend can stop doing // client-side object construction. Mirrors list_tasks_cmd's shape. db_get_task(&state.db, &request.id) .await .map_err(|e| e.to_string())? .map(TaskDto::from) .ok_or_else(|| format!("Task {} not found after insert", request.id)) } /// Patch-shaped update. Any field omitted (or explicit `null`) leaves the /// column untouched via `COALESCE` in the storage layer. Matches /// `update_transcript`'s partial-update philosophy. `done` / `doneAt` are /// intentionally absent — those flow through `complete_task_cmd` / /// `uncomplete_task_cmd` to stamp the server-side timestamp. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateTaskRequest { #[serde(default)] pub text: Option, #[serde(default)] pub bucket: Option, #[serde(default)] pub list_id: Option, #[serde(default)] pub effort: Option, #[serde(default)] pub notes: Option, } #[tauri::command] pub async fn update_task_cmd( state: tauri::State<'_, AppState>, id: String, patch: UpdateTaskRequest, ) -> Result { let row = db_update_task( &state.db, &id, patch.text.as_deref(), patch.bucket.as_deref(), patch.list_id.as_deref(), patch.effort.as_deref(), patch.notes.as_deref(), ) .await .map_err(|e| e.to_string())?; Ok(TaskDto::from(row)) } #[tauri::command] pub async fn list_tasks_cmd(state: tauri::State<'_, AppState>) -> Result, String> { db_list_tasks(&state.db) .await .map(|rows| rows.into_iter().map(TaskDto::from).collect()) .map_err(|e| e.to_string()) } #[tauri::command] pub async fn complete_task_cmd( state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { db_complete_task(&state.db, &id) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn delete_task_cmd(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { db_delete_task(&state.db, &id) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn uncomplete_task_cmd( state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { db_uncomplete_task(&state.db, &id) .await .map_err(|e| e.to_string()) } /// Phase 3: set or clear the `energy` tag on a task. Dedicated command /// rather than a field on `update_task_cmd` because the existing update /// path uses `COALESCE` semantics where `None` means "preserve" — which /// makes clearing the tag impossible. This command always writes exactly /// what you send, including `None` to explicitly clear. #[tauri::command] pub async fn set_task_energy_cmd( state: tauri::State<'_, AppState>, id: String, energy: Option, ) -> Result { let validated = validate_energy(energy.as_deref())?; let row = db_set_task_energy(&state.db, &id, validated) .await .map_err(|e| e.to_string())?; Ok(TaskDto::from(row)) } /// Convert HITL feedback rows fetched from storage into the few-shot /// exemplar shape the LLM crate consumes. We reconstruct the `input` /// (parent task text, transcript chunk) from `context_json` where the /// recorder has stored it. Rows without usable input are dropped — /// the prompt builder filters them too, but doing it here keeps the /// exemplar list tight and the prompt budget predictable. /// /// Malformed `context_json` is logged rather than silently dropped so /// data-integrity regressions surface instead of disappearing. fn to_llm_examples(rows: Vec) -> Vec { rows.into_iter() .filter_map(|r| { let raw = r.context_json.as_deref().unwrap_or("{}"); let ctx: serde_json::Value = match serde_json::from_str(raw) { Ok(v) => v, Err(e) => { eprintln!( "[feedback] skipping row id={} with malformed context_json: {e}", r.id ); return None; } }; let input = ctx .get("input") .and_then(|v| v.as_str()) .map(str::to_string) .unwrap_or_default(); if input.trim().is_empty() { return None; } Some(LlmFeedbackExample { input, original_output: r.original_text, corrected_output: r.corrected_text, }) }) .collect() } /// Rough character budget for the few-shot block. Qwen3's tokenizer /// averages ~3.5 chars per token in English, so 2000 chars is ~570 /// tokens — well inside the 64-token reserve + response-token gap /// against the 8192-token context cap (see `LlmEngine::generate`). /// /// Exceed this and we drop the oldest examples first. Rationale: the /// retrieval already orders most-recent-first, and the most recent /// correction is usually the one carrying the user's live preference. const FEW_SHOT_CHAR_BUDGET: usize = 2000; fn example_char_cost(ex: &LlmFeedbackExample) -> usize { // Matches the render path in `prompts::render_feedback_exemplar`: // "Input: {input}\nGood output: {good}". Prefix strings + newlines // + the two bodies. Slight overestimate to leave headroom. let good_len = ex .corrected_output .as_deref() .or(ex.original_output.as_deref()) .map(str::len) .unwrap_or(0); ex.input.len() + good_len + 24 } fn trim_to_budget(mut examples: Vec) -> Vec { let mut running = 0usize; let mut kept = Vec::with_capacity(examples.len()); for ex in examples.drain(..) { let cost = example_char_cost(&ex); if running + cost > FEW_SHOT_CHAR_BUDGET { break; } running += cost; kept.push(ex); } kept } #[tauri::command] pub async fn decompose_and_store( state: tauri::State<'_, AppState>, parent_task_id: String, profile_id: Option, ) -> Result, String> { let parent = db_get_task(&state.db, &parent_task_id) .await .map_err(|e| e.to_string())? .ok_or_else(|| format!("Task {parent_task_id} not found"))?; // Pull recent micro-step feedback so the system prompt gets // conditioned on the user's preferred decomposition style. We // cap at 5 examples AND at a char budget to keep the prompt // under token budget regardless of how much feedback has been // captured, and scope by profile so per-profile styles do not // leak into each other. let examples = db_list_feedback_examples( &state.db, FeedbackTargetType::MicroStep, 5, 0, profile_id.as_deref(), ) .await .map(to_llm_examples) .map(trim_to_budget) .unwrap_or_default(); let engine = state.llm_engine.clone(); let parent_text = parent.text.clone(); let steps = tokio::task::spawn_blocking(move || { engine.decompose_task_with_feedback(&parent_text, &examples) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let mut created = Vec::new(); for text in steps { let id = Uuid::new_v4().to_string(); db_insert_subtask(&state.db, &id, &text, &parent_task_id) .await .map_err(|e| e.to_string())?; if let Some(row) = db_get_task(&state.db, &id) .await .map_err(|e| e.to_string())? { created.push(TaskDto::from(row)); } } Ok(created) } #[tauri::command] pub async fn extract_tasks_from_transcript_cmd( state: tauri::State<'_, AppState>, transcript: String, profile_id: Option, ) -> Result, String> { let examples = db_list_feedback_examples( &state.db, FeedbackTargetType::TaskExtraction, 5, 0, profile_id.as_deref(), ) .await .map(to_llm_examples) .map(trim_to_budget) .unwrap_or_default(); let engine = state.llm_engine.clone(); tokio::task::spawn_blocking(move || engine.extract_tasks_with_feedback(&transcript, &examples)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] pub async fn list_subtasks_cmd( state: tauri::State<'_, AppState>, parent_task_id: String, ) -> Result, String> { db_list_subtasks(&state.db, &parent_task_id) .await .map(|rows| rows.into_iter().map(TaskDto::from).collect()) .map_err(|e| e.to_string()) } #[tauri::command] pub async fn complete_subtask_cmd( state: tauri::State<'_, AppState>, subtask_id: String, ) -> Result<(), String> { db_complete_subtask(&state.db, &subtask_id) .await .map_err(|e| e.to_string()) } /// Phase 8: daily completion counts for the Tasks-page badge and the /// 7-day momentum sparkline. Returns a fixed-length oldest-first /// series. Empty days are explicit zeros. #[tauri::command] pub async fn list_recent_completions_cmd( state: tauri::State<'_, AppState>, days: u32, ) -> Result, String> { db_list_recent_completions(&state.db, days) .await .map_err(|e| e.to_string()) }