lunaris_retrieve/operators/
modifiers.rs1use std::any::Any;
23
24use async_trait::async_trait;
25use lunaris_core::LunarisError;
26use lunaris_core::storage::types::Filter;
27use thiserror::Error;
28
29use super::{QueryContext, Retriever};
30use crate::types::RawHit;
31
32pub struct TopRetriever {
37 pub(crate) inner: Box<dyn Retriever>,
38 pub(crate) n: usize,
39}
40
41impl TopRetriever {
42 pub fn new(inner: Box<dyn Retriever>, n: usize) -> Self {
43 Self { inner, n }
44 }
45}
46
47#[async_trait]
48impl Retriever for TopRetriever {
49 async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
50 let mut hits = self.inner.retrieve(ctx).await?;
51 hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
52 hits.truncate(self.n);
53 Ok(hits)
54 }
55
56 fn as_any(&self) -> &dyn Any {
57 self
58 }
59}
60
61pub fn floored_top(
83 mut hits: Vec<RawHit>,
84 n: usize,
85 floor_index: &str,
86 floor_n: usize,
87) -> Vec<RawHit> {
88 hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
89 if hits.len() <= n {
90 return hits;
91 }
92 let is_floor = |h: &RawHit| match h.metadata.get("index") {
93 Some(serde_json::Value::String(ix)) => ix == floor_index,
94 _ => true,
95 };
96 let mut selected = vec![false; hits.len()];
97 let mut taken = 0usize;
98 let floor_target = floor_n.min(n);
99 for (i, h) in hits.iter().enumerate() {
100 if taken == floor_target {
101 break;
102 }
103 if is_floor(h) {
104 selected[i] = true;
105 taken += 1;
106 }
107 }
108 for flag in selected.iter_mut() {
109 if taken == n {
110 break;
111 }
112 if !*flag {
113 *flag = true;
114 taken += 1;
115 }
116 }
117 hits.into_iter().zip(selected).filter_map(|(h, keep)| keep.then_some(h)).collect()
118}
119
120pub struct FlooredTopRetriever {
129 inner: Box<dyn Retriever>,
130 n: usize,
131 floor_index: String,
132 floor_n: usize,
133}
134
135impl FlooredTopRetriever {
136 pub fn new(inner: Box<dyn Retriever>, n: usize, floor_index: &str, floor_n: usize) -> Self {
137 Self { inner, n, floor_index: floor_index.to_owned(), floor_n }
138 }
139
140 pub fn n(&self) -> usize {
141 self.n
142 }
143
144 pub fn floor_index(&self) -> &str {
145 &self.floor_index
146 }
147
148 pub fn floor_n(&self) -> usize {
149 self.floor_n
150 }
151}
152
153#[async_trait]
154impl Retriever for FlooredTopRetriever {
155 async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
156 let hits = self.inner.retrieve(ctx).await?;
157 Ok(floored_top(hits, self.n, &self.floor_index, self.floor_n))
158 }
159
160 fn as_any(&self) -> &dyn Any {
161 self
162 }
163}
164
165#[derive(Debug, Error)]
170pub enum FilterParseError {
171 #[error(
172 "filter_str: unsupported syntax — v0 supports only `field = 'value'` and `field LIKE 'prefix%'`"
173 )]
174 Unsupported,
175 #[error("filter_str: missing closing quote")]
176 UnclosedQuote,
177 #[error("filter_str: LIKE pattern must end with `%` and not contain other `%`")]
178 UnsupportedGlob,
179}
180
181pub fn filter_str(s: &str) -> Result<Filter, FilterParseError> {
185 let s = s.trim();
186 if s.is_empty() {
187 return Err(FilterParseError::Unsupported);
188 }
189
190 if let Some((field, rest)) = split_keyword_ci(s, " LIKE ") {
192 let pattern = read_quoted(rest)?;
193 let body = pattern.strip_suffix('%').ok_or(FilterParseError::UnsupportedGlob)?;
195 if body.contains('%') {
196 return Err(FilterParseError::UnsupportedGlob);
197 }
198 return Ok(Filter::StartsWith {
199 field: field.trim().to_string(),
200 prefix: body.to_string(),
201 });
202 }
203
204 if let Some((field, rest)) = split_keyword_ci(s, " = ") {
206 let value = read_quoted(rest)?;
207 return Ok(Filter::Eq {
208 field: field.trim().to_string(),
209 value: serde_json::Value::String(value.to_string()),
210 });
211 }
212
213 Err(FilterParseError::Unsupported)
214}
215
216fn split_keyword_ci<'a>(s: &'a str, sep: &str) -> Option<(&'a str, &'a str)> {
219 s.find(sep).map(|idx| (&s[..idx], &s[idx + sep.len()..]))
220}
221
222fn read_quoted(s: &str) -> Result<&str, FilterParseError> {
224 let s = s.trim_start();
225 let s = s.strip_prefix('\'').ok_or(FilterParseError::Unsupported)?;
226 let close = s.find('\'').ok_or(FilterParseError::UnclosedQuote)?;
227 Ok(&s[..close])
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn filter_str_parses_starts_with() {
236 let f = filter_str("source LIKE 'helios:fs/%'").unwrap();
239 match f {
240 Filter::StartsWith { field, prefix } => {
241 assert_eq!(field, "source");
242 assert_eq!(prefix, "helios:fs/");
243 }
244 other => panic!("unexpected: {other:?}"),
245 }
246 }
247
248 #[test]
249 fn filter_str_parses_eq_string() {
250 let f = filter_str("source = 'notes.md'").unwrap();
251 match f {
252 Filter::Eq { field, value } => {
253 assert_eq!(field, "source");
254 assert_eq!(value, serde_json::Value::String("notes.md".into()));
255 }
256 other => panic!("unexpected: {other:?}"),
257 }
258 }
259
260 #[test]
261 fn filter_str_rejects_unsupported_glob() {
262 let r = filter_str("source LIKE 'helios:fs/%.md'");
263 assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
264 }
265
266 #[test]
267 fn filter_str_rejects_like_without_trailing_percent() {
268 let r = filter_str("source LIKE 'helios:fs/'");
270 assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
271 let r = filter_str("source LIKE 'helios'");
273 assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
274 }
275
276 #[test]
277 fn filter_str_rejects_unclosed_quote() {
278 let r = filter_str("source = 'oops");
279 assert!(matches!(r, Err(FilterParseError::UnclosedQuote)));
280 }
281
282 #[test]
283 fn filter_str_rejects_garbage() {
284 assert!(filter_str("").is_err());
285 assert!(filter_str("DELETE FROM chunks").is_err());
286 assert!(filter_str("source").is_err());
287 }
288}