use crate::timeline::Step;
#[allow(dead_code)] pub(crate) const MAX_RESULTS: usize = 30;
pub const FEATURE_DISABLED_MESSAGE: &str = "semantic search not compiled in — rebuild with `cargo install agx --features embedding-search` or `cargo build --release --features embedding-search`";
#[cfg(not(feature = "embedding-search"))]
pub fn rank(_query: &str, _steps: &[Step]) -> Option<Vec<usize>> {
None
}
#[cfg(feature = "embedding-search")]
pub fn rank(query: &str, steps: &[Step]) -> Option<Vec<usize>> {
real::rank(query, steps)
}
#[cfg(feature = "embedding-search")]
mod real {
use super::{MAX_RESULTS, Step};
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
use std::sync::{Mutex, OnceLock};
static MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
fn model() -> &'static Mutex<TextEmbedding> {
MODEL.get_or_init(|| {
let init = InitOptions::new(EmbeddingModel::AllMiniLML6V2);
let model = TextEmbedding::try_new(init)
.expect("fastembed failed to initialize — check ~/.cache/agx/ writability");
Mutex::new(model)
})
}
fn step_input(step: &Step) -> String {
let mut s = String::with_capacity(step.label.len() + step.detail.len() + 2);
s.push_str(&step.label);
s.push('\n');
s.push_str(&step.detail);
s
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let mut dot = 0.0;
for i in 0..a.len() {
dot += a[i] * b[i];
}
dot
}
pub(super) fn rank(query: &str, steps: &[Step]) -> Option<Vec<usize>> {
let query = query.trim();
if query.is_empty() || steps.is_empty() {
return Some(Vec::new());
}
let lock = model().lock().ok()?;
let query_vec = {
let mut m = lock;
m.embed(vec![query.to_string()], None).ok()?
};
let q = query_vec.into_iter().next()?;
let inputs: Vec<String> = steps.iter().map(step_input).collect();
let step_vecs = {
let mut m = model().lock().ok()?;
m.embed(inputs, None).ok()?
};
let mut scored: Vec<(usize, f32)> = step_vecs
.into_iter()
.enumerate()
.map(|(i, v)| (i, cosine(&q, &v)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Some(
scored
.into_iter()
.filter(|(_, s)| *s >= 0.25)
.take(MAX_RESULTS)
.map(|(i, _)| i)
.collect(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_disabled_message_mentions_rebuild_hint() {
assert!(FEATURE_DISABLED_MESSAGE.contains("--features embedding-search"));
}
#[cfg(not(feature = "embedding-search"))]
#[test]
fn rank_returns_none_without_feature() {
use crate::timeline::user_text_step;
let steps = vec![user_text_step("hello"), user_text_step("world")];
assert!(rank("hello", &steps).is_none());
}
#[cfg(not(feature = "embedding-search"))]
#[test]
fn rank_ignores_empty_inputs_without_feature() {
assert!(rank("", &[]).is_none());
assert!(rank("q", &[]).is_none());
}
}