1use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
5
6use super::{KEY_FACTS_COLLECTION, SemanticMemory};
7use crate::embedding_store::MessageKind;
8use crate::error::MemoryError;
9use crate::types::{ConversationId, MessageId};
10use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
11
12#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
13pub struct StructuredSummary {
14 pub summary: String,
15 pub key_facts: Vec<String>,
16 pub entities: Vec<String>,
17}
18
19#[derive(Debug, Clone)]
20pub struct Summary {
21 pub id: i64,
22 pub conversation_id: ConversationId,
23 pub content: String,
24 pub first_message_id: Option<MessageId>,
26 pub last_message_id: Option<MessageId>,
28 pub token_estimate: i64,
29}
30
31#[derive(Debug, Clone, Copy)]
33pub struct SummarizeOutcome {
34 pub summary_id: i64,
36 pub messages_folded: usize,
39}
40
41#[must_use]
42pub fn build_summarization_prompt(messages: &[(MessageId, String, String)]) -> String {
43 let mut prompt = String::from(
44 "Summarize the following conversation. Extract key facts, decisions, entities, \
45 and context needed to continue the conversation.\n\n\
46 Respond in JSON with fields: summary (string), key_facts (list of strings), \
47 entities (list of strings).\n\nConversation:\n",
48 );
49
50 for (_, role, content) in messages {
51 prompt.push_str(role);
52 prompt.push_str(": ");
53 prompt.push_str(content);
54 prompt.push('\n');
55 }
56
57 prompt
58}
59
60impl SemanticMemory {
61 pub async fn load_summaries(
67 &self,
68 conversation_id: ConversationId,
69 ) -> Result<Vec<Summary>, MemoryError> {
70 let rows = self.sqlite.load_summaries(conversation_id).await?;
71 let summaries = rows
72 .into_iter()
73 .map(
74 |(
75 id,
76 conversation_id,
77 content,
78 first_message_id,
79 last_message_id,
80 token_estimate,
81 )| {
82 Summary {
83 id,
84 conversation_id,
85 content,
86 first_message_id,
87 last_message_id,
88 token_estimate,
89 }
90 },
91 )
92 .collect();
93 Ok(summaries)
94 }
95
96 #[tracing::instrument(name = "memory.summarize", skip_all, fields(input_msgs = %message_count, output_len = tracing::field::Empty))]
107 pub async fn summarize(
108 &self,
109 conversation_id: ConversationId,
110 message_count: usize,
111 ) -> Result<Option<SummarizeOutcome>, MemoryError> {
112 let total = self.sqlite.count_messages(conversation_id).await?;
113
114 if total <= i64::try_from(message_count)? {
115 return Ok(None);
116 }
117
118 let after_id = self
119 .sqlite
120 .latest_summary_last_message_id(conversation_id)
121 .await?
122 .unwrap_or(MessageId(0));
123
124 let messages = self
125 .sqlite
126 .load_messages_range(conversation_id, after_id, message_count)
127 .await?;
128
129 if messages.is_empty() {
130 return Ok(None);
131 }
132
133 let messages_folded = messages.len();
134 let prompt = build_summarization_prompt(&messages);
135 let chat_messages = vec![Message {
136 role: Role::User,
137 content: prompt,
138 parts: vec![],
139 metadata: MessageMetadata::default(),
140 }];
141
142 let structured = self.call_summarization_llm(&chat_messages).await?;
143 let summary_text = &structured.summary;
144
145 let token_estimate = i64::try_from(self.token_counter.count_tokens(summary_text))?;
146 let first_message_id = messages[0].0;
147 let last_message_id = messages[messages.len() - 1].0;
148
149 let summary_id = self
150 .sqlite
151 .save_summary(
152 conversation_id,
153 summary_text,
154 Some(first_message_id),
155 Some(last_message_id),
156 token_estimate,
157 )
158 .await?;
159
160 if let Some(qdrant) = &self.qdrant
161 && self.effective_embed_provider().supports_embeddings()
162 {
163 match tokio::time::timeout(
164 self.embed_timeout,
165 self.effective_embed_provider().embed(summary_text),
166 )
167 .await
168 {
169 Ok(Ok(vector)) => {
170 if let Err(e) = qdrant.ensure_collection_for_vector(&vector).await {
171 tracing::warn!("Failed to ensure Qdrant collection: {e:#}");
172 } else if let Err(e) = qdrant
173 .store(
174 MessageId(summary_id),
175 conversation_id,
176 "system",
177 vector,
178 MessageKind::Summary,
179 &self.embedding_model,
180 0,
181 Some("trusted"),
184 )
185 .await
186 {
187 tracing::warn!("Failed to embed summary: {e:#}");
188 }
189 }
190 Ok(Err(e)) => {
191 tracing::warn!("Failed to generate summary embedding: {e:#}");
192 }
193 Err(_) => {
194 tracing::warn!("summarize: embed timed out for summary text — skipping store");
195 }
196 }
197 }
198
199 if !structured.key_facts.is_empty() {
200 self.store_key_facts(conversation_id, summary_id, &structured.key_facts)
201 .await;
202 }
203
204 Ok(Some(SummarizeOutcome {
205 summary_id,
206 messages_folded,
207 }))
208 }
209
210 async fn call_summarization_llm(
219 &self,
220 chat_messages: &[Message],
221 ) -> Result<StructuredSummary, MemoryError> {
222 let timeout_secs = self.summarization_llm_timeout_secs;
223 let timeout = std::time::Duration::from_secs(timeout_secs);
224 match tokio::time::timeout(
225 timeout,
226 self.provider
227 .chat_typed_erased::<StructuredSummary>(chat_messages),
228 )
229 .await
230 {
231 Ok(Ok(s)) => Ok(s),
232 Ok(Err(e)) => {
233 tracing::warn!(
234 "structured summarization failed, falling back to plain text: {e:#}"
235 );
236 match tokio::time::timeout(timeout, self.provider.chat(chat_messages)).await {
237 Ok(Ok(plain)) => Ok(StructuredSummary {
238 summary: plain,
239 key_facts: vec![],
240 entities: vec![],
241 }),
242 Ok(Err(e)) => Err(MemoryError::Llm(e)),
243 Err(_elapsed) => {
244 tracing::warn!(
245 "summarization: plain text fallback LLM call timed out after {timeout_secs}s"
246 );
247 Err(MemoryError::Timeout("LLM call timed out".into()))
248 }
249 }
250 }
251 Err(_elapsed) => {
252 tracing::warn!(
253 "summarization: structured LLM call timed out after {timeout_secs}s"
254 );
255 Err(MemoryError::Timeout("LLM call timed out".into()))
256 }
257 }
258 }
259
260 pub(super) async fn store_key_facts(
261 &self,
262 conversation_id: ConversationId,
263 source_summary_id: i64,
264 key_facts: &[String],
265 ) {
266 let Some(qdrant) = &self.qdrant else {
267 return;
268 };
269 if !self.effective_embed_provider().supports_embeddings() {
270 return;
271 }
272
273 let filtered: Vec<&str> = key_facts
277 .iter()
278 .filter(|f| !is_policy_decision_fact(f.as_str()))
279 .map(String::as_str)
280 .collect();
281
282 let Some(first_fact) = filtered.first().copied() else {
283 return;
284 };
285 let first_vector = match tokio::time::timeout(
286 self.embed_timeout,
287 self.effective_embed_provider().embed(first_fact),
288 )
289 .await
290 {
291 Ok(Ok(v)) => v,
292 Ok(Err(e)) => {
293 tracing::warn!("Failed to embed key fact: {e:#}");
294 return;
295 }
296 Err(_) => {
297 tracing::warn!("store_key_facts: embed timed out for first fact — skipping");
298 return;
299 }
300 };
301 if let Err(e) = qdrant
302 .ensure_named_collection_for_vector(KEY_FACTS_COLLECTION, &first_vector)
303 .await
304 {
305 tracing::warn!("Failed to ensure key_facts collection: {e:#}");
306 return;
307 }
308
309 let threshold = self.key_facts_dedup_threshold;
310 self.store_key_fact_if_unique(
311 qdrant,
312 conversation_id,
313 source_summary_id,
314 first_fact,
315 first_vector,
316 threshold,
317 )
318 .await;
319
320 for fact in filtered[1..].iter().copied() {
321 match tokio::time::timeout(
322 self.embed_timeout,
323 self.effective_embed_provider().embed(fact),
324 )
325 .await
326 {
327 Ok(Ok(vector)) => {
328 self.store_key_fact_if_unique(
329 qdrant,
330 conversation_id,
331 source_summary_id,
332 fact,
333 vector,
334 threshold,
335 )
336 .await;
337 }
338 Ok(Err(e)) => {
339 tracing::warn!("Failed to embed key fact: {e:#}");
340 }
341 Err(_) => {
342 tracing::warn!("store_key_facts: embed timed out for fact — skipping");
343 }
344 }
345 }
346 }
347
348 async fn store_key_fact_if_unique(
349 &self,
350 qdrant: &crate::embedding_store::EmbeddingStore,
351 conversation_id: ConversationId,
352 source_summary_id: i64,
353 fact: &str,
354 vector: Vec<f32>,
355 threshold: f32,
356 ) {
357 let dedup_filter = Some(VectorFilter {
362 must: vec![
363 FieldCondition {
364 field: "conversation_id".into(),
365 value: FieldValue::Integer(conversation_id.0),
366 },
367 FieldCondition {
368 field: "db_instance_id".into(),
369 value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
370 },
371 ],
372 must_not: vec![],
373 });
374 match qdrant
375 .search_collection(KEY_FACTS_COLLECTION, &vector, 1, dedup_filter)
376 .await
377 {
378 Ok(hits) if hits.first().is_some_and(|h| h.score >= threshold) => {
379 tracing::debug!(
380 score = hits[0].score,
381 threshold,
382 "key-facts: skipping near-duplicate fact"
383 );
384 return;
385 }
386 Ok(_) => {}
387 Err(e) => {
388 tracing::warn!("key-facts: dedup search failed, storing anyway: {e:#}");
389 }
390 }
391
392 let payload = serde_json::json!({
393 "conversation_id": conversation_id.0,
394 "db_instance_id": qdrant.db_instance_id(),
395 "fact_text": fact,
396 "source_summary_id": source_summary_id,
397 });
398 if let Err(e) = qdrant
399 .store_to_collection(KEY_FACTS_COLLECTION, payload, vector)
400 .await
401 {
402 tracing::warn!("Failed to store key fact: {e:#}");
403 }
404 }
405
406 pub async fn search_key_facts(
417 &self,
418 query: &str,
419 limit: usize,
420 conversation_id: Option<ConversationId>,
421 ) -> Result<Vec<String>, MemoryError> {
422 let Some(qdrant) = &self.qdrant else {
423 tracing::debug!("key-facts: skipped, no vector store");
424 return Ok(Vec::new());
425 };
426 if !self.effective_embed_provider().supports_embeddings() {
427 tracing::debug!("key-facts: skipped, no embedding support");
428 return Ok(Vec::new());
429 }
430
431 let vector = match tokio::time::timeout(
432 self.embed_timeout,
433 self.effective_embed_provider().embed(query),
434 )
435 .await
436 {
437 Ok(Ok(v)) => v,
438 Ok(Err(e)) => return Err(e.into()),
439 Err(_) => {
440 tracing::warn!("search_key_facts: embed timed out, returning empty results");
441 return Ok(Vec::new());
442 }
443 };
444 qdrant
445 .ensure_named_collection_for_vector(KEY_FACTS_COLLECTION, &vector)
446 .await?;
447
448 let filter = conversation_id.map(|cid| VectorFilter {
449 must: vec![
450 FieldCondition {
451 field: "conversation_id".into(),
452 value: FieldValue::Integer(cid.0),
453 },
454 FieldCondition {
455 field: "db_instance_id".into(),
456 value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
457 },
458 ],
459 must_not: vec![],
460 });
461
462 let points = qdrant
463 .search_collection(KEY_FACTS_COLLECTION, &vector, limit, filter)
464 .await?;
465
466 tracing::debug!(
467 results = points.len(),
468 limit,
469 conversation_id = conversation_id.map(|c| c.0),
470 "key-facts: search complete"
471 );
472
473 let facts = points
474 .into_iter()
475 .filter_map(|p| p.payload.get("fact_text")?.as_str().map(String::from))
476 .collect();
477
478 Ok(facts)
479 }
480
481 pub async fn search_document_collection(
491 &self,
492 collection: &str,
493 query: &str,
494 limit: usize,
495 ) -> Result<Vec<crate::ScoredVectorPoint>, MemoryError> {
496 let Some(qdrant) = &self.qdrant else {
497 return Ok(Vec::new());
498 };
499 if !self.effective_embed_provider().supports_embeddings() {
500 return Ok(Vec::new());
501 }
502 if !qdrant.collection_exists(collection).await? {
503 return Ok(Vec::new());
504 }
505 let vector = match tokio::time::timeout(
506 self.embed_timeout,
507 self.effective_embed_provider().embed(query),
508 )
509 .await
510 {
511 Ok(Ok(v)) => v,
512 Ok(Err(e)) => return Err(e.into()),
513 Err(_) => {
514 tracing::warn!(
515 "search_document_collection: embed timed out, returning empty results"
516 );
517 return Ok(Vec::new());
518 }
519 };
520 let results = qdrant
521 .search_collection(collection, &vector, limit, None)
522 .await?;
523
524 tracing::debug!(
525 results = results.len(),
526 limit,
527 collection,
528 "document-collection: search complete"
529 );
530
531 Ok(results)
532 }
533}
534
535pub(crate) fn is_policy_decision_fact(fact: &str) -> bool {
541 const MARKERS: &[&str] = &[
542 "blocked",
543 "skipped",
544 "cannot access",
545 "security polic",
546 "utility polic",
547 "not allowed",
548 "permission denied",
549 "access denied",
550 "was denied",
551 ];
552 let lower = fact.to_lowercase();
553 MARKERS.iter().any(|m| lower.contains(m))
554}