1use zeph_llm::provider::LlmProvider as _;
5
6use crate::error::MemoryError;
7use crate::types::ConversationId;
8use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
9
10use super::{SESSION_SUMMARIES_COLLECTION, SemanticMemory};
11
12#[derive(Debug, Clone)]
13pub struct SessionSummaryResult {
14 pub summary_text: String,
15 pub score: f32,
16 pub conversation_id: ConversationId,
17}
18
19impl SemanticMemory {
20 pub async fn has_session_summary(
30 &self,
31 conversation_id: ConversationId,
32 ) -> Result<bool, MemoryError> {
33 let summaries = self.sqlite.load_summaries(conversation_id).await?;
34 Ok(!summaries.is_empty())
35 }
36
37 pub async fn store_shutdown_summary(
49 &self,
50 conversation_id: ConversationId,
51 summary_text: &str,
52 key_facts: &[String],
53 ) -> Result<(), MemoryError> {
54 let token_estimate =
55 i64::try_from(self.token_counter.count_tokens(summary_text)).unwrap_or(0);
56 let summary_id = self
59 .sqlite
60 .save_summary(conversation_id, summary_text, None, None, token_estimate)
61 .await?;
62
63 if let Err(e) = self
65 .store_session_summary(conversation_id, summary_text)
66 .await
67 {
68 tracing::warn!("shutdown summary: failed to embed into session summaries: {e:#}");
69 }
70
71 if !key_facts.is_empty() {
72 self.store_key_facts(conversation_id, summary_id, key_facts)
73 .await;
74 }
75
76 tracing::debug!(
77 conversation_id = conversation_id.0,
78 summary_id,
79 "stored shutdown session summary"
80 );
81 Ok(())
82 }
83
84 pub async fn store_session_summary(
90 &self,
91 conversation_id: ConversationId,
92 summary_text: &str,
93 ) -> Result<(), MemoryError> {
94 let Some(qdrant) = &self.qdrant else {
95 return Ok(());
96 };
97 if !self.effective_embed_provider().supports_embeddings() {
98 return Ok(());
99 }
100
101 let vector = match tokio::time::timeout(
102 self.embed_timeout,
103 self.effective_embed_provider().embed(summary_text),
104 )
105 .await
106 {
107 Ok(Ok(v)) => v,
108 Ok(Err(e)) => return Err(e.into()),
109 Err(_) => {
110 tracing::warn!("store_session_summary: embed timed out — skipping store");
111 return Ok(());
112 }
113 };
114 qdrant
115 .ensure_named_collection_for_vector(SESSION_SUMMARIES_COLLECTION, &vector)
116 .await?;
117
118 let point_id = {
119 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
120 let name = format!("{}:{}", qdrant.db_instance_id(), conversation_id.0);
121 uuid::Uuid::new_v5(&NS, name.as_bytes()).to_string()
122 };
123 let payload = serde_json::json!({
124 "conversation_id": conversation_id.0,
125 "db_instance_id": qdrant.db_instance_id(),
126 "summary_text": summary_text,
127 });
128
129 qdrant
130 .upsert_to_collection(SESSION_SUMMARIES_COLLECTION, &point_id, payload, vector)
131 .await?;
132
133 tracing::debug!(
134 conversation_id = conversation_id.0,
135 "stored session summary"
136 );
137 Ok(())
138 }
139
140 #[cfg_attr(
146 feature = "profiling",
147 tracing::instrument(name = "memory.cross_session", skip_all, fields(result_count = tracing::field::Empty))
148 )]
149 pub async fn search_session_summaries(
150 &self,
151 query: &str,
152 limit: usize,
153 exclude_conversation_id: Option<ConversationId>,
154 ) -> Result<Vec<SessionSummaryResult>, MemoryError> {
155 let Some(qdrant) = &self.qdrant else {
156 tracing::debug!("session-summaries: skipped, no vector store");
157 return Ok(Vec::new());
158 };
159 if !self.effective_embed_provider().supports_embeddings() {
160 tracing::debug!("session-summaries: skipped, no embedding support");
161 return Ok(Vec::new());
162 }
163
164 let vector = match tokio::time::timeout(
165 self.embed_timeout,
166 self.effective_embed_provider().embed(query),
167 )
168 .await
169 {
170 Ok(Ok(v)) => v,
171 Ok(Err(e)) => return Err(e.into()),
172 Err(_) => {
173 tracing::warn!(
174 "search_session_summaries: embed timed out, returning empty results"
175 );
176 return Ok(Vec::new());
177 }
178 };
179 qdrant
180 .ensure_named_collection_for_vector(SESSION_SUMMARIES_COLLECTION, &vector)
181 .await?;
182
183 let filter = Some(VectorFilter {
187 must: vec![FieldCondition {
188 field: "db_instance_id".into(),
189 value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
190 }],
191 must_not: exclude_conversation_id
192 .map(|cid| {
193 vec![FieldCondition {
194 field: "conversation_id".into(),
195 value: FieldValue::Integer(cid.0),
196 }]
197 })
198 .unwrap_or_default(),
199 });
200
201 let points = qdrant
202 .search_collection(SESSION_SUMMARIES_COLLECTION, &vector, limit, filter)
203 .await?;
204
205 tracing::debug!(
206 results = points.len(),
207 limit,
208 exclude_conversation_id = exclude_conversation_id.map(|c| c.0),
209 "session-summaries: search complete"
210 );
211
212 let results = points
213 .into_iter()
214 .filter_map(|point| {
215 let summary_text = point.payload.get("summary_text")?.as_str()?.to_owned();
216 let conversation_id =
217 ConversationId(point.payload.get("conversation_id")?.as_i64()?);
218 Some(SessionSummaryResult {
219 summary_text,
220 score: point.score,
221 conversation_id,
222 })
223 })
224 .collect();
225
226 Ok(results)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use zeph_llm::any::AnyProvider;
233 use zeph_llm::mock::MockProvider;
234
235 use crate::types::MessageId;
236
237 use super::*;
238
239 async fn make_memory() -> SemanticMemory {
240 SemanticMemory::new(
241 ":memory:",
242 "http://127.0.0.1:1",
243 None,
244 AnyProvider::Mock(MockProvider::default()),
245 "test-model",
246 )
247 .await
248 .unwrap()
249 }
250
251 async fn insert_message(memory: &SemanticMemory, cid: ConversationId) -> MessageId {
254 memory
255 .sqlite()
256 .save_message(cid, "user", "test message")
257 .await
258 .unwrap()
259 }
260
261 #[tokio::test]
262 async fn has_session_summary_returns_false_when_no_summaries() {
263 let memory = make_memory().await;
264 let cid = memory.sqlite().create_conversation().await.unwrap();
265
266 let result = memory.has_session_summary(cid).await.unwrap();
267 assert!(!result, "new conversation must have no summaries");
268 }
269
270 #[tokio::test]
271 async fn has_session_summary_returns_true_after_summary_stored_via_sqlite() {
272 let memory = make_memory().await;
273 let cid = memory.sqlite().create_conversation().await.unwrap();
274 let msg_id = insert_message(&memory, cid).await;
275
276 memory
278 .sqlite()
279 .save_summary(
280 cid,
281 "session about Rust and async",
282 Some(msg_id),
283 Some(msg_id),
284 10,
285 )
286 .await
287 .unwrap();
288
289 let result = memory.has_session_summary(cid).await.unwrap();
290 assert!(result, "must return true after a summary is persisted");
291 }
292
293 #[tokio::test]
294 async fn has_session_summary_is_isolated_per_conversation() {
295 let memory = make_memory().await;
296 let cid_a = memory.sqlite().create_conversation().await.unwrap();
297 let cid_b = memory.sqlite().create_conversation().await.unwrap();
298 let msg_id = insert_message(&memory, cid_a).await;
299
300 memory
301 .sqlite()
302 .save_summary(
303 cid_a,
304 "summary for conversation A",
305 Some(msg_id),
306 Some(msg_id),
307 5,
308 )
309 .await
310 .unwrap();
311
312 assert!(
313 memory.has_session_summary(cid_a).await.unwrap(),
314 "cid_a must have a summary"
315 );
316 assert!(
317 !memory.has_session_summary(cid_b).await.unwrap(),
318 "cid_b must not be affected by cid_a summary"
319 );
320 }
321
322 fn point_id_name(db_instance_id: &str, cid: ConversationId) -> String {
325 format!("{db_instance_id}:{}", cid.0)
326 }
327
328 #[test]
329 fn store_session_summary_point_id_is_deterministic() {
330 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
333 let cid = ConversationId(42);
334 let id1 = uuid::Uuid::new_v5(&NS, point_id_name("", cid).as_bytes()).to_string();
335 let id2 = uuid::Uuid::new_v5(&NS, point_id_name("", cid).as_bytes()).to_string();
336 assert_eq!(
337 id1, id2,
338 "point_id must be deterministic for the same inputs"
339 );
340
341 let cid2 = ConversationId(43);
342 let id3 = uuid::Uuid::new_v5(&NS, point_id_name("", cid2).as_bytes()).to_string();
343 assert_ne!(
344 id1, id3,
345 "different conversation_ids must produce different point_ids"
346 );
347 }
348
349 #[test]
350 fn store_session_summary_point_id_boundary_ids() {
351 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
354
355 let id_zero_a = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(0)).as_bytes());
356 let id_zero_b = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(0)).as_bytes());
357 assert_eq!(id_zero_a, id_zero_b, "zero conversation_id must be stable");
358
359 let id_neg = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(-1)).as_bytes());
360 assert_ne!(
361 id_zero_a, id_neg,
362 "zero and -1 conversation_ids must produce different point_ids"
363 );
364
365 assert_eq!(
367 id_zero_a.get_version_num(),
368 5,
369 "generated UUID must be version 5"
370 );
371 }
372
373 #[test]
377 fn store_session_summary_point_id_differs_across_db_instances() {
378 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
379 let cid = ConversationId(1);
380 let id_db_a = uuid::Uuid::new_v5(&NS, point_id_name("db-a", cid).as_bytes());
381 let id_db_b = uuid::Uuid::new_v5(&NS, point_id_name("db-b", cid).as_bytes());
382 assert_ne!(
383 id_db_a, id_db_b,
384 "same conversation_id in two different databases must produce different point_ids"
385 );
386 }
387
388 async fn make_embed_memory_with_db_instance(
394 db_instance_id: &str,
395 shared_vector_pool: zeph_db::DbPool,
396 ) -> SemanticMemory {
397 let mut mock = MockProvider::default();
398 mock.supports_embeddings = true;
399 mock.embedding = vec![1.0, 0.0, 0.0, 0.0];
400 let provider = AnyProvider::Mock(mock);
401
402 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
403 let pool = sqlite.pool().clone();
404 let store = crate::embedding_store::EmbeddingStore::with_store(
405 Box::new(crate::db_vector_store::DbVectorStore::new(
406 shared_vector_pool,
407 )),
408 pool,
409 )
410 .with_db_instance_id(db_instance_id);
411
412 SemanticMemory::base(
413 sqlite,
414 Some(std::sync::Arc::new(store)),
415 provider,
416 "test-model".into(),
417 0.7,
418 0.3,
419 std::sync::Arc::new(crate::token_counter::TokenCounter::new()),
420 )
421 }
422
423 #[tokio::test]
428 async fn search_session_summaries_scoped_excludes_other_database_same_conversation_id() {
429 let shared = crate::store::SqliteStore::new(":memory:").await.unwrap();
430 let shared_pool = shared.pool().clone();
431
432 let memory_a = make_embed_memory_with_db_instance("db-a", shared_pool.clone()).await;
433 let memory_b = make_embed_memory_with_db_instance("db-b", shared_pool.clone()).await;
434
435 let cid_a = memory_a.sqlite().create_conversation().await.unwrap();
436 let cid_b = memory_b.sqlite().create_conversation().await.unwrap();
437 assert_eq!(
438 cid_a, cid_b,
439 "both databases must independently start at conversation_id=1"
440 );
441
442 memory_a
443 .store_session_summary(cid_a, "summary from database A")
444 .await
445 .unwrap();
446 memory_b
447 .store_session_summary(cid_b, "summary from database B")
448 .await
449 .unwrap();
450
451 let results_a = memory_a
452 .search_session_summaries("summary", 10, None)
453 .await
454 .unwrap();
455 assert_eq!(
456 results_a.len(),
457 1,
458 "db-a's unscoped cross-session recall must not see db-b's summary"
459 );
460 assert_eq!(results_a[0].summary_text, "summary from database A");
461
462 let results_b = memory_b
463 .search_session_summaries("summary", 10, None)
464 .await
465 .unwrap();
466 assert_eq!(
467 results_b.len(),
468 1,
469 "db-b's unscoped cross-session recall must not see db-a's summary"
470 );
471 assert_eq!(results_b[0].summary_text, "summary from database B");
472 }
473
474 #[tokio::test]
475 async fn store_shutdown_summary_succeeds_with_null_message_ids() {
476 let memory = make_memory().await;
477 let cid = memory.sqlite().create_conversation().await.unwrap();
478
479 let result = memory
480 .store_shutdown_summary(cid, "summary text", &[])
481 .await;
482
483 assert!(
484 result.is_ok(),
485 "shutdown summary must succeed without messages"
486 );
487 assert!(
488 memory.has_session_summary(cid).await.unwrap(),
489 "SQLite must record the shutdown summary"
490 );
491 }
492}