asurada 0.3.1

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
//! 사용자 행동 신호 추출.
//!
//! 원시 hook 이벤트에서 의미 있는 신호를 도출해 별도 event_type 으로 기록.
//! Phase 2 범위: intervention (교정/거절), redo (반복 작업).
//! Phase 4 가 이 신호들을 군집화해 advice 로 surface.

use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use rusqlite::{params, Connection};
use std::hash::{DefaultHasher, Hash, Hasher};

/// Intervention(교정/거절) 패턴이 발견되면 매칭된 패턴들 반환.
///
/// 사용자가 자신의 직전 의도/지시를 뒤집는 어조 — 이게 잦으면
/// Asurada 가 사전에 잘못된 방향으로 가지 않도록 학습할 단서.
pub fn detect_intervention(prompt: &str) -> Option<Vec<&'static str>> {
    let lower = prompt.to_lowercase();
    let trimmed = lower.trim();

    // 한국어 + 영어 패턴. 단순 substring 매칭 — 오탐 가능하지만
    // Phase 2 는 신호 누적이 목적이라 false positive 도 학습 자료.
    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 {
        // 단어 경계 단순 검사 — substring 으로 충분 (한국어는 띄어쓰기 변동).
        if trimmed.contains(p) {
            matches.push(p);
        }
    }

    if matches.is_empty() {
        None
    } else {
        Some(matches)
    }
}

/// Task signature — 정규화된 prompt 의 해시.
///
/// 정규화: 공백 모두 제거 + lowercase + 200자 제한.
/// "리팩토링해줘" 와 "리팩토링 해줘" 가 같은 signature 가 되도록 공백 제거.
/// 의미적 유사도 매칭은 Phase 4 영역.
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
}

/// 직전 N일 내 같은 signature 의 hook.user_prompt 이벤트 개수 (project 일치).
/// 반환: (count, last_seen)
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))
}

/// hook.user_prompt 가 방금 기록된 직후 호출. 신호를 도출해 별도 이벤트로 INSERT.
///
/// `signature` 는 방금 기록된 hook.user_prompt 의 payload.signature 값.
/// signature 검색은 *방금 기록된 행 자신* 도 포함하므로, 자기 자신 제외 시
/// `prior_count = count - 1` 이 진짜 prior 횟수.
pub fn detect_and_record(
    conn: &Connection,
    user_id: &str,
    project: &str,
    prompt: &str,
    signature: Option<i64>,
) -> Result<()> {
    // 1. Intervention
    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")?;
    }

    // 2. Redo
    if let Some(sig) = signature {
        let (count_inclusive, last_seen) =
            count_prior_with_signature(conn, user_id, project, sig, 7)?;
        // 방금 INSERT 한 자기 자신 제외.
        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("커밋해줘"));
    }
}