1use zeph_db::{ActiveDialect, query, query_as, query_scalar, sql};
5
6use super::SqliteStore;
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: i32,
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 SqliteStore {
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 now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
59 let raw = format!(
60 "INSERT INTO persona_memory
61 (category, content, confidence, evidence_count, source_conversation_id,
62 supersedes_id, updated_at)
63 VALUES
64 (?, ?, ?, 1, ?, ?, {now})
65 ON CONFLICT(category, content) DO UPDATE SET
66 evidence_count = persona_memory.evidence_count + 1,
67 confidence = excluded.confidence,
68 supersedes_id = COALESCE(excluded.supersedes_id, persona_memory.supersedes_id),
69 updated_at = {now}
70 RETURNING id"
71 );
72 let query_sql = zeph_db::rewrite_placeholders(&raw);
73 let (id,): (i64,) = query_as(sqlx::AssertSqlSafe(query_sql))
74 .bind(category)
75 .bind(content)
76 .bind(confidence)
77 .bind(safe_source_id)
78 .bind(supersedes_id)
79 .fetch_one(self.pool())
80 .await?;
81
82 Ok(id)
83 }
84
85 pub async fn load_persona_facts(
93 &self,
94 min_confidence: f64,
95 ) -> Result<Vec<PersonaFactRow>, MemoryError> {
96 let created_at_sel =
104 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
105 let updated_at_sel =
106 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
107 let raw = format!(
108 "SELECT id, category, content, CAST(confidence AS DOUBLE PRECISION) AS confidence,
109 evidence_count, source_conversation_id, supersedes_id,
110 {created_at_sel} AS created_at, {updated_at_sel} AS updated_at
111 FROM persona_memory
112 WHERE confidence >= ?
113 AND id NOT IN (
114 SELECT supersedes_id FROM persona_memory
115 WHERE supersedes_id IS NOT NULL
116 )
117 ORDER BY confidence DESC"
118 );
119 let query_sql = zeph_db::rewrite_placeholders(&raw);
120 let rows: Vec<PersonaFactRow> = query_as(sqlx::AssertSqlSafe(query_sql))
121 .bind(min_confidence)
122 .fetch_all(self.pool())
123 .await?;
124
125 Ok(rows)
126 }
127
128 pub async fn delete_persona_fact(&self, id: i64) -> Result<bool, MemoryError> {
136 let affected = query(sql!("DELETE FROM persona_memory WHERE id = ?"))
137 .bind(id)
138 .execute(self.pool())
139 .await?
140 .rows_affected();
141
142 Ok(affected > 0)
143 }
144
145 pub async fn count_persona_facts(&self) -> Result<i64, MemoryError> {
151 let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM persona_memory"))
152 .fetch_one(self.pool())
153 .await?;
154
155 Ok(count)
156 }
157
158 pub async fn persona_last_extracted_message_id(&self) -> Result<i64, MemoryError> {
164 let id: i64 = query_scalar(sql!(
165 "SELECT last_extracted_message_id FROM persona_meta WHERE id = 1"
166 ))
167 .fetch_one(self.pool())
168 .await?;
169
170 Ok(id)
171 }
172
173 pub async fn set_persona_last_extracted_message_id(
179 &self,
180 message_id: i64,
181 ) -> Result<(), MemoryError> {
182 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
183 let raw = format!(
184 "UPDATE persona_meta
185 SET last_extracted_message_id = ?, updated_at = {now}
186 WHERE id = 1"
187 );
188 let query_sql = zeph_db::rewrite_placeholders(&raw);
189 query(sqlx::AssertSqlSafe(query_sql))
190 .bind(message_id)
191 .execute(self.pool())
192 .await?;
193
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 async fn make_store() -> SqliteStore {
203 SqliteStore::with_pool_size(":memory:", 1)
204 .await
205 .expect("in-memory store")
206 }
207
208 #[tokio::test]
209 async fn upsert_persona_fact_basic_insert() {
210 let store = make_store().await;
211 let id = store
212 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
213 .await
214 .expect("upsert");
215 assert!(id > 0);
216 assert_eq!(store.count_persona_facts().await.expect("count"), 1);
217 }
218
219 #[tokio::test]
220 async fn upsert_persona_fact_increments_evidence_count() {
221 let store = make_store().await;
222 let id1 = store
223 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
224 .await
225 .expect("first upsert");
226 let id2 = store
227 .upsert_persona_fact("preference", "I prefer dark mode", 0.95, None, None)
228 .await
229 .expect("second upsert");
230 assert_eq!(id1, id2);
232
233 let facts = store.load_persona_facts(0.0).await.expect("load");
234 assert_eq!(facts.len(), 1);
235 assert_eq!(facts[0].evidence_count, 2);
236 assert!((facts[0].confidence - 0.95).abs() < 1e-9);
238 }
239
240 #[tokio::test]
241 async fn upsert_persona_fact_supersedes_id_propagated() {
242 let store = make_store().await;
243 let old_id = store
244 .upsert_persona_fact("preference", "I prefer light mode", 0.8, None, None)
245 .await
246 .expect("old fact");
247
248 let _new_id = store
249 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, Some(old_id))
250 .await
251 .expect("new fact");
252
253 let facts = store.load_persona_facts(0.0).await.expect("load");
255 assert_eq!(facts.len(), 1);
256 assert_eq!(facts[0].content, "I prefer dark mode");
257 }
258
259 #[tokio::test]
260 async fn load_persona_facts_excludes_superseded() {
261 let store = make_store().await;
262 let old_id = store
263 .upsert_persona_fact("domain_knowledge", "I know Python", 0.7, None, None)
264 .await
265 .expect("old");
266 store
267 .upsert_persona_fact(
268 "domain_knowledge",
269 "I know Python and Rust",
270 0.85,
271 None,
272 Some(old_id),
273 )
274 .await
275 .expect("new");
276
277 let facts = store.load_persona_facts(0.0).await.expect("load");
278 assert_eq!(facts.len(), 1);
279 assert_eq!(facts[0].content, "I know Python and Rust");
280 }
281
282 #[tokio::test]
283 async fn load_persona_facts_min_confidence_filter() {
284 let store = make_store().await;
285 store
286 .upsert_persona_fact("background", "Senior engineer", 0.9, None, None)
287 .await
288 .expect("high confidence");
289 store
290 .upsert_persona_fact("background", "Works remotely", 0.3, None, None)
291 .await
292 .expect("low confidence");
293
294 let facts = store.load_persona_facts(0.5).await.expect("load");
295 assert_eq!(facts.len(), 1);
296 assert_eq!(facts[0].content, "Senior engineer");
297 }
298
299 #[tokio::test]
300 async fn delete_persona_fact_returns_true_when_found() {
301 let store = make_store().await;
302 let id = store
303 .upsert_persona_fact("working_style", "I prefer async comms", 0.8, None, None)
304 .await
305 .expect("upsert");
306 let deleted = store.delete_persona_fact(id).await.expect("delete");
307 assert!(deleted);
308 assert_eq!(store.count_persona_facts().await.expect("count"), 0);
309 }
310
311 #[tokio::test]
312 async fn delete_persona_fact_returns_false_when_not_found() {
313 let store = make_store().await;
314 let deleted = store.delete_persona_fact(9999).await.expect("delete");
315 assert!(!deleted);
316 }
317
318 #[tokio::test]
319 async fn count_persona_facts_is_zero_initially() {
320 let store = make_store().await;
321 assert_eq!(store.count_persona_facts().await.expect("count"), 0);
322 }
323
324 #[tokio::test]
325 async fn persona_meta_singleton_initial_value() {
326 let store = make_store().await;
327 let id = store
328 .persona_last_extracted_message_id()
329 .await
330 .expect("meta");
331 assert_eq!(id, 0);
332 }
333
334 #[tokio::test]
335 async fn set_persona_last_extracted_message_id_round_trip() {
336 let store = make_store().await;
337 store
338 .set_persona_last_extracted_message_id(42)
339 .await
340 .expect("set");
341 let id = store
342 .persona_last_extracted_message_id()
343 .await
344 .expect("get");
345 assert_eq!(id, 42);
346 }
347
348 #[tokio::test]
349 async fn upsert_persona_fact_invalid_source_conversation_id_stored_as_null() {
350 let store = make_store().await;
351 let id = store
353 .upsert_persona_fact("preference", "I prefer Vim", 0.8, Some(9999), None)
354 .await
355 .expect("upsert with invalid source_conversation_id");
356 assert!(id > 0);
357
358 let facts = store.load_persona_facts(0.0).await.expect("load");
359 assert_eq!(facts.len(), 1);
360 assert!(
361 facts[0].source_conversation_id.is_none(),
362 "source_conversation_id should be NULL for non-existent conversation"
363 );
364 }
365
366 #[tokio::test]
367 async fn upsert_persona_fact_valid_source_conversation_id_preserved() {
368 let store = make_store().await;
369 let cid = store
371 .create_conversation()
372 .await
373 .expect("create_conversation")
374 .0;
375 let id = store
376 .upsert_persona_fact("preference", "I prefer Emacs", 0.7, Some(cid), None)
377 .await
378 .expect("upsert with valid source_conversation_id");
379 assert!(id > 0);
380
381 let facts = store.load_persona_facts(0.0).await.expect("load");
382 assert_eq!(facts.len(), 1);
383 assert_eq!(
384 facts[0].source_conversation_id,
385 Some(cid),
386 "valid source_conversation_id must be preserved"
387 );
388 }
389}