1use rusqlite::types::Value as SqlValue;
23use rusqlite::{params_from_iter, Connection};
24
25use kindling_store::SqliteKindlingStore;
26use kindling_types::{
27 Id, Observation, ObservationKind, ProviderSearchOptions, ProviderSearchResult, RetrievedEntity,
28 ScopeIds, Summary, Timestamp,
29};
30
31use crate::error::{ProviderError, ProviderResult};
32
33const MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000;
35
36const DEFAULT_MAX_RESULTS: u32 = 50;
38
39const MATCH_CONTEXT_UNITS: usize = 100;
41
42pub trait RetrievalProvider {
48 fn name(&self) -> &str;
50
51 fn search(
53 &self,
54 options: &ProviderSearchOptions,
55 now: Timestamp,
56 ) -> ProviderResult<Vec<ProviderSearchResult>>;
57}
58
59struct RawObsRow {
61 id: String,
62 kind: String,
63 content: String,
64 provenance: String,
65 ts: Timestamp,
66 scope_ids: String,
67 redacted: i64,
68 fts_rank: f64,
69 recency: f64,
70}
71
72struct RawSumRow {
74 id: String,
75 capsule_id: String,
76 content: String,
77 confidence: f64,
78 evidence_refs: String,
79 created_at: Timestamp,
80 fts_rank: f64,
81 recency: f64,
82}
83
84pub struct LocalFtsProvider<'conn> {
86 conn: &'conn Connection,
87}
88
89impl<'conn> LocalFtsProvider<'conn> {
90 pub const NAME: &'static str = "local-fts";
92
93 pub fn new(conn: &'conn Connection) -> Self {
94 Self { conn }
95 }
96
97 pub fn from_store(store: &'conn SqliteKindlingStore) -> Self {
99 Self::new(store.connection())
100 }
101
102 fn search_observations_raw(
105 &self,
106 query: &str,
107 scope: &ScopeIds,
108 exclude_ids: &[Id],
109 include_redacted: bool,
110 now: Timestamp,
111 limit: u32,
112 ) -> ProviderResult<Vec<RawObsRow>> {
113 let (scope_clauses, scope_params) = build_scope_filters(scope, "o");
114 let exclude_filter = exclude_id_filter("o", exclude_ids);
115 let redacted_filter = if include_redacted {
116 ""
117 } else {
118 "AND o.redacted = 0"
119 };
120 let scope_filter = if scope_clauses.is_empty() {
121 String::new()
122 } else {
123 format!("AND {}", scope_clauses.join(" AND "))
124 };
125
126 let sql = format!(
127 "WITH fts_hits AS (
128 SELECT rowid, rank FROM observations_fts WHERE content MATCH ?
129 )
130 SELECT
131 o.id, o.kind, o.content, o.provenance, o.ts, o.scope_ids, o.redacted,
132 f.rank AS fts_rank,
133 MAX(0.0, 1.0 - CAST(? - o.ts AS REAL) / ?) AS recency
134 FROM fts_hits f
135 JOIN observations o ON f.rowid = o.rowid
136 WHERE 1=1
137 {redacted_filter}
138 {scope_filter}
139 {exclude_filter}
140 ORDER BY f.rank ASC
141 LIMIT ?"
142 );
143
144 let mut params: Vec<SqlValue> = vec![
145 SqlValue::Text(query.to_string()),
146 SqlValue::Integer(now),
147 SqlValue::Integer(MAX_AGE_MS),
148 ];
149 params.extend(scope_params);
150 params.extend(exclude_ids.iter().map(|id| SqlValue::Text(id.clone())));
151 params.push(SqlValue::Integer(i64::from(limit)));
152
153 let result = (|| -> Result<Vec<RawObsRow>, rusqlite::Error> {
154 let mut stmt = self.conn.prepare(&sql)?;
155 let rows = stmt.query_map(params_from_iter(params), |row| {
156 Ok(RawObsRow {
157 id: row.get(0)?,
158 kind: row.get(1)?,
159 content: row.get(2)?,
160 provenance: row.get(3)?,
161 ts: row.get(4)?,
162 scope_ids: row.get(5)?,
163 redacted: row.get(6)?,
164 fts_rank: row.get(7)?,
165 recency: row.get(8)?,
166 })
167 })?;
168 rows.collect()
169 })();
170
171 match result {
172 Ok(rows) => Ok(rows),
173 Err(err) if is_fts_syntax_error(&err) => Ok(Vec::new()),
174 Err(err) => Err(err.into()),
175 }
176 }
177
178 fn search_summaries_raw(
181 &self,
182 query: &str,
183 scope: &ScopeIds,
184 exclude_ids: &[Id],
185 now: Timestamp,
186 limit: u32,
187 ) -> ProviderResult<Vec<RawSumRow>> {
188 let (scope_clauses, scope_params) = build_scope_filters(scope, "c");
189 let exclude_filter = exclude_id_filter("s", exclude_ids);
190 let scope_filter = if scope_clauses.is_empty() {
191 String::new()
192 } else {
193 format!("AND {}", scope_clauses.join(" AND "))
194 };
195
196 let sql = format!(
197 "WITH fts_hits AS (
198 SELECT rowid, rank FROM summaries_fts WHERE content MATCH ?
199 )
200 SELECT
201 s.id, s.capsule_id, s.content, s.confidence, s.evidence_refs, s.created_at,
202 f.rank AS fts_rank,
203 MAX(0.0, 1.0 - CAST(? - s.created_at AS REAL) / ?) AS recency
204 FROM fts_hits f
205 JOIN summaries s ON f.rowid = s.rowid
206 JOIN capsules c ON s.capsule_id = c.id
207 WHERE 1=1
208 {scope_filter}
209 {exclude_filter}
210 ORDER BY f.rank ASC
211 LIMIT ?"
212 );
213
214 let mut params: Vec<SqlValue> = vec![
215 SqlValue::Text(query.to_string()),
216 SqlValue::Integer(now),
217 SqlValue::Integer(MAX_AGE_MS),
218 ];
219 params.extend(scope_params);
220 params.extend(exclude_ids.iter().map(|id| SqlValue::Text(id.clone())));
221 params.push(SqlValue::Integer(i64::from(limit)));
222
223 let result = (|| -> Result<Vec<RawSumRow>, rusqlite::Error> {
224 let mut stmt = self.conn.prepare(&sql)?;
225 let rows = stmt.query_map(params_from_iter(params), |row| {
226 Ok(RawSumRow {
227 id: row.get(0)?,
228 capsule_id: row.get(1)?,
229 content: row.get(2)?,
230 confidence: row.get(3)?,
231 evidence_refs: row.get(4)?,
232 created_at: row.get(5)?,
233 fts_rank: row.get(6)?,
234 recency: row.get(7)?,
235 })
236 })?;
237 rows.collect()
238 })();
239
240 match result {
241 Ok(rows) => Ok(rows),
242 Err(err) if is_fts_syntax_error(&err) => Ok(Vec::new()),
243 Err(err) => Err(err.into()),
244 }
245 }
246}
247
248impl RetrievalProvider for LocalFtsProvider<'_> {
249 fn name(&self) -> &str {
250 Self::NAME
251 }
252
253 fn search(
254 &self,
255 options: &ProviderSearchOptions,
256 now: Timestamp,
257 ) -> ProviderResult<Vec<ProviderSearchResult>> {
258 let max_results = options.max_results.unwrap_or(DEFAULT_MAX_RESULTS);
259 let exclude_ids = options.exclude_ids.as_deref().unwrap_or(&[]);
260 let include_redacted = options.include_redacted.unwrap_or(false);
261
262 let obs_raw = self.search_observations_raw(
263 &options.query,
264 &options.scope_ids,
265 exclude_ids,
266 include_redacted,
267 now,
268 max_results,
269 )?;
270 let sum_raw = self.search_summaries_raw(
271 &options.query,
272 &options.scope_ids,
273 exclude_ids,
274 now,
275 max_results,
276 )?;
277
278 let mut min_rank = f64::INFINITY;
281 let mut max_rank = f64::NEG_INFINITY;
282 for rank in obs_raw
283 .iter()
284 .map(|r| r.fts_rank)
285 .chain(sum_raw.iter().map(|r| r.fts_rank))
286 {
287 min_rank = min_rank.min(rank);
288 max_rank = max_rank.max(rank);
289 }
290 let rank_range = if min_rank <= max_rank {
291 max_rank - min_rank
292 } else {
293 0.0 };
295 let normalize_fts = |rank: f64| -> f64 {
296 if rank_range == 0.0 {
297 0.5 } else {
299 (max_rank - rank) / rank_range
300 }
301 };
302 let score_of = |fts_rank: f64, recency: f64| -> f64 {
303 let score = (normalize_fts(fts_rank) * 0.7 + recency * 0.3).clamp(0.0, 1.0);
304 (score * 1e10).round() / 1e10
305 };
306
307 let mut results: Vec<ProviderSearchResult> =
308 Vec::with_capacity(obs_raw.len() + sum_raw.len());
309
310 for row in obs_raw {
311 results.push(ProviderSearchResult {
312 score: score_of(row.fts_rank, row.recency),
313 match_context: Some(match_context(&row.content)),
314 entity: RetrievedEntity::Observation(Observation {
315 id: row.id,
316 kind: parse_observation_kind(&row.kind)?,
317 provenance: serde_json::from_str(&row.provenance)?,
318 ts: row.ts,
319 scope_ids: serde_json::from_str(&row.scope_ids)?,
320 redacted: row.redacted == 1,
321 content: row.content,
322 }),
323 });
324 }
325
326 for row in sum_raw {
327 results.push(ProviderSearchResult {
328 score: score_of(row.fts_rank, row.recency),
329 match_context: Some(match_context(&row.content)),
330 entity: RetrievedEntity::Summary(Summary {
331 id: row.id,
332 capsule_id: row.capsule_id,
333 confidence: row.confidence,
334 created_at: row.created_at,
335 evidence_refs: serde_json::from_str(&row.evidence_refs)?,
336 content: row.content,
337 }),
338 });
339 }
340
341 results.sort_by(|a, b| b.score.total_cmp(&a.score));
344 results.truncate(max_results as usize);
345 Ok(results)
346 }
347}
348
349fn match_context(content: &str) -> String {
353 let mut units = 0usize;
354 for (byte_idx, ch) in content.char_indices() {
355 let width = ch.len_utf16();
356 if units + width > MATCH_CONTEXT_UNITS {
357 return format!("{}...", &content[..byte_idx]);
358 }
359 units += width;
360 }
361 content.to_string()
362}
363
364fn build_scope_filters(scope: &ScopeIds, prefix: &str) -> (Vec<String>, Vec<SqlValue>) {
368 let mut clauses = Vec::new();
369 let mut params = Vec::new();
370 let filters = [
371 ("session_id", &scope.session_id),
372 ("repo_id", &scope.repo_id),
373 ("agent_id", &scope.agent_id),
374 ("user_id", &scope.user_id),
375 ];
376 for (column, value) in filters {
377 if let Some(value) = value {
378 clauses.push(format!("{prefix}.{column} = ?"));
379 params.push(SqlValue::Text(value.clone()));
380 }
381 }
382 (clauses, params)
383}
384
385fn exclude_id_filter(prefix: &str, exclude_ids: &[Id]) -> String {
387 if exclude_ids.is_empty() {
388 return String::new();
389 }
390 let placeholders = vec!["?"; exclude_ids.len()].join(",");
391 format!("AND {prefix}.id NOT IN ({placeholders})")
392}
393
394fn is_fts_syntax_error(err: &rusqlite::Error) -> bool {
398 let msg = err.to_string().to_lowercase();
399 msg.contains("fts5")
400 || msg.contains("fts syntax")
401 || msg.contains("unterminated string")
402 || msg.contains("unknown special query")
403}
404
405fn parse_observation_kind(value: &str) -> ProviderResult<ObservationKind> {
406 serde_json::from_value(serde_json::Value::String(value.to_string())).map_err(|_| {
407 ProviderError::UnexpectedRowValue {
408 column: "kind",
409 value: value.to_string(),
410 }
411 })
412}