1use crate::Result;
6use crate::config::{NamespaceWeightsConfig, SearchIntentConfig};
7use crate::hooks::search_intent::{SearchIntent, SearchIntentType};
8use crate::models::{Namespace, SearchFilter, SearchMode};
9use crate::services::{ContextBuilderService, RecallService};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone)]
15pub struct AdaptiveContextConfig {
16 pub base_count: usize,
18 pub max_count: usize,
20 pub max_tokens: usize,
22 pub preview_length: usize,
24 pub min_confidence: f32,
26 pub weights: NamespaceWeightsConfig,
28}
29
30const TOKENS_PER_CHAR: usize = 4;
32
33impl Default for AdaptiveContextConfig {
34 fn default() -> Self {
35 Self {
36 base_count: 5,
37 max_count: 15,
38 max_tokens: 4000,
39 preview_length: 200,
40 min_confidence: 0.5,
41 weights: NamespaceWeightsConfig::with_defaults(),
42 }
43 }
44}
45
46impl AdaptiveContextConfig {
47 #[must_use]
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 #[must_use]
55 pub const fn with_base_count(mut self, count: usize) -> Self {
56 self.base_count = count;
57 self
58 }
59
60 #[must_use]
62 pub const fn with_max_count(mut self, count: usize) -> Self {
63 self.max_count = count;
64 self
65 }
66
67 #[must_use]
69 pub const fn with_max_tokens(mut self, tokens: usize) -> Self {
70 self.max_tokens = tokens;
71 self
72 }
73
74 #[must_use]
76 pub const fn with_preview_length(mut self, length: usize) -> Self {
77 self.preview_length = length;
78 self
79 }
80
81 #[must_use]
83 pub const fn with_min_confidence(mut self, confidence: f32) -> Self {
84 self.min_confidence = confidence;
85 self
86 }
87
88 #[must_use]
90 pub fn from_search_intent_config(config: &SearchIntentConfig) -> Self {
91 Self {
92 base_count: config.base_count,
93 max_count: config.max_count,
94 max_tokens: config.max_tokens,
95 preview_length: 200,
96 min_confidence: config.min_confidence,
97 weights: config.weights.clone(),
98 }
99 }
100
101 #[must_use]
103 pub fn with_weights(mut self, weights: NamespaceWeightsConfig) -> Self {
104 self.weights = weights;
105 self
106 }
107
108 #[must_use]
110 pub const fn memories_for_confidence(&self, confidence: f32) -> usize {
111 if confidence >= 0.8 {
112 self.max_count
113 } else if confidence >= 0.5 {
114 self.base_count + 5
115 } else {
116 self.base_count
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default)]
123pub struct NamespaceWeights {
124 weights: HashMap<Namespace, f32>,
125}
126
127impl NamespaceWeights {
128 #[must_use]
130 pub fn new() -> Self {
131 Self::default()
132 }
133
134 #[must_use]
136 pub fn for_intent(intent_type: SearchIntentType) -> Self {
137 Self::for_intent_with_config(intent_type, &NamespaceWeightsConfig::default())
138 }
139
140 #[must_use]
145 pub fn for_intent_with_config(
146 intent_type: SearchIntentType,
147 config: &NamespaceWeightsConfig,
148 ) -> Self {
149 let mut weights = HashMap::with_capacity(14);
151
152 let intent_name = match intent_type {
154 SearchIntentType::HowTo => "howto",
155 SearchIntentType::Troubleshoot => "troubleshoot",
156 SearchIntentType::Location => "location",
157 SearchIntentType::Explanation => "explanation",
158 SearchIntentType::Comparison => "comparison",
159 SearchIntentType::General => "general",
160 };
161
162 let defaults = Self::get_defaults(intent_type);
164 for (ns, weight) in defaults {
165 weights.insert(ns, weight);
166 }
167
168 for (ns_str, weight) in config.get_intent_weights(intent_name) {
170 if let Ok(ns) = ns_str.parse::<Namespace>() {
171 weights.insert(ns, weight);
172 }
173 }
174
175 Self { weights }
176 }
177
178 fn get_defaults(intent_type: SearchIntentType) -> Vec<(Namespace, f32)> {
180 match intent_type {
181 SearchIntentType::HowTo => {
182 vec![
183 (Namespace::Patterns, 1.5),
184 (Namespace::Learnings, 1.3),
185 (Namespace::Decisions, 1.0),
186 ]
187 },
188 SearchIntentType::Troubleshoot => {
189 vec![
190 (Namespace::Blockers, 1.5),
191 (Namespace::Learnings, 1.3),
192 (Namespace::Decisions, 1.0),
193 ]
194 },
195 SearchIntentType::Location | SearchIntentType::Explanation => {
196 vec![
197 (Namespace::Decisions, 1.5),
198 (Namespace::Context, 1.3),
199 (Namespace::Patterns, 1.0),
200 ]
201 },
202 SearchIntentType::Comparison => {
203 vec![
204 (Namespace::Decisions, 1.5),
205 (Namespace::Patterns, 1.3),
206 (Namespace::Learnings, 1.0),
207 ]
208 },
209 SearchIntentType::General => {
210 vec![
211 (Namespace::Decisions, 1.2),
212 (Namespace::Patterns, 1.2),
213 (Namespace::Learnings, 1.0),
214 ]
215 },
216 }
217 }
218
219 #[must_use]
221 pub fn get(&self, namespace: &Namespace) -> f32 {
222 self.weights.get(namespace).copied().unwrap_or(1.0)
223 }
224
225 #[must_use]
227 pub fn apply(&self, namespace: &Namespace, score: f32) -> f32 {
228 score * self.get(namespace)
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct InjectedMemory {
235 pub id: String,
237 pub namespace: String,
239 pub content_preview: String,
241 pub score: f32,
243 #[serde(skip_serializing_if = "Vec::is_empty")]
245 pub tags: Vec<String>,
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct MemoryContext {
251 pub search_intent_detected: bool,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub intent_type: Option<String>,
256 #[serde(skip_serializing_if = "Vec::is_empty")]
258 pub topics: Vec<String>,
259 #[serde(skip_serializing_if = "Vec::is_empty")]
261 pub injected_memories: Vec<InjectedMemory>,
262 #[serde(skip_serializing_if = "Vec::is_empty")]
264 pub suggested_resources: Vec<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub reminder: Option<String>,
268}
269
270impl MemoryContext {
271 #[must_use]
273 pub fn empty() -> Self {
274 Self::default()
275 }
276
277 #[must_use]
279 pub fn from_intent(intent: &SearchIntent) -> Self {
280 Self {
281 search_intent_detected: true,
282 intent_type: Some(intent.intent_type.as_str().to_string()),
283 topics: intent.topics.clone(),
284 injected_memories: Vec::new(),
285 suggested_resources: Vec::new(),
286 reminder: None,
287 }
288 }
289
290 #[must_use]
292 pub fn with_memories(mut self, memories: Vec<InjectedMemory>) -> Self {
293 self.injected_memories = memories;
294 self
295 }
296
297 #[must_use]
299 pub fn with_resources(mut self, resources: Vec<String>) -> Self {
300 self.suggested_resources = resources;
301 self
302 }
303
304 #[must_use]
306 pub fn with_reminder(mut self, reminder: impl Into<String>) -> Self {
307 self.reminder = Some(reminder.into());
308 self
309 }
310}
311
312pub struct SearchContextBuilder<'a> {
314 config: AdaptiveContextConfig,
315 recall_service: Option<&'a RecallService>,
316}
317
318impl Default for SearchContextBuilder<'_> {
319 fn default() -> Self {
320 Self::new()
321 }
322}
323
324impl<'a> SearchContextBuilder<'a> {
325 #[must_use]
327 pub fn new() -> Self {
328 Self {
329 config: AdaptiveContextConfig::default(),
330 recall_service: None,
331 }
332 }
333
334 #[must_use]
336 pub fn with_config(mut self, config: AdaptiveContextConfig) -> Self {
337 self.config = config;
338 self
339 }
340
341 #[must_use]
343 pub const fn with_recall_service(mut self, service: &'a RecallService) -> Self {
344 self.recall_service = Some(service);
345 self
346 }
347
348 pub fn build_context(&self, intent: &SearchIntent) -> Result<MemoryContext> {
354 if intent.confidence < self.config.min_confidence {
356 return Ok(MemoryContext::empty());
357 }
358
359 let mut context = MemoryContext::from_intent(intent);
360
361 let resources = self.build_suggested_resources(intent);
363 context = context.with_resources(resources);
364
365 if intent.confidence >= self.config.min_confidence {
367 context = context.with_reminder(build_reminder_text(intent));
368 }
369
370 if let Some(recall) = self.recall_service {
372 let memories = self.retrieve_memories(recall, intent)?;
373 context = context.with_memories(memories);
374 }
375
376 Ok(context)
377 }
378
379 fn retrieve_memories(
381 &self,
382 recall: &RecallService,
383 intent: &SearchIntent,
384 ) -> Result<Vec<InjectedMemory>> {
385 let limit = self.config.memories_for_confidence(intent.confidence);
386 let weights =
387 NamespaceWeights::for_intent_with_config(intent.intent_type, &self.config.weights);
388
389 let query = build_search_query(intent);
391 if query.is_empty() {
392 return Ok(Vec::new());
393 }
394
395 let filter = SearchFilter::new();
397 let result = recall.search(&query, SearchMode::Hybrid, &filter, limit * 2)?;
398
399 let mut weighted_memories: Vec<_> = result
401 .memories
402 .into_iter()
403 .map(|hit| {
404 let weighted_score = weights.apply(&hit.memory.namespace, hit.score);
405 (hit, weighted_score)
406 })
407 .collect();
408
409 weighted_memories
411 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
412
413 let mut injected = Vec::new();
415 let mut remaining_tokens = self.config.max_tokens;
416
417 for (hit, score) in weighted_memories.into_iter().take(limit) {
418 let Some((content_preview, tokens_used)) = build_preview(
419 &hit.memory.content,
420 remaining_tokens,
421 self.config.preview_length,
422 ) else {
423 break;
424 };
425
426 remaining_tokens = remaining_tokens.saturating_sub(tokens_used);
427
428 injected.push(InjectedMemory {
429 id: format!("subcog://memories/{}", hit.memory.id.as_str()),
430 namespace: hit.memory.namespace.as_str().to_string(),
431 content_preview,
432 score,
433 tags: hit.memory.tags.clone(),
434 });
435 }
436
437 Ok(injected)
438 }
439
440 fn build_suggested_resources(&self, intent: &SearchIntent) -> Vec<String> {
442 let mut resources = Vec::with_capacity(4);
443
444 for topic in intent.topics.iter().take(3) {
446 resources.push(format!("subcog://topics/{topic}"));
447 }
448
449 if !intent.topics.is_empty() {
451 resources.push("subcog://topics".to_string());
452 }
453
454 resources
455 }
456}
457
458fn build_search_query(intent: &SearchIntent) -> String {
460 let mut parts = Vec::new();
461
462 parts.extend(intent.topics.iter().cloned());
464
465 for keyword in &intent.keywords {
467 let cleaned = keyword
468 .trim()
469 .to_lowercase()
470 .replace(|c: char| !c.is_alphanumeric() && c != ' ', "");
471 if !cleaned.is_empty() && !parts.contains(&cleaned) {
472 parts.push(cleaned);
473 }
474 }
475
476 parts.join(" ")
477}
478
479fn build_reminder_text(intent: &SearchIntent) -> String {
481 let intent_desc = match intent.intent_type {
482 SearchIntentType::HowTo => "implementation guidance",
483 SearchIntentType::Location => "code location",
484 SearchIntentType::Explanation => "explanation",
485 SearchIntentType::Comparison => "comparison",
486 SearchIntentType::Troubleshoot => "troubleshooting help",
487 SearchIntentType::General => "information",
488 };
489
490 format!(
491 "User appears to be seeking {intent_desc}. \
492 Consider using `subcog_recall` to retrieve relevant memories \
493 or access the suggested resources for more context."
494 )
495}
496
497fn truncate_content(content: &str, max_len: usize) -> String {
499 if content.len() <= max_len {
500 content.to_string()
501 } else {
502 let truncated = &content[..max_len.saturating_sub(3)];
503 truncated.rfind(' ').map_or_else(
505 || format!("{truncated}..."),
506 |last_space| format!("{}...", &truncated[..last_space]),
507 )
508 }
509}
510
511fn build_preview(
512 content: &str,
513 remaining_tokens: usize,
514 preview_len: usize,
515) -> Option<(String, usize)> {
516 if remaining_tokens == 0 {
517 return None;
518 }
519
520 let preview = truncate_content(content, preview_len);
521 let preview_tokens = ContextBuilderService::estimate_tokens(&preview);
522 if preview_tokens <= remaining_tokens {
523 return Some((preview, preview_tokens));
524 }
525
526 let max_chars = remaining_tokens.saturating_mul(TOKENS_PER_CHAR);
527 let truncated = truncate_content(content, max_chars);
528 let truncated_tokens = ContextBuilderService::estimate_tokens(&truncated);
529 let tokens_used = truncated_tokens.min(remaining_tokens);
530 if tokens_used == 0 {
531 return None;
532 }
533
534 Some((truncated, tokens_used))
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::hooks::search_intent::DetectionSource;
541 use crate::models::{Domain, Memory, MemoryId, MemoryStatus, Namespace};
542 use crate::services::RecallService;
543 use crate::storage::index::SqliteBackend;
544 use crate::storage::traits::IndexBackend;
545
546 fn create_test_intent() -> SearchIntent {
547 SearchIntent {
548 intent_type: SearchIntentType::HowTo,
549 confidence: 0.8,
550 keywords: vec!["how to".to_string(), "implement".to_string()],
551 topics: vec!["authentication".to_string(), "oauth".to_string()],
552 source: DetectionSource::Keyword,
553 }
554 }
555
556 #[test]
559 fn test_config_defaults() {
560 let config = AdaptiveContextConfig::default();
561 assert_eq!(config.base_count, 5);
562 assert_eq!(config.max_count, 15);
563 assert_eq!(config.max_tokens, 4000);
564 assert!((config.min_confidence - 0.5).abs() < f32::EPSILON);
565 }
566
567 #[test]
568 fn test_config_builder() {
569 let config = AdaptiveContextConfig::new()
570 .with_base_count(10)
571 .with_max_count(20)
572 .with_max_tokens(8000)
573 .with_min_confidence(0.6);
574
575 assert_eq!(config.base_count, 10);
576 assert_eq!(config.max_count, 20);
577 assert_eq!(config.max_tokens, 8000);
578 assert!((config.min_confidence - 0.6).abs() < f32::EPSILON);
579 }
580
581 #[test]
582 fn test_memories_for_confidence() {
583 let config = AdaptiveContextConfig::default();
584
585 assert_eq!(config.memories_for_confidence(0.9), 15);
587 assert_eq!(config.memories_for_confidence(0.8), 15);
588
589 assert_eq!(config.memories_for_confidence(0.7), 10);
591 assert_eq!(config.memories_for_confidence(0.5), 10);
592
593 assert_eq!(config.memories_for_confidence(0.4), 5);
595 assert_eq!(config.memories_for_confidence(0.1), 5);
596 }
597
598 #[test]
601 fn test_weights_for_howto() {
602 let weights = NamespaceWeights::for_intent(SearchIntentType::HowTo);
603
604 assert!((weights.get(&Namespace::Patterns) - 1.5).abs() < f32::EPSILON);
605 assert!((weights.get(&Namespace::Learnings) - 1.3).abs() < f32::EPSILON);
606 assert!((weights.get(&Namespace::Decisions) - 1.0).abs() < f32::EPSILON);
607 assert!((weights.get(&Namespace::Apis) - 1.0).abs() < f32::EPSILON);
609 }
610
611 #[test]
612 fn test_weights_for_troubleshoot() {
613 let weights = NamespaceWeights::for_intent(SearchIntentType::Troubleshoot);
614
615 assert!((weights.get(&Namespace::Blockers) - 1.5).abs() < f32::EPSILON);
616 assert!((weights.get(&Namespace::Learnings) - 1.3).abs() < f32::EPSILON);
617 }
618
619 #[test]
620 fn test_weights_apply() {
621 let weights = NamespaceWeights::for_intent(SearchIntentType::HowTo);
622
623 let score = 0.5;
624 let weighted = weights.apply(&Namespace::Patterns, score);
625 assert!((weighted - 0.75).abs() < f32::EPSILON); }
627
628 #[test]
631 fn test_memory_context_empty() {
632 let ctx = MemoryContext::empty();
633 assert!(!ctx.search_intent_detected);
634 assert!(ctx.intent_type.is_none());
635 assert!(ctx.topics.is_empty());
636 assert!(ctx.injected_memories.is_empty());
637 }
638
639 #[test]
640 fn test_memory_context_from_intent() {
641 let intent = create_test_intent();
642 let ctx = MemoryContext::from_intent(&intent);
643
644 assert!(ctx.search_intent_detected);
645 assert_eq!(ctx.intent_type, Some("howto".to_string()));
646 assert_eq!(ctx.topics, vec!["authentication", "oauth"]);
647 }
648
649 #[test]
650 fn test_memory_context_builder() {
651 let intent = create_test_intent();
652 let ctx = MemoryContext::from_intent(&intent)
653 .with_memories(vec![InjectedMemory {
654 id: "subcog://memories/test".to_string(),
655 namespace: "patterns".to_string(),
656 content_preview: "Test content".to_string(),
657 score: 0.9,
658 tags: vec![],
659 }])
660 .with_resources(vec!["subcog://topics/auth".to_string()])
661 .with_reminder("Test reminder");
662
663 assert_eq!(ctx.injected_memories.len(), 1);
664 assert_eq!(ctx.suggested_resources.len(), 1);
665 assert_eq!(ctx.reminder, Some("Test reminder".to_string()));
666 }
667
668 #[test]
671 fn test_build_context_low_confidence() {
672 let builder = SearchContextBuilder::new();
673 let intent = SearchIntent {
674 confidence: 0.3,
675 ..create_test_intent()
676 };
677
678 let ctx = builder.build_context(&intent).unwrap();
679 assert!(!ctx.search_intent_detected);
680 }
681
682 #[test]
683 fn test_build_context_high_confidence() {
684 let builder = SearchContextBuilder::new();
685 let intent = create_test_intent();
686
687 let ctx = builder.build_context(&intent).unwrap();
688 assert!(ctx.search_intent_detected);
689 assert!(ctx.reminder.is_some());
690 assert!(!ctx.suggested_resources.is_empty());
691 }
692
693 #[test]
694 fn test_build_suggested_resources() {
695 let builder = SearchContextBuilder::new();
696 let intent = create_test_intent();
697
698 let resources = builder.build_suggested_resources(&intent);
699 assert!(resources.contains(&"subcog://topics/authentication".to_string()));
700 assert!(resources.contains(&"subcog://topics/oauth".to_string()));
701 assert!(resources.contains(&"subcog://topics".to_string()));
702 }
703
704 #[test]
707 fn test_build_search_query() {
708 let intent = create_test_intent();
709 let query = build_search_query(&intent);
710
711 assert!(query.contains("authentication"));
712 assert!(query.contains("oauth"));
713 assert!(query.contains("implement"));
714 }
715
716 #[test]
717 fn test_truncate_content_short() {
718 let content = "Short content";
719 let truncated = truncate_content(content, 50);
720 assert_eq!(truncated, content);
721 }
722
723 #[test]
724 fn test_truncate_content_long() {
725 let content =
726 "This is a much longer content that needs to be truncated for display purposes";
727 let truncated = truncate_content(content, 30);
728 assert!(truncated.ends_with("..."));
729 assert!(truncated.len() <= 30);
730 }
731
732 #[test]
733 fn test_build_reminder_text() {
734 let intent = create_test_intent();
735 let reminder = build_reminder_text(&intent);
736 assert!(reminder.contains("implementation guidance"));
737 assert!(reminder.contains("subcog_recall"));
738 }
739
740 #[test]
743 fn test_injected_memory_serialization() {
744 let memory = InjectedMemory {
745 id: "subcog://memories/test-123".to_string(),
746 namespace: "decisions".to_string(),
747 content_preview: "Use PostgreSQL for storage".to_string(),
748 score: 0.85,
749 tags: vec!["database".to_string()],
750 };
751
752 let json = serde_json::to_string(&memory).unwrap();
753 assert!(json.contains("test-123"));
754 assert!(json.contains("decisions"));
755 assert!(json.contains("PostgreSQL"));
756 }
757
758 fn create_test_memory(id: &str, content: &str, now: u64) -> Memory {
759 Memory {
760 id: MemoryId::new(id),
761 content: content.to_string(),
762 namespace: Namespace::Decisions,
763 domain: Domain::new(),
764 project_id: None,
765 branch: None,
766 file_path: None,
767 status: MemoryStatus::Active,
768 created_at: now,
769 updated_at: now,
770 tombstoned_at: None,
771 expires_at: None,
772 embedding: None,
773 tags: vec![],
774 #[cfg(feature = "group-scope")]
775 group_id: None,
776 source: None,
777 is_summary: false,
778 source_memory_ids: None,
779 consolidation_timestamp: None,
780 }
781 }
782
783 #[test]
784 fn test_injected_memories_respect_token_budget() {
785 let index = SqliteBackend::in_memory().unwrap();
786 let now = 1_700_000_000;
787 let content = "authentication ".repeat(40);
788
789 let memory1 = create_test_memory("mem1", &content, now);
790 let memory2 = create_test_memory("mem2", &content, now);
791 index.index(&memory1).unwrap();
792 index.index(&memory2).unwrap();
793
794 let recall = RecallService::with_index(index);
795 let config = AdaptiveContextConfig::new()
796 .with_base_count(2)
797 .with_max_count(2)
798 .with_max_tokens(20)
799 .with_min_confidence(0.0);
800 let builder = SearchContextBuilder::new()
801 .with_config(config)
802 .with_recall_service(&recall);
803
804 let intent = SearchIntent {
805 intent_type: SearchIntentType::General,
806 confidence: 0.9,
807 keywords: vec!["authentication".to_string()],
808 topics: vec!["authentication".to_string()],
809 source: DetectionSource::Keyword,
810 };
811
812 let ctx = builder.build_context(&intent).unwrap();
813 let total_tokens: usize = ctx
814 .injected_memories
815 .iter()
816 .map(|memory| ContextBuilderService::estimate_tokens(&memory.content_preview))
817 .sum();
818
819 assert!(total_tokens <= 20);
820 }
821}