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 = self.effective_embed_provider().embed(summary_text).await?;
102 let vector_size = u64::try_from(vector.len()).unwrap_or(896);
103 qdrant
104 .ensure_named_collection(SESSION_SUMMARIES_COLLECTION, vector_size)
105 .await?;
106
107 let point_id = {
108 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
109 uuid::Uuid::new_v5(&NS, conversation_id.0.to_string().as_bytes()).to_string()
110 };
111 let payload = serde_json::json!({
112 "conversation_id": conversation_id.0,
113 "summary_text": summary_text,
114 });
115
116 qdrant
117 .upsert_to_collection(SESSION_SUMMARIES_COLLECTION, &point_id, payload, vector)
118 .await?;
119
120 tracing::debug!(
121 conversation_id = conversation_id.0,
122 "stored session summary"
123 );
124 Ok(())
125 }
126
127 #[cfg_attr(
133 feature = "profiling",
134 tracing::instrument(name = "memory.cross_session", skip_all, fields(result_count = tracing::field::Empty))
135 )]
136 pub async fn search_session_summaries(
137 &self,
138 query: &str,
139 limit: usize,
140 exclude_conversation_id: Option<ConversationId>,
141 ) -> Result<Vec<SessionSummaryResult>, MemoryError> {
142 let Some(qdrant) = &self.qdrant else {
143 tracing::debug!("session-summaries: skipped, no vector store");
144 return Ok(Vec::new());
145 };
146 if !self.effective_embed_provider().supports_embeddings() {
147 tracing::debug!("session-summaries: skipped, no embedding support");
148 return Ok(Vec::new());
149 }
150
151 let vector = self.effective_embed_provider().embed(query).await?;
152 let vector_size = u64::try_from(vector.len()).unwrap_or(896);
153 qdrant
154 .ensure_named_collection(SESSION_SUMMARIES_COLLECTION, vector_size)
155 .await?;
156
157 let filter = exclude_conversation_id.map(|cid| VectorFilter {
158 must: vec![],
159 must_not: vec![FieldCondition {
160 field: "conversation_id".into(),
161 value: FieldValue::Integer(cid.0),
162 }],
163 });
164
165 let points = qdrant
166 .search_collection(SESSION_SUMMARIES_COLLECTION, &vector, limit, filter)
167 .await?;
168
169 tracing::debug!(
170 results = points.len(),
171 limit,
172 exclude_conversation_id = exclude_conversation_id.map(|c| c.0),
173 "session-summaries: search complete"
174 );
175
176 let results = points
177 .into_iter()
178 .filter_map(|point| {
179 let summary_text = point.payload.get("summary_text")?.as_str()?.to_owned();
180 let conversation_id =
181 ConversationId(point.payload.get("conversation_id")?.as_i64()?);
182 Some(SessionSummaryResult {
183 summary_text,
184 score: point.score,
185 conversation_id,
186 })
187 })
188 .collect();
189
190 Ok(results)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use zeph_llm::any::AnyProvider;
197 use zeph_llm::mock::MockProvider;
198
199 use crate::types::MessageId;
200
201 use super::*;
202
203 async fn make_memory() -> SemanticMemory {
204 SemanticMemory::new(
205 ":memory:",
206 "http://127.0.0.1:1",
207 None,
208 AnyProvider::Mock(MockProvider::default()),
209 "test-model",
210 )
211 .await
212 .unwrap()
213 }
214
215 async fn insert_message(memory: &SemanticMemory, cid: ConversationId) -> MessageId {
218 memory
219 .sqlite()
220 .save_message(cid, "user", "test message")
221 .await
222 .unwrap()
223 }
224
225 #[tokio::test]
226 async fn has_session_summary_returns_false_when_no_summaries() {
227 let memory = make_memory().await;
228 let cid = memory.sqlite().create_conversation().await.unwrap();
229
230 let result = memory.has_session_summary(cid).await.unwrap();
231 assert!(!result, "new conversation must have no summaries");
232 }
233
234 #[tokio::test]
235 async fn has_session_summary_returns_true_after_summary_stored_via_sqlite() {
236 let memory = make_memory().await;
237 let cid = memory.sqlite().create_conversation().await.unwrap();
238 let msg_id = insert_message(&memory, cid).await;
239
240 memory
242 .sqlite()
243 .save_summary(
244 cid,
245 "session about Rust and async",
246 Some(msg_id),
247 Some(msg_id),
248 10,
249 )
250 .await
251 .unwrap();
252
253 let result = memory.has_session_summary(cid).await.unwrap();
254 assert!(result, "must return true after a summary is persisted");
255 }
256
257 #[tokio::test]
258 async fn has_session_summary_is_isolated_per_conversation() {
259 let memory = make_memory().await;
260 let cid_a = memory.sqlite().create_conversation().await.unwrap();
261 let cid_b = memory.sqlite().create_conversation().await.unwrap();
262 let msg_id = insert_message(&memory, cid_a).await;
263
264 memory
265 .sqlite()
266 .save_summary(
267 cid_a,
268 "summary for conversation A",
269 Some(msg_id),
270 Some(msg_id),
271 5,
272 )
273 .await
274 .unwrap();
275
276 assert!(
277 memory.has_session_summary(cid_a).await.unwrap(),
278 "cid_a must have a summary"
279 );
280 assert!(
281 !memory.has_session_summary(cid_b).await.unwrap(),
282 "cid_b must not be affected by cid_a summary"
283 );
284 }
285
286 #[test]
287 fn store_session_summary_point_id_is_deterministic() {
288 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
291 let cid = ConversationId(42);
292 let id1 = uuid::Uuid::new_v5(&NS, cid.0.to_string().as_bytes()).to_string();
293 let id2 = uuid::Uuid::new_v5(&NS, cid.0.to_string().as_bytes()).to_string();
294 assert_eq!(
295 id1, id2,
296 "point_id must be deterministic for the same conversation_id"
297 );
298
299 let cid2 = ConversationId(43);
300 let id3 = uuid::Uuid::new_v5(&NS, cid2.0.to_string().as_bytes()).to_string();
301 assert_ne!(
302 id1, id3,
303 "different conversation_ids must produce different point_ids"
304 );
305 }
306
307 #[test]
308 fn store_session_summary_point_id_boundary_ids() {
309 const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
312
313 let id_zero_a = uuid::Uuid::new_v5(&NS, ConversationId(0).0.to_string().as_bytes());
314 let id_zero_b = uuid::Uuid::new_v5(&NS, ConversationId(0).0.to_string().as_bytes());
315 assert_eq!(id_zero_a, id_zero_b, "zero conversation_id must be stable");
316
317 let id_neg = uuid::Uuid::new_v5(&NS, ConversationId(-1).0.to_string().as_bytes());
318 assert_ne!(
319 id_zero_a, id_neg,
320 "zero and -1 conversation_ids must produce different point_ids"
321 );
322
323 assert_eq!(
325 id_zero_a.get_version_num(),
326 5,
327 "generated UUID must be version 5"
328 );
329 }
330
331 #[tokio::test]
332 async fn store_shutdown_summary_succeeds_with_null_message_ids() {
333 let memory = make_memory().await;
334 let cid = memory.sqlite().create_conversation().await.unwrap();
335
336 let result = memory
337 .store_shutdown_summary(cid, "summary text", &[])
338 .await;
339
340 assert!(
341 result.is_ok(),
342 "shutdown summary must succeed without messages"
343 );
344 assert!(
345 memory.has_session_summary(cid).await.unwrap(),
346 "SQLite must record the shutdown summary"
347 );
348 }
349}