codex_memory/
storage.rs

1use crate::error::Result;
2use crate::models::{
3    Memory, SearchParams, SearchResult, SearchResultWithMetadata, SearchStrategy, StorageStats,
4};
5use async_trait::async_trait;
6use sqlx::{PgPool, Row};
7use uuid::Uuid;
8
9/// Storage trait defining the interface for memory storage operations
10#[async_trait]
11pub trait StorageInterface: Send + Sync {
12    /// Store text content with context and summary
13    async fn store(
14        &self,
15        content: &str,
16        context: String,
17        summary: String,
18        tags: Option<Vec<String>>,
19    ) -> Result<Uuid>;
20
21    /// Store a chunk with parent reference
22    async fn store_chunk(
23        &self,
24        content: &str,
25        context: String,
26        summary: String,
27        tags: Option<Vec<String>>,
28        chunk_index: i32,
29        total_chunks: i32,
30        parent_id: Uuid,
31    ) -> Result<Uuid>;
32
33    /// Get memory by ID
34    async fn get(&self, id: Uuid) -> Result<Option<Memory>>;
35
36    /// Delete memory by ID
37    async fn delete(&self, id: Uuid) -> Result<bool>;
38
39    /// Search memories with flexible parameters
40    async fn search(&self, params: SearchParams) -> Result<Vec<SearchResult>>;
41
42    /// Get storage statistics
43    async fn stats(&self) -> Result<StorageStats>;
44
45    /// List recent memories
46    async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>>;
47
48    /// Get all chunks for a parent document
49    async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>>;
50}
51
52/// Concrete storage implementation using PostgreSQL
53pub struct Storage {
54    pool: PgPool,
55}
56
57impl Storage {
58    /// Create a new storage instance
59    pub fn new(pool: PgPool) -> Self {
60        Self { pool }
61    }
62
63    /// Store text with context and summary (deduplication by hash)
64    pub async fn store(
65        &self,
66        content: &str,
67        context: String,
68        summary: String,
69        tags: Option<Vec<String>>,
70    ) -> Result<Uuid> {
71        let memory = Memory::new(content.to_string(), context, summary, tags);
72
73        // Simple content deduplication based on content hash
74        let result: Uuid = sqlx::query_scalar(
75            r#"
76            INSERT INTO memories (id, content, content_hash, tags, context, summary, chunk_index, total_chunks, parent_id, created_at, updated_at)
77            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
78            ON CONFLICT (content_hash) DO UPDATE SET
79                context = EXCLUDED.context,
80                summary = EXCLUDED.summary,
81                tags = EXCLUDED.tags,
82                updated_at = EXCLUDED.updated_at
83            RETURNING id
84            "#
85        )
86        .bind(memory.id)
87        .bind(memory.content)
88        .bind(memory.content_hash)
89        .bind(&memory.tags)
90        .bind(&memory.context)
91        .bind(&memory.summary)
92        .bind(memory.chunk_index)
93        .bind(memory.total_chunks)
94        .bind(memory.parent_id)
95        .bind(memory.created_at)
96        .bind(memory.updated_at)
97        .fetch_one(&self.pool)
98        .await?;
99
100        Ok(result)
101    }
102
103    /// Store a chunk with parent reference
104    pub async fn store_chunk(
105        &self,
106        content: &str,
107        context: String,
108        summary: String,
109        tags: Option<Vec<String>>,
110        chunk_index: i32,
111        total_chunks: i32,
112        parent_id: Uuid,
113    ) -> Result<Uuid> {
114        let memory = Memory::new_chunk(
115            content.to_string(),
116            context,
117            summary,
118            tags,
119            chunk_index,
120            total_chunks,
121            parent_id,
122        );
123
124        // Insert chunk (no deduplication for chunks to preserve order)
125        let result: Uuid = sqlx::query_scalar(
126            r#"
127            INSERT INTO memories (id, content, content_hash, tags, context, summary, chunk_index, total_chunks, parent_id, created_at, updated_at)
128            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
129            RETURNING id
130            "#
131        )
132        .bind(memory.id)
133        .bind(memory.content)
134        .bind(memory.content_hash)
135        .bind(&memory.tags)
136        .bind(&memory.context)
137        .bind(&memory.summary)
138        .bind(memory.chunk_index)
139        .bind(memory.total_chunks)
140        .bind(memory.parent_id)
141        .bind(memory.created_at)
142        .bind(memory.updated_at)
143        .fetch_one(&self.pool)
144        .await?;
145
146        Ok(result)
147    }
148
149    /// Get memory by ID
150    pub async fn get(&self, id: Uuid) -> Result<Option<Memory>> {
151        let memory = sqlx::query_as::<_, Memory>(
152            r#"
153            SELECT 
154                id,
155                content,
156                content_hash,
157                tags,
158                context,
159                summary,
160                chunk_index,
161                total_chunks,
162                parent_id,
163                created_at,
164                updated_at
165            FROM memories
166            WHERE id = $1
167            "#,
168        )
169        .bind(id)
170        .fetch_optional(&self.pool)
171        .await?;
172
173        Ok(memory)
174    }
175
176    /// Get all chunks for a parent document, ordered by chunk index
177    pub async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>> {
178        let memories = sqlx::query_as::<_, Memory>(
179            r#"
180            SELECT 
181                id,
182                content,
183                content_hash,
184                tags,
185                context,
186                summary,
187                chunk_index,
188                total_chunks,
189                parent_id,
190                created_at,
191                updated_at
192            FROM memories
193            WHERE parent_id = $1
194            ORDER BY chunk_index ASC
195            "#,
196        )
197        .bind(parent_id)
198        .fetch_all(&self.pool)
199        .await?;
200
201        Ok(memories)
202    }
203
204    /// Delete memory by ID
205    pub async fn delete(&self, id: Uuid) -> Result<bool> {
206        let result = sqlx::query("DELETE FROM memories WHERE id = $1")
207            .bind(id)
208            .execute(&self.pool)
209            .await?;
210
211        Ok(result.rows_affected() > 0)
212    }
213
214    /// Get basic storage statistics
215    pub async fn stats(&self) -> Result<StorageStats> {
216        let row = sqlx::query(
217            r#"
218            SELECT 
219                COUNT(*) as total_memories,
220                pg_size_pretty(pg_total_relation_size('memories')) as table_size,
221                MAX(created_at) as last_memory_created
222            FROM memories
223            "#,
224        )
225        .fetch_one(&self.pool)
226        .await?;
227
228        let stats = StorageStats {
229            total_memories: row.get("total_memories"),
230            table_size: row.get("table_size"),
231            last_memory_created: row.get("last_memory_created"),
232        };
233
234        Ok(stats)
235    }
236
237    /// List recent memories (for basic browsing)
238    pub async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>> {
239        let memories = sqlx::query_as::<_, Memory>(
240            r#"
241            SELECT 
242                id,
243                content,
244                content_hash,
245                tags,
246                context,
247                summary,
248                chunk_index,
249                total_chunks,
250                parent_id,
251                created_at,
252                updated_at
253            FROM memories
254            ORDER BY created_at DESC
255            LIMIT $1
256            "#,
257        )
258        .bind(limit)
259        .fetch_all(&self.pool)
260        .await?;
261
262        Ok(memories)
263    }
264
265    /// Find memories with similar content but different contexts
266    /// Implements transfer appropriate processing - matching content with varying contexts
267    pub async fn find_similar_content(
268        &self,
269        content_hash: &str,
270        limit: i64,
271    ) -> Result<Vec<Memory>> {
272        let memories = sqlx::query_as::<_, Memory>(
273            r#"
274            SELECT 
275                id,
276                content,
277                content_hash,
278                tags,
279                context,
280                summary,
281                chunk_index,
282                total_chunks,
283                parent_id,
284                created_at,
285                updated_at
286            FROM memories
287            WHERE content_hash = $1
288            ORDER BY created_at DESC
289            LIMIT $2
290            "#,
291        )
292        .bind(content_hash)
293        .bind(limit)
294        .fetch_all(&self.pool)
295        .await?;
296
297        Ok(memories)
298    }
299
300    /// Check if a specific content already exists
301    /// Simplified deduplication based on content hash only
302    pub async fn exists_with_content(&self, content_hash: &str) -> Result<bool> {
303        let count: i64 =
304            sqlx::query_scalar("SELECT COUNT(*) FROM memories WHERE content_hash = $1")
305                .bind(content_hash)
306                .fetch_one(&self.pool)
307                .await?;
308
309        Ok(count > 0)
310    }
311
312    /// Get content statistics showing duplicate content
313    /// Useful for understanding deduplication effectiveness
314    pub async fn get_content_stats(&self) -> Result<Vec<(String, i64)>> {
315        let rows = sqlx::query(
316            r#"
317            SELECT 
318                content_hash,
319                COUNT(*) as total_count
320            FROM memories 
321            GROUP BY content_hash
322            HAVING COUNT(*) > 1
323            ORDER BY total_count DESC
324            LIMIT 50
325            "#,
326        )
327        .fetch_all(&self.pool)
328        .await?;
329
330        let stats = rows
331            .into_iter()
332            .map(|row| {
333                (
334                    row.get::<String, _>("content_hash"),
335                    row.get::<i64, _>("total_count"),
336                )
337            })
338            .collect();
339
340        Ok(stats)
341    }
342
343    /// Semantic similarity search using existing embeddings from codex-dreams
344    /// Implements progressive search strategy with automatic threshold relaxation
345    pub async fn search_memories(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
346        // Use progressive search strategy for better results
347        self.search_memories_progressive(params).await
348    }
349
350    /// Progressive search strategy that automatically retries with relaxed criteria
351    /// Stage 1: Search with original parameters
352    /// Stage 2: If no results, lower threshold by 0.25
353    /// Stage 3: If still no results, do content-only similarity search
354    async fn search_memories_progressive(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
355        let result_with_metadata = self
356            .search_memories_progressive_with_metadata(params)
357            .await?;
358        Ok(result_with_metadata.results)
359    }
360
361    /// Progressive search with metadata about which stage was used
362    pub async fn search_memories_progressive_with_metadata(
363        &self,
364        params: SearchParams,
365    ) -> Result<SearchResultWithMetadata> {
366        use crate::models::SearchMetadata;
367
368        // Stage 1: Try with original parameters
369        let stage1_results = self.search_memories_internal(params.clone()).await?;
370        if !stage1_results.is_empty() {
371            let metadata = SearchMetadata {
372                stage_used: 1,
373                stage_description: "Original parameters".to_string(),
374                threshold_used: params.similarity_threshold,
375                total_results: stage1_results.len(),
376            };
377            return Ok(SearchResultWithMetadata {
378                results: stage1_results,
379                metadata,
380            });
381        }
382
383        // Stage 2: Lower threshold by 0.25 (minimum 0.1)
384        let mut relaxed_params = params.clone();
385        relaxed_params.similarity_threshold = (params.similarity_threshold - 0.25).max(0.1);
386
387        let stage2_results = self
388            .search_memories_internal(relaxed_params.clone())
389            .await?;
390        if !stage2_results.is_empty() {
391            let metadata = SearchMetadata {
392                stage_used: 2,
393                stage_description: "Relaxed threshold".to_string(),
394                threshold_used: relaxed_params.similarity_threshold,
395                total_results: stage2_results.len(),
396            };
397            return Ok(SearchResultWithMetadata {
398                results: stage2_results,
399                metadata,
400            });
401        }
402
403        // Stage 3: Content-only search with very low threshold
404        let mut content_params = params.clone();
405        content_params.similarity_threshold = 0.1;
406        content_params.use_tag_embedding = false;
407        content_params.search_strategy = SearchStrategy::ContentFirst;
408
409        let stage3_results = self.search_memories_internal(content_params).await?;
410        let metadata = SearchMetadata {
411            stage_used: 3,
412            stage_description: "Content-only similarity".to_string(),
413            threshold_used: 0.1,
414            total_results: stage3_results.len(),
415        };
416
417        Ok(SearchResultWithMetadata {
418            results: stage3_results,
419            metadata,
420        })
421    }
422
423    /// Internal search implementation (original search_memories logic)
424    async fn search_memories_internal(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
425        // Check if embedding columns exist (graceful degradation for test environments)
426        let has_embeddings = self.check_embedding_columns_exist().await?;
427
428        if !has_embeddings || (!params.use_tag_embedding && !params.use_content_embedding) {
429            // Use fallback text search when embeddings unavailable or disabled
430            return self.search_memories_fallback(params).await;
431        }
432
433        // Step 1: Generate query embedding by finding a similar memory first
434        // This is a simplified approach - in production, you'd use the same embedding service as codex-dreams
435        let query_memory_ids = if params.use_tag_embedding || params.use_content_embedding {
436            // Find memories with similar text content for embedding reference
437            let similar_text_rows = sqlx::query(
438                r#"
439                SELECT id, summary, content
440                FROM memories 
441                WHERE to_tsvector('english', summary || ' ' || content) @@ plainto_tsquery('english', $1)
442                AND embedding_vector IS NOT NULL
443                LIMIT 5
444                "#
445            )
446            .bind(&params.query)
447            .fetch_all(&self.pool)
448            .await;
449
450            match similar_text_rows {
451                Ok(rows) => {
452                    if rows.is_empty() {
453                        // Fallback to basic text search if no embedding matches
454                        return self.search_memories_fallback(params).await;
455                    }
456                    rows.into_iter()
457                        .map(|row| row.get::<Uuid, _>("id"))
458                        .collect::<Vec<_>>()
459                }
460                Err(_) => {
461                    // Embedding columns don't exist, use fallback
462                    return self.search_memories_fallback(params).await;
463                }
464            }
465        } else {
466            vec![]
467        };
468
469        // Step 2: Main search based on strategy
470        let mut results = match params.search_strategy {
471            SearchStrategy::TagsFirst => self.search_tags_first(&params, &query_memory_ids).await?,
472            SearchStrategy::ContentFirst => {
473                self.search_content_first(&params, &query_memory_ids)
474                    .await?
475            }
476            SearchStrategy::Hybrid => self.search_hybrid(&params, &query_memory_ids).await?,
477        };
478
479        // Step 3: Apply recency boost if requested
480        if params.boost_recent {
481            self.apply_recency_boost(&mut results);
482        }
483
484        // Step 4: Sort by combined score and limit results
485        results.sort_by(|a, b| b.combined_score.partial_cmp(&a.combined_score).unwrap());
486        results.truncate(params.max_results);
487
488        Ok(results)
489    }
490
491    /// Check if embedding columns exist in the database
492    async fn check_embedding_columns_exist(&self) -> Result<bool> {
493        let result = sqlx::query(
494            r#"
495            SELECT COUNT(*) as count
496            FROM information_schema.columns 
497            WHERE table_name = 'memories' 
498            AND column_name IN ('embedding_vector', 'tag_embedding')
499            "#,
500        )
501        .fetch_one(&self.pool)
502        .await;
503
504        match result {
505            Ok(row) => {
506                let count: i64 = row.get("count");
507                Ok(count >= 2) // Both embedding columns should exist
508            }
509            Err(_) => Ok(false),
510        }
511    }
512
513    /// Search using tag embeddings first, then content embeddings within results
514    async fn search_tags_first(
515        &self,
516        params: &SearchParams,
517        query_ids: &[Uuid],
518    ) -> Result<Vec<SearchResult>> {
519        if query_ids.is_empty() {
520            return Ok(vec![]);
521        }
522
523        // Use the first similar memory's tag embedding as reference
524        let tag_results = sqlx::query(
525            r#"
526            WITH query_embedding AS (
527                SELECT tag_embedding as query_vector
528                FROM memories 
529                WHERE id = $1 AND tag_embedding IS NOT NULL
530                LIMIT 1
531            )
532            SELECT m.*, 
533                   (m.tag_embedding <=> q.query_vector) as tag_similarity,
534                   m.semantic_cluster
535            FROM memories m, query_embedding q
536            WHERE m.tag_embedding IS NOT NULL
537            AND ($2::text[] IS NULL OR m.tags && $2::text[])
538            AND (m.tag_embedding <=> q.query_vector) <= $3
539            ORDER BY m.tag_embedding <=> q.query_vector
540            LIMIT $4
541            "#,
542        )
543        .bind(query_ids[0])
544        .bind(&params.tag_filter)
545        .bind(1.0 - params.similarity_threshold) // Convert similarity to distance
546        .bind((params.max_results * 3) as i64) // Get more candidates for content filtering
547        .fetch_all(&self.pool)
548        .await?;
549
550        self.enhance_with_content_similarity(tag_results, query_ids, params)
551            .await
552    }
553
554    /// Search using content embeddings first
555    async fn search_content_first(
556        &self,
557        params: &SearchParams,
558        query_ids: &[Uuid],
559    ) -> Result<Vec<SearchResult>> {
560        if query_ids.is_empty() {
561            return Ok(vec![]);
562        }
563
564        let content_results = sqlx::query(
565            r#"
566            WITH query_embedding AS (
567                SELECT embedding_vector as query_vector
568                FROM memories 
569                WHERE id = $1 AND embedding_vector IS NOT NULL
570                LIMIT 1
571            )
572            SELECT m.*, 
573                   (m.embedding_vector <=> q.query_vector) as content_similarity,
574                   m.semantic_cluster
575            FROM memories m, query_embedding q
576            WHERE m.embedding_vector IS NOT NULL
577            AND ($2::text[] IS NULL OR m.tags && $2::text[])
578            AND (m.embedding_vector <=> q.query_vector) <= $3
579            ORDER BY m.embedding_vector <=> q.query_vector
580            LIMIT $4
581            "#,
582        )
583        .bind(query_ids[0])
584        .bind(&params.tag_filter)
585        .bind(1.0 - params.similarity_threshold)
586        .bind((params.max_results * 2) as i64)
587        .fetch_all(&self.pool)
588        .await?;
589
590        self.enhance_with_tag_similarity(content_results, query_ids, params)
591            .await
592    }
593
594    /// Hybrid search combining both approaches
595    async fn search_hybrid(
596        &self,
597        params: &SearchParams,
598        query_ids: &[Uuid],
599    ) -> Result<Vec<SearchResult>> {
600        if query_ids.is_empty() {
601            return Ok(vec![]);
602        }
603
604        let results = sqlx::query(
605            r#"
606            WITH query_embeddings AS (
607                SELECT 
608                    embedding_vector as content_query_vector,
609                    tag_embedding as tag_query_vector
610                FROM memories 
611                WHERE id = $1 
612                AND embedding_vector IS NOT NULL 
613                AND tag_embedding IS NOT NULL
614                LIMIT 1
615            )
616            SELECT m.*, 
617                   (m.embedding_vector <=> q.content_query_vector) as content_similarity,
618                   (m.tag_embedding <=> q.tag_query_vector) as tag_similarity,
619                   m.semantic_cluster
620            FROM memories m, query_embeddings q
621            WHERE m.embedding_vector IS NOT NULL 
622            AND m.tag_embedding IS NOT NULL
623            AND ($2::text[] IS NULL OR m.tags && $2::text[])
624            AND (
625                (m.embedding_vector <=> q.content_query_vector) <= $3 OR
626                (m.tag_embedding <=> q.tag_query_vector) <= $3
627            )
628            ORDER BY LEAST(
629                m.embedding_vector <=> q.content_query_vector,
630                m.tag_embedding <=> q.tag_query_vector
631            )
632            LIMIT $4
633            "#,
634        )
635        .bind(query_ids[0])
636        .bind(&params.tag_filter)
637        .bind(1.0 - params.similarity_threshold)
638        .bind((params.max_results * 2) as i64)
639        .fetch_all(&self.pool)
640        .await?;
641
642        Ok(self.rows_to_search_results(results, params))
643    }
644
645    /// Enhance tag results with content similarity scores
646    async fn enhance_with_content_similarity(
647        &self,
648        tag_results: Vec<sqlx::postgres::PgRow>,
649        query_ids: &[Uuid],
650        params: &SearchParams,
651    ) -> Result<Vec<SearchResult>> {
652        if query_ids.is_empty() || tag_results.is_empty() {
653            return Ok(vec![]);
654        }
655
656        // Get content similarities for the tag results
657        let memory_ids: Vec<Uuid> = tag_results.iter().map(|r| r.get("id")).collect();
658
659        let content_similarities = if params.use_content_embedding {
660            sqlx::query(
661                r#"
662                WITH query_embedding AS (
663                    SELECT embedding_vector as query_vector
664                    FROM memories 
665                    WHERE id = $1 AND embedding_vector IS NOT NULL
666                    LIMIT 1
667                )
668                SELECT m.id, (m.embedding_vector <=> q.query_vector) as content_similarity
669                FROM memories m, query_embedding q
670                WHERE m.id = ANY($2) AND m.embedding_vector IS NOT NULL
671                "#,
672            )
673            .bind(query_ids[0])
674            .bind(&memory_ids)
675            .fetch_all(&self.pool)
676            .await?
677        } else {
678            vec![]
679        };
680
681        // Create lookup map for content similarities
682        let content_sim_map: std::collections::HashMap<Uuid, f64> = content_similarities
683            .into_iter()
684            .map(|row| (row.get("id"), 1.0 - row.get::<f64, _>("content_similarity")))
685            .collect();
686
687        // Combine results
688        let mut results = vec![];
689        for row in tag_results {
690            let memory_id: Uuid = row.get("id");
691            let tag_similarity = Some(1.0 - row.get::<f64, _>("tag_similarity"));
692            let content_similarity = content_sim_map.get(&memory_id).copied();
693            let semantic_cluster = row.get("semantic_cluster");
694
695            let memory = self.row_to_memory(&row);
696            let result = SearchResult::new(
697                memory,
698                tag_similarity,
699                content_similarity,
700                semantic_cluster,
701                params.tag_weight,
702                params.content_weight,
703            );
704
705            if result.combined_score >= params.similarity_threshold {
706                results.push(result);
707            }
708        }
709
710        Ok(results)
711    }
712
713    /// Enhance content results with tag similarity scores  
714    async fn enhance_with_tag_similarity(
715        &self,
716        content_results: Vec<sqlx::postgres::PgRow>,
717        query_ids: &[Uuid],
718        params: &SearchParams,
719    ) -> Result<Vec<SearchResult>> {
720        if query_ids.is_empty() || content_results.is_empty() {
721            return Ok(vec![]);
722        }
723
724        let memory_ids: Vec<Uuid> = content_results.iter().map(|r| r.get("id")).collect();
725
726        let tag_similarities = if params.use_tag_embedding {
727            sqlx::query(
728                r#"
729                WITH query_embedding AS (
730                    SELECT tag_embedding as query_vector
731                    FROM memories 
732                    WHERE id = $1 AND tag_embedding IS NOT NULL
733                    LIMIT 1
734                )
735                SELECT m.id, (m.tag_embedding <=> q.query_vector) as tag_similarity
736                FROM memories m, query_embedding q
737                WHERE m.id = ANY($2) AND m.tag_embedding IS NOT NULL
738                "#,
739            )
740            .bind(query_ids[0])
741            .bind(&memory_ids)
742            .fetch_all(&self.pool)
743            .await?
744        } else {
745            vec![]
746        };
747
748        let tag_sim_map: std::collections::HashMap<Uuid, f64> = tag_similarities
749            .into_iter()
750            .map(|row| (row.get("id"), 1.0 - row.get::<f64, _>("tag_similarity")))
751            .collect();
752
753        let mut results = vec![];
754        for row in content_results {
755            let memory_id: Uuid = row.get("id");
756            let content_similarity = Some(1.0 - row.get::<f64, _>("content_similarity"));
757            let tag_similarity = tag_sim_map.get(&memory_id).copied();
758            let semantic_cluster = row.get("semantic_cluster");
759
760            let memory = self.row_to_memory(&row);
761            let result = SearchResult::new(
762                memory,
763                tag_similarity,
764                content_similarity,
765                semantic_cluster,
766                params.tag_weight,
767                params.content_weight,
768            );
769
770            if result.combined_score >= params.similarity_threshold {
771                results.push(result);
772            }
773        }
774
775        Ok(results)
776    }
777
778    /// Convert database rows to SearchResult objects
779    fn rows_to_search_results(
780        &self,
781        rows: Vec<sqlx::postgres::PgRow>,
782        params: &SearchParams,
783    ) -> Vec<SearchResult> {
784        rows.into_iter()
785            .filter_map(|row| {
786                let tag_similarity = row
787                    .try_get::<f64, _>("tag_similarity")
788                    .ok()
789                    .map(|v| 1.0 - v);
790                let content_similarity = row
791                    .try_get::<f64, _>("content_similarity")
792                    .ok()
793                    .map(|v| 1.0 - v);
794                let semantic_cluster = row.get("semantic_cluster");
795
796                let memory = self.row_to_memory(&row);
797                let result = SearchResult::new(
798                    memory,
799                    tag_similarity,
800                    content_similarity,
801                    semantic_cluster,
802                    params.tag_weight,
803                    params.content_weight,
804                );
805
806                if result.combined_score >= params.similarity_threshold {
807                    Some(result)
808                } else {
809                    None
810                }
811            })
812            .collect()
813    }
814
815    /// Convert database row to Memory object
816    /// Note: This function is now mostly used for complex search queries with extra columns
817    fn row_to_memory(&self, row: &sqlx::postgres::PgRow) -> Memory {
818        Memory {
819            id: row.get("id"),
820            content: row.get("content"),
821            content_hash: row.get("content_hash"),
822            tags: row.get("tags"),
823            context: row.get("context"),
824            summary: row.get("summary"),
825            chunk_index: row.get("chunk_index"),
826            total_chunks: row.get("total_chunks"),
827            parent_id: row.get("parent_id"),
828            created_at: row.get("created_at"),
829            updated_at: row.get("updated_at"),
830        }
831    }
832
833    /// Apply recency boost to search results
834    fn apply_recency_boost(&self, results: &mut [SearchResult]) {
835        let now = chrono::Utc::now();
836        for result in results.iter_mut() {
837            let age_days = (now - result.memory.created_at).num_days() as f64;
838            let recency_factor = (1.0 / (1.0 + age_days / 30.0)).max(0.1); // Boost recent memories
839            result.combined_score *= recency_factor;
840        }
841    }
842
843    /// Fallback search using simple pattern matching when embeddings unavailable
844    async fn search_memories_fallback(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
845        // Use ILIKE for case-insensitive pattern matching as fallback
846        let search_pattern = format!("%{}%", params.query);
847
848        let query_sql = if let Some(ref _tag_filter) = params.tag_filter {
849            r#"
850            SELECT *, 
851                   CAST(CASE 
852                     WHEN content ILIKE $1 AND summary ILIKE $1 THEN 1.0
853                     WHEN content ILIKE $1 OR summary ILIKE $1 THEN 0.8
854                     WHEN context ILIKE $1 THEN 0.6
855                     WHEN EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1) THEN 0.5
856                     ELSE 0.4
857                   END AS FLOAT8) as rank
858            FROM memories 
859            WHERE (content ILIKE $1 OR summary ILIKE $1 OR context ILIKE $1 
860                   OR EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1))
861            AND tags && $2::text[]
862            ORDER BY rank DESC, created_at DESC
863            LIMIT $3
864            "#
865        } else {
866            r#"
867            SELECT *, 
868                   CAST(CASE 
869                     WHEN content ILIKE $1 AND summary ILIKE $1 THEN 1.0
870                     WHEN content ILIKE $1 OR summary ILIKE $1 THEN 0.8
871                     WHEN context ILIKE $1 THEN 0.6
872                     WHEN EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1) THEN 0.5
873                     ELSE 0.4
874                   END AS FLOAT8) as rank
875            FROM memories 
876            WHERE content ILIKE $1 OR summary ILIKE $1 OR context ILIKE $1 
877                  OR EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1)
878            ORDER BY rank DESC, created_at DESC
879            LIMIT $2
880            "#
881        };
882
883        let rows = if let Some(ref tag_filter) = params.tag_filter {
884            sqlx::query(query_sql)
885                .bind(&search_pattern)
886                .bind(tag_filter)
887                .bind(params.max_results as i64)
888                .fetch_all(&self.pool)
889                .await?
890        } else {
891            sqlx::query(query_sql)
892                .bind(&search_pattern)
893                .bind(params.max_results as i64)
894                .fetch_all(&self.pool)
895                .await?
896        };
897
898        let results = rows
899            .into_iter()
900            .map(|row| {
901                let rank: f64 = row.get("rank");
902                let memory = self.row_to_memory(&row);
903                // For fallback search, use rank directly as it's already a final score
904                SearchResult {
905                    memory,
906                    tag_similarity: None,
907                    content_similarity: Some(rank),
908                    combined_score: rank, // Use rank directly, not weighted
909                    semantic_cluster: None,
910                }
911            })
912            .filter(|result| result.combined_score >= params.similarity_threshold)
913            .collect();
914
915        Ok(results)
916    }
917}
918
919#[async_trait]
920impl StorageInterface for Storage {
921    async fn store(
922        &self,
923        content: &str,
924        context: String,
925        summary: String,
926        tags: Option<Vec<String>>,
927    ) -> Result<Uuid> {
928        self.store(content, context, summary, tags).await
929    }
930
931    async fn store_chunk(
932        &self,
933        content: &str,
934        context: String,
935        summary: String,
936        tags: Option<Vec<String>>,
937        chunk_index: i32,
938        total_chunks: i32,
939        parent_id: Uuid,
940    ) -> Result<Uuid> {
941        self.store_chunk(content, context, summary, tags, chunk_index, total_chunks, parent_id)
942            .await
943    }
944
945    async fn get(&self, id: Uuid) -> Result<Option<Memory>> {
946        self.get(id).await
947    }
948
949    async fn delete(&self, id: Uuid) -> Result<bool> {
950        self.delete(id).await
951    }
952
953    async fn search(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
954        self.search_memories(params).await
955    }
956
957    async fn stats(&self) -> Result<StorageStats> {
958        self.stats().await
959    }
960
961    async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>> {
962        self.list_recent(limit).await
963    }
964
965    async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>> {
966        self.get_chunks(parent_id).await
967    }
968}