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:
@@ -334,6 +334,49 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
FROM transcripts;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
10,
|
||||
"feedback: HITL thumbs + correction capture",
|
||||
r#"
|
||||
-- Feedback rows capture human-in-the-loop signal on AI-generated
|
||||
-- output. Two flavours bundled into one table:
|
||||
-- - thumbs (rating = -1 | +1, original_text optional, corrected_text NULL)
|
||||
-- - correction (rating defaults to +1, original_text + corrected_text present)
|
||||
--
|
||||
-- `target_type` names the producing surface:
|
||||
-- 'microstep' — subtask decomposition from DECOMPOSE_TASK_SYSTEM
|
||||
-- 'task_extraction' — tasks lifted from a transcript (EXTRACT_TASKS_SYSTEM)
|
||||
-- 'cleanup' — transcript cleanup output
|
||||
--
|
||||
-- `target_id` is the surface-specific identifier where one exists
|
||||
-- (subtask id, task id, transcript id). NULL is allowed because
|
||||
-- not every feedback event has a stable target id yet.
|
||||
--
|
||||
-- `context_json` carries the input the AI was conditioned on
|
||||
-- (parent task text, transcript chunk, etc.) so future prompt
|
||||
-- builders can reconstruct the original I/O pair for few-shot
|
||||
-- injection or semantic retrieval.
|
||||
CREATE TABLE feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_type TEXT NOT NULL
|
||||
CHECK (target_type IN ('microstep', 'task_extraction', 'cleanup')),
|
||||
target_id TEXT,
|
||||
rating INTEGER NOT NULL
|
||||
CHECK (rating IN (-1, 0, 1)),
|
||||
original_text TEXT,
|
||||
corrected_text TEXT,
|
||||
context_json TEXT,
|
||||
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
|
||||
REFERENCES profiles(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_feedback_target_type_rating
|
||||
ON feedback(target_type, rating, created_at DESC);
|
||||
CREATE INDEX idx_feedback_profile
|
||||
ON feedback(profile_id, target_type, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -483,7 +526,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 10);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -502,7 +545,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -859,8 +902,11 @@ mod tests {
|
||||
// The poisoned migration below first creates `poison_marker`
|
||||
// (syntactically valid, would succeed against any SQLite) and then
|
||||
// runs a guaranteed-invalid function call. Under the new atomic
|
||||
// implementation, neither `poison_marker` nor the v9 row should
|
||||
// implementation, neither `poison_marker` nor the poison row should
|
||||
// survive the failed call.
|
||||
//
|
||||
// Version number must sit above the real MIGRATIONS max so the
|
||||
// baseline migrate cleanly finishes first.
|
||||
#[tokio::test]
|
||||
async fn multi_statement_migration_rolls_back_on_failure() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -871,8 +917,18 @@ mod tests {
|
||||
|
||||
run_migrations(&pool).await.expect("baseline migrate");
|
||||
|
||||
const POISON: &[(i64, &str, &str)] = &[(
|
||||
10,
|
||||
// Discover the real max version so the poison migration is
|
||||
// always exactly one past the end of MIGRATIONS, regardless of
|
||||
// how many real migrations we add in future.
|
||||
let real_max: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
let poison_version = real_max + 1;
|
||||
|
||||
let poison: &[(i64, &str, &str)] = &[(
|
||||
poison_version,
|
||||
"rb-02 atomicity poison",
|
||||
r#"
|
||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||
@@ -880,7 +936,7 @@ mod tests {
|
||||
"#,
|
||||
)];
|
||||
|
||||
let result = run_migrations_slice(&pool, POISON).await;
|
||||
let result = run_migrations_slice(&pool, poison).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"poisoned migration must return Err, got: {result:?}"
|
||||
@@ -896,14 +952,14 @@ mod tests {
|
||||
"poison_marker must not exist; got: {marker:?}"
|
||||
);
|
||||
|
||||
// `schema_version` must not include v10 — version insert is part
|
||||
// of the same transaction that rolled back.
|
||||
// `schema_version` must not include the poison version — version
|
||||
// insert is part of the same transaction that rolled back.
|
||||
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
assert_eq!(
|
||||
max, 9,
|
||||
max, real_max,
|
||||
"schema_version must not advance past the failed migration"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user