1use anyhow::Result;
2use rusqlite::Connection;
3
4use crate::db;
5use crate::memory::{map_memory_row_pub, Memory};
6use crate::retrieval::memory_search::filters::{push_branch_filter, push_project_filter};
7
8#[derive(Debug, Clone)]
9pub struct FtsMemoryHit {
10 pub memory: Memory,
11 pub score: f64,
12}
13
14pub fn search_memories_fts(
16 conn: &Connection,
17 query: &str,
18 project: Option<&str>,
19 memory_type: Option<&str>,
20 limit: i64,
21 offset: i64,
22) -> Result<Vec<Memory>> {
23 search_memories_fts_filtered(
24 conn,
25 query,
26 project,
27 memory_type,
28 limit,
29 offset,
30 false,
31 None,
32 )
33}
34
35pub fn search_memories_fts_filtered(
36 conn: &Connection,
37 query: &str,
38 project: Option<&str>,
39 memory_type: Option<&str>,
40 limit: i64,
41 offset: i64,
42 include_inactive: bool,
43 branch: Option<&str>,
44) -> Result<Vec<Memory>> {
45 Ok(search_memories_fts_hits_filtered(
46 conn,
47 query,
48 project,
49 memory_type,
50 limit,
51 offset,
52 include_inactive,
53 branch,
54 )?
55 .into_iter()
56 .map(|hit| hit.memory)
57 .collect())
58}
59
60pub fn search_memories_fts_hits_filtered(
61 conn: &Connection,
62 query: &str,
63 project: Option<&str>,
64 memory_type: Option<&str>,
65 limit: i64,
66 offset: i64,
67 include_inactive: bool,
68 branch: Option<&str>,
69) -> Result<Vec<FtsMemoryHit>> {
70 crate::memory::retrieval_enrichment::ensure_retrieval_open(conn)?;
71 let mut conditions = vec!["memories_fts MATCH ?1".to_string()];
72 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(query.to_string())];
73
74 let mut idx = 2;
75 conditions.push(crate::memory::memory_current_filter_sql(
76 "m.status",
77 "m.expires_at_epoch",
78 include_inactive,
79 ));
80
81 idx = push_project_filter(
82 conn,
83 "m.project",
84 project,
85 idx,
86 &mut conditions,
87 &mut param_values,
88 )?;
89 idx = push_branch_filter("m.branch", branch, idx, &mut conditions, &mut param_values);
90 if let Some(memory_type) = memory_type {
91 conditions.push(format!("m.memory_type = ?{idx}"));
92 param_values.push(Box::new(memory_type.to_string()));
93 idx += 1;
94 }
95
96 param_values.push(Box::new(limit));
97 param_values.push(Box::new(offset));
98
99 let sql = format!(
100 "WITH ranked AS (
101 SELECT m.id, m.session_id, m.project, m.topic_key, m.title, m.content,
102 m.memory_type, m.files, m.created_at_epoch, m.updated_at_epoch,
103 m.status, m.branch, m.scope,
104 (bm25(memories_fts, 10.0, 1.0, 3.0) * CASE WHEN m.memory_type IN ('decision','bugfix') THEN 1.5 ELSE 1.0 END) AS rank_score
105 FROM memories m
106 JOIN memories_fts ON memories_fts.rowid = m.id
107 WHERE {}
108 )
109 SELECT id, session_id, project, topic_key, title, content,
110 memory_type, files, created_at_epoch, updated_at_epoch,
111 status, branch, scope, rank_score
112 FROM ranked
113 ORDER BY rank_score ASC, id ASC
114 LIMIT ?{} OFFSET ?{}",
115 conditions.join(" AND "),
116 idx,
117 idx + 1
118 );
119
120 let mut stmt = conn.prepare(&sql)?;
121 let refs = db::to_sql_refs(¶m_values);
122 let rows = stmt.query_map(refs.as_slice(), |row| {
123 Ok(FtsMemoryHit {
124 memory: map_memory_row_pub(row)?,
125 score: row.get(13)?,
126 })
127 })?;
128 crate::db::query::collect_rows(rows)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn setup_conn() -> Connection {
136 let conn = Connection::open_in_memory().unwrap();
137 crate::migrate::run_migrations(&conn).unwrap();
138 conn
139 }
140
141 fn insert_memory(conn: &Connection, id: i64, body: &str, status: &str) {
142 conn.execute(
143 "INSERT INTO memories(id, project, title, content, memory_type, created_at_epoch,
144 updated_at_epoch, status)
145 VALUES (?1, 'proj', 'title', ?2, 'decision', 100, 100, ?3)",
146 rusqlite::params![id, body, status],
147 )
148 .unwrap();
149 }
150
151 #[test]
154 fn include_inactive_finds_stale_rows() {
155 let conn = setup_conn();
156 insert_memory(&conn, 1, "deprecated zookeeper approach", "stale");
157
158 let active_only = search_memories_fts_filtered(
160 &conn,
161 "zookeeper",
162 Some("proj"),
163 None,
164 10,
165 0,
166 false,
167 None,
168 )
169 .unwrap();
170 assert!(
171 active_only.is_empty(),
172 "active-only search must hide stale rows: {active_only:?}"
173 );
174
175 let with_inactive =
177 search_memories_fts_filtered(&conn, "zookeeper", Some("proj"), None, 10, 0, true, None)
178 .unwrap();
179 assert_eq!(
180 with_inactive.len(),
181 1,
182 "include_inactive must retrieve stale"
183 );
184 assert_eq!(with_inactive[0].status, "stale");
185 }
186
187 #[test]
189 fn active_path_still_finds_active_rows() {
190 let conn = setup_conn();
191 insert_memory(&conn, 1, "current kafka pipeline", "active");
192
193 let hits =
194 search_memories_fts_filtered(&conn, "kafka", Some("proj"), None, 10, 0, false, None)
195 .unwrap();
196 assert_eq!(hits.len(), 1);
197 assert_eq!(hits[0].status, "active");
198 }
199
200 #[test]
201 fn equal_rank_scores_use_memory_id_as_a_stable_tiebreaker() -> anyhow::Result<()> {
202 let conn = setup_conn();
203 insert_memory(&conn, 2, "stable rankbridge result", "active");
204 insert_memory(&conn, 1, "stable rankbridge result", "active");
205
206 let hits = search_memories_fts_filtered(
207 &conn,
208 "rankbridge",
209 Some("proj"),
210 None,
211 10,
212 0,
213 false,
214 None,
215 )?;
216
217 assert_eq!(
218 hits.iter().map(|hit| hit.id).collect::<Vec<_>>(),
219 vec![1, 2]
220 );
221 Ok(())
222 }
223}