1use std::collections::HashMap;
10use std::sync::Arc;
11
12use parking_lot::RwLock;
13use storage::VectorStorage;
14
15use crate::distance::calculate_distance;
16use common::DistanceMetric;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct RouteMatch {
21 pub namespace: String,
22 pub similarity: f32,
23 pub memory_count: usize,
24}
25
26pub struct SemanticRouterConfig {
28 pub sample_size: usize,
30 pub refresh_interval_secs: u64,
32}
33
34impl Default for SemanticRouterConfig {
35 fn default() -> Self {
36 Self {
37 sample_size: 20,
38 refresh_interval_secs: 1800, }
40 }
41}
42
43impl SemanticRouterConfig {
44 pub fn from_env() -> Self {
45 let sample_size: usize = std::env::var("DAKERA_ROUTE_SAMPLE_SIZE")
46 .ok()
47 .and_then(|v| v.parse().ok())
48 .unwrap_or(20);
49
50 let refresh_interval_secs: u64 = std::env::var("DAKERA_ROUTE_REFRESH_SECS")
51 .ok()
52 .and_then(|v| v.parse().ok())
53 .unwrap_or(1800);
54
55 Self {
56 sample_size,
57 refresh_interval_secs,
58 }
59 }
60}
61
62#[derive(Clone)]
64struct CentroidEntry {
65 centroid: Vec<f32>,
66 count: usize,
67}
68
69pub struct SemanticRouter {
71 config: SemanticRouterConfig,
72 cache: RwLock<HashMap<String, CentroidEntry>>,
74}
75
76impl SemanticRouter {
77 pub fn new(config: SemanticRouterConfig) -> Self {
78 Self {
79 config,
80 cache: RwLock::new(HashMap::new()),
81 }
82 }
83
84 pub fn route(&self, query: &[f32], top_k: usize, min_similarity: f32) -> Vec<RouteMatch> {
89 let cache = self.cache.read();
90 let mut matches: Vec<RouteMatch> = cache
91 .iter()
92 .filter_map(|(ns, entry)| {
93 if entry.centroid.len() != query.len() {
94 return None; }
96 let sim = calculate_distance(query, &entry.centroid, DistanceMetric::Cosine);
97 if sim >= min_similarity {
98 Some(RouteMatch {
99 namespace: ns.clone(),
100 similarity: sim,
101 memory_count: entry.count,
102 })
103 } else {
104 None
105 }
106 })
107 .collect();
108
109 matches.sort_by(|a, b| {
110 b.similarity
111 .partial_cmp(&a.similarity)
112 .unwrap_or(std::cmp::Ordering::Equal)
113 });
114 matches.truncate(top_k);
115 matches
116 }
117
118 pub async fn refresh_centroids(&self, storage: &Arc<dyn VectorStorage>) {
123 let namespaces = match storage.list_namespaces().await {
124 Ok(ns) => ns,
125 Err(e) => {
126 tracing::warn!(error = %e, "Failed to list namespaces for centroid refresh");
127 return;
128 }
129 };
130
131 let mut new_cache: HashMap<String, CentroidEntry> = HashMap::new();
132
133 for namespace in &namespaces {
134 if !namespace.starts_with("_dakera_agent_") {
135 continue;
136 }
137
138 let vectors = match storage.get_all(namespace).await {
139 Ok(v) => v,
140 Err(_) => continue,
141 };
142
143 if vectors.is_empty() {
144 continue;
145 }
146
147 let count = vectors.len();
148
149 let sample: Vec<&Vec<f32>> = vectors
151 .iter()
152 .filter(|v| !v.values.is_empty())
153 .take(self.config.sample_size)
154 .map(|v| &v.values)
155 .collect();
156
157 if sample.is_empty() {
158 continue;
159 }
160
161 let dim = sample[0].len();
163 let mut centroid = vec![0.0f32; dim];
164 let mut valid = 0usize;
165 for embedding in &sample {
166 if embedding.len() == dim {
167 for (i, val) in embedding.iter().enumerate() {
168 centroid[i] += val;
169 }
170 valid += 1;
171 }
172 }
173
174 if valid > 0 {
175 for val in &mut centroid {
176 *val /= valid as f32;
177 }
178 let norm: f32 = centroid.iter().map(|x| x * x).sum::<f32>().sqrt();
180 if norm > 1e-8 {
181 for val in &mut centroid {
182 *val /= norm;
183 }
184 }
185 new_cache.insert(namespace.clone(), CentroidEntry { centroid, count });
186 }
187 }
188
189 let refreshed_count = new_cache.len();
190 *self.cache.write() = new_cache;
191
192 tracing::info!(
193 namespaces_cached = refreshed_count,
194 "Semantic router centroid cache refreshed"
195 );
196 }
197
198 pub fn spawn_refresh(
200 router: Arc<SemanticRouter>,
201 storage: Arc<dyn VectorStorage>,
202 ) -> tokio::task::JoinHandle<()> {
203 let interval_secs = router.config.refresh_interval_secs;
204 tokio::spawn(async move {
205 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
207 router.refresh_centroids(&storage).await;
208
209 let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
210 loop {
211 interval.tick().await;
212 router.refresh_centroids(&storage).await;
213 }
214 })
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum QueryKind {
225 Keyword,
227 Semantic,
229 Hybrid,
231 Temporal,
238}
239
240pub struct QueryClassifier;
243
244impl QueryClassifier {
245 pub fn classify(query: &str) -> QueryKind {
258 let trimmed = query.trim();
259 let word_count = trimmed.split_whitespace().count();
260 let lower = trimmed.to_lowercase();
261
262 let is_temporal = lower.starts_with("when ")
267 || lower.starts_with("when did")
268 || lower.starts_with("when was")
269 || lower.starts_with("when were")
270 || lower.starts_with("when is")
271 || lower.contains("what year")
272 || lower.contains("what date")
273 || lower.contains("what time did")
274 || lower.contains("what time was")
275 || lower.contains("how long ago")
276 || lower.contains("how many years")
277 || lower.contains("how many months")
278 || lower.contains("how many days")
279 || lower.contains("since when")
280 || lower.contains("at what age")
281 || lower.contains("how old was")
282 || lower.contains("how old were");
283
284 if is_temporal {
285 return QueryKind::Temporal;
286 }
287
288 let is_question = trimmed.contains('?')
291 || lower.starts_with("what ")
292 || lower.starts_with("how ")
293 || lower.starts_with("why ")
294 || lower.starts_with("when ")
295 || lower.starts_with("where ")
296 || lower.starts_with("who ")
297 || lower.starts_with("tell me")
298 || lower.starts_with("explain")
299 || lower.starts_with("describe");
300
301 if is_question {
302 QueryKind::Hybrid
303 } else if word_count >= 8 || trimmed.contains('.') {
304 QueryKind::Semantic
305 } else if word_count <= 3 {
306 QueryKind::Keyword
307 } else {
308 QueryKind::Hybrid
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_route_empty_cache() {
319 let router = SemanticRouter::new(SemanticRouterConfig::default());
320 let results = router.route(&[1.0, 0.0, 0.0], 3, 0.5);
321 assert!(results.is_empty());
322 }
323
324 #[test]
325 fn test_route_with_cached_centroids() {
326 let router = SemanticRouter::new(SemanticRouterConfig::default());
327
328 {
330 let mut cache = router.cache.write();
331 cache.insert(
332 "_dakera_agent_dev".to_string(),
333 CentroidEntry {
334 centroid: vec![1.0, 0.0, 0.0],
335 count: 100,
336 },
337 );
338 cache.insert(
339 "_dakera_agent_ops".to_string(),
340 CentroidEntry {
341 centroid: vec![0.0, 1.0, 0.0],
342 count: 50,
343 },
344 );
345 cache.insert(
346 "_dakera_agent_sec".to_string(),
347 CentroidEntry {
348 centroid: vec![0.707, 0.707, 0.0],
349 count: 30,
350 },
351 );
352 }
353
354 let results = router.route(&[1.0, 0.0, 0.0], 3, 0.0);
356 assert_eq!(results.len(), 3);
357 assert_eq!(results[0].namespace, "_dakera_agent_dev");
358 assert!(results[0].similarity > results[1].similarity);
359 }
360
361 #[test]
362 fn test_route_min_similarity_filter() {
363 let router = SemanticRouter::new(SemanticRouterConfig::default());
364
365 {
366 let mut cache = router.cache.write();
367 cache.insert(
368 "_dakera_agent_a".to_string(),
369 CentroidEntry {
370 centroid: vec![1.0, 0.0, 0.0],
371 count: 10,
372 },
373 );
374 cache.insert(
375 "_dakera_agent_b".to_string(),
376 CentroidEntry {
377 centroid: vec![0.0, 1.0, 0.0],
378 count: 10,
379 },
380 );
381 }
382
383 let results = router.route(&[1.0, 0.0, 0.0], 5, 0.9);
385 assert_eq!(results.len(), 1);
386 assert_eq!(results[0].namespace, "_dakera_agent_a");
387 }
388
389 #[test]
390 fn test_route_top_k_truncation() {
391 let router = SemanticRouter::new(SemanticRouterConfig::default());
392
393 {
394 let mut cache = router.cache.write();
395 for i in 0..10 {
396 let mut centroid = vec![0.0f32; 3];
397 centroid[0] = 1.0 - (i as f32 * 0.05);
398 centroid[1] = i as f32 * 0.05;
399 let norm = (centroid[0] * centroid[0] + centroid[1] * centroid[1]).sqrt();
400 centroid[0] /= norm;
401 centroid[1] /= norm;
402 cache.insert(
403 format!("_dakera_agent_{}", i),
404 CentroidEntry {
405 centroid,
406 count: 10,
407 },
408 );
409 }
410 }
411
412 let results = router.route(&[1.0, 0.0, 0.0], 3, 0.0);
413 assert_eq!(results.len(), 3);
414 }
415
416 #[test]
417 fn test_route_dimension_mismatch_skipped() {
418 let router = SemanticRouter::new(SemanticRouterConfig::default());
419
420 {
421 let mut cache = router.cache.write();
422 cache.insert(
423 "_dakera_agent_3d".to_string(),
424 CentroidEntry {
425 centroid: vec![1.0, 0.0, 0.0],
426 count: 10,
427 },
428 );
429 cache.insert(
430 "_dakera_agent_5d".to_string(),
431 CentroidEntry {
432 centroid: vec![1.0, 0.0, 0.0, 0.0, 0.0],
433 count: 10,
434 },
435 );
436 }
437
438 let results = router.route(&[1.0, 0.0, 0.0], 5, 0.0);
440 assert_eq!(results.len(), 1);
441 assert_eq!(results[0].namespace, "_dakera_agent_3d");
442 }
443
444 #[test]
445 fn test_config_defaults() {
446 let config = SemanticRouterConfig::default();
447 assert_eq!(config.sample_size, 20);
448 assert_eq!(config.refresh_interval_secs, 1800);
449 }
450
451 #[test]
454 fn test_classify_keyword_short() {
455 assert_eq!(QueryClassifier::classify("rust async"), QueryKind::Keyword);
456 assert_eq!(QueryClassifier::classify("HNSW"), QueryKind::Keyword);
457 assert_eq!(
458 QueryClassifier::classify("memory importance"),
459 QueryKind::Keyword
460 );
461 }
462
463 #[test]
464 fn test_classify_question_routes_hybrid() {
465 assert_eq!(
467 QueryClassifier::classify(
468 "what is the best way to store long term memories in an AI system"
469 ),
470 QueryKind::Hybrid
471 );
472 assert_eq!(
473 QueryClassifier::classify("tell me about the agent memory architecture"),
474 QueryKind::Hybrid
475 );
476 assert_eq!(
477 QueryClassifier::classify("how does HNSW work?"),
478 QueryKind::Hybrid
479 );
480 assert_eq!(
481 QueryClassifier::classify("What sport did Sarah's brother play in high school?"),
482 QueryKind::Hybrid
483 );
484 }
485
486 #[test]
487 fn test_classify_semantic_long_prose() {
488 assert_eq!(
490 QueryClassifier::classify(
491 "the agent memory platform stores embeddings with adaptive decay weighting"
492 ),
493 QueryKind::Semantic
494 );
495 }
496
497 #[test]
498 fn test_classify_hybrid_middle() {
499 assert_eq!(
500 QueryClassifier::classify("vector search memory agent"),
501 QueryKind::Hybrid
502 );
503 }
504
505 #[test]
508 fn test_classify_temporal_when_prefix() {
509 assert_eq!(
511 QueryClassifier::classify("when did Caroline go to the store?"),
512 QueryKind::Temporal
513 );
514 assert_eq!(
515 QueryClassifier::classify("When was the last time they spoke?"),
516 QueryKind::Temporal
517 );
518 assert_eq!(
519 QueryClassifier::classify("When were the siblings born?"),
520 QueryKind::Temporal
521 );
522 }
523
524 #[test]
525 fn test_classify_temporal_date_year_patterns() {
526 assert_eq!(
527 QueryClassifier::classify("What year did they get married?"),
528 QueryKind::Temporal
529 );
530 assert_eq!(
531 QueryClassifier::classify("what date did the conference take place?"),
532 QueryKind::Temporal
533 );
534 assert_eq!(
535 QueryClassifier::classify("What time did the meeting start?"),
536 QueryKind::Temporal
537 );
538 assert_eq!(
539 QueryClassifier::classify("How long ago did this happen?"),
540 QueryKind::Temporal
541 );
542 assert_eq!(
543 QueryClassifier::classify("How many years have they been friends?"),
544 QueryKind::Temporal
545 );
546 assert_eq!(
547 QueryClassifier::classify("How old was Sarah when she graduated?"),
548 QueryKind::Temporal
549 );
550 }
551
552 #[test]
553 fn test_classify_temporal_does_not_capture_non_temporal_what() {
554 assert_eq!(
556 QueryClassifier::classify("What sport did Sarah's brother play in high school?"),
557 QueryKind::Hybrid
558 );
559 assert_eq!(
560 QueryClassifier::classify("what is the best way to find old memories"),
561 QueryKind::Hybrid
562 );
563 }
564}