diff --git a/crates/llm/src/prompts.rs b/crates/llm/src/prompts.rs index 8893807..ed2d12e 100644 --- a/crates/llm/src/prompts.rs +++ b/crates/llm/src/prompts.rs @@ -1,13 +1,40 @@ -pub const DECOMPOSE_TASK_SYSTEM: &str = "\ +pub const DECOMPOSE_LIGHT_SYSTEM: &str = "\ You are a task-decomposition assistant. Given a task description, produce \ -between 3 and 7 concrete, physical micro-steps. Each step must be a short \ +exactly 3 concrete, physical micro-steps. Each step must be a short, \ +verb-first imperative sentence — atomic enough to do without thinking. \ +No commentary. Where the task description contains a natural cue (a \ +place, a time, a preceding action, an object the user will already be \ +holding), phrase that step as \"When [cue], [action]\" so the cue \ +triggers the action. Use this framing only where the cue is genuinely \ +present in the input — do not invent cues. Output ONLY a JSON array of \ +strings."; + +pub const DECOMPOSE_DEFAULT_SYSTEM: &str = "\ +You are a task-decomposition assistant. Given a task description, produce \ +between 4 and 5 concrete, physical micro-steps. Each step must be a short \ imperative sentence, actionable today, with no commentary. Where the task \ -description contains a natural cue (a place, a time, a preceding action, an \ -object the user will already be holding), phrase that step as \ -\"When [cue], [action]\" so the cue triggers the action. Use this framing \ -only where the cue is genuinely present in the input — do not invent cues. \ -Steps without a natural cue stay as plain imperatives. Output ONLY a \ -JSON array of strings."; +description contains a natural cue (a place, a time, a preceding action, \ +an object the user will already be holding), phrase that step as \ +\"When [cue], [action]\" so the cue triggers the action. Use this \ +framing only where the cue is genuinely present in the input — do not \ +invent cues. Steps without a natural cue stay as plain imperatives. \ +Output ONLY a JSON array of strings."; + +pub const DECOMPOSE_DETAILED_SYSTEM: &str = "\ +You are a task-decomposition assistant. Given a task description, produce \ +between 6 and 7 concrete, physical micro-steps. Each step must be a short \ +imperative sentence, actionable today. Brief context (one short clause) \ +is allowed where it makes the next move obvious; otherwise no commentary. \ +Where the task description contains a natural cue (a place, a time, a \ +preceding action, an object the user will already be holding), phrase \ +that step as \"When [cue], [action]\" so the cue triggers the action. \ +Use this framing only where the cue is genuinely present in the input — \ +do not invent cues. Steps without a natural cue stay as plain imperatives. \ +Output ONLY a JSON array of strings."; + +/// Back-compat alias — existing callers and tests that reference +/// `DECOMPOSE_TASK_SYSTEM` continue to compile unchanged. +pub const DECOMPOSE_TASK_SYSTEM: &str = DECOMPOSE_DEFAULT_SYSTEM; // Phase 9 content-tag extraction. The model emits a {topic, intent} // JSON pair under a strict GBNF (see grammars::CONTENT_TAGS_GRAMMAR). @@ -139,6 +166,42 @@ pub fn build_conditioned_system_prompt(base: &str, examples: &[FeedbackExample]) mod tests { use super::*; + // --- B3.3 snapshot tests --- + + #[test] + fn light_prompt_contains_cue_anchored_framing() { + assert!( + DECOMPOSE_LIGHT_SYSTEM.contains("When [cue], [action]"), + "DECOMPOSE_LIGHT_SYSTEM must contain the cue-anchored framing" + ); + } + + #[test] + fn default_prompt_contains_cue_anchored_framing() { + assert!( + DECOMPOSE_DEFAULT_SYSTEM.contains("When [cue], [action]"), + "DECOMPOSE_DEFAULT_SYSTEM must contain the cue-anchored framing" + ); + } + + #[test] + fn detailed_prompt_contains_cue_anchored_framing() { + assert!( + DECOMPOSE_DETAILED_SYSTEM.contains("When [cue], [action]"), + "DECOMPOSE_DETAILED_SYSTEM must contain the cue-anchored framing" + ); + } + + #[test] + fn default_alias_matches_default_const() { + assert_eq!( + DECOMPOSE_TASK_SYSTEM, DECOMPOSE_DEFAULT_SYSTEM, + "DECOMPOSE_TASK_SYSTEM must be the same value as DECOMPOSE_DEFAULT_SYSTEM" + ); + } + + // --- existing conditioned-prompt tests --- + #[test] fn builds_plain_prompt_when_no_examples() { let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]); diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 85638e2..b1c9f0f 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -1837,6 +1837,118 @@ pub async fn list_feedback_examples( .collect()) } +// --- B3.10: microstep mastery-fade patterns -------------------------------- + +/// Row returned from the `microstep_patterns` table (migration v18). +pub struct MicrostepPatternRow { + pub normalized_title: String, + pub sample_title: String, + pub completed_count: i64, + pub skip_breakdown: bool, + pub prompted_at: Option, +} + +/// Normalise a raw task title for use as a `microstep_patterns` key. +/// Lowercases, trims, and collapses internal whitespace so +/// " Email Sarah " and "email sarah" share the same row. +pub fn normalize_microstep_title(raw: &str) -> String { + raw.split_whitespace() + .map(|s| s.to_lowercase()) + .collect::>() + .join(" ") +} + +fn microstep_pattern_row_from(r: &sqlx::sqlite::SqliteRow) -> MicrostepPatternRow { + MicrostepPatternRow { + normalized_title: r.get("normalized_title"), + sample_title: r.get("sample_title"), + completed_count: r.get("completed_count"), + skip_breakdown: r.get::("skip_breakdown") != 0, + prompted_at: r.get("prompted_at"), + } +} + +/// Look up a microstep pattern row by normalised title. +pub async fn get_microstep_pattern( + pool: &SqlitePool, + normalized_title: &str, +) -> Result> { + let row = sqlx::query( + "SELECT normalized_title, sample_title, completed_count, skip_breakdown, prompted_at \ + FROM microstep_patterns WHERE normalized_title = ?", + ) + .bind(normalized_title) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("get_microstep_pattern failed: {e}")))?; + + Ok(row.as_ref().map(microstep_pattern_row_from)) +} + +/// Upsert a microstep pattern row, incrementing `completed_count` by 1. +/// Also updates `sample_title` to the latest value passed (so the prompt +/// always shows a recent, natural-looking title). +/// +/// ON CONFLICT creates the row if it does not exist yet; on subsequent +/// calls it bumps the count and refreshes the sample title. +pub async fn upsert_microstep_pattern_increment( + pool: &SqlitePool, + normalized_title: &str, + sample_title: &str, +) -> Result { + sqlx::query( + "INSERT INTO microstep_patterns (normalized_title, sample_title, completed_count) \ + VALUES (?, ?, 1) \ + ON CONFLICT(normalized_title) DO UPDATE SET \ + completed_count = completed_count + 1, \ + sample_title = excluded.sample_title", + ) + .bind(normalized_title) + .bind(sample_title) + .execute(pool) + .await + .map_err(|e| { + KonError::StorageError(format!("upsert_microstep_pattern_increment failed: {e}")) + })?; + + get_microstep_pattern(pool, normalized_title) + .await? + .ok_or_else(|| { + KonError::StorageError(format!( + "upsert_microstep_pattern_increment: row {normalized_title} missing after upsert" + )) + }) +} + +/// Persist the user's skip-breakdown decision and stamp `prompted_at` with the +/// current UTC timestamp so the one-time prompt never re-appears. +pub async fn set_microstep_pattern_decision( + pool: &SqlitePool, + normalized_title: &str, + skip: bool, +) -> Result { + sqlx::query( + "UPDATE microstep_patterns \ + SET skip_breakdown = ?, prompted_at = datetime('now') \ + WHERE normalized_title = ?", + ) + .bind(skip as i64) + .bind(normalized_title) + .execute(pool) + .await + .map_err(|e| { + KonError::StorageError(format!("set_microstep_pattern_decision failed: {e}")) + })?; + + get_microstep_pattern(pool, normalized_title) + .await? + .ok_or_else(|| { + KonError::StorageError(format!( + "set_microstep_pattern_decision: row {normalized_title} missing after update" + )) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 5c73670..6372ebc 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -522,6 +522,32 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name); "#, ), + ( + 18, + "microstep_patterns: mastery-fade tracking (B3.10)", + r#" + -- B3.10 microstep mastery fade. After the same normalised parent-task + -- title is decomposed AND the parent fully completes N=3 times, the + -- user is prompted once to skip future breakdowns. + -- + -- normalized_title is lowercase + whitespace-collapsed so "Email Sarah" + -- and "email sarah" map to the same row. + -- completed_count bumps each time the parent auto-completes. + -- skip_breakdown is set by the user's one-time choice (yes=1, no=0). + -- prompted_at stamps when the prompt was surfaced so it never re-shows. + -- + -- Avoid semicolons inside comments - the SQL splitter treats them as + -- statement boundaries. + CREATE TABLE IF NOT EXISTS microstep_patterns ( + normalized_title TEXT PRIMARY KEY, + sample_title TEXT NOT NULL, + completed_count INTEGER NOT NULL DEFAULT 0, + skip_breakdown INTEGER NOT NULL DEFAULT 0, + prompted_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_microstep_patterns_count ON microstep_patterns(completed_count); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -671,7 +697,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 17); + assert_eq!(count, 18); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -690,7 +716,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 17); + assert_eq!(count, 18); } #[tokio::test]