1use std::collections::HashMap;
10
11use crate::embeddings::{Embedder, EmbeddingError};
12use crate::vector_store::VectorStore;
13
14#[derive(Debug, Clone)]
16pub struct ConversationTurn {
17 pub session_id: String,
19 pub turn_id: usize,
21 pub user_message: String,
23 pub assistant_message: String,
25 pub timestamp: u64,
27 pub model: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct TurnMatch {
34 pub session_id: String,
36 pub turn_id: usize,
38 pub user_message: String,
40 pub assistant_message: String,
42 pub score: f32,
44 pub timestamp: u64,
46}
47
48#[non_exhaustive]
50#[derive(Debug)]
51pub enum IndexError {
52 Embedding(EmbeddingError),
54}
55
56impl std::fmt::Display for IndexError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 IndexError::Embedding(e) => write!(f, "index embedding error: {e}"),
60 }
61 }
62}
63
64impl std::error::Error for IndexError {}
65
66impl From<EmbeddingError> for IndexError {
67 fn from(e: EmbeddingError) -> Self {
68 IndexError::Embedding(e)
69 }
70}
71
72pub struct SessionIndexer {
78 store: VectorStore,
79 embedder: Box<dyn Embedder>,
80}
81
82impl SessionIndexer {
83 pub async fn new(embedder: Box<dyn Embedder>) -> Self {
85 Self {
86 store: VectorStore::new(),
87 embedder,
88 }
89 }
90
91 pub async fn index_turn(&mut self, turn: &ConversationTurn) -> Result<(), IndexError> {
96 let combined = format!(
97 "User: {}\nAssistant: {}",
98 turn.user_message, turn.assistant_message
99 );
100 let embedding = self.embedder.embed(&combined).await?;
101
102 let id = format!("{}:{}", turn.session_id, turn.turn_id);
103 let mut metadata = HashMap::new();
104 metadata.insert(
105 "session_id".into(),
106 serde_json::Value::String(turn.session_id.clone()),
107 );
108 metadata.insert(
109 "turn_id".into(),
110 serde_json::Value::Number(turn.turn_id.into()),
111 );
112 metadata.insert(
113 "user_message".into(),
114 serde_json::Value::String(turn.user_message.clone()),
115 );
116 metadata.insert(
117 "assistant_message".into(),
118 serde_json::Value::String(turn.assistant_message.clone()),
119 );
120 metadata.insert(
121 "model".into(),
122 serde_json::Value::String(turn.model.clone()),
123 );
124
125 self.store.add_with_timestamp(
126 id,
127 combined,
128 embedding,
129 vec![turn.session_id.clone()],
130 metadata,
131 turn.timestamp,
132 );
133
134 Ok(())
135 }
136
137 pub async fn search_turns(
142 &self,
143 query: &str,
144 session_id: Option<&str>,
145 top_k: usize,
146 ) -> Result<Vec<TurnMatch>, IndexError> {
147 let query_embedding = self.embedder.embed(query).await?;
148
149 let search_k = if session_id.is_some() {
152 top_k * 10
153 } else {
154 top_k
155 };
156
157 let results = self.store.search(&query_embedding, search_k);
158
159 let mut matches: Vec<TurnMatch> = results
160 .into_iter()
161 .filter(|r| {
162 if let Some(sid) = session_id {
163 r.tags.contains(&sid.to_string())
164 } else {
165 true
166 }
167 })
168 .filter_map(|r| {
169 let parts: Vec<&str> = r.id.splitn(2, ':').collect();
171 if parts.len() != 2 {
172 return None;
173 }
174 let sid = parts[0].to_string();
175 let tid: usize = parts[1].parse().ok()?;
176
177 let (user_msg, assistant_msg) = parse_combined_text(&r.text);
179
180 Some(TurnMatch {
181 session_id: sid,
182 turn_id: tid,
183 user_message: user_msg,
184 assistant_message: assistant_msg,
185 score: r.score,
186 timestamp: r.timestamp,
187 })
188 })
189 .take(top_k)
190 .collect();
191
192 let _ = &mut matches;
194 Ok(matches)
195 }
196
197 pub fn len(&self) -> usize {
199 self.store.len()
200 }
201
202 pub fn is_empty(&self) -> bool {
204 self.store.is_empty()
205 }
206}
207
208fn parse_combined_text(text: &str) -> (String, String) {
210 if let Some(idx) = text.find("\nAssistant: ") {
211 let user_part = &text[..idx];
212 let assistant_part = &text[idx + "\nAssistant: ".len()..];
213 let user_msg = user_part.strip_prefix("User: ").unwrap_or(user_part);
214 (user_msg.to_string(), assistant_part.to_string())
215 } else {
216 (text.to_string(), String::new())
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::embeddings::hash_embedder::HashEmbedder;
224
225 fn make_embedder() -> Box<dyn Embedder> {
226 Box::new(HashEmbedder::new(64))
227 }
228
229 fn make_turn(
230 session_id: &str,
231 turn_id: usize,
232 user: &str,
233 assistant: &str,
234 ) -> ConversationTurn {
235 ConversationTurn {
236 session_id: session_id.into(),
237 turn_id,
238 user_message: user.into(),
239 assistant_message: assistant.into(),
240 timestamp: 1000 + turn_id as u64,
241 model: "test-model".into(),
242 }
243 }
244
245 #[tokio::test]
246 async fn index_and_search_single_turn() {
247 let mut indexer = SessionIndexer::new(make_embedder()).await;
248 let turn = make_turn(
249 "sess1",
250 0,
251 "What is Rust?",
252 "Rust is a systems programming language",
253 );
254 indexer.index_turn(&turn).await.unwrap();
255
256 let results = indexer
257 .search_turns("Rust programming", None, 5)
258 .await
259 .unwrap();
260 assert_eq!(results.len(), 1);
261 assert_eq!(results[0].session_id, "sess1");
262 assert_eq!(results[0].turn_id, 0);
263 assert_eq!(results[0].user_message, "What is Rust?");
264 assert_eq!(
265 results[0].assistant_message,
266 "Rust is a systems programming language"
267 );
268 }
269
270 #[tokio::test]
271 async fn cross_session_search() {
272 let mut indexer = SessionIndexer::new(make_embedder()).await;
273
274 indexer
275 .index_turn(&make_turn(
276 "sess1",
277 0,
278 "What is Rust?",
279 "Rust is a systems programming language",
280 ))
281 .await
282 .unwrap();
283 indexer
284 .index_turn(&make_turn(
285 "sess2",
286 0,
287 "Tell me about Python",
288 "Python is an interpreted language",
289 ))
290 .await
291 .unwrap();
292
293 let results = indexer
295 .search_turns("programming language", None, 10)
296 .await
297 .unwrap();
298 assert_eq!(results.len(), 2);
299 let session_ids: Vec<&str> = results.iter().map(|r| r.session_id.as_str()).collect();
301 assert!(session_ids.contains(&"sess1"));
302 assert!(session_ids.contains(&"sess2"));
303 }
304
305 #[tokio::test]
306 async fn session_filtered_search() {
307 let mut indexer = SessionIndexer::new(make_embedder()).await;
308
309 indexer
310 .index_turn(&make_turn(
311 "sess1",
312 0,
313 "What is Rust?",
314 "Rust is a language",
315 ))
316 .await
317 .unwrap();
318 indexer
319 .index_turn(&make_turn(
320 "sess2",
321 0,
322 "What is Rust?",
323 "Rust is a language",
324 ))
325 .await
326 .unwrap();
327
328 let results = indexer
330 .search_turns("Rust language", Some("sess1"), 10)
331 .await
332 .unwrap();
333 assert!(!results.is_empty());
334 for r in &results {
335 assert_eq!(r.session_id, "sess1");
336 }
337 }
338
339 #[tokio::test]
340 async fn multiple_turns_indexed_correctly() {
341 let mut indexer = SessionIndexer::new(make_embedder()).await;
342
343 indexer
344 .index_turn(&make_turn("sess1", 0, "First question", "First answer"))
345 .await
346 .unwrap();
347 indexer
348 .index_turn(&make_turn("sess1", 1, "Second question", "Second answer"))
349 .await
350 .unwrap();
351 indexer
352 .index_turn(&make_turn("sess1", 2, "Third question", "Third answer"))
353 .await
354 .unwrap();
355
356 assert_eq!(indexer.len(), 3);
357 assert!(!indexer.is_empty());
358
359 let results = indexer.search_turns("question", None, 10).await.unwrap();
360 assert_eq!(results.len(), 3);
361 }
362
363 #[tokio::test]
364 async fn empty_indexer_search() {
365 let indexer = SessionIndexer::new(make_embedder()).await;
366 let results = indexer.search_turns("anything", None, 5).await.unwrap();
367 assert!(results.is_empty());
368 assert!(indexer.is_empty());
369 assert_eq!(indexer.len(), 0);
370 }
371
372 #[tokio::test]
373 async fn search_results_sorted_by_score() {
374 let mut indexer = SessionIndexer::new(make_embedder()).await;
375
376 indexer
377 .index_turn(&make_turn(
378 "s1",
379 0,
380 "database optimization query planning",
381 "index usage and query optimization",
382 ))
383 .await
384 .unwrap();
385 indexer
386 .index_turn(&make_turn(
387 "s1",
388 1,
389 "unrelated cooking recipe pasta",
390 "boil water add pasta cook 10 minutes",
391 ))
392 .await
393 .unwrap();
394
395 let results = indexer
396 .search_turns("database query optimization", None, 2)
397 .await
398 .unwrap();
399 assert_eq!(results.len(), 2);
400 assert!(
401 results[0].score >= results[1].score,
402 "results should be sorted by descending score"
403 );
404 }
405
406 #[test]
407 fn parse_combined_text_normal() {
408 let (user, assistant) = parse_combined_text("User: hello\nAssistant: hi there");
409 assert_eq!(user, "hello");
410 assert_eq!(assistant, "hi there");
411 }
412
413 #[test]
414 fn parse_combined_text_no_assistant() {
415 let (user, assistant) = parse_combined_text("just some text");
416 assert_eq!(user, "just some text");
417 assert!(assistant.is_empty());
418 }
419
420 #[tokio::test]
421 async fn top_k_limits_results() {
422 let mut indexer = SessionIndexer::new(make_embedder()).await;
423
424 for i in 0..10 {
425 indexer
426 .index_turn(&make_turn(
427 "s1",
428 i,
429 &format!("question {i}"),
430 &format!("answer {i}"),
431 ))
432 .await
433 .unwrap();
434 }
435
436 let results = indexer.search_turns("question", None, 3).await.unwrap();
437 assert_eq!(results.len(), 3);
438 }
439
440 #[test]
441 fn index_error_display() {
442 let err = IndexError::Embedding(EmbeddingError::Internal("test".into()));
443 assert!(format!("{err}").contains("test"));
444 }
445}