Skip to main content

zeph_memory/semantic/
corrections.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_llm::provider::LlmProvider as _;
5
6use crate::error::MemoryError;
7
8use super::{CORRECTIONS_COLLECTION, SemanticMemory};
9
10impl SemanticMemory {
11    /// Store an embedding for a user correction in the vector store.
12    ///
13    /// Silently skips if no vector store is configured or embeddings are unsupported.
14    ///
15    /// # Errors
16    ///
17    /// Returns an error if embedding generation or vector store write fails.
18    pub async fn store_correction_embedding(
19        &self,
20        correction_id: i64,
21        correction_text: &str,
22    ) -> Result<(), MemoryError> {
23        let Some(ref store) = self.qdrant else {
24            return Ok(());
25        };
26        if !self.effective_embed_provider().supports_embeddings() {
27            return Ok(());
28        }
29        let embedding = match tokio::time::timeout(
30            self.embed_timeout,
31            self.effective_embed_provider().embed(correction_text),
32        )
33        .await
34        {
35            Ok(Ok(v)) => v,
36            Ok(Err(e)) => return Err(MemoryError::Llm(e)),
37            Err(_) => {
38                tracing::warn!("corrections: embed timed out, skipping vector store write");
39                return Ok(());
40            }
41        };
42        store
43            .ensure_named_collection_for_vector(CORRECTIONS_COLLECTION, &embedding)
44            .await?;
45        let payload = serde_json::json!({ "correction_id": correction_id });
46        store
47            .store_to_collection(CORRECTIONS_COLLECTION, payload, embedding)
48            .await?;
49        Ok(())
50    }
51
52    /// Retrieve corrections semantically similar to `query`.
53    ///
54    /// Returns up to `limit` corrections scoring above `min_score`.
55    /// Returns an empty vec if no vector store is configured.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if embedding generation or vector search fails.
60    pub async fn retrieve_similar_corrections(
61        &self,
62        query: &str,
63        limit: usize,
64        min_score: f32,
65    ) -> Result<Vec<crate::store::corrections::UserCorrectionRow>, MemoryError> {
66        let Some(ref store) = self.qdrant else {
67            tracing::debug!("corrections: skipped, no vector store");
68            return Ok(vec![]);
69        };
70        if !self.effective_embed_provider().supports_embeddings() {
71            tracing::debug!("corrections: skipped, no embedding support");
72            return Ok(vec![]);
73        }
74        let embedding = match tokio::time::timeout(
75            self.embed_timeout,
76            self.effective_embed_provider().embed(query),
77        )
78        .await
79        {
80            Ok(Ok(v)) => v,
81            Ok(Err(e)) => return Err(MemoryError::Llm(e)),
82            Err(_) => {
83                tracing::warn!("search_corrections: embed() timed out, returning empty");
84                return Ok(vec![]);
85            }
86        };
87        store
88            .ensure_named_collection_for_vector(CORRECTIONS_COLLECTION, &embedding)
89            .await?;
90        let scored = store
91            .search_collection(CORRECTIONS_COLLECTION, &embedding, limit, None)
92            .await
93            .unwrap_or_default();
94
95        tracing::debug!(
96            candidates = scored.len(),
97            min_score = %min_score,
98            limit,
99            "corrections: search complete"
100        );
101
102        let mut results = Vec::new();
103        for point in scored {
104            if point.score < min_score {
105                continue;
106            }
107            if let Some(id_val) = point.payload.get("correction_id")
108                && let Some(id) = id_val.as_i64()
109            {
110                let rows = self.sqlite.load_corrections_for_id(id).await?;
111                results.extend(rows);
112            }
113        }
114
115        tracing::debug!(
116            retained = results.len(),
117            "corrections: after min_score filter"
118        );
119
120        Ok(results)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::sync::Arc;
127
128    use zeph_llm::any::AnyProvider;
129    use zeph_llm::mock::MockProvider;
130
131    use crate::embedding_store::EmbeddingStore;
132    use crate::in_memory_store::InMemoryVectorStore;
133    use crate::semantic::SemanticMemory;
134    use crate::store::SqliteStore;
135    use crate::token_counter::TokenCounter;
136
137    async fn mem_with_slow_embed(embed_delay_ms: u64) -> SemanticMemory {
138        let sqlite = SqliteStore::new(":memory:").await.unwrap();
139        let pool = sqlite.pool().clone();
140        let qdrant = EmbeddingStore::with_store(Box::new(InMemoryVectorStore::new()), pool);
141        let base_provider = AnyProvider::Mock(MockProvider::default());
142        let slow_embed =
143            AnyProvider::Mock(MockProvider::default().with_embed_delay(embed_delay_ms));
144        SemanticMemory::from_parts(
145            sqlite,
146            Some(Arc::new(qdrant)),
147            base_provider,
148            "test-model",
149            0.7,
150            0.3,
151            Arc::new(TokenCounter::new()),
152        )
153        .with_embedding_provider(slow_embed)
154    }
155
156    /// `embed()` timeout in `store_correction_embedding` → returns `Ok(())` (fail-open, skips write).
157    #[tokio::test]
158    async fn store_correction_embedding_embed_timeout_is_ok() {
159        // Build memory before pausing time — SQLite pool uses tokio timers internally.
160        let mem = mem_with_slow_embed(10_000).await;
161
162        tokio::time::pause();
163
164        let fut = mem.store_correction_embedding(42, "I prefer detailed answers");
165        let (result, ()) = tokio::join!(fut, async {
166            tokio::time::advance(std::time::Duration::from_secs(6)).await;
167        });
168
169        assert!(
170            result.is_ok(),
171            "embed timeout must return Ok(()) (fail-open, skip write), got {result:?}"
172        );
173    }
174
175    /// `embed()` timeout in `retrieve_similar_corrections` → returns `Ok(vec![])` (fail-open).
176    #[tokio::test]
177    async fn retrieve_similar_corrections_embed_timeout_returns_empty() {
178        let mem = mem_with_slow_embed(10_000).await;
179
180        tokio::time::pause();
181
182        let fut = mem.retrieve_similar_corrections("prefer concise answers", 5, 0.7);
183        let (result, ()) = tokio::join!(fut, async {
184            tokio::time::advance(std::time::Duration::from_secs(6)).await;
185        });
186
187        match result {
188            Ok(rows) => assert!(
189                rows.is_empty(),
190                "embed timeout must return empty vec (fail-open), got {rows:?}"
191            ),
192            Err(e) => panic!("embed timeout must not propagate error, got {e:?}"),
193        }
194    }
195}