Skip to main content

difflore_core/skills/
stats.rs

1use super::REMEMBER_DAILY_LIMIT;
2
3#[derive(Debug, Clone, serde::Serialize)]
4#[serde(rename_all = "camelCase")]
5pub struct RulesStats {
6    pub total: i64,
7    pub by_origin: Vec<OriginCount>,
8    pub conversation_captures_today: i64,
9    pub conversation_daily_limit: i64,
10    pub top_strengthened: Vec<StrengthenedRule>,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct OriginCount {
16    pub origin: String,
17    pub count: i64,
18}
19
20#[derive(Debug, Clone, serde::Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct StrengthenedRule {
23    pub id: String,
24    pub name: String,
25    pub origin: String,
26    pub confidence: f64,
27}
28
29pub async fn stats(db: &sqlx::SqlitePool) -> crate::Result<RulesStats> {
30    let total = sqlx::query_scalar!("SELECT COUNT(*) FROM skills WHERE status = 'active'")
31        .fetch_one(db)
32        .await?;
33
34    // Per-origin breakdown, sorted by count desc so the dominant
35    // channel surfaces first. Pending candidates are excluded — the
36    // stats dashboard reflects the live rule corpus.
37    let by_origin_rows = sqlx::query!(
38        "SELECT origin, COUNT(*) AS c FROM skills WHERE status = 'active' \
39         GROUP BY origin ORDER BY c DESC, origin ASC",
40    )
41    .fetch_all(db)
42    .await?;
43    let by_origin: Vec<OriginCount> = by_origin_rows
44        .into_iter()
45        .map(|r| OriginCount {
46            origin: r.origin,
47            count: r.c,
48        })
49        .collect();
50
51    let conversation_captures_today = count_captures_today(db, "conversation").await?;
52
53    // Top 5 rules by confidence, restricted to conversation-origin rules
54    // that have been bumped above the 0.6 base. These are the ones the
55    // user (or agent) re-captured — a strong signal of "this matters".
56    // Limit 5 to keep terminal output digestible.
57    let top_rows = sqlx::query!(
58        "SELECT id, name, origin, confidence_score FROM skills \
59         WHERE origin = 'conversation' AND confidence_score > 0.6 \
60         AND status = 'active' \
61         ORDER BY confidence_score DESC, updated_at DESC LIMIT 5",
62    )
63    .fetch_all(db)
64    .await?;
65    let top_strengthened: Vec<StrengthenedRule> = top_rows
66        .into_iter()
67        .map(|r| StrengthenedRule {
68            id: r.id,
69            name: r.name,
70            origin: r.origin,
71            confidence: r.confidence_score,
72        })
73        .collect();
74
75    Ok(RulesStats {
76        total,
77        by_origin,
78        conversation_captures_today,
79        conversation_daily_limit: REMEMBER_DAILY_LIMIT,
80        top_strengthened,
81    })
82}
83
84/// How many conversation-channel captures landed today. Used both for
85/// the rate-limit warn threshold and for surfacing `captures_today` on
86/// the outcome so callers can render guidance like "12/50 today, getting
87/// close to the cap". Returns 0 for non-conversation origins (the rate
88/// limit only protects against agent runaway).
89pub async fn count_captures_today(db: &sqlx::SqlitePool, origin: &str) -> crate::Result<i64> {
90    if origin != "conversation" {
91        return Ok(0);
92    }
93    let local_day = chrono::Local::now().date_naive().to_string();
94    let n = sqlx::query_scalar::<_, i64>(
95        "SELECT
96            (SELECT COUNT(*) FROM skills
97             WHERE origin = 'conversation'
98             AND date(installed_at, 'localtime') = ?1)
99            +
100            (SELECT COUNT(*) FROM rule_events
101             WHERE source = 'remember_rule'
102             AND date(created_at, 'localtime') = ?1)",
103    )
104    .bind(local_day)
105    .fetch_one(db)
106    .await?;
107    Ok(n)
108}