From fcca6509cc79ee8c16e3a0bc4f02d16b08ab1e8b Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 26 Apr 2026 19:58:02 +0100 Subject: [PATCH] =?UTF-8?q?wip(microsteps):=20B3.3=20+=20B3.10=20part=201?= =?UTF-8?q?=20=E2=80=94=20prompts=20+=20microstep=5Fpatterns=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage-side scaffolding for B3.3 (granularity prompt variants) and B3.10 (mastery fade). The user-facing wiring (decompose_and_store granularity threading, complete_subtask_cmd count bump, MicroSteps inline prompt + skip-check, Settings UI) is part 2 of this batch. B3.3 (prompts.rs): three new system-prompt constants DECOMPOSE_LIGHT_SYSTEM (3 atomic verb-first steps), DECOMPOSE_DEFAULT_SYSTEM (4-5 balanced steps), DECOMPOSE_DETAILED_SYSTEM (6-7 with brief context) all preserve the cue-anchored "When [cue], [action]" framing from PR 1.1. DECOMPOSE_TASK_SYSTEM remains as an alias of Default for back-compat with existing callers and tests. 4 snapshot tests assert the framing survives across variants and the alias matches. B3.10 (storage): migration v18 adds microstep_patterns (normalized_title PK, sample_title, completed_count, skip_breakdown, prompted_at) plus an index on completed_count. New storage functions: normalize_microstep_title (lowercase + whitespace-collapse + trim), get_microstep_pattern, upsert_microstep_pattern_increment, set_microstep_pattern_decision. 5 storage tests cover normalisation, upsert/bump semantics, decision persistence, and the threshold query. 74 storage tests pass (was 69), 9 prompts tests pass (was 5). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/llm/src/prompts.rs | 79 +++++++++++++++++++--- crates/storage/src/database.rs | 112 +++++++++++++++++++++++++++++++ crates/storage/src/migrations.rs | 30 ++++++++- 3 files changed, 211 insertions(+), 10 deletions(-) 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]