Skip to main content

clawft_core/
session_indexer.rs

1//! Semantic indexing for conversation turns.
2//!
3//! Indexes user + assistant message pairs as embeddings so that past
4//! conversations can be searched by semantic similarity. Supports
5//! both cross-session and single-session search.
6//!
7//! This module is gated behind the `vector-memory` feature flag.
8
9use std::collections::HashMap;
10
11use crate::embeddings::{Embedder, EmbeddingError};
12use crate::vector_store::VectorStore;
13
14/// A single conversation turn to be indexed.
15#[derive(Debug, Clone)]
16pub struct ConversationTurn {
17    /// The session this turn belongs to.
18    pub session_id: String,
19    /// The turn number within the session (0-based).
20    pub turn_id: usize,
21    /// The user's message text.
22    pub user_message: String,
23    /// The assistant's response text.
24    pub assistant_message: String,
25    /// Unix timestamp (seconds since epoch).
26    pub timestamp: u64,
27    /// The model that generated the response.
28    pub model: String,
29}
30
31/// A search result from the session indexer.
32#[derive(Debug, Clone)]
33pub struct TurnMatch {
34    /// The session this turn belongs to.
35    pub session_id: String,
36    /// The turn number within the session.
37    pub turn_id: usize,
38    /// The user's message text.
39    pub user_message: String,
40    /// The assistant's response text.
41    pub assistant_message: String,
42    /// Cosine similarity score.
43    pub score: f32,
44    /// Unix timestamp of the turn.
45    pub timestamp: u64,
46}
47
48/// Errors that can occur during session indexing.
49#[non_exhaustive]
50#[derive(Debug)]
51pub enum IndexError {
52    /// An embedding operation failed.
53    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
72/// Indexes conversation turns for semantic search.
73///
74/// Each turn is embedded as a combination of the user and assistant messages,
75/// then stored in a [`VectorStore`]. Search results can be filtered by
76/// session ID.
77pub struct SessionIndexer {
78    store: VectorStore,
79    embedder: Box<dyn Embedder>,
80}
81
82impl SessionIndexer {
83    /// Create a new session indexer with the given embedder.
84    pub async fn new(embedder: Box<dyn Embedder>) -> Self {
85        Self {
86            store: VectorStore::new(),
87            embedder,
88        }
89    }
90
91    /// Index a conversation turn.
92    ///
93    /// The embedding is generated from the concatenation of user and assistant
94    /// messages: `"User: {user_message}\nAssistant: {assistant_message}"`.
95    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    /// Search indexed turns by semantic similarity.
138    ///
139    /// If `session_id` is `Some`, results are filtered to only that session.
140    /// The `top_k` parameter limits the number of results returned.
141    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        // Search more results than needed if filtering by session,
150        // to ensure we get enough matches after filtering.
151        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                // Parse the ID to extract session_id and turn_id.
170                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                // Extract user and assistant messages from the combined text.
178                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        // Already sorted by score from VectorStore::search.
193        let _ = &mut matches;
194        Ok(matches)
195    }
196
197    /// Return the number of indexed turns.
198    pub fn len(&self) -> usize {
199        self.store.len()
200    }
201
202    /// Return `true` if no turns have been indexed.
203    pub fn is_empty(&self) -> bool {
204        self.store.is_empty()
205    }
206}
207
208/// Parse a combined "User: ...\nAssistant: ..." text back into components.
209fn 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        // Search across all sessions.
294        let results = indexer
295            .search_turns("programming language", None, 10)
296            .await
297            .unwrap();
298        assert_eq!(results.len(), 2);
299        // Both sessions should be represented.
300        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        // Filter to sess1 only.
329        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}