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:
110
src-tauri/src/commands/feedback.rs
Normal file
110
src-tauri/src/commands/feedback.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Tauri commands for human-in-the-loop feedback capture and retrieval.
|
||||
// Phase 2 of the feature-complete roadmap: thumbs + correction capture
|
||||
// on AI-generated output feeds a few-shot loop that conditions future
|
||||
// prompts on the user's preferred style.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kon_storage::{
|
||||
list_feedback_examples as db_list_feedback_examples, record_feedback as db_record_feedback,
|
||||
FeedbackRow, FeedbackTargetType, RecordFeedbackParams,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordFeedbackInput {
|
||||
/// One of "microstep", "task_extraction", "cleanup".
|
||||
pub target_type: String,
|
||||
/// Optional surface-specific id (subtask id, task id, transcript id).
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
|
||||
pub rating: i8,
|
||||
#[serde(default)]
|
||||
pub original_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub corrected_text: Option<String>,
|
||||
/// Freeform JSON context: e.g. the parent task text, the transcript
|
||||
/// chunk the AI was given, etc. Used later by the prompt builder
|
||||
/// to reconstruct the (input, preferred-output) pair.
|
||||
#[serde(default)]
|
||||
pub context_json: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FeedbackDto {
|
||||
pub id: i64,
|
||||
pub target_type: String,
|
||||
pub target_id: Option<String>,
|
||||
pub rating: i64,
|
||||
pub original_text: Option<String>,
|
||||
pub corrected_text: Option<String>,
|
||||
pub context_json: Option<String>,
|
||||
pub profile_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<FeedbackRow> for FeedbackDto {
|
||||
fn from(r: FeedbackRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
target_type: r.target_type,
|
||||
target_id: r.target_id,
|
||||
rating: r.rating,
|
||||
original_text: r.original_text,
|
||||
corrected_text: r.corrected_text,
|
||||
context_json: r.context_json,
|
||||
profile_id: r.profile_id,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_target_type(raw: &str) -> Result<FeedbackTargetType, String> {
|
||||
FeedbackTargetType::parse(raw).ok_or_else(|| format!("unknown feedback target_type: {raw}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn record_feedback(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: RecordFeedbackInput,
|
||||
) -> Result<i64, String> {
|
||||
let target_type = parse_target_type(&input.target_type)?;
|
||||
db_record_feedback(
|
||||
&state.db,
|
||||
RecordFeedbackParams {
|
||||
target_type,
|
||||
target_id: input.target_id,
|
||||
rating: input.rating,
|
||||
original_text: input.original_text,
|
||||
corrected_text: input.corrected_text,
|
||||
context_json: input.context_json,
|
||||
profile_id: input.profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_feedback_examples_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
target_type: String,
|
||||
limit: Option<i64>,
|
||||
min_rating: Option<i8>,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<Vec<FeedbackDto>, String> {
|
||||
let target = parse_target_type(&target_type)?;
|
||||
let limit = limit.unwrap_or(8).clamp(1, 64);
|
||||
let min_rating = min_rating.unwrap_or(0).clamp(-1, 1);
|
||||
let rows =
|
||||
db_list_feedback_examples(&state.db, target, limit, min_rating, profile_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.into_iter().map(FeedbackDto::from).collect())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod audio;
|
||||
pub mod clipboard;
|
||||
pub mod diagnostics;
|
||||
pub mod feedback;
|
||||
pub mod hardware;
|
||||
pub mod hotkey;
|
||||
pub mod live;
|
||||
|
||||
@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use kon_llm::prompts::FeedbackExample as LlmFeedbackExample;
|
||||
use kon_storage::{
|
||||
complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task,
|
||||
delete_task as db_delete_task, get_task_by_id as db_get_task,
|
||||
insert_subtask as db_insert_subtask, insert_task as db_insert_task,
|
||||
list_subtasks as db_list_subtasks, list_tasks as db_list_tasks,
|
||||
uncomplete_task as db_uncomplete_task, update_task as db_update_task, TaskRow,
|
||||
list_feedback_examples as db_list_feedback_examples, list_subtasks as db_list_subtasks,
|
||||
list_tasks as db_list_tasks, uncomplete_task as db_uncomplete_task,
|
||||
update_task as db_update_task, FeedbackRow, FeedbackTargetType, TaskRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
@@ -166,6 +168,34 @@ pub async fn uncomplete_task_cmd(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Convert HITL feedback rows fetched from storage into the few-shot
|
||||
/// exemplar shape the LLM crate consumes. We reconstruct the `input`
|
||||
/// (parent task text, transcript chunk) from `context_json` where the
|
||||
/// 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.
|
||||
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 input = ctx
|
||||
.get("input")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
if input.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(LlmFeedbackExample {
|
||||
input,
|
||||
original_output: r.original_text,
|
||||
corrected_output: r.corrected_text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn decompose_and_store(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -176,12 +206,23 @@ pub async fn decompose_and_store(
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
|
||||
|
||||
// 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();
|
||||
|
||||
let engine = state.llm_engine.clone();
|
||||
let parent_text = parent.text.clone();
|
||||
let steps = tokio::task::spawn_blocking(move || engine.decompose_task(&parent_text))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let steps = tokio::task::spawn_blocking(move || {
|
||||
engine.decompose_task_with_feedback(&parent_text, &examples)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut created = Vec::new();
|
||||
for text in steps {
|
||||
@@ -205,8 +246,14 @@ pub async fn extract_tasks_from_transcript_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
transcript: 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 engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || engine.extract_tasks(&transcript))
|
||||
tokio::task::spawn_blocking(move || engine.extract_tasks_with_feedback(&transcript, &examples))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -288,6 +288,9 @@ pub fn run() {
|
||||
commands::tasks::extract_tasks_from_transcript_cmd,
|
||||
commands::tasks::list_subtasks_cmd,
|
||||
commands::tasks::complete_subtask_cmd,
|
||||
// HITL feedback (Phase 2 roadmap)
|
||||
commands::feedback::record_feedback,
|
||||
commands::feedback::list_feedback_examples_cmd,
|
||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||
commands::profiles::list_profiles_cmd,
|
||||
commands::profiles::get_profile_cmd,
|
||||
|
||||
Reference in New Issue
Block a user