use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use rusqlite::{params, Connection};
use crate::db::{advice, intent};
#[derive(Debug, Clone)]
pub struct RedoCluster {
pub signature: i64,
pub project: String,
pub count: usize,
pub first_seen: String,
pub last_seen: String,
pub prompt_preview: Option<String>,
}
pub fn scan_redo_clusters(
conn: &Connection,
user_id: &str,
project: Option<&str>,
days: i64,
threshold: usize,
) -> Result<Vec<RedoCluster>> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let mut sql = String::from(
r#"SELECT
CAST(json_extract(payload, '$.signature') AS INTEGER) AS sig,
project,
COUNT(*) AS cnt,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM events
WHERE user_id = ?1
AND event_type = 'hook.user_prompt'
AND json_extract(payload, '$.signature') IS NOT NULL
AND created_at > ?2"#,
);
let mut binds: Vec<rusqlite::types::Value> = vec![user_id.to_string().into(), since.into()];
if let Some(p) = project {
sql.push_str(" AND project = ?3");
binds.push(p.to_string().into());
}
sql.push_str(" GROUP BY sig, project HAVING cnt >= ?");
binds.push((threshold as i64).into());
sql.push_str(" ORDER BY last_seen DESC");
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<(i64, String, i64, String, String)> = stmt
.query_map(rusqlite::params_from_iter(binds.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})?
.filter_map(|r| r.ok())
.collect();
let mut clusters = Vec::with_capacity(rows.len());
for (sig, proj, cnt, first, last) in rows {
let preview = fetch_signature_prompt(conn, user_id, sig)?;
clusters.push(RedoCluster {
signature: sig,
project: proj,
count: cnt as usize,
first_seen: first,
last_seen: last,
prompt_preview: preview,
});
}
Ok(clusters)
}
fn fetch_signature_prompt(
conn: &Connection,
user_id: &str,
signature: i64,
) -> Result<Option<String>> {
let mut stmt = conn.prepare(
r#"SELECT json_extract(payload, '$.prompt')
FROM events
WHERE user_id = ?1
AND event_type = 'hook.user_prompt'
AND CAST(json_extract(payload, '$.signature') AS INTEGER) = ?2
ORDER BY created_at DESC
LIMIT 1"#,
)?;
let row: rusqlite::Result<Option<String>> =
stmt.query_row(params![user_id, signature], |r| r.get(0));
Ok(row.ok().flatten())
}
pub fn already_proposed_redo(conn: &Connection, user_id: &str, signature: i64) -> Result<bool> {
let mut stmt = conn.prepare(
r#"SELECT 1 FROM advice
WHERE user_id = ?1
AND CAST(json_extract(metadata, '$.signature') AS INTEGER) = ?2
AND json_extract(metadata, '$.pattern_type') = 'redo_cluster'
LIMIT 1"#,
)?;
Ok(stmt
.query_row(params![user_id, signature], |_| Ok(()))
.is_ok())
}
pub fn cluster_to_advice(user_id: &str, cluster: &RedoCluster) -> advice::AdviceInput {
let preview_short = cluster
.prompt_preview
.as_deref()
.map(|p| {
let preview: String = p.chars().take(60).collect();
if p.chars().count() > 60 {
format!("{}…", preview)
} else {
preview
}
})
.unwrap_or_else(|| "(no preview)".into());
let text = format!(
"이 패턴을 {}회 반복하셨습니다 — 규칙으로 등록할까요?\n예: \"{}\"",
cluster.count, preview_short
);
let proposed_intent_text = format!(
"자주 반복되는 작업: {}",
cluster.prompt_preview.as_deref().unwrap_or("(?)")
);
let metadata = serde_json::json!({
"source": "pattern_scan",
"pattern_type": "redo_cluster",
"signature": cluster.signature,
"count": cluster.count,
"first_seen": cluster.first_seen,
"last_seen": cluster.last_seen,
"prompt_preview": cluster.prompt_preview,
"proposed_intent": {
"strength": "context",
"intent_text": proposed_intent_text,
"project": cluster.project,
}
});
advice::AdviceInput {
user_id: user_id.into(),
project: cluster.project.clone(),
text,
severity: "suggest".into(),
paths: vec![],
verifiable: false,
metadata: Some(metadata),
}
}
pub fn propose_from_clusters(
conn: &Connection,
user_id: &str,
clusters: &[RedoCluster],
) -> Result<usize> {
let mut added = 0usize;
for c in clusters {
if already_proposed_redo(conn, user_id, c.signature)? {
continue;
}
let input = cluster_to_advice(user_id, c);
advice::insert(conn, input)?;
added += 1;
}
Ok(added)
}
#[derive(Debug, Clone)]
pub struct PatternEvolutionCandidate {
pub pattern_id: String,
pub pattern_title: String,
pub pattern_slug: String,
pub project: String,
pub use_count: i64,
pub intervention_count: usize,
pub recent_intervention_prompts: Vec<String>,
}
pub fn scan_pattern_evolution_candidates(
conn: &Connection,
user_id: &str,
intervention_threshold: usize,
days: i64,
) -> Result<Vec<PatternEvolutionCandidate>> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let patterns = crate::db::pattern::list(conn, user_id, None)?;
let mut out = Vec::new();
for h in patterns {
let mut stmt = conn.prepare(
r#"SELECT created_at FROM events
WHERE user_id = ?1 AND event_type = 'signal.harness_use'
AND json_extract(payload, '$.harness_id') = ?2
AND created_at > ?3
ORDER BY created_at ASC"#,
)?;
let use_times: Vec<String> = stmt
.query_map(params![user_id, &h.id, &since], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
if use_times.is_empty() {
continue;
}
let mut intervention_count = 0usize;
let mut prompts: Vec<String> = Vec::new();
for use_at in &use_times {
let window_end = match chrono::DateTime::parse_from_rfc3339(use_at) {
Ok(dt) => (dt + Duration::minutes(30))
.with_timezone(&Utc)
.to_rfc3339(),
Err(_) => continue,
};
let mut s2 = conn.prepare(
r#"SELECT json_extract(payload, '$.prompt') FROM events
WHERE user_id = ?1 AND event_type = 'signal.intervention'
AND project = ?2
AND created_at >= ?3 AND created_at <= ?4
ORDER BY created_at ASC"#,
)?;
let interventions: Vec<Option<String>> = s2
.query_map(params![user_id, &h.project, use_at, &window_end], |r| {
r.get(0)
})?
.filter_map(|r| r.ok())
.collect();
intervention_count += interventions.len();
for p in interventions.into_iter().flatten() {
prompts.push(p);
}
}
if intervention_count >= intervention_threshold {
out.push(PatternEvolutionCandidate {
pattern_id: h.id.clone(),
pattern_title: h.title.clone(),
pattern_slug: h.slug.clone(),
project: h.project.clone(),
use_count: h.usage_count,
intervention_count,
recent_intervention_prompts: prompts.into_iter().rev().take(5).collect(),
});
}
}
Ok(out)
}
pub fn propose_pattern_evolution(
conn: &Connection,
user_id: &str,
candidate: &PatternEvolutionCandidate,
) -> Result<advice::Advice> {
let metadata = serde_json::json!({
"source": "harness_evolution_scan",
"pattern_type": "harness_evolution",
"harness_id": candidate.pattern_id,
"harness_slug": candidate.pattern_slug,
"use_count": candidate.use_count,
"intervention_count": candidate.intervention_count,
"evolution_action": "append_caution_to_workflow",
"caution_notes": candidate.recent_intervention_prompts,
});
let text = format!(
"패턴 \"{}\" 워크플로우 검토 — {}회 사용 중 사용자 교정 {}회 발생.\n\
confirm 시 SKILL.md 의 workflow 블록에 주의사항이 자동 추가됩니다 \
(사용자 직접 편집 영역은 보존).",
candidate.pattern_title, candidate.use_count, candidate.intervention_count
);
let input = advice::AdviceInput {
user_id: user_id.into(),
project: candidate.project.clone(),
text,
severity: "suggest".into(),
paths: vec![],
verifiable: false,
metadata: Some(metadata),
};
advice::insert(conn, input)
}
pub fn already_proposed_evolution(
conn: &Connection,
user_id: &str,
pattern_id: &str,
) -> Result<bool> {
let mut stmt = conn.prepare(
r#"SELECT 1 FROM advice
WHERE user_id = ?1
AND json_extract(metadata, '$.harness_id') = ?2
AND json_extract(metadata, '$.pattern_type') = 'harness_evolution'
AND confirmed_at IS NULL
LIMIT 1"#,
)?;
Ok(stmt
.query_row(params![user_id, pattern_id], |_| Ok(()))
.is_ok())
}
#[derive(Debug)]
pub enum PromoteResult {
NewIntent(intent::Intent),
PatternEvolved {
pattern_id: String,
skill_path: std::path::PathBuf,
},
}
pub fn promote_advice(conn: &Connection, user_id: &str, advice_id: &str) -> Result<PromoteResult> {
let adv = advice::get(conn, user_id, advice_id)?
.with_context(|| format!("advice 없음: {}", advice_id))?;
let pattern_type = adv
.metadata
.get("pattern_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let result = match pattern_type {
"harness_evolution" => promote_pattern_evolution(conn, user_id, &adv)?,
_ => promote_to_intent(conn, user_id, &adv)?,
};
advice::confirm(conn, user_id, &adv.id, "promote")?;
advice::set_state(conn, user_id, &adv.id, "done")?;
Ok(result)
}
fn promote_to_intent(
conn: &Connection,
user_id: &str,
adv: &advice::Advice,
) -> Result<PromoteResult> {
let proposed = adv
.metadata
.get("proposed_intent")
.with_context(|| "이 advice 는 proposed_intent 가 없습니다 (수동 생성?)")?;
let strength_str = proposed
.get("strength")
.and_then(|v| v.as_str())
.unwrap_or("context");
let intent_text = proposed
.get("intent_text")
.and_then(|v| v.as_str())
.with_context(|| "proposed_intent.intent_text 누락")?
.to_string();
let project = proposed
.get("project")
.and_then(|v| v.as_str())
.map(String::from);
let strength = intent::Strength::parse(strength_str)
.with_context(|| format!("invalid strength in proposed_intent: {}", strength_str))?;
let intent_record = intent::insert(
conn,
intent::IntentInput {
user_id: user_id.into(),
project,
strength,
intent_text,
source: intent::Source::AdvicePromotion,
source_signal_ids: vec![adv.id.clone()],
metadata: serde_json::json!({
"promoted_from_advice": adv.id,
"promoted_at": Utc::now().to_rfc3339(),
}),
},
)?;
Ok(PromoteResult::NewIntent(intent_record))
}
fn promote_pattern_evolution(
conn: &Connection,
user_id: &str,
adv: &advice::Advice,
) -> Result<PromoteResult> {
use crate::db::pattern as db_h;
let pattern_id = adv
.metadata
.get("harness_id")
.and_then(|v| v.as_str())
.context("harness_evolution advice 에 harness_id 누락")?;
let cautions: Vec<String> = adv
.metadata
.get("caution_notes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let h = db_h::get(conn, user_id, pattern_id)?
.with_context(|| format!("패턴 없음: {}", pattern_id))?;
let project_path = crate::pattern::resolve_project_path(conn, user_id, &h.project)?;
let skill_rel = h
.file_paths
.iter()
.find(|p| p.ends_with("/SKILL.md"))
.with_context(|| "패턴 의 SKILL.md 파일 경로를 찾을 수 없음")?;
let skill_path = project_path.join(skill_rel);
crate::pattern::append_caution_to_workflow(&skill_path, &cautions)?;
db_h::append_evolution(
conn,
&h.id,
db_h::EvolutionEntry {
at: Utc::now().to_rfc3339(),
kind: "evolved".into(),
summary: format!(
"워크플로우에 주의사항 {}건 추가 (사용자 교정 누적 {}회)",
cautions.len(),
adv.metadata
.get("intervention_count")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
source_advice_id: Some(adv.id.clone()),
},
)?;
Ok(PromoteResult::PatternEvolved {
pattern_id: h.id,
skill_path,
})
}