feat(feedback): Phase 2 — HITL thumbs + correction capture with prompt-conditioning loop
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

Closes the human-in-the-loop gap from docs/brief/feature-set.md and
Phase 2 of the 2026-04-23 feature-complete roadmap.

Storage (kon-storage):
- Migration v10 adds the `feedback` table: (target_type, target_id,
  rating, original_text, corrected_text, context_json, profile_id,
  created_at) with CHECK constraints on target_type and rating, plus
  indexes on (target_type, rating, created_at DESC) for prompt-time
  retrieval and (profile_id, target_type, created_at DESC) for
  per-profile scoping.
- New public API: `FeedbackTargetType`, `RecordFeedbackParams`,
  `FeedbackRow`, `record_feedback`, `list_feedback_examples`.
- Tests updated — the RB-02 rollback regression now discovers the
  real max version at runtime instead of hard-coding v10 for its
  poison migration.

LLM (kon-llm):
- `prompts::FeedbackExample` — local shape for few-shot exemplars so
  kon-llm stays independent of kon-storage.
- `prompts::build_conditioned_system_prompt` — appends a "here is
  the style this user prefers" block to the base system prompt
  when examples are available; returns the base prompt unchanged
  when empty, so new users and early sessions see generic output.
- `LlmEngine::decompose_task_with_feedback` and
  `LlmEngine::extract_tasks_with_feedback` thread examples through
  to the builder. The old one-arg variants are preserved and now
  call through with an empty slice.
- 4 unit tests covering empty, empty-input-skip, correction-wins,
  and thumbs-up-only fallback.

Tauri (src-tauri):
- New commands::feedback module: `record_feedback`,
  `list_feedback_examples_cmd`.
- `decompose_and_store` and `extract_tasks_from_transcript_cmd`
  now fetch the last 5 positive/neutral feedback rows for their
  target type and pass them through to the LLM, wiring the
  learning loop end-to-end.
- Shared `to_llm_examples` helper parses the `context_json.input`
  field (where the recorder stashes the parent task text / transcript
  chunk) back into the exemplar shape.

Frontend (MicroSteps.svelte):
- Thumbs-up and thumbs-down buttons on every micro-step row.
  Hover-revealed; the vote recolours the icon; clicking again
  clears the local highlight (the row itself stays in the audit
  trail).
- Pencil icon + double-click to edit step text. Save flows through
  update_task_cmd for persistence and records a correction feedback
  row with (original_text, corrected_text) — the highest-value
  training signal.
- Parent task text is captured in context_json.input at record time
  so the prompt builder can reconstruct the (input, preferred-output)
  pair on subsequent decompositions.
- Feedback capture is best-effort — a record_feedback failure never
  interrupts the primary action.

What's deferred to a later phase:
- Thumbs + corrections on extracted tasks (same pipeline, different
  surface — probably TasksPage after the AI-extraction path)
- Thumbs on transcript cleanup output
- Semantic retrieval over the feedback corpus (once there is enough
  data to justify embedding infrastructure; the storage shape is
  already ready for it)
This commit is contained in:
2026-04-24 12:53:51 +01:00
parent f25f8db818
commit 46be0a5aca
11 changed files with 678 additions and 29 deletions

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
let { parentTaskId, reduceMotion = false } = $props();
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
interface Subtask {
id: string;
@@ -15,6 +15,15 @@
let error = $state('');
let decomposing = $state(false);
// Per-step UI state. Keyed by subtask id so we never lose state when
// the list reorders. Values:
// rating[id] — 1 | -1 — the thumbs vote the user gave this session
// editing[id] — true while the user is editing the step text
// draft[id] — the in-flight edit value before save
let rating = $state<Record<string, 1 | -1 | undefined>>({});
let editing = $state<Record<string, boolean>>({});
let draft = $state<Record<string, string>>({});
async function loadSubtasks() {
loading = true;
error = '';
@@ -53,6 +62,88 @@
}));
}
// --- HITL feedback --------------------------------------------------------
//
// All three paths (thumbs up, thumbs down, correction-via-edit) route
// into the same `record_feedback` command. The parent task text is the
// "input" the AI was given, so it travels in context_json so the prompt
// builder can reconstruct the (input, good-output) pair.
function feedbackContextJson() {
return JSON.stringify({ input: parentTaskText ?? '' });
}
async function recordThumb(step: Subtask, ratingValue: 1 | -1) {
// Toggle: if the user already voted the same way, clear it (record
// rating 0 means correction, not a thumb-off — we just skip the
// re-record and drop the local highlight). Unvoting isn't stored;
// the audit trail stays immutable.
if (rating[step.id] === ratingValue) {
const next = { ...rating };
delete next[step.id];
rating = next;
return;
}
rating = { ...rating, [step.id]: ratingValue };
try {
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: ratingValue,
originalText: step.text,
correctedText: null,
contextJson: feedbackContextJson(),
},
});
} catch (_) { /* feedback capture is best-effort, never fatal */ }
}
function startEdit(step: Subtask) {
editing = { ...editing, [step.id]: true };
draft = { ...draft, [step.id]: step.text };
}
function cancelEdit(stepId: string) {
const nextE = { ...editing }; delete nextE[stepId]; editing = nextE;
const nextD = { ...draft }; delete nextD[stepId]; draft = nextD;
}
async function saveEdit(step: Subtask) {
const next = (draft[step.id] ?? '').trim();
cancelEdit(step.id);
if (!next || next === step.text) return;
const original = step.text;
// Update in-memory first so the UI is snappy; roll back if the
// persistence call fails so we never show stale-but-different text.
const idx = subtasks.findIndex(s => s.id === step.id);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: next };
try {
await invoke('update_task_cmd', {
id: step.id,
patch: { text: next },
});
// Record correction as the highest-value feedback signal.
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: 0,
originalText: original,
correctedText: next,
contextJson: feedbackContextJson(),
},
}).catch(() => {});
} catch (_) {
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: original };
}
}
function handleEditKeydown(evt: KeyboardEvent, step: Subtask) {
if (evt.key === 'Enter') { evt.preventDefault(); saveEdit(step); }
else if (evt.key === 'Escape') { evt.preventDefault(); cancelEdit(step.id); }
}
$effect(() => {
if (parentTaskId) loadSubtasks();
});
@@ -97,10 +188,64 @@
<Check size={9} aria-hidden="true" />
{/if}
</button>
<span class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate">
{step.text}
</span>
{#if !step.done}
{#if editing[step.id]}
<!-- svelte-ignore a11y_autofocus — deliberate: inline edit
is user-initiated and focus must land on the input to
match the UX pattern users expect from any task app. -->
<input
type="text"
bind:value={draft[step.id]}
onkeydown={(e) => handleEditKeydown(e, step)}
onblur={() => saveEdit(step)}
class="text-[12px] flex-1 min-w-0 bg-bg-input border border-accent rounded px-1.5 py-0.5 text-text focus:outline-none"
autofocus
data-no-transition
/>
{:else}
<button
type="button"
class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate text-left cursor-text bg-transparent border-0 p-0"
ondblclick={() => !step.done && startEdit(step)}
disabled={step.done}
aria-label="Double-click to edit this step"
title="Double-click to edit"
>{step.text}</button>
{/if}
{#if !step.done && !editing[step.id]}
<!-- HITL feedback: thumbs vote + pencil edit. All three
route into record_feedback and feed the prompt-conditioning
loop. See docs/roadmap/2026-04-23-... Phase 2. -->
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-success
{rating[step.id] === 1 ? '!opacity-100 text-success' : ''}"
onclick={() => recordThumb(step, 1)}
aria-label={rating[step.id] === 1 ? 'Remove thumbs up' : 'Thumbs up — this is a good step'}
title="Thumbs up — train the model on this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsUp size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-danger
{rating[step.id] === -1 ? '!opacity-100 text-danger' : ''}"
onclick={() => recordThumb(step, -1)}
aria-label={rating[step.id] === -1 ? 'Remove thumbs down' : 'Thumbs down — this misses the mark'}
title="Thumbs down — avoid this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsDown size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-accent"
onclick={() => startEdit(step)}
aria-label="Edit this step (the correction trains future suggestions)"
title="Edit — this is the strongest training signal"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Pencil size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
onclick={() => startTimer(step.id)}

View File

@@ -107,7 +107,7 @@
</div>
<!-- Micro-steps panel (expanded) -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} />
<MicroSteps parentTaskId={task.id} parentTaskText={task.text} />
{/if}
</div>
{/each}