use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use rusqlite::{params, Connection};
use std::hash::{DefaultHasher, Hash, Hasher};
pub fn detect_intervention(prompt: &str) -> Option<Vec<&'static str>> {
let lower = prompt.to_lowercase();
let trimmed = lower.trim();
let candidates: &[&'static str] = &[
"아니",
"그렇게 하지",
"그게 아니",
"잘못",
"다시 해",
"다르게",
"안 돼",
"안돼",
"취소",
"되돌려",
"원래대로",
"하지 마",
"하지마",
"아니야",
"no, ",
"stop ",
"cancel",
"wrong",
"undo",
"revert",
"don't ",
"do not ",
"that's not",
"not what",
];
let mut matches: Vec<&'static str> = Vec::new();
for &p in candidates {
if trimmed.contains(p) {
matches.push(p);
}
}
if matches.is_empty() {
None
} else {
Some(matches)
}
}
pub fn task_signature(text: &str) -> i64 {
let normalized: String = text
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.take(200)
.collect();
let mut h = DefaultHasher::new();
normalized.hash(&mut h);
h.finish() as i64
}
pub fn count_prior_with_signature(
conn: &Connection,
user_id: &str,
project: &str,
signature: i64,
days: i64,
) -> Result<(usize, Option<String>)> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let mut stmt = conn.prepare(
r#"SELECT created_at FROM events
WHERE user_id = ?1 AND project = ?2
AND event_type = 'hook.user_prompt'
AND json_extract(payload, '$.signature') = ?3
AND created_at > ?4
ORDER BY created_at DESC"#,
)?;
let rows: Vec<String> = stmt
.query_map(params![user_id, project, signature, since], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
let last = rows.first().cloned();
Ok((rows.len(), last))
}
pub fn detect_and_record(
conn: &Connection,
user_id: &str,
project: &str,
prompt: &str,
signature: Option<i64>,
) -> Result<()> {
if let Some(patterns) = detect_intervention(prompt) {
crate::db::event::insert(
conn,
crate::db::event::EventInput {
user_id: user_id.into(),
project: project.into(),
event_type: "signal.intervention".into(),
path: None,
payload: serde_json::json!({
"patterns": patterns,
"prompt": prompt,
}),
},
)
.context("insert signal.intervention")?;
}
if let Some(sig) = signature {
let (count_inclusive, last_seen) =
count_prior_with_signature(conn, user_id, project, sig, 7)?;
let prior_count = count_inclusive.saturating_sub(1);
if prior_count > 0 {
crate::db::event::insert(
conn,
crate::db::event::EventInput {
user_id: user_id.into(),
project: project.into(),
event_type: "signal.redo".into(),
path: None,
payload: serde_json::json!({
"signature": sig,
"prior_count": prior_count,
"last_seen": last_seen,
"prompt_preview": preview(prompt, 80),
}),
},
)
.context("insert signal.redo")?;
}
}
Ok(())
}
fn preview(s: &str, max: usize) -> String {
let preview: String = s.chars().take(max).collect();
if s.chars().count() > max {
format!("{}…", preview)
} else {
preview
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intervention_korean() {
assert!(detect_intervention("아니 그게 아니야").is_some());
assert!(detect_intervention("그렇게 하지 말고 다시 해줘").is_some());
assert!(detect_intervention("취소해줘").is_some());
}
#[test]
fn intervention_english() {
assert!(detect_intervention("Stop that, undo it").is_some());
assert!(detect_intervention("That's not what I meant").is_some());
assert!(detect_intervention("don't do that").is_some());
}
#[test]
fn intervention_negative_cases() {
assert!(detect_intervention("이거 구현해줘").is_none());
assert!(detect_intervention("Add a new feature").is_none());
}
#[test]
fn signature_collapses_whitespace() {
assert_eq!(
task_signature("리팩토링해줘"),
task_signature("리팩토링 해줘")
);
assert_eq!(
task_signature("Refactor this code"),
task_signature("refactorthiscode")
);
}
#[test]
fn signature_distinguishes_different_text() {
assert_ne!(
task_signature("리팩토링해줘"),
task_signature("커밋해줘")
);
}
}