1use rusqlite::params;
2
3use crate::types::MemoryMetric;
4use crate::Result;
5
6use super::Store;
7
8impl Store {
9 pub fn log_access(&self, action: &str, query: Option<&str>, memory_ids: &[i64]) -> Result<()> {
10 let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11 self.conn().execute(
12 "INSERT INTO access_log (action, query, memory_ids) VALUES (?1, ?2, ?3)",
13 params![action, query, ids_json],
14 )?;
15 Ok(())
16 }
17
18 pub fn record_injection(&self, memory_ids: &[i64]) -> Result<()> {
19 let tx = self.conn().unchecked_transaction()?;
20 for id in memory_ids {
21 tx.execute(
22 "INSERT INTO metrics (memory_id, injections, last_injected_at)
23 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
24 ON CONFLICT(memory_id) DO UPDATE SET
25 injections = injections + 1,
26 last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
27 params![id],
28 )?;
29 }
30 tx.commit()?;
31 Ok(())
32 }
33
34 pub fn record_hit(&self, memory_id: i64) -> Result<()> {
35 self.conn().execute(
36 "INSERT INTO metrics (memory_id, hits, last_hit_at)
37 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
38 ON CONFLICT(memory_id) DO UPDATE SET
39 hits = hits + 1,
40 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
41 params![memory_id],
42 )?;
43 Ok(())
44 }
45
46 pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
47 let tx = self.conn().unchecked_transaction()?;
48 for id in memory_ids {
49 tx.execute(
50 "INSERT INTO metrics (memory_id, hits, last_hit_at)
51 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
52 ON CONFLICT(memory_id) DO UPDATE SET
53 hits = hits + 1,
54 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
55 params![id],
56 )?;
57 }
58 tx.commit()?;
59 Ok(())
60 }
61
62 pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
64 let (injections, hits) = self.conn().query_row(
65 "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
66 [],
67 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
68 )?;
69 let unique = self.conn().query_row(
70 "SELECT COUNT(*) FROM metrics WHERE injections > 0",
71 [],
72 |row| row.get::<_, i64>(0),
73 )?;
74 Ok(crate::types::TokenStats {
75 injections,
76 hits,
77 unique_memories_injected: unique,
78 })
79 }
80
81 pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
83 let started_at: String = self.conn().query_row(
85 "SELECT started_at FROM sessions WHERE id = ?1",
86 params![session_id],
87 |row| row.get(0),
88 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
89
90 let injection_rows: Vec<String> = {
93 let mut stmt = self.conn().prepare(
94 "SELECT memory_ids FROM access_log
95 WHERE action = 'context'
96 AND created_at >= ?1"
97 )?;
98 stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
99 .filter_map(|r| r.ok())
100 .collect()
101 };
102 let mut injections: i64 = 0;
103 let mut injected_set = std::collections::HashSet::new();
104 for ids_json in &injection_rows {
105 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
106 injections += ids.len() as i64;
107 injected_set.extend(ids);
108 }
109 }
110
111 let hit_rows: Vec<String> = {
113 let mut stmt = self.conn().prepare(
114 "SELECT memory_ids FROM access_log
115 WHERE action IN ('search', 'detail', 'context')
116 AND created_at >= ?1"
117 )?;
118 stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
119 .filter_map(|r| r.ok())
120 .collect()
121 };
122 let mut hits: i64 = 0;
123 for ids_json in &hit_rows {
124 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
125 hits += ids.len() as i64;
126 }
127 }
128
129 Ok(crate::types::TokenStats {
130 injections,
131 hits,
132 unique_memories_injected: injected_set.len() as i64,
133 })
134 }
135
136 pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
137 let mut stmt = self.conn().prepare(
138 "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
139 )?;
140 let results = stmt
141 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
142 .collect::<std::result::Result<Vec<_>, _>>()?;
143 Ok(results)
144 }
145
146 pub fn access_log_total(&self) -> Result<i64> {
147 Ok(self.conn().query_row(
148 "SELECT COUNT(*) FROM access_log",
149 [],
150 |row| row.get(0),
151 )?)
152 }
153
154 pub fn dedup_total(&self) -> Result<i64> {
155 Ok(self.conn().query_row(
156 "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
157 [],
158 |row| row.get(0),
159 )?)
160 }
161
162 pub fn revision_total(&self) -> Result<i64> {
163 Ok(self.conn().query_row(
164 "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
165 [],
166 |row| row.get(0),
167 )?)
168 }
169
170 pub fn low_roi_count(&self) -> Result<i64> {
171 Ok(self.conn().query_row(
172 "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
173 [],
174 |row| row.get(0),
175 )?)
176 }
177
178 pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
179 let mut stmt = self.conn().prepare(
180 "SELECT query, COUNT(*) as cnt FROM access_log
181 WHERE action = 'search' AND query IS NOT NULL
182 GROUP BY lower(query)
183 ORDER BY cnt DESC
184 LIMIT ?1",
185 )?;
186 let results = stmt
187 .query_map(rusqlite::params![limit], |row| {
188 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
189 })?
190 .collect::<std::result::Result<Vec<_>, _>>()?;
191 Ok(results)
192 }
193
194 pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
195 let mut stmt = self.conn().prepare(
196 "SELECT m.id, m.key, m.scope,
197 COALESCE(mt.injections, 0),
198 COALESCE(mt.hits, 0),
199 COALESCE(mt.tokens_injected, 0),
200 CASE WHEN COALESCE(mt.injections, 0) > 0
201 THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
202 ELSE 0.0 END
203 FROM memories m
204 LEFT JOIN metrics mt ON mt.memory_id = m.id
205 WHERE m.deleted_at IS NULL
206 ORDER BY COALESCE(mt.injections, 0) DESC
207 LIMIT 100",
208 )?;
209 let results = stmt
210 .query_map([], |row| {
211 Ok(MemoryMetric {
212 id: row.get(0)?,
213 key: row.get(1)?,
214 scope: row.get(2)?,
215 injections: row.get(3)?,
216 hits: row.get(4)?,
217 tokens_injected: row.get(5)?,
218 hit_rate: row.get(6)?,
219 })
220 })?
221 .collect::<std::result::Result<Vec<_>, _>>()?;
222 Ok(results)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use crate::store::Store;
229 use crate::types::SaveParams;
230
231 fn make_memory(store: &Store, key: &str) -> i64 {
232 store
233 .save(SaveParams {
234 key: key.to_string(),
235 value: "test value".to_string(),
236 ..Default::default()
237 })
238 .unwrap()
239 .id()
240 }
241
242 #[test]
243 fn test_log_access_no_query() {
244 let store = Store::open_in_memory().unwrap();
245 store.log_access("search", None, &[]).unwrap();
246 }
247
248 #[test]
249 fn test_log_access_with_query_and_ids() {
250 let store = Store::open_in_memory().unwrap();
251 let id = make_memory(&store, "test/key");
252 store.log_access("search", Some("test query"), &[id]).unwrap();
253 }
254
255 #[test]
256 fn test_record_injection_increments() {
257 let store = Store::open_in_memory().unwrap();
258 let id = make_memory(&store, "test/key");
259
260 store.record_injection(&[id]).unwrap();
261 store.record_injection(&[id]).unwrap();
262
263 let metrics = store.get_metrics().unwrap();
264 let m = metrics.iter().find(|m| m.id == id).unwrap();
265 assert_eq!(m.injections, 2);
266 }
267
268 #[test]
269 fn test_record_hit_increments() {
270 let store = Store::open_in_memory().unwrap();
271 let id = make_memory(&store, "test/key");
272
273 store.record_hit(id).unwrap();
274 store.record_hit(id).unwrap();
275 store.record_hit(id).unwrap();
276
277 let metrics = store.get_metrics().unwrap();
278 let m = metrics.iter().find(|m| m.id == id).unwrap();
279 assert_eq!(m.hits, 3);
280 }
281
282 #[test]
283 fn test_hit_rate_calculation() {
284 let store = Store::open_in_memory().unwrap();
285 let id = make_memory(&store, "test/key");
286
287 store.record_injection(&[id]).unwrap();
288 store.record_injection(&[id]).unwrap();
289 store.record_hit(id).unwrap();
290
291 let metrics = store.get_metrics().unwrap();
292 let m = metrics.iter().find(|m| m.id == id).unwrap();
293 assert_eq!(m.injections, 2);
294 assert_eq!(m.hits, 1);
295 assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
296 }
297
298 #[test]
299 fn test_hit_rate_zero_when_no_injections() {
300 let store = Store::open_in_memory().unwrap();
301 let id = make_memory(&store, "test/key");
302
303 let metrics = store.get_metrics().unwrap();
304 let m = metrics.iter().find(|m| m.id == id).unwrap();
305 assert_eq!(m.hit_rate, 0.0);
306 }
307
308 #[test]
309 fn test_get_metrics_excludes_deleted() {
310 let store = Store::open_in_memory().unwrap();
311 let id = make_memory(&store, "test/key");
312 store.delete("test/key", None, false).unwrap();
313
314 let metrics = store.get_metrics().unwrap();
315 assert!(metrics.iter().find(|m| m.id == id).is_none());
316 }
317
318 #[test]
319 fn test_get_metrics_ordered_by_injections_desc() {
320 let store = Store::open_in_memory().unwrap();
321 let id1 = make_memory(&store, "key/one");
322 let id2 = make_memory(&store, "key/two");
323
324 store.record_injection(&[id1]).unwrap();
325 store.record_injection(&[id1]).unwrap();
326 store.record_injection(&[id1]).unwrap();
327 store.record_injection(&[id2]).unwrap();
328
329 let metrics = store.get_metrics().unwrap();
330 let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
331 let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
332 assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
333 }
334
335 #[test]
336 fn test_record_injection_multiple_ids() {
337 let store = Store::open_in_memory().unwrap();
338 let id1 = make_memory(&store, "key/one");
339 let id2 = make_memory(&store, "key/two");
340
341 store.record_injection(&[id1, id2]).unwrap();
342
343 let metrics = store.get_metrics().unwrap();
344 let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
345 let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
346 assert_eq!(m1.injections, 1);
347 assert_eq!(m2.injections, 1);
348 }
349}