wip(microsteps): B3.3 + B3.10 part 1 — prompts + microstep_patterns schema

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:58:02 +01:00
parent 423c0caca4
commit fcca6509cc
3 changed files with 211 additions and 10 deletions

View File

@@ -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 \ 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 \ imperative sentence, actionable today, with no commentary. Where the task \
description contains a natural cue (a place, a time, a preceding action, an \ description contains a natural cue (a place, a time, a preceding action, \
object the user will already be holding), phrase that step as \ an object the user will already be holding), phrase that step as \
\"When [cue], [action]\" so the cue triggers the action. Use this framing \ \"When [cue], [action]\" so the cue triggers the action. Use this \
only where the cue is genuinely present in the input — do not invent cues. \ framing only where the cue is genuinely present in the input — do not \
Steps without a natural cue stay as plain imperatives. Output ONLY a \ invent cues. Steps without a natural cue stay as plain imperatives. \
JSON array of strings."; 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} // Phase 9 content-tag extraction. The model emits a {topic, intent}
// JSON pair under a strict GBNF (see grammars::CONTENT_TAGS_GRAMMAR). // 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 { mod tests {
use super::*; 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] #[test]
fn builds_plain_prompt_when_no_examples() { fn builds_plain_prompt_when_no_examples() {
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]); let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]);

View File

@@ -1837,6 +1837,118 @@ pub async fn list_feedback_examples(
.collect()) .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<String>,
}
/// 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::<Vec<_>>()
.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::<i64, _>("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<Option<MicrostepPatternRow>> {
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<MicrostepPatternRow> {
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<MicrostepPatternRow> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -522,6 +522,32 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name); 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. /// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -671,7 +697,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 17); assert_eq!(count, 18);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool) .execute(&pool)
@@ -690,7 +716,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 17); assert_eq!(count, 18);
} }
#[tokio::test] #[tokio::test]