feat(feedback): Phase 2 — HITL thumbs + correction capture with prompt-conditioning loop
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:
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user