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:
@@ -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, &[]);
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user