1use rusqlite::params;
2
3use crate::types::MemoryMetric;
4use crate::Result;
5
6use super::Store;
7
8impl Store {
9 pub fn log_access(&self, session_id: Option<&str>, action: &str, query: Option<&str>, memory_ids: &[i64], tokens_injected: i32) -> Result<()> {
10 let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11 self.conn().execute(
12 "INSERT INTO access_log (session_id, action, query, memory_ids, tokens_injected) VALUES (?1, ?2, ?3, ?4, ?5)",
13 params![session_id, action, query, ids_json, tokens_injected],
14 )?;
15 Ok(())
16 }
17
18 pub fn get_session_access_log(&self, session_id: &str, limit: Option<i32>) -> Result<Vec<crate::types::AccessLogEntry>> {
21 let started_at: String = self.conn().query_row(
22 "SELECT started_at FROM sessions WHERE id = ?1",
23 params![session_id],
24 |r| r.get(0),
25 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
26
27 let ended_at: Option<String> = self.conn().query_row(
28 "SELECT ended_at FROM sessions WHERE id = ?1",
29 params![session_id],
30 |r| r.get(0),
31 ).ok().flatten();
32
33 let lim = limit.unwrap_or(1000);
34 let mut stmt = self.conn().prepare(
35 "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
36 FROM access_log
37 WHERE session_id = ?1
38 OR (session_id IS NULL AND created_at >= ?2 AND (?3 IS NULL OR created_at <= ?3))
39 ORDER BY created_at ASC
40 LIMIT ?4",
41 )?;
42 let results = stmt.query_map(params![session_id, started_at, ended_at, lim], |row| {
43 Ok(crate::types::AccessLogEntry {
44 id: row.get(0)?,
45 session_id: row.get(1)?,
46 action: row.get(2)?,
47 query: row.get(3)?,
48 memory_ids: row.get(4)?,
49 tokens_injected: row.get(5)?,
50 created_at: row.get(6)?,
51 })
52 })?.collect::<std::result::Result<Vec<_>, _>>()?;
53 Ok(results)
54 }
55
56 pub fn get_session_access_log_all(&self, limit: i32) -> Result<Vec<crate::types::AccessLogEntry>> {
58 let mut stmt = self.conn().prepare(
59 "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
60 FROM access_log ORDER BY created_at DESC LIMIT ?1",
61 )?;
62 let results = stmt.query_map(params![limit], |row| {
63 Ok(crate::types::AccessLogEntry {
64 id: row.get(0)?,
65 session_id: row.get(1)?,
66 action: row.get(2)?,
67 query: row.get(3)?,
68 memory_ids: row.get(4)?,
69 tokens_injected: row.get(5)?,
70 created_at: row.get(6)?,
71 })
72 })?.collect::<std::result::Result<Vec<_>, _>>()?;
73 Ok(results)
74 }
75
76 pub fn delete_access_log_entry(&self, id: i64) -> Result<()> {
77 self.conn().execute("DELETE FROM access_log WHERE id = ?1", params![id])?;
78 Ok(())
79 }
80
81 pub fn clear_session_access_log(&self, session_id: &str) -> Result<()> {
85 self.conn().query_row(
87 "SELECT id FROM sessions WHERE id = ?1",
88 params![session_id],
89 |r| r.get::<_, String>(0),
90 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
91
92 self.conn().execute(
93 "DELETE FROM access_log WHERE session_id = ?1",
94 params![session_id],
95 )?;
96 Ok(())
97 }
98
99 pub fn record_injection(&self, memory_ids: &[i64]) -> Result<()> {
100 let tx = self.conn().unchecked_transaction()?;
101 for id in memory_ids {
102 tx.execute(
103 "INSERT INTO metrics (memory_id, injections, last_injected_at)
104 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
105 ON CONFLICT(memory_id) DO UPDATE SET
106 injections = injections + 1,
107 last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
108 params![id],
109 )?;
110 }
111 tx.commit()?;
112 Ok(())
113 }
114
115 pub fn record_hit(&self, memory_id: i64) -> Result<()> {
116 self.conn().execute(
117 "INSERT INTO metrics (memory_id, hits, last_hit_at)
118 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
119 ON CONFLICT(memory_id) DO UPDATE SET
120 hits = hits + 1,
121 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
122 params![memory_id],
123 )?;
124 Ok(())
125 }
126
127 pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
128 let tx = self.conn().unchecked_transaction()?;
129 for id in memory_ids {
130 tx.execute(
131 "INSERT INTO metrics (memory_id, hits, last_hit_at)
132 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
133 ON CONFLICT(memory_id) DO UPDATE SET
134 hits = hits + 1,
135 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
136 params![id],
137 )?;
138 }
139 tx.commit()?;
140 Ok(())
141 }
142
143 pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
145 let (injections, hits) = self.conn().query_row(
146 "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
147 [],
148 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
149 )?;
150 let unique = self.conn().query_row(
151 "SELECT COUNT(*) FROM metrics WHERE injections > 0",
152 [],
153 |row| row.get::<_, i64>(0),
154 )?;
155 Ok(crate::types::TokenStats {
156 injections,
157 hits,
158 unique_memories_injected: unique,
159 })
160 }
161
162 pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
164 let started_at: String = self.conn().query_row(
166 "SELECT started_at FROM sessions WHERE id = ?1",
167 params![session_id],
168 |row| row.get(0),
169 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
170
171 let injection_rows: Vec<String> = {
174 let mut stmt = self.conn().prepare(
175 "SELECT memory_ids FROM access_log
176 WHERE action = 'context'
177 AND created_at >= ?1"
178 )?;
179 stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
180 .filter_map(|r| r.ok())
181 .collect()
182 };
183 let mut injections: i64 = 0;
184 let mut injected_set = std::collections::HashSet::new();
185 for ids_json in &injection_rows {
186 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
187 injections += ids.len() as i64;
188 injected_set.extend(ids);
189 }
190 }
191
192 let hit_rows: Vec<String> = {
194 let mut stmt = self.conn().prepare(
195 "SELECT memory_ids FROM access_log
196 WHERE action IN ('search', 'detail', 'context')
197 AND created_at >= ?1"
198 )?;
199 stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
200 .filter_map(|r| r.ok())
201 .collect()
202 };
203 let mut hits: i64 = 0;
204 for ids_json in &hit_rows {
205 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
206 hits += ids.len() as i64;
207 }
208 }
209
210 Ok(crate::types::TokenStats {
211 injections,
212 hits,
213 unique_memories_injected: injected_set.len() as i64,
214 })
215 }
216
217 pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
218 let mut stmt = self.conn().prepare(
219 "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
220 )?;
221 let results = stmt
222 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
223 .collect::<std::result::Result<Vec<_>, _>>()?;
224 Ok(results)
225 }
226
227 pub fn access_log_total(&self) -> Result<i64> {
228 Ok(self.conn().query_row(
229 "SELECT COUNT(*) FROM access_log",
230 [],
231 |row| row.get(0),
232 )?)
233 }
234
235 pub fn dedup_total(&self) -> Result<i64> {
236 Ok(self.conn().query_row(
237 "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
238 [],
239 |row| row.get(0),
240 )?)
241 }
242
243 pub fn revision_total(&self) -> Result<i64> {
244 Ok(self.conn().query_row(
245 "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
246 [],
247 |row| row.get(0),
248 )?)
249 }
250
251 pub fn low_roi_count(&self) -> Result<i64> {
252 Ok(self.conn().query_row(
253 "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
254 [],
255 |row| row.get(0),
256 )?)
257 }
258
259 pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
260 let mut stmt = self.conn().prepare(
261 "SELECT query, COUNT(*) as cnt FROM access_log
262 WHERE action = 'search' AND query IS NOT NULL
263 GROUP BY lower(query)
264 ORDER BY cnt DESC
265 LIMIT ?1",
266 )?;
267 let results = stmt
268 .query_map(rusqlite::params![limit], |row| {
269 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
270 })?
271 .collect::<std::result::Result<Vec<_>, _>>()?;
272 Ok(results)
273 }
274
275 pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
276 let mut stmt = self.conn().prepare(
277 "SELECT m.id, m.key, m.scope,
278 COALESCE(mt.injections, 0),
279 COALESCE(mt.hits, 0),
280 COALESCE(mt.tokens_injected, 0),
281 CASE WHEN COALESCE(mt.injections, 0) > 0
282 THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
283 ELSE 0.0 END
284 FROM memories m
285 LEFT JOIN metrics mt ON mt.memory_id = m.id
286 WHERE m.deleted_at IS NULL
287 ORDER BY COALESCE(mt.injections, 0) DESC
288 LIMIT 100",
289 )?;
290 let results = stmt
291 .query_map([], |row| {
292 Ok(MemoryMetric {
293 id: row.get(0)?,
294 key: row.get(1)?,
295 scope: row.get(2)?,
296 injections: row.get(3)?,
297 hits: row.get(4)?,
298 tokens_injected: row.get(5)?,
299 hit_rate: row.get(6)?,
300 })
301 })?
302 .collect::<std::result::Result<Vec<_>, _>>()?;
303 Ok(results)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use crate::store::Store;
310 use crate::types::SaveParams;
311
312 fn make_memory(store: &Store, key: &str) -> i64 {
313 store
314 .save(SaveParams {
315 key: key.to_string(),
316 value: "test value".to_string(),
317 ..Default::default()
318 })
319 .unwrap()
320 .id()
321 }
322
323 #[test]
324 fn test_log_access_no_query() {
325 let store = Store::open_in_memory().unwrap();
326 store.log_access(None, "search", None, &[], 0).unwrap();
327 }
328
329 #[test]
330 fn test_log_access_with_query_and_ids() {
331 let store = Store::open_in_memory().unwrap();
332 let id = make_memory(&store, "test/key");
333 store.log_access(None, "search", Some("test query"), &[id], 0).unwrap();
334 }
335
336 #[test]
337 fn test_record_injection_increments() {
338 let store = Store::open_in_memory().unwrap();
339 let id = make_memory(&store, "test/key");
340
341 store.record_injection(&[id]).unwrap();
342 store.record_injection(&[id]).unwrap();
343
344 let metrics = store.get_metrics().unwrap();
345 let m = metrics.iter().find(|m| m.id == id).unwrap();
346 assert_eq!(m.injections, 2);
347 }
348
349 #[test]
350 fn test_record_hit_increments() {
351 let store = Store::open_in_memory().unwrap();
352 let id = make_memory(&store, "test/key");
353
354 store.record_hit(id).unwrap();
355 store.record_hit(id).unwrap();
356 store.record_hit(id).unwrap();
357
358 let metrics = store.get_metrics().unwrap();
359 let m = metrics.iter().find(|m| m.id == id).unwrap();
360 assert_eq!(m.hits, 3);
361 }
362
363 #[test]
364 fn test_hit_rate_calculation() {
365 let store = Store::open_in_memory().unwrap();
366 let id = make_memory(&store, "test/key");
367
368 store.record_injection(&[id]).unwrap();
369 store.record_injection(&[id]).unwrap();
370 store.record_hit(id).unwrap();
371
372 let metrics = store.get_metrics().unwrap();
373 let m = metrics.iter().find(|m| m.id == id).unwrap();
374 assert_eq!(m.injections, 2);
375 assert_eq!(m.hits, 1);
376 assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
377 }
378
379 #[test]
380 fn test_hit_rate_zero_when_no_injections() {
381 let store = Store::open_in_memory().unwrap();
382 let id = make_memory(&store, "test/key");
383
384 let metrics = store.get_metrics().unwrap();
385 let m = metrics.iter().find(|m| m.id == id).unwrap();
386 assert_eq!(m.hit_rate, 0.0);
387 }
388
389 #[test]
390 fn test_get_metrics_excludes_deleted() {
391 let store = Store::open_in_memory().unwrap();
392 let id = make_memory(&store, "test/key");
393 store.delete("test/key", None, false).unwrap();
394
395 let metrics = store.get_metrics().unwrap();
396 assert!(metrics.iter().find(|m| m.id == id).is_none());
397 }
398
399 #[test]
400 fn test_get_metrics_ordered_by_injections_desc() {
401 let store = Store::open_in_memory().unwrap();
402 let id1 = make_memory(&store, "key/one");
403 let id2 = make_memory(&store, "key/two");
404
405 store.record_injection(&[id1]).unwrap();
406 store.record_injection(&[id1]).unwrap();
407 store.record_injection(&[id1]).unwrap();
408 store.record_injection(&[id2]).unwrap();
409
410 let metrics = store.get_metrics().unwrap();
411 let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
412 let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
413 assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
414 }
415
416 #[test]
417 fn test_record_injection_multiple_ids() {
418 let store = Store::open_in_memory().unwrap();
419 let id1 = make_memory(&store, "key/one");
420 let id2 = make_memory(&store, "key/two");
421
422 store.record_injection(&[id1, id2]).unwrap();
423
424 let metrics = store.get_metrics().unwrap();
425 let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
426 let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
427 assert_eq!(m1.injections, 1);
428 assert_eq!(m2.injections, 1);
429 }
430
431 #[test]
432 fn test_migration_006_columns_exist() {
433 let store = Store::open_in_memory().unwrap();
434 store.conn().execute(
436 "INSERT INTO access_log (action, tokens_injected) VALUES ('context', 42)",
437 [],
438 ).unwrap();
439 let tok: i32 = store.conn().query_row(
440 "SELECT tokens_injected FROM access_log WHERE action = 'context'",
441 [],
442 |r| r.get(0),
443 ).unwrap();
444 assert_eq!(tok, 42);
445
446 store.conn().execute(
448 "UPDATE sessions SET tokens_used_input = 100, tokens_used_output = 50 WHERE 1=0",
449 [],
450 ).unwrap();
451 }
452
453 #[test]
454 fn test_get_session_access_log_returns_entries() {
455 let store = Store::open_in_memory().unwrap();
456 let session = store.session_start("test-proj", None).unwrap();
457 store.log_access(Some(&session.id), "context", None, &[], 320).unwrap();
458 store.log_access(Some(&session.id), "search", Some("rust errors"), &[], 0).unwrap();
459 let entries = store.get_session_access_log(&session.id, None).unwrap();
460 assert_eq!(entries.len(), 2);
461 assert_eq!(entries[0].action, "context");
462 assert_eq!(entries[0].tokens_injected, 320);
463 assert_eq!(entries[1].action, "search");
464 }
465
466 #[test]
467 fn test_get_session_access_log_isolation() {
468 let store = Store::open_in_memory().unwrap();
470 let session_a = store.session_start("project-a", None).unwrap();
471 let session_b = store.session_start("project-b", None).unwrap();
472 store.log_access(Some(&session_a.id), "search", Some("aaa"), &[], 0).unwrap();
473 store.log_access(Some(&session_b.id), "search", Some("bbb"), &[], 0).unwrap();
474 let entries_a = store.get_session_access_log(&session_a.id, None).unwrap();
475 let entries_b = store.get_session_access_log(&session_b.id, None).unwrap();
476 assert_eq!(entries_a.len(), 1);
477 assert_eq!(entries_a[0].query.as_deref(), Some("aaa"));
478 assert_eq!(entries_b.len(), 1);
479 assert_eq!(entries_b[0].query.as_deref(), Some("bbb"));
480 }
481
482 #[test]
483 fn test_delete_access_log_entry() {
484 let store = Store::open_in_memory().unwrap();
485 store.log_access(None, "context", None, &[], 100).unwrap();
486 let entries = store.get_session_access_log_all(10).unwrap();
487 assert_eq!(entries.len(), 1);
488 let id = entries[0].id;
489 store.delete_access_log_entry(id).unwrap();
490 let entries = store.get_session_access_log_all(10).unwrap();
491 assert_eq!(entries.len(), 0);
492 }
493
494 #[test]
495 fn test_clear_session_access_log() {
496 let store = Store::open_in_memory().unwrap();
497 let session = store.session_start("proj", None).unwrap();
498 store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
499 store.log_access(Some(&session.id), "search", Some("q"), &[], 0).unwrap();
500 store.clear_session_access_log(&session.id).unwrap();
501 let entries = store.get_session_access_log(&session.id, None).unwrap();
502 assert_eq!(entries.len(), 0);
503 }
504}