1use zeph_db::{query, query_as, query_scalar, sql};
5
6use super::DbStore;
7use crate::error::MemoryError;
8
9#[derive(Debug, Clone, sqlx::FromRow)]
11pub struct PersonaFactRow {
12 pub id: i64,
13 pub category: String,
14 pub content: String,
15 pub confidence: f64,
16 pub evidence_count: i64,
17 pub source_conversation_id: Option<i64>,
18 pub supersedes_id: Option<i64>,
19 pub created_at: String,
20 pub updated_at: String,
21}
22
23impl DbStore {
24 pub async fn upsert_persona_fact(
36 &self,
37 category: &str,
38 content: &str,
39 confidence: f64,
40 source_conversation_id: Option<i64>,
41 supersedes_id: Option<i64>,
42 ) -> Result<i64, MemoryError> {
43 let safe_source_id = match source_conversation_id {
46 None => None,
47 Some(cid) => {
48 let exists: bool = query_scalar(sql!(
49 "SELECT EXISTS(SELECT 1 FROM conversations WHERE id = ?)"
50 ))
51 .bind(cid)
52 .fetch_one(self.pool())
53 .await?;
54 if exists { Some(cid) } else { None }
55 }
56 };
57
58 let (id,): (i64,) = query_as(sql!(
59 "INSERT INTO persona_memory
60 (category, content, confidence, evidence_count, source_conversation_id,
61 supersedes_id, updated_at)
62 VALUES
63 (?, ?, ?, 1, ?, ?, datetime('now'))
64 ON CONFLICT(category, content) DO UPDATE SET
65 evidence_count = evidence_count + 1,
66 confidence = excluded.confidence,
67 supersedes_id = COALESCE(excluded.supersedes_id, persona_memory.supersedes_id),
68 updated_at = datetime('now')
69 RETURNING id"
70 ))
71 .bind(category)
72 .bind(content)
73 .bind(confidence)
74 .bind(safe_source_id)
75 .bind(supersedes_id)
76 .fetch_one(self.pool())
77 .await?;
78
79 Ok(id)
80 }
81
82 pub async fn load_persona_facts(
90 &self,
91 min_confidence: f64,
92 ) -> Result<Vec<PersonaFactRow>, MemoryError> {
93 let rows: Vec<PersonaFactRow> = query_as(sql!(
96 "SELECT id, category, content, confidence, evidence_count,
97 source_conversation_id, supersedes_id, created_at, updated_at
98 FROM persona_memory
99 WHERE confidence >= ?
100 AND id NOT IN (
101 SELECT supersedes_id FROM persona_memory
102 WHERE supersedes_id IS NOT NULL
103 )
104 ORDER BY confidence DESC"
105 ))
106 .bind(min_confidence)
107 .fetch_all(self.pool())
108 .await?;
109
110 Ok(rows)
111 }
112
113 pub async fn delete_persona_fact(&self, id: i64) -> Result<bool, MemoryError> {
121 let affected = query(sql!("DELETE FROM persona_memory WHERE id = ?"))
122 .bind(id)
123 .execute(self.pool())
124 .await?
125 .rows_affected();
126
127 Ok(affected > 0)
128 }
129
130 pub async fn count_persona_facts(&self) -> Result<i64, MemoryError> {
136 let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM persona_memory"))
137 .fetch_one(self.pool())
138 .await?;
139
140 Ok(count)
141 }
142
143 pub async fn persona_last_extracted_message_id(&self) -> Result<i64, MemoryError> {
149 let id: i64 = query_scalar(sql!(
150 "SELECT last_extracted_message_id FROM persona_meta WHERE id = 1"
151 ))
152 .fetch_one(self.pool())
153 .await?;
154
155 Ok(id)
156 }
157
158 pub async fn set_persona_last_extracted_message_id(
164 &self,
165 message_id: i64,
166 ) -> Result<(), MemoryError> {
167 query(sql!(
168 "UPDATE persona_meta
169 SET last_extracted_message_id = ?, updated_at = datetime('now')
170 WHERE id = 1"
171 ))
172 .bind(message_id)
173 .execute(self.pool())
174 .await?;
175
176 Ok(())
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 async fn make_store() -> DbStore {
185 DbStore::with_pool_size(":memory:", 1)
186 .await
187 .expect("in-memory store")
188 }
189
190 #[tokio::test]
191 async fn upsert_persona_fact_basic_insert() {
192 let store = make_store().await;
193 let id = store
194 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
195 .await
196 .expect("upsert");
197 assert!(id > 0);
198 assert_eq!(store.count_persona_facts().await.expect("count"), 1);
199 }
200
201 #[tokio::test]
202 async fn upsert_persona_fact_increments_evidence_count() {
203 let store = make_store().await;
204 let id1 = store
205 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
206 .await
207 .expect("first upsert");
208 let id2 = store
209 .upsert_persona_fact("preference", "I prefer dark mode", 0.95, None, None)
210 .await
211 .expect("second upsert");
212 assert_eq!(id1, id2);
214
215 let facts = store.load_persona_facts(0.0).await.expect("load");
216 assert_eq!(facts.len(), 1);
217 assert_eq!(facts[0].evidence_count, 2);
218 assert!((facts[0].confidence - 0.95).abs() < 1e-9);
220 }
221
222 #[tokio::test]
223 async fn upsert_persona_fact_supersedes_id_propagated() {
224 let store = make_store().await;
225 let old_id = store
226 .upsert_persona_fact("preference", "I prefer light mode", 0.8, None, None)
227 .await
228 .expect("old fact");
229
230 let _new_id = store
231 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, Some(old_id))
232 .await
233 .expect("new fact");
234
235 let facts = store.load_persona_facts(0.0).await.expect("load");
237 assert_eq!(facts.len(), 1);
238 assert_eq!(facts[0].content, "I prefer dark mode");
239 }
240
241 #[tokio::test]
242 async fn load_persona_facts_excludes_superseded() {
243 let store = make_store().await;
244 let old_id = store
245 .upsert_persona_fact("domain_knowledge", "I know Python", 0.7, None, None)
246 .await
247 .expect("old");
248 store
249 .upsert_persona_fact(
250 "domain_knowledge",
251 "I know Python and Rust",
252 0.85,
253 None,
254 Some(old_id),
255 )
256 .await
257 .expect("new");
258
259 let facts = store.load_persona_facts(0.0).await.expect("load");
260 assert_eq!(facts.len(), 1);
261 assert_eq!(facts[0].content, "I know Python and Rust");
262 }
263
264 #[tokio::test]
265 async fn load_persona_facts_min_confidence_filter() {
266 let store = make_store().await;
267 store
268 .upsert_persona_fact("background", "Senior engineer", 0.9, None, None)
269 .await
270 .expect("high confidence");
271 store
272 .upsert_persona_fact("background", "Works remotely", 0.3, None, None)
273 .await
274 .expect("low confidence");
275
276 let facts = store.load_persona_facts(0.5).await.expect("load");
277 assert_eq!(facts.len(), 1);
278 assert_eq!(facts[0].content, "Senior engineer");
279 }
280
281 #[tokio::test]
282 async fn delete_persona_fact_returns_true_when_found() {
283 let store = make_store().await;
284 let id = store
285 .upsert_persona_fact("working_style", "I prefer async comms", 0.8, None, None)
286 .await
287 .expect("upsert");
288 let deleted = store.delete_persona_fact(id).await.expect("delete");
289 assert!(deleted);
290 assert_eq!(store.count_persona_facts().await.expect("count"), 0);
291 }
292
293 #[tokio::test]
294 async fn delete_persona_fact_returns_false_when_not_found() {
295 let store = make_store().await;
296 let deleted = store.delete_persona_fact(9999).await.expect("delete");
297 assert!(!deleted);
298 }
299
300 #[tokio::test]
301 async fn count_persona_facts_is_zero_initially() {
302 let store = make_store().await;
303 assert_eq!(store.count_persona_facts().await.expect("count"), 0);
304 }
305
306 #[tokio::test]
307 async fn persona_meta_singleton_initial_value() {
308 let store = make_store().await;
309 let id = store
310 .persona_last_extracted_message_id()
311 .await
312 .expect("meta");
313 assert_eq!(id, 0);
314 }
315
316 #[tokio::test]
317 async fn set_persona_last_extracted_message_id_round_trip() {
318 let store = make_store().await;
319 store
320 .set_persona_last_extracted_message_id(42)
321 .await
322 .expect("set");
323 let id = store
324 .persona_last_extracted_message_id()
325 .await
326 .expect("get");
327 assert_eq!(id, 42);
328 }
329
330 #[tokio::test]
331 async fn upsert_persona_fact_invalid_source_conversation_id_stored_as_null() {
332 let store = make_store().await;
333 let id = store
335 .upsert_persona_fact("preference", "I prefer Vim", 0.8, Some(9999), None)
336 .await
337 .expect("upsert with invalid source_conversation_id");
338 assert!(id > 0);
339
340 let facts = store.load_persona_facts(0.0).await.expect("load");
341 assert_eq!(facts.len(), 1);
342 assert!(
343 facts[0].source_conversation_id.is_none(),
344 "source_conversation_id should be NULL for non-existent conversation"
345 );
346 }
347
348 #[tokio::test]
349 async fn upsert_persona_fact_valid_source_conversation_id_preserved() {
350 let store = make_store().await;
351 let cid = store
353 .create_conversation()
354 .await
355 .expect("create_conversation")
356 .0;
357 let id = store
358 .upsert_persona_fact("preference", "I prefer Emacs", 0.7, Some(cid), None)
359 .await
360 .expect("upsert with valid source_conversation_id");
361 assert!(id > 0);
362
363 let facts = store.load_persona_facts(0.0).await.expect("load");
364 assert_eq!(facts.len(), 1);
365 assert_eq!(
366 facts[0].source_conversation_id,
367 Some(cid),
368 "valid source_conversation_id must be preserved"
369 );
370 }
371}