1use crate::entry::MemoryEntry;
2use aegis_provider::Provider;
3use aegis_types::message::Message;
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
8pub struct RelevanceResult {
9 pub entry_id: String,
10 pub score: f32,
11 pub relevant: bool,
12}
13
14#[derive(Debug, Clone)]
16pub struct SidecarConfig {
17 pub enabled: bool,
18 pub min_confidence: f32,
21 pub min_score: f32,
23 pub timeout_ms: u64,
25 pub reinforce_on_check: bool,
28 pub reinforce_pass_score: f32,
30 pub reinforce_fail_score: f32,
32}
33
34impl Default for SidecarConfig {
35 fn default() -> Self {
36 Self {
37 enabled: true,
38 min_confidence: 0.3,
39 min_score: 0.5,
40 timeout_ms: 500,
41 reinforce_on_check: true,
42 reinforce_pass_score: 0.05,
43 reinforce_fail_score: -0.02,
44 }
45 }
46}
47
48pub async fn spawn_relevance_check(
57 query: &str,
58 mut candidates: Vec<MemoryEntry>,
59 provider: &dyn Provider,
60 config: &SidecarConfig,
61) -> Vec<MemoryEntry> {
62 if candidates.is_empty() || !config.enabled {
63 return candidates;
64 }
65
66 let before_count = candidates.len();
68 candidates.retain(|e| e.effective_confidence() >= config.min_confidence);
69 let filtered_count = before_count - candidates.len();
70 if filtered_count > 0 {
71 tracing::debug!("sidecar: pre-filtered {filtered_count} entries below confidence {}", config.min_confidence);
72 }
73
74 if candidates.is_empty() {
75 return candidates;
76 }
77
78 let listing: String = candidates
80 .iter()
81 .enumerate()
82 .map(|(i, e)| {
83 format!(
84 "{}. [conf={:.2} trust={:?}] {}",
85 i,
86 e.effective_confidence(),
87 e.trust,
88 e.content
89 )
90 })
91 .collect::<Vec<_>>()
92 .join("\n");
93
94 let prompt = format!(
95 "Given the user query: \"{query}\"\n\n\
96 Here are candidate memories:\n{listing}\n\n\
97 Return a JSON array of the indices (0-based) that are relevant to the query. \
98 Only output the JSON array, nothing else."
99 );
100
101 let messages = vec![Message::user(prompt)];
102
103 let timeout = Duration::from_millis(config.timeout_ms);
104 let result = tokio::time::timeout(timeout, provider.chat(&messages, None)).await;
105
106 let response = match result {
107 Ok(Ok(resp)) => resp,
108 _ => {
109 tracing::warn!("sidecar: relevance check failed or timed out, returning all candidates");
110 return candidates;
111 }
112 };
113
114 let text = response.message.text();
115 let indices: Vec<usize> = match serde_json::from_str(&text) {
116 Ok(v) => v,
117 Err(_) => {
118 let start = text.find('[');
120 let end = text.rfind(']');
121 match (start, end) {
122 (Some(s), Some(e)) if s < e => match serde_json::from_str(&text[s..=e]) {
123 Ok(v) => v,
124 Err(_) => return candidates,
125 },
126 _ => return candidates,
127 }
128 }
129 };
130
131 if config.reinforce_on_check {
133 for (i, entry) in candidates.iter_mut().enumerate() {
134 if indices.contains(&i) {
135 entry.reinforce(config.reinforce_pass_score, "sidecar: passed relevance check");
136 } else {
137 entry.reinforce(config.reinforce_fail_score, "sidecar: failed relevance check");
138 }
139 }
140 }
141
142 let len = candidates.len();
144 candidates
145 .into_iter()
146 .enumerate()
147 .filter(|(i, _)| indices.contains(i) && *i < len)
148 .map(|(_, e)| e)
149 .collect()
150}
151
152pub async fn batch_relevance_check(
155 queries: &[&str],
156 candidates_by_query: Vec<Vec<MemoryEntry>>,
157 provider: &dyn Provider,
158 config: &SidecarConfig,
159) -> Vec<Vec<MemoryEntry>> {
160 let mut results = Vec::with_capacity(queries.len());
161 for (query, candidates) in queries.iter().zip(candidates_by_query) {
162 let checked = spawn_relevance_check(query, candidates, provider, config).await;
163 results.push(checked);
164 }
165 results
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_sidecar_config_defaults() {
174 let config = SidecarConfig::default();
175 assert!(config.enabled);
176 assert_eq!(config.min_confidence, 0.3);
177 assert_eq!(config.min_score, 0.5);
178 assert!(config.reinforce_on_check);
179 }
180
181 #[test]
182 fn test_relevance_result_clone() {
183 let r = RelevanceResult {
184 entry_id: "test".to_string(),
185 score: 0.8,
186 relevant: true,
187 };
188 let r2 = r.clone();
189 assert_eq!(r2.entry_id, "test");
190 assert_eq!(r2.score, 0.8);
191 }
192}