Skip to main content

skm_learn/
analytics.rs

1//! Usage analytics for tracking selection patterns.
2
3use std::collections::HashMap;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use skm_core::SkillName;
9use skm_select::Confidence;
10
11use crate::error::LearnError;
12
13/// A recorded selection event.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SelectionEvent {
16    /// Timestamp.
17    pub timestamp: SystemTime,
18
19    /// User query.
20    pub query: String,
21
22    /// Selected skill (if any).
23    pub selected_skill: Option<SkillName>,
24
25    /// Selection score.
26    pub score: Option<f32>,
27
28    /// Confidence level.
29    pub confidence: Option<Confidence>,
30
31    /// Strategy that produced the result.
32    pub strategy: Option<String>,
33
34    /// Latency in milliseconds.
35    pub latency_ms: u64,
36
37    /// User feedback (if any).
38    pub feedback: Option<Feedback>,
39
40    /// Custom metadata.
41    pub metadata: HashMap<String, String>,
42}
43
44/// User feedback on a selection.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub enum Feedback {
47    /// Selection was correct.
48    Correct,
49
50    /// Selection was incorrect.
51    Incorrect {
52        /// What skill should have been selected.
53        expected: Option<SkillName>,
54    },
55
56    /// Selection was partially correct.
57    Partial { reason: String },
58}
59
60/// Trait for analytics storage backends.
61pub trait AnalyticsStore: Send + Sync {
62    /// Record a selection event.
63    fn record(&self, event: SelectionEvent) -> Result<(), LearnError>;
64
65    /// Query recent events.
66    fn query_recent(&self, limit: usize) -> Result<Vec<SelectionEvent>, LearnError>;
67
68    /// Query events for a specific skill.
69    fn query_by_skill(
70        &self,
71        skill: &SkillName,
72        limit: usize,
73    ) -> Result<Vec<SelectionEvent>, LearnError>;
74
75    /// Get event count.
76    fn count(&self) -> Result<usize, LearnError>;
77}
78
79/// In-memory analytics store (for testing/development).
80pub struct InMemoryAnalyticsStore {
81    events: std::sync::Mutex<Vec<SelectionEvent>>,
82    max_events: usize,
83}
84
85impl InMemoryAnalyticsStore {
86    /// Create a new in-memory store.
87    pub fn new(max_events: usize) -> Self {
88        Self {
89            events: std::sync::Mutex::new(Vec::new()),
90            max_events,
91        }
92    }
93}
94
95impl Default for InMemoryAnalyticsStore {
96    fn default() -> Self {
97        Self::new(10000)
98    }
99}
100
101impl AnalyticsStore for InMemoryAnalyticsStore {
102    fn record(&self, event: SelectionEvent) -> Result<(), LearnError> {
103        let mut events = self.events.lock().unwrap();
104
105        if events.len() >= self.max_events {
106            events.remove(0);
107        }
108
109        events.push(event);
110        Ok(())
111    }
112
113    fn query_recent(&self, limit: usize) -> Result<Vec<SelectionEvent>, LearnError> {
114        let events = self.events.lock().unwrap();
115        let start = events.len().saturating_sub(limit);
116        Ok(events[start..].to_vec())
117    }
118
119    fn query_by_skill(
120        &self,
121        skill: &SkillName,
122        limit: usize,
123    ) -> Result<Vec<SelectionEvent>, LearnError> {
124        let events = self.events.lock().unwrap();
125        let filtered: Vec<_> = events
126            .iter()
127            .filter(|e| e.selected_skill.as_ref() == Some(skill))
128            .cloned()
129            .collect();
130
131        let start = filtered.len().saturating_sub(limit);
132        Ok(filtered[start..].to_vec())
133    }
134
135    fn count(&self) -> Result<usize, LearnError> {
136        Ok(self.events.lock().unwrap().len())
137    }
138}
139
140/// SQLite-based analytics store.
141#[cfg(feature = "analytics-sqlite")]
142pub struct SqliteAnalyticsStore {
143    conn: std::sync::Mutex<rusqlite::Connection>,
144}
145
146#[cfg(feature = "analytics-sqlite")]
147impl SqliteAnalyticsStore {
148    /// Create a new SQLite store.
149    pub fn new(path: &std::path::Path) -> Result<Self, LearnError> {
150        let conn = rusqlite::Connection::open(path)?;
151
152        conn.execute(
153            "CREATE TABLE IF NOT EXISTS selection_events (
154                id INTEGER PRIMARY KEY,
155                timestamp INTEGER NOT NULL,
156                query TEXT NOT NULL,
157                selected_skill TEXT,
158                score REAL,
159                confidence TEXT,
160                strategy TEXT,
161                latency_ms INTEGER NOT NULL,
162                feedback TEXT,
163                metadata TEXT
164            )",
165            [],
166        )?;
167
168        conn.execute(
169            "CREATE INDEX IF NOT EXISTS idx_timestamp ON selection_events(timestamp)",
170            [],
171        )?;
172
173        conn.execute(
174            "CREATE INDEX IF NOT EXISTS idx_skill ON selection_events(selected_skill)",
175            [],
176        )?;
177
178        Ok(Self {
179            conn: std::sync::Mutex::new(conn),
180        })
181    }
182
183    /// Create an in-memory store (for testing).
184    pub fn in_memory() -> Result<Self, LearnError> {
185        let conn = rusqlite::Connection::open_in_memory()?;
186
187        conn.execute(
188            "CREATE TABLE IF NOT EXISTS selection_events (
189                id INTEGER PRIMARY KEY,
190                timestamp INTEGER NOT NULL,
191                query TEXT NOT NULL,
192                selected_skill TEXT,
193                score REAL,
194                confidence TEXT,
195                strategy TEXT,
196                latency_ms INTEGER NOT NULL,
197                feedback TEXT,
198                metadata TEXT
199            )",
200            [],
201        )?;
202
203        Ok(Self {
204            conn: std::sync::Mutex::new(conn),
205        })
206    }
207}
208
209#[cfg(feature = "analytics-sqlite")]
210impl AnalyticsStore for SqliteAnalyticsStore {
211    fn record(&self, event: SelectionEvent) -> Result<(), LearnError> {
212        let conn = self.conn.lock().unwrap();
213
214        let timestamp = event
215            .timestamp
216            .duration_since(SystemTime::UNIX_EPOCH)
217            .unwrap()
218            .as_secs() as i64;
219
220        conn.execute(
221            "INSERT INTO selection_events 
222            (timestamp, query, selected_skill, score, confidence, strategy, latency_ms, feedback, metadata)
223            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
224            rusqlite::params![
225                timestamp,
226                event.query,
227                event.selected_skill.as_ref().map(|s| s.as_str()),
228                event.score,
229                event.confidence.map(|c| format!("{:?}", c)),
230                event.strategy,
231                event.latency_ms as i64,
232                event.feedback.as_ref().map(|f| serde_json::to_string(f).unwrap()),
233                serde_json::to_string(&event.metadata).ok(),
234            ],
235        )?;
236
237        Ok(())
238    }
239
240    fn query_recent(&self, limit: usize) -> Result<Vec<SelectionEvent>, LearnError> {
241        let conn = self.conn.lock().unwrap();
242
243        let mut stmt = conn.prepare(
244            "SELECT timestamp, query, selected_skill, score, confidence, strategy, latency_ms, feedback, metadata
245            FROM selection_events
246            ORDER BY timestamp DESC
247            LIMIT ?1",
248        )?;
249
250        let events: Vec<SelectionEvent> = stmt
251            .query_map([limit], |row| {
252                let timestamp_secs: i64 = row.get(0)?;
253                let query: String = row.get(1)?;
254                let selected_skill: Option<String> = row.get(2)?;
255                let score: Option<f64> = row.get(3)?;
256                let confidence: Option<String> = row.get(4)?;
257                let strategy: Option<String> = row.get(5)?;
258                let latency_ms: i64 = row.get(6)?;
259                let feedback: Option<String> = row.get(7)?;
260                let metadata: Option<String> = row.get(8)?;
261
262                Ok(SelectionEvent {
263                    timestamp: SystemTime::UNIX_EPOCH
264                        + std::time::Duration::from_secs(timestamp_secs as u64),
265                    query,
266                    selected_skill: selected_skill.and_then(|s| SkillName::new(&s).ok()),
267                    score: score.map(|s| s as f32),
268                    confidence: confidence.and_then(|c| match c.as_str() {
269                        "None" => Some(Confidence::None),
270                        "Low" => Some(Confidence::Low),
271                        "Medium" => Some(Confidence::Medium),
272                        "High" => Some(Confidence::High),
273                        "Definite" => Some(Confidence::Definite),
274                        _ => None,
275                    }),
276                    strategy,
277                    latency_ms: latency_ms as u64,
278                    feedback: feedback.and_then(|f| serde_json::from_str(&f).ok()),
279                    metadata: metadata
280                        .and_then(|m| serde_json::from_str(&m).ok())
281                        .unwrap_or_default(),
282                })
283            })?
284            .filter_map(|r| r.ok())
285            .collect();
286
287        Ok(events)
288    }
289
290    fn query_by_skill(
291        &self,
292        skill: &SkillName,
293        limit: usize,
294    ) -> Result<Vec<SelectionEvent>, LearnError> {
295        let conn = self.conn.lock().unwrap();
296
297        let mut stmt = conn.prepare(
298            "SELECT timestamp, query, selected_skill, score, confidence, strategy, latency_ms, feedback, metadata
299            FROM selection_events
300            WHERE selected_skill = ?1
301            ORDER BY timestamp DESC
302            LIMIT ?2",
303        )?;
304
305        let events: Vec<SelectionEvent> = stmt
306            .query_map(rusqlite::params![skill.as_str(), limit], |row| {
307                let timestamp_secs: i64 = row.get(0)?;
308                let query: String = row.get(1)?;
309                let selected_skill: Option<String> = row.get(2)?;
310                let score: Option<f64> = row.get(3)?;
311                let confidence: Option<String> = row.get(4)?;
312                let strategy: Option<String> = row.get(5)?;
313                let latency_ms: i64 = row.get(6)?;
314                let feedback: Option<String> = row.get(7)?;
315                let metadata: Option<String> = row.get(8)?;
316
317                Ok(SelectionEvent {
318                    timestamp: SystemTime::UNIX_EPOCH
319                        + std::time::Duration::from_secs(timestamp_secs as u64),
320                    query,
321                    selected_skill: selected_skill.and_then(|s| SkillName::new(&s).ok()),
322                    score: score.map(|s| s as f32),
323                    confidence: confidence.and_then(|c| match c.as_str() {
324                        "None" => Some(Confidence::None),
325                        "Low" => Some(Confidence::Low),
326                        "Medium" => Some(Confidence::Medium),
327                        "High" => Some(Confidence::High),
328                        "Definite" => Some(Confidence::Definite),
329                        _ => None,
330                    }),
331                    strategy,
332                    latency_ms: latency_ms as u64,
333                    feedback: feedback.and_then(|f| serde_json::from_str(&f).ok()),
334                    metadata: metadata
335                        .and_then(|m| serde_json::from_str(&m).ok())
336                        .unwrap_or_default(),
337                })
338            })?
339            .filter_map(|r| r.ok())
340            .collect();
341
342        Ok(events)
343    }
344
345    fn count(&self) -> Result<usize, LearnError> {
346        let conn = self.conn.lock().unwrap();
347        let count: i64 = conn.query_row("SELECT COUNT(*) FROM selection_events", [], |row| {
348            row.get(0)
349        })?;
350        Ok(count as usize)
351    }
352}
353
354/// High-level usage analytics interface.
355pub struct UsageAnalytics {
356    store: Box<dyn AnalyticsStore>,
357}
358
359impl UsageAnalytics {
360    /// Create with a specific store.
361    pub fn new(store: Box<dyn AnalyticsStore>) -> Self {
362        Self { store }
363    }
364
365    /// Create with in-memory store.
366    pub fn in_memory() -> Self {
367        Self::new(Box::new(InMemoryAnalyticsStore::default()))
368    }
369
370    /// Record an event.
371    pub fn record(&self, event: SelectionEvent) -> Result<(), LearnError> {
372        self.store.record(event)
373    }
374
375    /// Get recent events.
376    pub fn recent(&self, limit: usize) -> Result<Vec<SelectionEvent>, LearnError> {
377        self.store.query_recent(limit)
378    }
379
380    /// Get events for a skill.
381    pub fn by_skill(&self, skill: &SkillName, limit: usize) -> Result<Vec<SelectionEvent>, LearnError> {
382        self.store.query_by_skill(skill, limit)
383    }
384
385    /// Get total event count.
386    pub fn count(&self) -> Result<usize, LearnError> {
387        self.store.count()
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_in_memory_store() {
397        let store = InMemoryAnalyticsStore::new(100);
398
399        let event = SelectionEvent {
400            timestamp: SystemTime::now(),
401            query: "test query".to_string(),
402            selected_skill: Some(SkillName::new("test-skill").unwrap()),
403            score: Some(0.9),
404            confidence: Some(Confidence::High),
405            strategy: Some("trigger".to_string()),
406            latency_ms: 10,
407            feedback: None,
408            metadata: HashMap::new(),
409        };
410
411        store.record(event).unwrap();
412
413        assert_eq!(store.count().unwrap(), 1);
414
415        let events = store.query_recent(10).unwrap();
416        assert_eq!(events.len(), 1);
417        assert_eq!(events[0].query, "test query");
418    }
419
420    #[test]
421    fn test_in_memory_store_limit() {
422        let store = InMemoryAnalyticsStore::new(5);
423
424        for i in 0..10 {
425            let event = SelectionEvent {
426                timestamp: SystemTime::now(),
427                query: format!("query {}", i),
428                selected_skill: None,
429                score: None,
430                confidence: None,
431                strategy: None,
432                latency_ms: 10,
433                feedback: None,
434                metadata: HashMap::new(),
435            };
436            store.record(event).unwrap();
437        }
438
439        // Should only keep last 5
440        assert_eq!(store.count().unwrap(), 5);
441    }
442
443    #[test]
444    fn test_query_by_skill() {
445        let store = InMemoryAnalyticsStore::default();
446
447        let skill_a = SkillName::new("skill-a").unwrap();
448        let skill_b = SkillName::new("skill-b").unwrap();
449
450        // Add events for skill_a
451        for _ in 0..3 {
452            store
453                .record(SelectionEvent {
454                    timestamp: SystemTime::now(),
455                    query: "query".to_string(),
456                    selected_skill: Some(skill_a.clone()),
457                    score: None,
458                    confidence: None,
459                    strategy: None,
460                    latency_ms: 10,
461                    feedback: None,
462                    metadata: HashMap::new(),
463                })
464                .unwrap();
465        }
466
467        // Add event for skill_b
468        store
469            .record(SelectionEvent {
470                timestamp: SystemTime::now(),
471                query: "query".to_string(),
472                selected_skill: Some(skill_b.clone()),
473                score: None,
474                confidence: None,
475                strategy: None,
476                latency_ms: 10,
477                feedback: None,
478                metadata: HashMap::new(),
479            })
480            .unwrap();
481
482        let events = store.query_by_skill(&skill_a, 10).unwrap();
483        assert_eq!(events.len(), 3);
484    }
485
486    #[cfg(feature = "analytics-sqlite")]
487    #[test]
488    fn test_sqlite_store() {
489        let store = SqliteAnalyticsStore::in_memory().unwrap();
490
491        let event = SelectionEvent {
492            timestamp: SystemTime::now(),
493            query: "test query".to_string(),
494            selected_skill: Some(SkillName::new("test-skill").unwrap()),
495            score: Some(0.9),
496            confidence: Some(Confidence::High),
497            strategy: Some("trigger".to_string()),
498            latency_ms: 10,
499            feedback: Some(Feedback::Correct),
500            metadata: HashMap::new(),
501        };
502
503        store.record(event).unwrap();
504
505        assert_eq!(store.count().unwrap(), 1);
506
507        let events = store.query_recent(10).unwrap();
508        assert_eq!(events.len(), 1);
509        assert_eq!(events[0].query, "test query");
510    }
511
512    #[test]
513    fn test_usage_analytics() {
514        let analytics = UsageAnalytics::in_memory();
515
516        let event = SelectionEvent {
517            timestamp: SystemTime::now(),
518            query: "test".to_string(),
519            selected_skill: None,
520            score: None,
521            confidence: None,
522            strategy: None,
523            latency_ms: 10,
524            feedback: None,
525            metadata: HashMap::new(),
526        };
527
528        analytics.record(event).unwrap();
529
530        assert_eq!(analytics.count().unwrap(), 1);
531    }
532}