Files
Lumotia/src-tauri/src/commands/feedback.rs
Jake 46be0a5aca
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
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)
2026-04-24 12:53:51 +01:00

111 lines
3.5 KiB
Rust

// 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())
}