zeph_memory/semantic/
corrections.rs1use zeph_llm::provider::LlmProvider as _;
5
6use crate::error::MemoryError;
7
8use super::{CORRECTIONS_COLLECTION, SemanticMemory};
9
10impl SemanticMemory {
11 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 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 #[tokio::test]
158 async fn store_correction_embedding_embed_timeout_is_ok() {
159 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 #[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}