diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index e22561f..ef44df4 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -1007,7 +1007,7 @@ pub async fn list_feedback_examples( WHERE target_type = ? AND profile_id = ? AND rating >= ? - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT ?", ) .bind(target_type.as_str()) diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index 72df4d3..dfd38cd 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -174,11 +174,23 @@ pub async fn uncomplete_task_cmd( /// 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 ctx: serde_json::Value = - serde_json::from_str(r.context_json.as_deref().unwrap_or("{}")).ok()?; + 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()) @@ -196,10 +208,48 @@ fn to_llm_examples(rows: Vec) -> Vec { .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 @@ -208,12 +258,21 @@ pub async fn decompose_and_store( // Pull recent micro-step feedback so the system prompt gets // conditioned on the user's preferred decomposition style. We - // cap at 5 examples to keep the prompt under budget regardless - // of how much feedback has been captured. - let examples = db_list_feedback_examples(&state.db, FeedbackTargetType::MicroStep, 5, 0, None) - .await - .map(to_llm_examples) - .unwrap_or_default(); + // 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(); @@ -245,12 +304,19 @@ pub async fn decompose_and_store( 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, None) - .await - .map(to_llm_examples) - .unwrap_or_default(); + 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)) diff --git a/src/lib/components/MicroSteps.svelte b/src/lib/components/MicroSteps.svelte index a34f7e4..bc57c17 100644 --- a/src/lib/components/MicroSteps.svelte +++ b/src/lib/components/MicroSteps.svelte @@ -1,6 +1,7 @@