Skip to main content

zeph_memory/
semantic.rs

1use zeph_llm::any::AnyProvider;
2use zeph_llm::provider::{LlmProvider, Message, Role};
3
4use crate::embedding_store::{EmbeddingStore, MessageKind, SearchFilter};
5use crate::error::MemoryError;
6use crate::sqlite::SqliteStore;
7use crate::types::{ConversationId, MessageId};
8use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
9
10const SESSION_SUMMARIES_COLLECTION: &str = "zeph_session_summaries";
11const KEY_FACTS_COLLECTION: &str = "zeph_key_facts";
12
13#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
14pub struct StructuredSummary {
15    pub summary: String,
16    pub key_facts: Vec<String>,
17    pub entities: Vec<String>,
18}
19
20#[derive(Debug)]
21pub struct RecalledMessage {
22    pub message: Message,
23    pub score: f32,
24}
25
26#[derive(Debug, Clone)]
27pub struct Summary {
28    pub id: i64,
29    pub conversation_id: ConversationId,
30    pub content: String,
31    pub first_message_id: MessageId,
32    pub last_message_id: MessageId,
33    pub token_estimate: i64,
34}
35
36#[derive(Debug, Clone)]
37pub struct SessionSummaryResult {
38    pub summary_text: String,
39    pub score: f32,
40    pub conversation_id: ConversationId,
41}
42
43/// Estimate token count using bytes/3 heuristic.
44#[must_use]
45pub fn estimate_tokens(text: &str) -> usize {
46    text.len() / 3
47}
48
49fn build_summarization_prompt(messages: &[(MessageId, String, String)]) -> String {
50    let mut prompt = String::from(
51        "Summarize the following conversation. Extract key facts, decisions, entities, \
52         and context needed to continue the conversation.\n\n\
53         Respond in JSON with fields: summary (string), key_facts (list of strings), \
54         entities (list of strings).\n\nConversation:\n",
55    );
56
57    for (_, role, content) in messages {
58        prompt.push_str(role);
59        prompt.push_str(": ");
60        prompt.push_str(content);
61        prompt.push('\n');
62    }
63
64    prompt
65}
66
67pub struct SemanticMemory {
68    sqlite: SqliteStore,
69    qdrant: Option<EmbeddingStore>,
70    provider: AnyProvider,
71    embedding_model: String,
72    vector_weight: f64,
73    keyword_weight: f64,
74}
75
76impl SemanticMemory {
77    /// Create a new `SemanticMemory` instance with default hybrid search weights (0.7/0.3).
78    ///
79    /// Qdrant connection is best-effort: if unavailable, semantic search is disabled.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if `SQLite` cannot be initialized.
84    pub async fn new(
85        sqlite_path: &str,
86        qdrant_url: &str,
87        provider: AnyProvider,
88        embedding_model: &str,
89    ) -> Result<Self, MemoryError> {
90        Self::with_weights(sqlite_path, qdrant_url, provider, embedding_model, 0.7, 0.3).await
91    }
92
93    /// Create a new `SemanticMemory` with custom vector/keyword weights for hybrid search.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if `SQLite` cannot be initialized.
98    pub async fn with_weights(
99        sqlite_path: &str,
100        qdrant_url: &str,
101        provider: AnyProvider,
102        embedding_model: &str,
103        vector_weight: f64,
104        keyword_weight: f64,
105    ) -> Result<Self, MemoryError> {
106        let sqlite = SqliteStore::new(sqlite_path).await?;
107        let pool = sqlite.pool().clone();
108
109        let qdrant = match EmbeddingStore::new(qdrant_url, pool) {
110            Ok(store) => Some(store),
111            Err(e) => {
112                tracing::warn!("Qdrant unavailable, semantic search disabled: {e:#}");
113                None
114            }
115        };
116
117        Ok(Self {
118            sqlite,
119            qdrant,
120            provider,
121            embedding_model: embedding_model.into(),
122            vector_weight,
123            keyword_weight,
124        })
125    }
126
127    /// Save a message to `SQLite` and optionally embed and store in Qdrant.
128    ///
129    /// Returns the message ID assigned by `SQLite`.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the `SQLite` save fails. Embedding failures are logged but not
134    /// propagated.
135    pub async fn remember(
136        &self,
137        conversation_id: ConversationId,
138        role: &str,
139        content: &str,
140    ) -> Result<MessageId, MemoryError> {
141        let message_id = self
142            .sqlite
143            .save_message(conversation_id, role, content)
144            .await?;
145
146        if let Some(qdrant) = &self.qdrant
147            && self.provider.supports_embeddings()
148        {
149            match self.provider.embed(content).await {
150                Ok(vector) => {
151                    // Ensure collection exists before storing
152                    let vector_size = u64::try_from(vector.len()).unwrap_or(896);
153                    if let Err(e) = qdrant.ensure_collection(vector_size).await {
154                        tracing::warn!("Failed to ensure Qdrant collection: {e:#}");
155                    } else if let Err(e) = qdrant
156                        .store(
157                            message_id,
158                            conversation_id,
159                            role,
160                            vector,
161                            MessageKind::Regular,
162                            &self.embedding_model,
163                        )
164                        .await
165                    {
166                        tracing::warn!("Failed to store embedding: {e:#}");
167                    }
168                }
169                Err(e) => {
170                    tracing::warn!("Failed to generate embedding: {e:#}");
171                }
172            }
173        }
174
175        Ok(message_id)
176    }
177
178    /// Save a message with pre-serialized parts JSON to `SQLite` and optionally embed in Qdrant.
179    ///
180    /// Returns `(message_id, embedding_stored)` tuple where `embedding_stored` is `true` if
181    /// an embedding was successfully generated and stored in Qdrant.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the `SQLite` save fails.
186    pub async fn remember_with_parts(
187        &self,
188        conversation_id: ConversationId,
189        role: &str,
190        content: &str,
191        parts_json: &str,
192    ) -> Result<(MessageId, bool), MemoryError> {
193        let message_id = self
194            .sqlite
195            .save_message_with_parts(conversation_id, role, content, parts_json)
196            .await?;
197
198        let mut embedding_stored = false;
199
200        if let Some(qdrant) = &self.qdrant
201            && self.provider.supports_embeddings()
202        {
203            match self.provider.embed(content).await {
204                Ok(vector) => {
205                    let vector_size = u64::try_from(vector.len()).unwrap_or(896);
206                    if let Err(e) = qdrant.ensure_collection(vector_size).await {
207                        tracing::warn!("Failed to ensure Qdrant collection: {e:#}");
208                    } else if let Err(e) = qdrant
209                        .store(
210                            message_id,
211                            conversation_id,
212                            role,
213                            vector,
214                            MessageKind::Regular,
215                            &self.embedding_model,
216                        )
217                        .await
218                    {
219                        tracing::warn!("Failed to store embedding: {e:#}");
220                    } else {
221                        embedding_stored = true;
222                    }
223                }
224                Err(e) => {
225                    tracing::warn!("Failed to generate embedding: {e:#}");
226                }
227            }
228        }
229
230        Ok((message_id, embedding_stored))
231    }
232
233    /// Recall relevant messages using hybrid search (vector + FTS5 keyword).
234    ///
235    /// When Qdrant is available, runs both vector and keyword searches, then merges
236    /// results using weighted scoring. When Qdrant is unavailable, falls back to
237    /// FTS5-only keyword search.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if embedding generation, Qdrant search, or FTS5 query fails.
242    pub async fn recall(
243        &self,
244        query: &str,
245        limit: usize,
246        filter: Option<SearchFilter>,
247    ) -> Result<Vec<RecalledMessage>, MemoryError> {
248        let conversation_id = filter.as_ref().and_then(|f| f.conversation_id);
249
250        // FTS5 keyword search (always available)
251        let keyword_results = match self
252            .sqlite
253            .keyword_search(query, limit * 2, conversation_id)
254            .await
255        {
256            Ok(results) => results,
257            Err(e) => {
258                tracing::warn!("FTS5 keyword search failed: {e:#}");
259                Vec::new()
260            }
261        };
262
263        // Vector search (only when Qdrant available)
264        let vector_results = if let Some(qdrant) = &self.qdrant
265            && self.provider.supports_embeddings()
266        {
267            let query_vector = self.provider.embed(query).await?;
268            let vector_size = u64::try_from(query_vector.len()).unwrap_or(896);
269            qdrant.ensure_collection(vector_size).await?;
270            qdrant.search(&query_vector, limit * 2, filter).await?
271        } else {
272            Vec::new()
273        };
274
275        // Merge results with weighted scoring
276        let mut scores: std::collections::HashMap<MessageId, f64> =
277            std::collections::HashMap::new();
278
279        if !vector_results.is_empty() {
280            let max_vs = vector_results
281                .iter()
282                .map(|r| r.score)
283                .fold(f32::NEG_INFINITY, f32::max);
284            let norm = if max_vs > 0.0 { max_vs } else { 1.0 };
285            for r in &vector_results {
286                let normalized = f64::from(r.score / norm);
287                *scores.entry(r.message_id).or_default() += normalized * self.vector_weight;
288            }
289        }
290
291        if !keyword_results.is_empty() {
292            let max_ks = keyword_results
293                .iter()
294                .map(|r| r.1)
295                .fold(f64::NEG_INFINITY, f64::max);
296            let norm = if max_ks > 0.0 { max_ks } else { 1.0 };
297            for &(msg_id, score) in &keyword_results {
298                let normalized = score / norm;
299                *scores.entry(msg_id).or_default() += normalized * self.keyword_weight;
300            }
301        }
302
303        if scores.is_empty() {
304            return Ok(Vec::new());
305        }
306
307        // Sort by combined score descending, take top `limit`
308        let mut ranked: Vec<(MessageId, f64)> = scores.into_iter().collect();
309        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
310        ranked.truncate(limit);
311
312        let ids: Vec<MessageId> = ranked.iter().map(|r| r.0).collect();
313        let messages = self.sqlite.messages_by_ids(&ids).await?;
314        let msg_map: std::collections::HashMap<MessageId, _> = messages.into_iter().collect();
315
316        let recalled = ranked
317            .iter()
318            .filter_map(|(msg_id, score)| {
319                msg_map.get(msg_id).map(|msg| RecalledMessage {
320                    message: msg.clone(),
321                    #[expect(clippy::cast_possible_truncation)]
322                    score: *score as f32,
323                })
324            })
325            .collect();
326
327        Ok(recalled)
328    }
329
330    /// Check whether an embedding exists for a given message ID.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if the `SQLite` query fails.
335    pub async fn has_embedding(&self, message_id: MessageId) -> Result<bool, MemoryError> {
336        match &self.qdrant {
337            Some(qdrant) => qdrant.has_embedding(message_id).await,
338            None => Ok(false),
339        }
340    }
341
342    /// Embed all messages that do not yet have embeddings.
343    ///
344    /// Returns the count of successfully embedded messages.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if collection initialization or database query fails.
349    /// Individual embedding failures are logged but do not stop processing.
350    pub async fn embed_missing(&self) -> Result<usize, MemoryError> {
351        let Some(qdrant) = &self.qdrant else {
352            return Ok(0);
353        };
354        if !self.provider.supports_embeddings() {
355            return Ok(0);
356        }
357
358        let unembedded = self.sqlite.unembedded_message_ids(Some(1000)).await?;
359
360        if unembedded.is_empty() {
361            return Ok(0);
362        }
363
364        let probe = self.provider.embed("probe").await?;
365        let vector_size = u64::try_from(probe.len())?;
366        qdrant.ensure_collection(vector_size).await?;
367
368        let mut count = 0;
369        for (msg_id, conversation_id, role, content) in &unembedded {
370            match self.provider.embed(content).await {
371                Ok(vector) => {
372                    if let Err(e) = qdrant
373                        .store(
374                            *msg_id,
375                            *conversation_id,
376                            role,
377                            vector,
378                            MessageKind::Regular,
379                            &self.embedding_model,
380                        )
381                        .await
382                    {
383                        tracing::warn!("Failed to store embedding for msg {msg_id}: {e:#}");
384                        continue;
385                    }
386                    count += 1;
387                }
388                Err(e) => {
389                    tracing::warn!("Failed to embed msg {msg_id}: {e:#}");
390                }
391            }
392        }
393
394        tracing::info!("Embedded {count}/{} missing messages", unembedded.len());
395        Ok(count)
396    }
397
398    /// Store a session summary into the dedicated `zeph_session_summaries` Qdrant collection.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if embedding or Qdrant storage fails.
403    pub async fn store_session_summary(
404        &self,
405        conversation_id: ConversationId,
406        summary_text: &str,
407    ) -> Result<(), MemoryError> {
408        let Some(qdrant) = &self.qdrant else {
409            return Ok(());
410        };
411        if !self.provider.supports_embeddings() {
412            return Ok(());
413        }
414
415        let vector = self.provider.embed(summary_text).await?;
416        let vector_size = u64::try_from(vector.len()).unwrap_or(896);
417        qdrant
418            .ensure_named_collection(SESSION_SUMMARIES_COLLECTION, vector_size)
419            .await?;
420
421        let payload = serde_json::json!({
422            "conversation_id": conversation_id.0,
423            "summary_text": summary_text,
424        });
425
426        qdrant
427            .store_to_collection(SESSION_SUMMARIES_COLLECTION, payload, vector)
428            .await?;
429
430        tracing::debug!(
431            conversation_id = conversation_id.0,
432            "stored session summary"
433        );
434        Ok(())
435    }
436
437    /// Search session summaries from other conversations.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if embedding or Qdrant search fails.
442    pub async fn search_session_summaries(
443        &self,
444        query: &str,
445        limit: usize,
446        exclude_conversation_id: Option<ConversationId>,
447    ) -> Result<Vec<SessionSummaryResult>, MemoryError> {
448        let Some(qdrant) = &self.qdrant else {
449            return Ok(Vec::new());
450        };
451        if !self.provider.supports_embeddings() {
452            return Ok(Vec::new());
453        }
454
455        let vector = self.provider.embed(query).await?;
456        let vector_size = u64::try_from(vector.len()).unwrap_or(896);
457        qdrant
458            .ensure_named_collection(SESSION_SUMMARIES_COLLECTION, vector_size)
459            .await?;
460
461        let filter = exclude_conversation_id.map(|cid| VectorFilter {
462            must: vec![],
463            must_not: vec![FieldCondition {
464                field: "conversation_id".into(),
465                value: FieldValue::Integer(cid.0),
466            }],
467        });
468
469        let points = qdrant
470            .search_collection(SESSION_SUMMARIES_COLLECTION, &vector, limit, filter)
471            .await?;
472
473        let results = points
474            .into_iter()
475            .filter_map(|point| {
476                let summary_text = point.payload.get("summary_text")?.as_str()?.to_owned();
477                let conversation_id =
478                    ConversationId(point.payload.get("conversation_id")?.as_i64()?);
479                Some(SessionSummaryResult {
480                    summary_text,
481                    score: point.score,
482                    conversation_id,
483                })
484            })
485            .collect();
486
487        Ok(results)
488    }
489
490    /// Access the underlying `SqliteStore` for operations that don't involve semantics.
491    #[must_use]
492    pub fn sqlite(&self) -> &SqliteStore {
493        &self.sqlite
494    }
495
496    /// Check if Qdrant is available for semantic search.
497    #[must_use]
498    pub fn has_qdrant(&self) -> bool {
499        self.qdrant.is_some()
500    }
501
502    /// Count messages in a conversation.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the query fails.
507    pub async fn message_count(&self, conversation_id: ConversationId) -> Result<i64, MemoryError> {
508        self.sqlite.count_messages(conversation_id).await
509    }
510
511    /// Count messages not yet covered by any summary.
512    ///
513    /// # Errors
514    ///
515    /// Returns an error if the query fails.
516    pub async fn unsummarized_message_count(
517        &self,
518        conversation_id: ConversationId,
519    ) -> Result<i64, MemoryError> {
520        let after_id = self
521            .sqlite
522            .latest_summary_last_message_id(conversation_id)
523            .await?
524            .unwrap_or(MessageId(0));
525        self.sqlite
526            .count_messages_after(conversation_id, after_id)
527            .await
528    }
529
530    /// Load all summaries for a conversation.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if the query fails.
535    pub async fn load_summaries(
536        &self,
537        conversation_id: ConversationId,
538    ) -> Result<Vec<Summary>, MemoryError> {
539        let rows = self.sqlite.load_summaries(conversation_id).await?;
540        let summaries = rows
541            .into_iter()
542            .map(
543                |(
544                    id,
545                    conversation_id,
546                    content,
547                    first_message_id,
548                    last_message_id,
549                    token_estimate,
550                )| {
551                    Summary {
552                        id,
553                        conversation_id,
554                        content,
555                        first_message_id,
556                        last_message_id,
557                        token_estimate,
558                    }
559                },
560            )
561            .collect();
562        Ok(summaries)
563    }
564
565    /// Generate a summary of the oldest unsummarized messages.
566    ///
567    /// Returns `Ok(None)` if there are not enough messages to summarize.
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if LLM call or database operation fails.
572    pub async fn summarize(
573        &self,
574        conversation_id: ConversationId,
575        message_count: usize,
576    ) -> Result<Option<i64>, MemoryError> {
577        let total = self.sqlite.count_messages(conversation_id).await?;
578
579        if total <= i64::try_from(message_count)? {
580            return Ok(None);
581        }
582
583        let after_id = self
584            .sqlite
585            .latest_summary_last_message_id(conversation_id)
586            .await?
587            .unwrap_or(MessageId(0));
588
589        let messages = self
590            .sqlite
591            .load_messages_range(conversation_id, after_id, message_count)
592            .await?;
593
594        if messages.is_empty() {
595            return Ok(None);
596        }
597
598        let prompt = build_summarization_prompt(&messages);
599        let chat_messages = vec![Message {
600            role: Role::User,
601            content: prompt,
602            parts: vec![],
603        }];
604
605        let structured = match self
606            .provider
607            .chat_typed_erased::<StructuredSummary>(&chat_messages)
608            .await
609        {
610            Ok(s) => s,
611            Err(e) => {
612                tracing::warn!(
613                    "structured summarization failed, falling back to plain text: {e:#}"
614                );
615                let plain = self.provider.chat(&chat_messages).await?;
616                StructuredSummary {
617                    summary: plain,
618                    key_facts: vec![],
619                    entities: vec![],
620                }
621            }
622        };
623        let summary_text = &structured.summary;
624
625        let token_estimate = i64::try_from(estimate_tokens(summary_text))?;
626        let first_message_id = messages[0].0;
627        let last_message_id = messages[messages.len() - 1].0;
628
629        let summary_id = self
630            .sqlite
631            .save_summary(
632                conversation_id,
633                summary_text,
634                first_message_id,
635                last_message_id,
636                token_estimate,
637            )
638            .await?;
639
640        if let Some(qdrant) = &self.qdrant
641            && self.provider.supports_embeddings()
642        {
643            match self.provider.embed(summary_text).await {
644                Ok(vector) => {
645                    // Ensure collection exists before storing
646                    let vector_size = u64::try_from(vector.len()).unwrap_or(896);
647                    if let Err(e) = qdrant.ensure_collection(vector_size).await {
648                        tracing::warn!("Failed to ensure Qdrant collection: {e:#}");
649                    } else if let Err(e) = qdrant
650                        .store(
651                            MessageId(summary_id),
652                            conversation_id,
653                            "system",
654                            vector,
655                            MessageKind::Summary,
656                            &self.embedding_model,
657                        )
658                        .await
659                    {
660                        tracing::warn!("Failed to embed summary: {e:#}");
661                    }
662                }
663                Err(e) => {
664                    tracing::warn!("Failed to generate summary embedding: {e:#}");
665                }
666            }
667        }
668
669        // Store key facts as individual Qdrant points
670        if !structured.key_facts.is_empty() {
671            self.store_key_facts(conversation_id, summary_id, &structured.key_facts)
672                .await;
673        }
674
675        Ok(Some(summary_id))
676    }
677
678    async fn store_key_facts(
679        &self,
680        conversation_id: ConversationId,
681        source_summary_id: i64,
682        key_facts: &[String],
683    ) {
684        let Some(qdrant) = &self.qdrant else {
685            return;
686        };
687        if !self.provider.supports_embeddings() {
688            return;
689        }
690
691        let Some(first_fact) = key_facts.first() else {
692            return;
693        };
694        let first_vector = match self.provider.embed(first_fact).await {
695            Ok(v) => v,
696            Err(e) => {
697                tracing::warn!("Failed to embed key fact: {e:#}");
698                return;
699            }
700        };
701        let vector_size = u64::try_from(first_vector.len()).unwrap_or(896);
702        if let Err(e) = qdrant
703            .ensure_named_collection(KEY_FACTS_COLLECTION, vector_size)
704            .await
705        {
706            tracing::warn!("Failed to ensure key_facts collection: {e:#}");
707            return;
708        }
709
710        let first_payload = serde_json::json!({
711            "conversation_id": conversation_id.0,
712            "fact_text": first_fact,
713            "source_summary_id": source_summary_id,
714        });
715        if let Err(e) = qdrant
716            .store_to_collection(KEY_FACTS_COLLECTION, first_payload, first_vector)
717            .await
718        {
719            tracing::warn!("Failed to store key fact: {e:#}");
720        }
721
722        for fact in &key_facts[1..] {
723            match self.provider.embed(fact).await {
724                Ok(vector) => {
725                    let payload = serde_json::json!({
726                        "conversation_id": conversation_id.0,
727                        "fact_text": fact,
728                        "source_summary_id": source_summary_id,
729                    });
730                    if let Err(e) = qdrant
731                        .store_to_collection(KEY_FACTS_COLLECTION, payload, vector)
732                        .await
733                    {
734                        tracing::warn!("Failed to store key fact: {e:#}");
735                    }
736                }
737                Err(e) => {
738                    tracing::warn!("Failed to embed key fact: {e:#}");
739                }
740            }
741        }
742    }
743
744    /// Search key facts extracted from conversation summaries.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if embedding or Qdrant search fails.
749    pub async fn search_key_facts(
750        &self,
751        query: &str,
752        limit: usize,
753    ) -> Result<Vec<String>, MemoryError> {
754        let Some(qdrant) = &self.qdrant else {
755            return Ok(Vec::new());
756        };
757        if !self.provider.supports_embeddings() {
758            return Ok(Vec::new());
759        }
760
761        let vector = self.provider.embed(query).await?;
762        let vector_size = u64::try_from(vector.len()).unwrap_or(896);
763        qdrant
764            .ensure_named_collection(KEY_FACTS_COLLECTION, vector_size)
765            .await?;
766
767        let points = qdrant
768            .search_collection(KEY_FACTS_COLLECTION, &vector, limit, None)
769            .await?;
770
771        let facts = points
772            .into_iter()
773            .filter_map(|p| p.payload.get("fact_text")?.as_str().map(String::from))
774            .collect();
775
776        Ok(facts)
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use zeph_llm::mock::MockProvider;
783    use zeph_llm::provider::Role;
784
785    use super::*;
786
787    fn test_provider() -> AnyProvider {
788        AnyProvider::Mock(MockProvider::default())
789    }
790
791    async fn test_semantic_memory(_supports_embeddings: bool) -> SemanticMemory {
792        let provider = test_provider();
793        let sqlite = SqliteStore::new(":memory:").await.unwrap();
794
795        SemanticMemory {
796            sqlite,
797            qdrant: None,
798            provider,
799            embedding_model: "test-model".into(),
800            vector_weight: 0.7,
801            keyword_weight: 0.3,
802        }
803    }
804
805    #[tokio::test]
806    async fn remember_saves_to_sqlite() {
807        let memory = test_semantic_memory(false).await;
808
809        let cid = memory.sqlite.create_conversation().await.unwrap();
810        let msg_id = memory.remember(cid, "user", "hello").await.unwrap();
811
812        assert_eq!(msg_id, MessageId(1));
813
814        let history = memory.sqlite.load_history(cid, 50).await.unwrap();
815        assert_eq!(history.len(), 1);
816        assert_eq!(history[0].role, Role::User);
817        assert_eq!(history[0].content, "hello");
818    }
819
820    #[tokio::test]
821    async fn remember_with_parts_saves_parts_json() {
822        let memory = test_semantic_memory(false).await;
823        let cid = memory.sqlite.create_conversation().await.unwrap();
824
825        let parts_json =
826            r#"[{"kind":"ToolOutput","tool_name":"shell","body":"hello","compacted_at":null}]"#;
827        let (msg_id, _embedding_stored) = memory
828            .remember_with_parts(cid, "assistant", "tool output", parts_json)
829            .await
830            .unwrap();
831        assert!(msg_id > MessageId(0));
832
833        let history = memory.sqlite.load_history(cid, 50).await.unwrap();
834        assert_eq!(history.len(), 1);
835        assert_eq!(history[0].content, "tool output");
836    }
837
838    #[tokio::test]
839    async fn recall_returns_empty_without_qdrant() {
840        let memory = test_semantic_memory(true).await;
841
842        let recalled = memory.recall("test", 5, None).await.unwrap();
843        assert!(recalled.is_empty());
844    }
845
846    #[tokio::test]
847    async fn has_embedding_without_qdrant() {
848        let memory = test_semantic_memory(true).await;
849
850        let has_embedding = memory.has_embedding(MessageId(1)).await.unwrap();
851        assert!(!has_embedding);
852    }
853
854    #[tokio::test]
855    async fn embed_missing_without_qdrant() {
856        let memory = test_semantic_memory(true).await;
857
858        let count = memory.embed_missing().await.unwrap();
859        assert_eq!(count, 0);
860    }
861
862    #[tokio::test]
863    async fn sqlite_accessor() {
864        let memory = test_semantic_memory(false).await;
865
866        let cid = memory.sqlite().create_conversation().await.unwrap();
867        assert_eq!(cid, ConversationId(1));
868
869        memory
870            .sqlite()
871            .save_message(cid, "user", "test")
872            .await
873            .unwrap();
874
875        let history = memory.sqlite().load_history(cid, 50).await.unwrap();
876        assert_eq!(history.len(), 1);
877    }
878
879    #[tokio::test]
880    async fn has_qdrant_returns_false_when_unavailable() {
881        let memory = test_semantic_memory(false).await;
882        assert!(!memory.has_qdrant());
883    }
884
885    #[tokio::test]
886    async fn recall_returns_empty_when_embeddings_not_supported() {
887        let memory = test_semantic_memory(false).await;
888
889        let recalled = memory.recall("test", 5, None).await.unwrap();
890        assert!(recalled.is_empty());
891    }
892
893    #[tokio::test]
894    async fn embed_missing_returns_zero_when_embeddings_not_supported() {
895        let memory = test_semantic_memory(false).await;
896
897        let cid = memory.sqlite().create_conversation().await.unwrap();
898        memory
899            .sqlite()
900            .save_message(cid, "user", "test")
901            .await
902            .unwrap();
903
904        let count = memory.embed_missing().await.unwrap();
905        assert_eq!(count, 0);
906    }
907
908    #[test]
909    fn estimate_tokens_ascii() {
910        let text = "Hello, world!";
911        assert_eq!(estimate_tokens(text), 4);
912    }
913
914    #[test]
915    fn estimate_tokens_unicode() {
916        let text = "Привет мир";
917        assert_eq!(estimate_tokens(text), 6);
918    }
919
920    #[test]
921    fn estimate_tokens_empty() {
922        assert_eq!(estimate_tokens(""), 0);
923    }
924
925    #[tokio::test]
926    async fn message_count_empty_conversation() {
927        let memory = test_semantic_memory(false).await;
928        let cid = memory.sqlite().create_conversation().await.unwrap();
929
930        let count = memory.message_count(cid).await.unwrap();
931        assert_eq!(count, 0);
932    }
933
934    #[tokio::test]
935    async fn message_count_after_saves() {
936        let memory = test_semantic_memory(false).await;
937        let cid = memory.sqlite().create_conversation().await.unwrap();
938
939        memory.remember(cid, "user", "msg1").await.unwrap();
940        memory.remember(cid, "assistant", "msg2").await.unwrap();
941
942        let count = memory.message_count(cid).await.unwrap();
943        assert_eq!(count, 2);
944    }
945
946    #[tokio::test]
947    async fn unsummarized_count_decreases_after_summary() {
948        let memory = test_semantic_memory(false).await;
949        let cid = memory.sqlite().create_conversation().await.unwrap();
950
951        for i in 0..10 {
952            memory
953                .remember(cid, "user", &format!("msg{i}"))
954                .await
955                .unwrap();
956        }
957        assert_eq!(memory.unsummarized_message_count(cid).await.unwrap(), 10);
958
959        memory.summarize(cid, 5).await.unwrap();
960
961        assert!(memory.unsummarized_message_count(cid).await.unwrap() < 10);
962        assert_eq!(memory.message_count(cid).await.unwrap(), 10);
963    }
964
965    #[tokio::test]
966    async fn load_summaries_empty() {
967        let memory = test_semantic_memory(false).await;
968        let cid = memory.sqlite().create_conversation().await.unwrap();
969
970        let summaries = memory.load_summaries(cid).await.unwrap();
971        assert!(summaries.is_empty());
972    }
973
974    #[tokio::test]
975    async fn load_summaries_ordered() {
976        let memory = test_semantic_memory(false).await;
977        let cid = memory.sqlite().create_conversation().await.unwrap();
978
979        let msg_id1 = memory.remember(cid, "user", "m1").await.unwrap();
980        let msg_id2 = memory.remember(cid, "assistant", "m2").await.unwrap();
981        let msg_id3 = memory.remember(cid, "user", "m3").await.unwrap();
982
983        let s1 = memory
984            .sqlite()
985            .save_summary(cid, "summary1", msg_id1, msg_id2, 3)
986            .await
987            .unwrap();
988        let s2 = memory
989            .sqlite()
990            .save_summary(cid, "summary2", msg_id2, msg_id3, 3)
991            .await
992            .unwrap();
993
994        let summaries = memory.load_summaries(cid).await.unwrap();
995        assert_eq!(summaries.len(), 2);
996        assert_eq!(summaries[0].id, s1);
997        assert_eq!(summaries[0].content, "summary1");
998        assert_eq!(summaries[1].id, s2);
999        assert_eq!(summaries[1].content, "summary2");
1000    }
1001
1002    #[tokio::test]
1003    async fn summarize_below_threshold() {
1004        let memory = test_semantic_memory(false).await;
1005        let cid = memory.sqlite().create_conversation().await.unwrap();
1006
1007        memory.remember(cid, "user", "hello").await.unwrap();
1008
1009        let result = memory.summarize(cid, 10).await.unwrap();
1010        assert!(result.is_none());
1011    }
1012
1013    #[tokio::test]
1014    async fn summarize_stores_summary() {
1015        let memory = test_semantic_memory(false).await;
1016        let cid = memory.sqlite().create_conversation().await.unwrap();
1017
1018        for i in 0..5 {
1019            memory
1020                .remember(cid, "user", &format!("message {i}"))
1021                .await
1022                .unwrap();
1023        }
1024
1025        let summary_id = memory.summarize(cid, 3).await.unwrap();
1026        assert!(summary_id.is_some());
1027
1028        let summaries = memory.load_summaries(cid).await.unwrap();
1029        assert_eq!(summaries.len(), 1);
1030        assert_eq!(summaries[0].id, summary_id.unwrap());
1031        assert!(!summaries[0].content.is_empty());
1032    }
1033
1034    #[tokio::test]
1035    async fn summarize_respects_previous_summaries() {
1036        let memory = test_semantic_memory(false).await;
1037        let cid = memory.sqlite().create_conversation().await.unwrap();
1038
1039        for i in 0..10 {
1040            memory
1041                .remember(cid, "user", &format!("message {i}"))
1042                .await
1043                .unwrap();
1044        }
1045
1046        let s1 = memory.summarize(cid, 3).await.unwrap();
1047        assert!(s1.is_some());
1048
1049        let s2 = memory.summarize(cid, 3).await.unwrap();
1050        assert!(s2.is_some());
1051
1052        let summaries = memory.load_summaries(cid).await.unwrap();
1053        assert_eq!(summaries.len(), 2);
1054        assert!(summaries[0].last_message_id < summaries[1].first_message_id);
1055    }
1056
1057    #[tokio::test]
1058    async fn remember_multiple_messages_increments_ids() {
1059        let memory = test_semantic_memory(false).await;
1060        let cid = memory.sqlite.create_conversation().await.unwrap();
1061
1062        let id1 = memory.remember(cid, "user", "first").await.unwrap();
1063        let id2 = memory.remember(cid, "assistant", "second").await.unwrap();
1064        let id3 = memory.remember(cid, "user", "third").await.unwrap();
1065
1066        assert!(id1 < id2);
1067        assert!(id2 < id3);
1068    }
1069
1070    #[tokio::test]
1071    async fn message_count_across_conversations() {
1072        let memory = test_semantic_memory(false).await;
1073        let cid1 = memory.sqlite().create_conversation().await.unwrap();
1074        let cid2 = memory.sqlite().create_conversation().await.unwrap();
1075
1076        memory.remember(cid1, "user", "msg1").await.unwrap();
1077        memory.remember(cid1, "user", "msg2").await.unwrap();
1078        memory.remember(cid2, "user", "msg3").await.unwrap();
1079
1080        assert_eq!(memory.message_count(cid1).await.unwrap(), 2);
1081        assert_eq!(memory.message_count(cid2).await.unwrap(), 1);
1082    }
1083
1084    #[tokio::test]
1085    async fn summarize_exact_threshold_returns_none() {
1086        let memory = test_semantic_memory(false).await;
1087        let cid = memory.sqlite().create_conversation().await.unwrap();
1088
1089        for i in 0..3 {
1090            memory
1091                .remember(cid, "user", &format!("msg {i}"))
1092                .await
1093                .unwrap();
1094        }
1095
1096        let result = memory.summarize(cid, 3).await.unwrap();
1097        assert!(result.is_none());
1098    }
1099
1100    #[tokio::test]
1101    async fn summarize_one_above_threshold_produces_summary() {
1102        let memory = test_semantic_memory(false).await;
1103        let cid = memory.sqlite().create_conversation().await.unwrap();
1104
1105        for i in 0..4 {
1106            memory
1107                .remember(cid, "user", &format!("msg {i}"))
1108                .await
1109                .unwrap();
1110        }
1111
1112        let result = memory.summarize(cid, 3).await.unwrap();
1113        assert!(result.is_some());
1114    }
1115
1116    #[tokio::test]
1117    async fn summary_fields_populated() {
1118        let memory = test_semantic_memory(false).await;
1119        let cid = memory.sqlite().create_conversation().await.unwrap();
1120
1121        for i in 0..5 {
1122            memory
1123                .remember(cid, "user", &format!("msg {i}"))
1124                .await
1125                .unwrap();
1126        }
1127
1128        memory.summarize(cid, 3).await.unwrap();
1129        let summaries = memory.load_summaries(cid).await.unwrap();
1130        let s = &summaries[0];
1131
1132        assert_eq!(s.conversation_id, cid);
1133        assert!(s.first_message_id > MessageId(0));
1134        assert!(s.last_message_id >= s.first_message_id);
1135        assert!(s.token_estimate >= 0);
1136        assert!(!s.content.is_empty());
1137    }
1138
1139    #[test]
1140    fn build_summarization_prompt_format() {
1141        let messages = vec![
1142            (MessageId(1), "user".into(), "Hello".into()),
1143            (MessageId(2), "assistant".into(), "Hi there".into()),
1144        ];
1145        let prompt = build_summarization_prompt(&messages);
1146        assert!(prompt.contains("user: Hello"));
1147        assert!(prompt.contains("assistant: Hi there"));
1148        assert!(prompt.contains("key_facts"));
1149    }
1150
1151    #[test]
1152    fn build_summarization_prompt_empty() {
1153        let messages: Vec<(MessageId, String, String)> = vec![];
1154        let prompt = build_summarization_prompt(&messages);
1155        assert!(prompt.contains("key_facts"));
1156    }
1157
1158    #[test]
1159    fn structured_summary_deserialize() {
1160        let json = r#"{"summary":"s","key_facts":["f1","f2"],"entities":["e1"]}"#;
1161        let ss: StructuredSummary = serde_json::from_str(json).unwrap();
1162        assert_eq!(ss.summary, "s");
1163        assert_eq!(ss.key_facts.len(), 2);
1164        assert_eq!(ss.entities.len(), 1);
1165    }
1166
1167    #[test]
1168    fn structured_summary_empty_facts() {
1169        let json = r#"{"summary":"s","key_facts":[],"entities":[]}"#;
1170        let ss: StructuredSummary = serde_json::from_str(json).unwrap();
1171        assert!(ss.key_facts.is_empty());
1172        assert!(ss.entities.is_empty());
1173    }
1174
1175    #[tokio::test]
1176    async fn search_key_facts_no_qdrant_empty() {
1177        let memory = test_semantic_memory(false).await;
1178        let facts = memory.search_key_facts("query", 5).await.unwrap();
1179        assert!(facts.is_empty());
1180    }
1181
1182    #[test]
1183    fn recalled_message_debug() {
1184        let recalled = RecalledMessage {
1185            message: Message {
1186                role: Role::User,
1187                content: "test".into(),
1188                parts: vec![],
1189            },
1190            score: 0.95,
1191        };
1192        let dbg = format!("{recalled:?}");
1193        assert!(dbg.contains("RecalledMessage"));
1194        assert!(dbg.contains("0.95"));
1195    }
1196
1197    #[test]
1198    fn summary_clone() {
1199        let summary = Summary {
1200            id: 1,
1201            conversation_id: ConversationId(2),
1202            content: "test summary".into(),
1203            first_message_id: MessageId(1),
1204            last_message_id: MessageId(5),
1205            token_estimate: 10,
1206        };
1207        let cloned = summary.clone();
1208        assert_eq!(summary.id, cloned.id);
1209        assert_eq!(summary.content, cloned.content);
1210    }
1211
1212    #[test]
1213    fn estimate_tokens_short_text() {
1214        assert_eq!(estimate_tokens("ab"), 0);
1215    }
1216
1217    #[test]
1218    fn estimate_tokens_longer_text() {
1219        let text = "a".repeat(100);
1220        assert_eq!(estimate_tokens(&text), 33);
1221    }
1222
1223    #[tokio::test]
1224    async fn remember_preserves_role_mapping() {
1225        let memory = test_semantic_memory(false).await;
1226        let cid = memory.sqlite.create_conversation().await.unwrap();
1227
1228        memory.remember(cid, "user", "u").await.unwrap();
1229        memory.remember(cid, "assistant", "a").await.unwrap();
1230        memory.remember(cid, "system", "s").await.unwrap();
1231
1232        let history = memory.sqlite.load_history(cid, 50).await.unwrap();
1233        assert_eq!(history.len(), 3);
1234        assert_eq!(history[0].role, Role::User);
1235        assert_eq!(history[1].role, Role::Assistant);
1236        assert_eq!(history[2].role, Role::System);
1237    }
1238
1239    #[tokio::test]
1240    async fn new_with_invalid_qdrant_url_graceful() {
1241        let mut mock = MockProvider::default();
1242        mock.supports_embeddings = true;
1243        let provider = AnyProvider::Mock(mock);
1244        let result =
1245            SemanticMemory::new(":memory:", "http://127.0.0.1:1", provider, "test-model").await;
1246        assert!(result.is_ok());
1247    }
1248
1249    #[tokio::test]
1250    async fn remember_with_embeddings_supported_but_no_qdrant() {
1251        let memory = test_semantic_memory(true).await;
1252        let cid = memory.sqlite.create_conversation().await.unwrap();
1253
1254        let msg_id = memory.remember(cid, "user", "hello embed").await.unwrap();
1255        assert!(msg_id > MessageId(0));
1256
1257        let history = memory.sqlite.load_history(cid, 50).await.unwrap();
1258        assert_eq!(history.len(), 1);
1259        assert_eq!(history[0].content, "hello embed");
1260    }
1261
1262    #[tokio::test]
1263    async fn remember_verifies_content_via_load_history() {
1264        let memory = test_semantic_memory(false).await;
1265        let cid = memory.sqlite.create_conversation().await.unwrap();
1266
1267        memory.remember(cid, "user", "alpha").await.unwrap();
1268        memory.remember(cid, "assistant", "beta").await.unwrap();
1269        memory.remember(cid, "user", "gamma").await.unwrap();
1270
1271        let history = memory.sqlite().load_history(cid, 50).await.unwrap();
1272        assert_eq!(history.len(), 3);
1273        assert_eq!(history[0].content, "alpha");
1274        assert_eq!(history[1].content, "beta");
1275        assert_eq!(history[2].content, "gamma");
1276    }
1277
1278    #[tokio::test]
1279    async fn message_count_multiple_conversations_isolated() {
1280        let memory = test_semantic_memory(false).await;
1281        let cid1 = memory.sqlite().create_conversation().await.unwrap();
1282        let cid2 = memory.sqlite().create_conversation().await.unwrap();
1283        let cid3 = memory.sqlite().create_conversation().await.unwrap();
1284
1285        for _ in 0..5 {
1286            memory.remember(cid1, "user", "msg").await.unwrap();
1287        }
1288        for _ in 0..3 {
1289            memory.remember(cid2, "user", "msg").await.unwrap();
1290        }
1291
1292        assert_eq!(memory.message_count(cid1).await.unwrap(), 5);
1293        assert_eq!(memory.message_count(cid2).await.unwrap(), 3);
1294        assert_eq!(memory.message_count(cid3).await.unwrap(), 0);
1295    }
1296
1297    #[tokio::test]
1298    async fn summarize_empty_messages_range_returns_none() {
1299        let memory = test_semantic_memory(false).await;
1300        let cid = memory.sqlite().create_conversation().await.unwrap();
1301
1302        for i in 0..6 {
1303            memory
1304                .remember(cid, "user", &format!("msg {i}"))
1305                .await
1306                .unwrap();
1307        }
1308
1309        memory.summarize(cid, 3).await.unwrap();
1310        memory.summarize(cid, 3).await.unwrap();
1311
1312        let summaries = memory.load_summaries(cid).await.unwrap();
1313        assert_eq!(summaries.len(), 2);
1314    }
1315
1316    #[tokio::test]
1317    async fn summarize_token_estimate_populated() {
1318        let memory = test_semantic_memory(false).await;
1319        let cid = memory.sqlite().create_conversation().await.unwrap();
1320
1321        for i in 0..5 {
1322            memory
1323                .remember(cid, "user", &format!("message {i}"))
1324                .await
1325                .unwrap();
1326        }
1327
1328        memory.summarize(cid, 3).await.unwrap();
1329        let summaries = memory.load_summaries(cid).await.unwrap();
1330        let token_est = summaries[0].token_estimate;
1331        let expected = i64::try_from(estimate_tokens(&summaries[0].content)).unwrap();
1332        assert_eq!(token_est, expected);
1333    }
1334
1335    #[tokio::test]
1336    async fn summarize_fails_when_provider_chat_fails() {
1337        let sqlite = SqliteStore::new(":memory:").await.unwrap();
1338        let provider = AnyProvider::Ollama(zeph_llm::ollama::OllamaProvider::new(
1339            "http://127.0.0.1:1",
1340            "test".into(),
1341            "embed".into(),
1342        ));
1343        let memory = SemanticMemory {
1344            sqlite,
1345            qdrant: None,
1346            provider,
1347            embedding_model: "test".into(),
1348            vector_weight: 0.7,
1349            keyword_weight: 0.3,
1350        };
1351        let cid = memory.sqlite().create_conversation().await.unwrap();
1352
1353        for i in 0..5 {
1354            memory
1355                .remember(cid, "user", &format!("msg {i}"))
1356                .await
1357                .unwrap();
1358        }
1359
1360        let result = memory.summarize(cid, 3).await;
1361        assert!(result.is_err());
1362    }
1363
1364    #[tokio::test]
1365    async fn embed_missing_without_embedding_support_returns_zero() {
1366        let memory = test_semantic_memory(false).await;
1367        let cid = memory.sqlite().create_conversation().await.unwrap();
1368        memory
1369            .sqlite()
1370            .save_message(cid, "user", "test message")
1371            .await
1372            .unwrap();
1373
1374        let count = memory.embed_missing().await.unwrap();
1375        assert_eq!(count, 0);
1376    }
1377
1378    #[tokio::test]
1379    async fn has_embedding_returns_false_when_no_qdrant() {
1380        let memory = test_semantic_memory(false).await;
1381        let cid = memory.sqlite.create_conversation().await.unwrap();
1382        let msg_id = memory.remember(cid, "user", "test").await.unwrap();
1383        assert!(!memory.has_embedding(msg_id).await.unwrap());
1384    }
1385
1386    #[tokio::test]
1387    async fn recall_empty_without_qdrant_regardless_of_filter() {
1388        let memory = test_semantic_memory(true).await;
1389        let filter = SearchFilter {
1390            conversation_id: Some(ConversationId(1)),
1391            role: None,
1392        };
1393        let recalled = memory.recall("query", 10, Some(filter)).await.unwrap();
1394        assert!(recalled.is_empty());
1395    }
1396
1397    #[tokio::test]
1398    async fn summarize_message_range_bounds() {
1399        let memory = test_semantic_memory(false).await;
1400        let cid = memory.sqlite().create_conversation().await.unwrap();
1401
1402        for i in 0..8 {
1403            memory
1404                .remember(cid, "user", &format!("msg {i}"))
1405                .await
1406                .unwrap();
1407        }
1408
1409        let summary_id = memory.summarize(cid, 4).await.unwrap().unwrap();
1410        let summaries = memory.load_summaries(cid).await.unwrap();
1411        assert_eq!(summaries.len(), 1);
1412        assert_eq!(summaries[0].id, summary_id);
1413        assert!(summaries[0].first_message_id >= MessageId(1));
1414        assert!(summaries[0].last_message_id >= summaries[0].first_message_id);
1415    }
1416
1417    #[test]
1418    fn build_summarization_prompt_preserves_order() {
1419        let messages = vec![
1420            (MessageId(1), "user".into(), "first".into()),
1421            (MessageId(2), "assistant".into(), "second".into()),
1422            (MessageId(3), "user".into(), "third".into()),
1423        ];
1424        let prompt = build_summarization_prompt(&messages);
1425        let first_pos = prompt.find("user: first").unwrap();
1426        let second_pos = prompt.find("assistant: second").unwrap();
1427        let third_pos = prompt.find("user: third").unwrap();
1428        assert!(first_pos < second_pos);
1429        assert!(second_pos < third_pos);
1430    }
1431
1432    #[test]
1433    fn summary_debug() {
1434        let summary = Summary {
1435            id: 1,
1436            conversation_id: ConversationId(2),
1437            content: "test".into(),
1438            first_message_id: MessageId(1),
1439            last_message_id: MessageId(5),
1440            token_estimate: 10,
1441        };
1442        let dbg = format!("{summary:?}");
1443        assert!(dbg.contains("Summary"));
1444    }
1445
1446    #[tokio::test]
1447    async fn message_count_nonexistent_conversation() {
1448        let memory = test_semantic_memory(false).await;
1449        let count = memory.message_count(ConversationId(999)).await.unwrap();
1450        assert_eq!(count, 0);
1451    }
1452
1453    #[tokio::test]
1454    async fn load_summaries_nonexistent_conversation() {
1455        let memory = test_semantic_memory(false).await;
1456        let summaries = memory.load_summaries(ConversationId(999)).await.unwrap();
1457        assert!(summaries.is_empty());
1458    }
1459
1460    #[tokio::test]
1461    async fn store_session_summary_no_qdrant_noop() {
1462        let memory = test_semantic_memory(true).await;
1463        let result = memory
1464            .store_session_summary(ConversationId(1), "test summary")
1465            .await;
1466        assert!(result.is_ok());
1467    }
1468
1469    #[tokio::test]
1470    async fn store_session_summary_no_embeddings_noop() {
1471        let memory = test_semantic_memory(false).await;
1472        let result = memory
1473            .store_session_summary(ConversationId(1), "test summary")
1474            .await;
1475        assert!(result.is_ok());
1476    }
1477
1478    #[tokio::test]
1479    async fn search_session_summaries_no_qdrant_empty() {
1480        let memory = test_semantic_memory(true).await;
1481        let results = memory
1482            .search_session_summaries("query", 5, None)
1483            .await
1484            .unwrap();
1485        assert!(results.is_empty());
1486    }
1487
1488    #[tokio::test]
1489    async fn search_session_summaries_no_embeddings_empty() {
1490        let memory = test_semantic_memory(false).await;
1491        let results = memory
1492            .search_session_summaries("query", 5, Some(ConversationId(1)))
1493            .await
1494            .unwrap();
1495        assert!(results.is_empty());
1496    }
1497
1498    #[test]
1499    fn session_summary_result_debug() {
1500        let result = SessionSummaryResult {
1501            summary_text: "test".into(),
1502            score: 0.9,
1503            conversation_id: ConversationId(1),
1504        };
1505        let dbg = format!("{result:?}");
1506        assert!(dbg.contains("SessionSummaryResult"));
1507    }
1508
1509    #[test]
1510    fn session_summary_result_clone() {
1511        let result = SessionSummaryResult {
1512            summary_text: "test".into(),
1513            score: 0.9,
1514            conversation_id: ConversationId(1),
1515        };
1516        let cloned = result.clone();
1517        assert_eq!(result.summary_text, cloned.summary_text);
1518        assert_eq!(result.conversation_id, cloned.conversation_id);
1519    }
1520
1521    #[tokio::test]
1522    async fn recall_fts5_fallback_without_qdrant() {
1523        let memory = test_semantic_memory(false).await;
1524        let cid = memory.sqlite.create_conversation().await.unwrap();
1525
1526        memory
1527            .remember(cid, "user", "rust programming guide")
1528            .await
1529            .unwrap();
1530        memory
1531            .remember(cid, "assistant", "python tutorial")
1532            .await
1533            .unwrap();
1534        memory
1535            .remember(cid, "user", "advanced rust patterns")
1536            .await
1537            .unwrap();
1538
1539        let recalled = memory.recall("rust", 5, None).await.unwrap();
1540        assert_eq!(recalled.len(), 2);
1541        assert!(recalled[0].score >= recalled[1].score);
1542    }
1543
1544    #[tokio::test]
1545    async fn recall_fts5_fallback_with_filter() {
1546        let memory = test_semantic_memory(false).await;
1547        let cid1 = memory.sqlite.create_conversation().await.unwrap();
1548        let cid2 = memory.sqlite.create_conversation().await.unwrap();
1549
1550        memory.remember(cid1, "user", "hello world").await.unwrap();
1551        memory
1552            .remember(cid2, "user", "hello universe")
1553            .await
1554            .unwrap();
1555
1556        let filter = SearchFilter {
1557            conversation_id: Some(cid1),
1558            role: None,
1559        };
1560        let recalled = memory.recall("hello", 5, Some(filter)).await.unwrap();
1561        assert_eq!(recalled.len(), 1);
1562    }
1563
1564    #[tokio::test]
1565    async fn recall_fts5_no_matches_returns_empty() {
1566        let memory = test_semantic_memory(false).await;
1567        let cid = memory.sqlite.create_conversation().await.unwrap();
1568
1569        memory.remember(cid, "user", "hello world").await.unwrap();
1570
1571        let recalled = memory.recall("nonexistent", 5, None).await.unwrap();
1572        assert!(recalled.is_empty());
1573    }
1574
1575    #[tokio::test]
1576    async fn recall_fts5_respects_limit() {
1577        let memory = test_semantic_memory(false).await;
1578        let cid = memory.sqlite.create_conversation().await.unwrap();
1579
1580        for i in 0..10 {
1581            memory
1582                .remember(cid, "user", &format!("test message number {i}"))
1583                .await
1584                .unwrap();
1585        }
1586
1587        let recalled = memory.recall("test", 3, None).await.unwrap();
1588        assert_eq!(recalled.len(), 3);
1589    }
1590
1591    // Priority 2: summarize fallback path
1592
1593    #[tokio::test]
1594    async fn summarize_fallback_to_plain_text_when_structured_fails() {
1595        // Use OllamaProvider pointing at an unreachable URL for chat_typed_erased,
1596        // but MockProvider for the plain chat call.
1597        // The easiest way: MockProvider returns non-JSON plain text so chat_typed_erased
1598        // (which uses chat() + JSON parse) will fail to parse, then falls back to chat().
1599        // However MockProvider.chat_typed calls chat() which returns default_response.
1600        // chat_typed tries to parse it as JSON → fails → retries → fails → returns StructuredParse error.
1601        // Then the fallback calls plain chat() which succeeds.
1602        let sqlite = SqliteStore::new(":memory:").await.unwrap();
1603        let mut mock = MockProvider::default();
1604        // First two calls go to chat_typed (attempt + retry), third call is the plain fallback
1605        mock.default_response = "plain text summary".into();
1606        let provider = AnyProvider::Mock(mock);
1607
1608        let memory = SemanticMemory {
1609            sqlite,
1610            qdrant: None,
1611            provider,
1612            embedding_model: "test".into(),
1613            vector_weight: 0.7,
1614            keyword_weight: 0.3,
1615        };
1616
1617        let cid = memory.sqlite().create_conversation().await.unwrap();
1618        for i in 0..5 {
1619            memory
1620                .remember(cid, "user", &format!("msg {i}"))
1621                .await
1622                .unwrap();
1623        }
1624
1625        let result = memory.summarize(cid, 3).await;
1626        // The summarize will either succeed (with plain text fallback) or fail
1627        // depending on how many retries chat_typed_erased does internally.
1628        // With MockProvider returning non-JSON plain text, chat_typed fails to parse.
1629        // The fallback plain chat() returns "plain text summary".
1630        // Result should be Ok with a summary stored.
1631        assert!(result.is_ok());
1632        let summaries = memory.load_summaries(cid).await.unwrap();
1633        assert_eq!(summaries.len(), 1);
1634        assert!(!summaries[0].content.is_empty());
1635    }
1636
1637    // Priority 3: proptest
1638
1639    use proptest::prelude::*;
1640
1641    proptest! {
1642        #[test]
1643        fn estimate_tokens_never_panics(s in ".*") {
1644            let _ = estimate_tokens(&s);
1645        }
1646    }
1647}