fix(feedback): Phase 2 follow-up — Codex review MAJORs + NIT
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Independent review surfaced three majors and one nit. All actioned.

MAJOR 1 — profile scoping:
`decompose_and_store` and `extract_tasks_from_transcript_cmd` now
accept an optional `profile_id` (wired from `profilesStore.activeProfileId`
in MicroSteps.svelte and DictationPage.svelte), and thread it into the
feedback-retrieval query so per-profile decomposition styles do not
leak into each other. `record_feedback` gets the same treatment.

MAJOR 2 — prompt-budget regression on long inputs:
New `trim_to_budget` helper + `FEW_SHOT_CHAR_BUDGET = 2000` char cap
in `src-tauri/src/commands/tasks.rs`. Retrieval still pulls up to 5
rows but they are char-counted and truncated against the budget
before being sent to the LLM. Char cost matches the `Input: ...\n
Good output: ...` render path so the budget maps cleanly to ~570
Qwen3 tokens, well inside the 8192-context reserve after the 512-
or 768-token response allocation. Oldest-first drop order (iteration
stops at cost exceeded) preserves the most recent correction which
is the one carrying the user's live preference.

MAJOR 3 — inline edit stale-rollback race:
`saveEdit` in MicroSteps.svelte now stamps a monotonic per-step
`saveToken`. Each edit bumps the token; on failure the rollback
only fires if `saveToken[step.id] === myToken`, so a slow-failing
first save can no longer overwrite a faster successful second save.

NIT — retrieval ordering stability:
`list_feedback_examples` ORDER BY now `created_at DESC, id DESC`.
SQLite timestamp precision is one second; without the secondary
key, bursty feedback within the same second would select
non-deterministically.

Also: malformed `context_json` now warns via eprintln! rather than
disappearing silently — Codex minor.

All green: cargo build + 249 tests + clippy -D warnings + fmt
+ svelte-check (0/0) + npm run build.
This commit is contained in:
2026-04-24 13:32:52 +01:00
parent 46be0a5aca
commit d307722c7a
4 changed files with 110 additions and 17 deletions

View File

@@ -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<FeedbackRow>) -> Vec<LlmFeedbackExample> {
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<FeedbackRow>) -> Vec<LlmFeedbackExample> {
.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<LlmFeedbackExample>) -> Vec<LlmFeedbackExample> {
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<String>,
) -> Result<Vec<TaskDto>, 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<String>,
) -> Result<Vec<String>, 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))