1use crate::Result;
6use crate::models::{Memory, Namespace, SearchFilter, SearchMode};
7use crate::services::RecallService;
8use std::borrow::Cow;
9use std::collections::HashMap;
10
11const CONTEXT_DECISIONS_LIMIT: usize = 5;
14const CONTEXT_PATTERNS_LIMIT: usize = 3;
16const CONTEXT_PROJECT_LIMIT: usize = 3;
18const CONTEXT_TECH_DEBT_LIMIT: usize = 2;
20const SEARCH_RESULT_LIMIT: usize = 10;
22const RECENT_MEMORIES_LIMIT: usize = 100;
24const TOP_TAGS_LIMIT: usize = 10;
26const MAX_TOPICS: usize = 10;
28const TOKENS_PER_CHAR: usize = 4;
30const MEMORY_CONTENT_PREVIEW_LENGTH: usize = 200;
32const TOPIC_WORDS_LIMIT: usize = 5;
34const MAX_TOPIC_DISPLAY_LENGTH: usize = 50;
36
37#[derive(Debug, Clone, Default)]
39pub struct MemoryStatistics {
40 pub total_count: usize,
42 pub namespace_counts: HashMap<String, usize>,
44 pub top_tags: Vec<(String, usize)>,
46 pub recent_topics: Vec<String>,
48}
49
50pub struct ContextBuilderService {
52 recall: Option<RecallService>,
54}
55
56impl ContextBuilderService {
57 #[must_use]
59 pub const fn new() -> Self {
60 Self { recall: None }
61 }
62
63 #[must_use]
65 pub const fn with_recall(recall: RecallService) -> Self {
66 Self {
67 recall: Some(recall),
68 }
69 }
70
71 pub fn build_context(&self, max_tokens: usize) -> Result<String> {
77 let max_chars = max_tokens * TOKENS_PER_CHAR;
79
80 let mut context_parts = Vec::new();
81
82 if let Some(decisions) =
84 self.get_relevant_memories(Namespace::Decisions, CONTEXT_DECISIONS_LIMIT)?
85 && !decisions.is_empty()
86 {
87 context_parts.push(format_section("Recent Decisions", &decisions));
88 }
89
90 if let Some(patterns) =
92 self.get_relevant_memories(Namespace::Patterns, CONTEXT_PATTERNS_LIMIT)?
93 && !patterns.is_empty()
94 {
95 context_parts.push(format_section("Active Patterns", &patterns));
96 }
97
98 if let Some(ctx) = self.get_relevant_memories(Namespace::Context, CONTEXT_PROJECT_LIMIT)?
100 && !ctx.is_empty()
101 {
102 context_parts.push(format_section("Project Context", &ctx));
103 }
104
105 if let Some(debt) =
107 self.get_relevant_memories(Namespace::TechDebt, CONTEXT_TECH_DEBT_LIMIT)?
108 && !debt.is_empty()
109 {
110 context_parts.push(format_section("Known Tech Debt", &debt));
111 }
112
113 let full_context = context_parts.join("\n\n");
115
116 if full_context.len() > max_chars {
117 Ok(truncate_context(&full_context, max_chars))
118 } else {
119 Ok(full_context)
120 }
121 }
122
123 pub fn build_query_context(&self, query: &str, max_tokens: usize) -> Result<String> {
129 let max_chars = max_tokens * TOKENS_PER_CHAR;
130
131 let recall = self
132 .recall
133 .as_ref()
134 .ok_or_else(|| crate::Error::OperationFailed {
135 operation: "build_query_context".to_string(),
136 cause: "No recall service configured".to_string(),
137 })?;
138
139 let result = recall.search(
141 query,
142 SearchMode::Hybrid,
143 &SearchFilter::new(),
144 SEARCH_RESULT_LIMIT,
145 )?;
146
147 if result.memories.is_empty() {
148 return Ok(String::new());
149 }
150
151 let mut context_parts = Vec::new();
152 context_parts.push("# Relevant Memories".to_string());
153
154 for hit in &result.memories {
155 let memory = &hit.memory;
156 context_parts.push(format!(
157 "## {} ({})\n{}\n_Score: {:.2}_",
158 memory.namespace,
159 memory.id.as_str(),
160 memory.content,
161 hit.score
162 ));
163 }
164
165 let full_context = context_parts.join("\n\n");
166
167 if full_context.len() > max_chars {
168 Ok(truncate_context(&full_context, max_chars))
169 } else {
170 Ok(full_context)
171 }
172 }
173
174 #[allow(clippy::unnecessary_wraps)] const fn get_relevant_memories(
180 &self,
181 _namespace: Namespace,
182 _limit: usize,
183 ) -> Result<Option<Vec<Memory>>> {
184 if self.recall.is_none() {
186 return Ok(None);
187 }
188
189 Ok(Some(Vec::new()))
191 }
192
193 #[must_use]
195 pub const fn estimate_tokens(text: &str) -> usize {
196 text.len() / TOKENS_PER_CHAR
198 }
199
200 pub fn get_statistics(&self) -> Result<MemoryStatistics> {
210 let Some(recall) = &self.recall else {
211 return Ok(MemoryStatistics::default());
212 };
213
214 let result = recall.list_all_with_content(&SearchFilter::new(), RECENT_MEMORIES_LIMIT)?;
216
217 let mut namespace_counts: HashMap<String, usize> = HashMap::new();
218 let mut tag_counts: HashMap<String, usize> = HashMap::new();
219 let mut topics: Vec<String> = Vec::new();
220
221 for hit in &result.memories {
222 let memory = &hit.memory;
223
224 *namespace_counts
226 .entry(memory.namespace.as_str().to_string())
227 .or_insert(0) += 1;
228
229 for tag in &memory.tags {
231 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
232 }
233
234 if let Some(topic) = extract_topic(&memory.content) {
236 add_topic_if_unique(&mut topics, topic);
237 }
238 }
239
240 let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
242 top_tags.sort_by(|a, b| b.1.cmp(&a.1));
243 top_tags.truncate(TOP_TAGS_LIMIT);
244
245 Ok(MemoryStatistics {
246 total_count: result.memories.len(),
247 namespace_counts,
248 top_tags,
249 recent_topics: topics,
250 })
251 }
252}
253
254impl Default for ContextBuilderService {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260fn format_section(title: &str, memories: &[Memory]) -> String {
262 let mut parts = vec![format!("## {title}")];
263
264 for memory in memories {
265 parts.push(format!(
266 "- **{}** ({}): {}",
267 memory.namespace,
268 memory.id.as_str(),
269 truncate_content(&memory.content, MEMORY_CONTENT_PREVIEW_LENGTH)
270 ));
271 }
272
273 parts.join("\n")
274}
275
276fn truncate_content(content: &str, max_len: usize) -> Cow<'_, str> {
283 if content.len() <= max_len {
284 Cow::Borrowed(content)
285 } else {
286 Cow::Owned(format!("{}...", &content[..max_len - 3]))
287 }
288}
289
290fn add_topic_if_unique(topics: &mut Vec<String>, topic: String) {
292 if !topics.contains(&topic) && topics.len() < MAX_TOPICS {
293 topics.push(topic);
294 }
295}
296
297fn extract_topic(content: &str) -> Option<String> {
299 let words: Vec<&str> = content
301 .split_whitespace()
302 .filter(|w| w.len() > 2)
303 .take(TOPIC_WORDS_LIMIT)
304 .collect();
305
306 if words.is_empty() {
307 return None;
308 }
309
310 let topic = words.join(" ");
311 if topic.len() > MAX_TOPIC_DISPLAY_LENGTH {
312 Some(format!("{}...", &topic[..MAX_TOPIC_DISPLAY_LENGTH - 3]))
313 } else {
314 Some(topic)
315 }
316}
317
318#[allow(clippy::option_if_let_else)] fn truncate_context(context: &str, max_chars: usize) -> String {
321 if context.len() <= max_chars {
322 return context.to_string();
323 }
324
325 let truncated = &context[..max_chars];
327 if let Some(last_section) = truncated.rfind("\n##") {
328 format!(
329 "{}\n\n_[Context truncated due to token limit]_",
330 &context[..last_section]
331 )
332 } else if let Some(last_newline) = truncated.rfind('\n') {
333 format!(
334 "{}\n\n_[Context truncated due to token limit]_",
335 &context[..last_newline]
336 )
337 } else {
338 format!("{}...", &context[..max_chars - 3])
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn test_context_builder_creation() {
348 let service = ContextBuilderService::default();
349 let result = service.build_context(1000);
350 assert!(result.is_ok());
351 }
352
353 #[test]
354 fn test_estimate_tokens() {
355 let text = "This is a test string with about 40 characters.";
356 let tokens = ContextBuilderService::estimate_tokens(text);
357 assert!(tokens > 0);
358 assert!(tokens < text.len());
359 }
360
361 #[test]
362 fn test_truncate_content() {
363 let short = "Short text";
364 assert_eq!(truncate_content(short, 100), short);
365
366 let long =
367 "This is a longer text that should be truncated because it exceeds the maximum length";
368 let truncated = truncate_content(long, 30);
369 assert!(truncated.ends_with("..."));
370 assert!(truncated.len() <= 30);
371 }
372
373 #[test]
374 fn test_truncate_context() {
375 let context =
376 "## Section 1\nContent 1\n\n## Section 2\nContent 2\n\n## Section 3\nContent 3";
377
378 let result = truncate_context(context, 1000);
380 assert_eq!(result, context);
381
382 let result = truncate_context(context, 40);
384 assert!(result.contains("truncated"));
385 }
386
387 #[test]
388 fn test_format_section() {
389 use crate::models::{Domain, MemoryId, MemoryStatus};
390
391 let memories = vec![Memory {
392 id: MemoryId::new("test_id"),
393 content: "Test content".to_string(),
394 namespace: Namespace::Decisions,
395 domain: Domain::new(),
396 project_id: None,
397 branch: None,
398 file_path: None,
399 status: MemoryStatus::Active,
400 created_at: 0,
401 updated_at: 0,
402 tombstoned_at: None,
403 expires_at: None,
404 embedding: None,
405 tags: vec![],
406 #[cfg(feature = "group-scope")]
407 group_id: None,
408 source: None,
409 is_summary: false,
410 source_memory_ids: None,
411 consolidation_timestamp: None,
412 }];
413
414 let section = format_section("Test Section", &memories);
415 assert!(section.contains("## Test Section"));
416 assert!(section.contains("Test content"));
417 }
418
419 #[test]
420 fn test_build_query_context_no_recall() {
421 let service = ContextBuilderService::default();
422 let result = service.build_query_context("test", 1000);
423 assert!(result.is_err());
424 }
425}