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 access_log_stats(&self) -> Result<Vec<(String, i64)>> {
63 let mut stmt = self.conn().prepare(
64 "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
65 )?;
66 let results = stmt
67 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
68 .collect::<std::result::Result<Vec<_>, _>>()?;
69 Ok(results)
70 }
71
72 pub fn access_log_total(&self) -> Result<i64> {
73 Ok(self.conn().query_row(
74 "SELECT COUNT(*) FROM access_log",
75 [],
76 |row| row.get(0),
77 )?)
78 }
79
80 pub fn dedup_total(&self) -> Result<i64> {
81 Ok(self.conn().query_row(
82 "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
83 [],
84 |row| row.get(0),
85 )?)
86 }
87
88 pub fn revision_total(&self) -> Result<i64> {
89 Ok(self.conn().query_row(
90 "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
91 [],
92 |row| row.get(0),
93 )?)
94 }
95
96 pub fn low_roi_count(&self) -> Result<i64> {
97 Ok(self.conn().query_row(
98 "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
99 [],
100 |row| row.get(0),
101 )?)
102 }
103
104 pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
105 let mut stmt = self.conn().prepare(
106 "SELECT query, COUNT(*) as cnt FROM access_log
107 WHERE action = 'search' AND query IS NOT NULL
108 GROUP BY lower(query)
109 ORDER BY cnt DESC
110 LIMIT ?1",
111 )?;
112 let results = stmt
113 .query_map(rusqlite::params![limit], |row| {
114 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
115 })?
116 .collect::<std::result::Result<Vec<_>, _>>()?;
117 Ok(results)
118 }
119
120 pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
121 let mut stmt = self.conn().prepare(
122 "SELECT m.id, m.key, m.scope,
123 COALESCE(mt.injections, 0),
124 COALESCE(mt.hits, 0),
125 COALESCE(mt.tokens_injected, 0),
126 CASE WHEN COALESCE(mt.injections, 0) > 0
127 THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
128 ELSE 0.0 END
129 FROM memories m
130 LEFT JOIN metrics mt ON mt.memory_id = m.id
131 WHERE m.deleted_at IS NULL
132 ORDER BY COALESCE(mt.injections, 0) DESC
133 LIMIT 100",
134 )?;
135 let results = stmt
136 .query_map([], |row| {
137 Ok(MemoryMetric {
138 id: row.get(0)?,
139 key: row.get(1)?,
140 scope: row.get(2)?,
141 injections: row.get(3)?,
142 hits: row.get(4)?,
143 tokens_injected: row.get(5)?,
144 hit_rate: row.get(6)?,
145 })
146 })?
147 .collect::<std::result::Result<Vec<_>, _>>()?;
148 Ok(results)
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use crate::store::Store;
155 use crate::types::SaveParams;
156
157 fn make_memory(store: &Store, key: &str) -> i64 {
158 store
159 .save(SaveParams {
160 key: key.to_string(),
161 value: "test value".to_string(),
162 ..Default::default()
163 })
164 .unwrap()
165 .id()
166 }
167
168 #[test]
169 fn test_log_access_no_query() {
170 let store = Store::open_in_memory().unwrap();
171 store.log_access("search", None, &[]).unwrap();
172 }
173
174 #[test]
175 fn test_log_access_with_query_and_ids() {
176 let store = Store::open_in_memory().unwrap();
177 let id = make_memory(&store, "test/key");
178 store.log_access("search", Some("test query"), &[id]).unwrap();
179 }
180
181 #[test]
182 fn test_record_injection_increments() {
183 let store = Store::open_in_memory().unwrap();
184 let id = make_memory(&store, "test/key");
185
186 store.record_injection(&[id]).unwrap();
187 store.record_injection(&[id]).unwrap();
188
189 let metrics = store.get_metrics().unwrap();
190 let m = metrics.iter().find(|m| m.id == id).unwrap();
191 assert_eq!(m.injections, 2);
192 }
193
194 #[test]
195 fn test_record_hit_increments() {
196 let store = Store::open_in_memory().unwrap();
197 let id = make_memory(&store, "test/key");
198
199 store.record_hit(id).unwrap();
200 store.record_hit(id).unwrap();
201 store.record_hit(id).unwrap();
202
203 let metrics = store.get_metrics().unwrap();
204 let m = metrics.iter().find(|m| m.id == id).unwrap();
205 assert_eq!(m.hits, 3);
206 }
207
208 #[test]
209 fn test_hit_rate_calculation() {
210 let store = Store::open_in_memory().unwrap();
211 let id = make_memory(&store, "test/key");
212
213 store.record_injection(&[id]).unwrap();
214 store.record_injection(&[id]).unwrap();
215 store.record_hit(id).unwrap();
216
217 let metrics = store.get_metrics().unwrap();
218 let m = metrics.iter().find(|m| m.id == id).unwrap();
219 assert_eq!(m.injections, 2);
220 assert_eq!(m.hits, 1);
221 assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
222 }
223
224 #[test]
225 fn test_hit_rate_zero_when_no_injections() {
226 let store = Store::open_in_memory().unwrap();
227 let id = make_memory(&store, "test/key");
228
229 let metrics = store.get_metrics().unwrap();
230 let m = metrics.iter().find(|m| m.id == id).unwrap();
231 assert_eq!(m.hit_rate, 0.0);
232 }
233
234 #[test]
235 fn test_get_metrics_excludes_deleted() {
236 let store = Store::open_in_memory().unwrap();
237 let id = make_memory(&store, "test/key");
238 store.delete("test/key", None, false).unwrap();
239
240 let metrics = store.get_metrics().unwrap();
241 assert!(metrics.iter().find(|m| m.id == id).is_none());
242 }
243
244 #[test]
245 fn test_get_metrics_ordered_by_injections_desc() {
246 let store = Store::open_in_memory().unwrap();
247 let id1 = make_memory(&store, "key/one");
248 let id2 = make_memory(&store, "key/two");
249
250 store.record_injection(&[id1]).unwrap();
251 store.record_injection(&[id1]).unwrap();
252 store.record_injection(&[id1]).unwrap();
253 store.record_injection(&[id2]).unwrap();
254
255 let metrics = store.get_metrics().unwrap();
256 let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
257 let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
258 assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
259 }
260
261 #[test]
262 fn test_record_injection_multiple_ids() {
263 let store = Store::open_in_memory().unwrap();
264 let id1 = make_memory(&store, "key/one");
265 let id2 = make_memory(&store, "key/two");
266
267 store.record_injection(&[id1, id2]).unwrap();
268
269 let metrics = store.get_metrics().unwrap();
270 let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
271 let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
272 assert_eq!(m1.injections, 1);
273 assert_eq!(m2.injections, 1);
274 }
275}