1use mentedb_core::memory::MemoryType;
8use mentedb_core::types::MemoryId;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, thiserror::Error)]
17pub enum LlmJudgeError {
18 #[error("LLM provider returned an error: {0}")]
19 ProviderError(String),
20 #[error("failed to parse LLM response: {0}")]
21 ParseError(String),
22 #[error("LLM returned an unexpected verdict: {0}")]
23 UnexpectedVerdict(String),
24}
25
26pub trait LlmJudge: Send + Sync {
35 fn complete(
37 &self,
38 system_prompt: &str,
39 user_prompt: &str,
40 ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send;
41}
42
43impl<T: LlmJudge> LlmJudge for std::sync::Arc<T> {
44 fn complete(
45 &self,
46 system_prompt: &str,
47 user_prompt: &str,
48 ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send {
49 (**self).complete(system_prompt, user_prompt)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "verdict", rename_all = "snake_case")]
60pub enum InvalidationVerdict {
61 Keep { reason: String },
63 Invalidate { reason: String },
65 Update {
67 merged_content: String,
68 reason: String,
69 },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "verdict", rename_all = "snake_case")]
75pub enum ContradictionVerdict {
76 Compatible { reason: String },
78 Contradicts { reason: String },
80 Supersedes {
82 #[serde(rename = "superseding_id")]
83 winner: String,
84 reason: String,
85 },
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct EntityMergeGroup {
91 pub canonical: String,
93 pub aliases: Vec<String>,
95 pub confidence: f32,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EntityCandidate {
102 pub name: String,
104 pub context: Option<String>,
106 pub memory_id: Option<MemoryId>,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(tag = "action", rename_all = "snake_case")]
113pub enum ConsolidationDecision {
114 KeepAll { reason: String },
116 Merge {
118 merged_content: String,
119 merged_type: String,
120 keep_ids: Vec<String>,
121 remove_ids: Vec<String>,
122 reason: String,
123 },
124 Deduplicate {
126 keep_id: String,
127 remove_ids: Vec<String>,
128 reason: String,
129 },
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct TopicLabel {
135 pub topic: String,
137 pub is_new: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct CommunitySummary {
144 pub summary: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct UserProfile {
150 pub profile: String,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MemorySummary {
160 pub id: MemoryId,
161 pub content: String,
162 pub memory_type: MemoryType,
163 pub confidence: f32,
164 pub created_at: u64,
165}
166
167mod prompts {
172 pub const INVALIDATION_SYSTEM: &str = r#"You are a memory validity judge for an AI agent's long term memory system.
173
174Given an EXISTING memory and a NEW memory, determine the relationship:
175
176- "keep": Both memories are valid. They describe different facts or the old one is still true.
177- "invalidate": The new memory makes the old one no longer true. Example: old says "Alice works at Acme", new says "Alice joined Google" — the old employment fact is now outdated.
178- "update": The old memory should be updated to incorporate new information. Example: old says "project uses React", new says "project migrated from React to Vue" — merge into one memory.
179
180Respond with ONLY a JSON object. Examples:
181{"verdict": "keep", "reason": "These describe different aspects of the same topic"}
182{"verdict": "invalidate", "reason": "The user changed jobs, old employment is outdated"}
183{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "The new memory adds temporal context to the old one"}"#;
184
185 pub const CONTRADICTION_SYSTEM: &str = r#"You are a contradiction detector for an AI agent's long term memory system.
186
187Given two memories A and B, determine their relationship:
188
189- "compatible": They can both be true at the same time.
190- "contradicts": They directly contradict each other and cannot both be true.
191- "supersedes": One replaces the other due to a change over time (not a logical contradiction but a temporal update).
192
193For "supersedes", include which memory wins using its ID.
194
195Respond with ONLY a JSON object. Examples:
196{"verdict": "compatible", "reason": "These describe different topics"}
197{"verdict": "contradicts", "reason": "Cannot prefer both PostgreSQL and MySQL as primary database"}
198{"verdict": "supersedes", "superseding_id": "<id of winner>", "reason": "Memory B reflects a more recent decision"}"#;
199
200 pub const ENTITY_RESOLUTION_SYSTEM: &str = r#"You are an entity resolution system for an AI agent's long term memory.
201
202Given a list of entity references (names, pronouns, descriptions), group the ones that refer to the same real world entity. Use the provided context to disambiguate.
203
204Rules:
205- Only group entities you are confident refer to the same thing
206- "Python" (language) and "python" (the snake) are DIFFERENT entities if context makes that clear
207- Pronouns like "my manager" can be grouped with a name if context confirms it
208- Include a confidence score (0.0 to 1.0) for each group
209
210Respond with ONLY a JSON object:
211{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#;
212
213 pub const CONSOLIDATION_SYSTEM: &str = r#"You are a memory consolidation system for an AI agent's long term memory.
214
215Given a cluster of similar memories, decide how to combine them:
216
217- "keep_all": The memories describe genuinely different facts despite being similar.
218- "merge": Multiple memories should be combined into one richer memory. Provide the merged content.
219- "deduplicate": One memory captures everything, the others are redundant. Keep the best one.
220
221When merging, preserve all important information from each memory. The merged content should be strictly better than any individual memory.
222
223Respond with ONLY a JSON object. Examples:
224{"action": "keep_all", "reason": "Each memory describes a different API endpoint"}
225{"action": "merge", "merged_content": "User prefers Rust for systems programming due to memory safety and zero cost abstractions", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["id1", "id2"], "reason": "Both memories express the same preference with complementary detail"}
226{"action": "deduplicate", "keep_id": "id1", "remove_ids": ["id2", "id3"], "reason": "id1 is the most complete version"}"#;
227
228 pub const TOPIC_SYSTEM: &str = r#"You are a topic labeler for a conversation tracking system.
229
230Given a user message and a list of existing topic labels, either:
2311. Assign the MOST appropriate existing label if the message fits
2322. Create a new 1-3 word label if no existing label fits
233
234Rules:
235- Labels should be lowercase, concise (1-3 words max)
236- Prefer existing labels when the topic clearly matches
237- "deploy to staging" and "deploy to production" are the SAME topic: "deployment"
238- "auth error" and "set up authentication" are the SAME topic: "authentication"
239- Be general enough to group related messages but specific enough to be useful
240
241Respond with ONLY a JSON object:
242{"topic": "authentication", "is_new": false}"#;
243
244 pub const COMMUNITY_SUMMARY_SYSTEM: &str = r#"You are a knowledge summarizer for an AI agent's long-term memory system.
245
246Given a category name and a list of entities the user has interacted with, create a concise summary that captures the user's relationship to this cluster of entities.
247
248Rules:
249- Mention the key entities by name — prioritize the most significant ones
250- Focus on the user's relationship to each (uses, likes, works with, etc.)
251- Keep it to 2-3 sentences maximum
252- The summary should be findable when someone searches for this topic
253- Write in third person ("The user...")
254
255Respond with ONLY a JSON object:
256{"summary": "The user works with Python and Rust for systems programming, uses PostgreSQL for databases, and deploys on AWS."}"#;
257
258 pub const USER_PROFILE_SYSTEM: &str = r#"You are a profile generator for an AI agent's long-term memory system.
259
260Given a collection of facts, preferences, and patterns about a user, generate a concise profile that captures the most important information an AI assistant should know.
261
262Rules:
263- Prioritize: preferences > active projects > skills > relationships > habits
264- Be factual — only include what the evidence supports
265- Keep it under 200 words
266- Write in third person ("The user...")
267- Group related facts together
268- Include specific names, tools, and preferences — not vague generalities
269- This profile will be included in EVERY conversation, so only include persistently relevant facts
270
271Respond with ONLY a JSON object:
272{"profile": "The user is a software engineer who prefers Rust and Python. They are building MenteDB, a cognitive memory database..."}"#;
273}
274
275pub struct CognitiveLlmService<J: LlmJudge> {
284 judge: J,
285}
286
287impl<J: LlmJudge> CognitiveLlmService<J> {
288 pub fn new(judge: J) -> Self {
290 Self { judge }
291 }
292
293 pub async fn judge_invalidation(
300 &self,
301 old: &MemorySummary,
302 new: &MemorySummary,
303 ) -> Result<InvalidationVerdict, LlmJudgeError> {
304 let user_prompt = format!(
305 "EXISTING memory:\n{}\n\nNEW memory:\n{}",
306 serde_json::to_string_pretty(old).unwrap_or_default(),
307 serde_json::to_string_pretty(new).unwrap_or_default(),
308 );
309
310 let response = self
311 .judge
312 .complete(prompts::INVALIDATION_SYSTEM, &user_prompt)
313 .await?;
314 parse_json_response::<InvalidationVerdict>(&response)
315 }
316
317 pub async fn detect_contradiction(
319 &self,
320 a: &MemorySummary,
321 b: &MemorySummary,
322 ) -> Result<ContradictionVerdict, LlmJudgeError> {
323 let user_prompt = format!(
324 "Memory A:\n{}\n\nMemory B:\n{}",
325 serde_json::to_string_pretty(a).unwrap_or_default(),
326 serde_json::to_string_pretty(b).unwrap_or_default(),
327 );
328
329 let response = self
330 .judge
331 .complete(prompts::CONTRADICTION_SYSTEM, &user_prompt)
332 .await?;
333 parse_json_response::<ContradictionVerdict>(&response)
334 }
335
336 pub async fn resolve_entities(
338 &self,
339 candidates: &[EntityCandidate],
340 ) -> Result<Vec<EntityMergeGroup>, LlmJudgeError> {
341 if candidates.is_empty() {
342 return Ok(Vec::new());
343 }
344
345 let user_prompt = format!(
346 "Entity references to resolve:\n{}",
347 serde_json::to_string_pretty(candidates).unwrap_or_default(),
348 );
349
350 let response = self
351 .judge
352 .complete(prompts::ENTITY_RESOLUTION_SYSTEM, &user_prompt)
353 .await?;
354
355 #[derive(Deserialize)]
356 struct EntityResponse {
357 groups: Vec<EntityMergeGroup>,
358 }
359
360 let parsed = parse_json_response::<EntityResponse>(&response)?;
361 Ok(parsed.groups)
362 }
363
364 pub async fn consolidate(
366 &self,
367 cluster: &[ClusterMember],
368 ) -> Result<ConsolidationDecision, LlmJudgeError> {
369 if cluster.is_empty() {
370 return Ok(ConsolidationDecision::KeepAll {
371 reason: "Empty cluster".to_string(),
372 });
373 }
374
375 let user_prompt = format!(
376 "Memory cluster to consolidate:\n{}",
377 serde_json::to_string_pretty(cluster).unwrap_or_default(),
378 );
379
380 let response = self
381 .judge
382 .complete(prompts::CONSOLIDATION_SYSTEM, &user_prompt)
383 .await?;
384 parse_json_response::<ConsolidationDecision>(&response)
385 }
386
387 pub async fn canonicalize_topic(
389 &self,
390 message: &str,
391 existing_topics: &[String],
392 ) -> Result<TopicLabel, LlmJudgeError> {
393 let user_prompt = format!(
394 "User message: \"{}\"\n\nExisting topic labels: {:?}",
395 message, existing_topics,
396 );
397
398 let response = self
399 .judge
400 .complete(prompts::TOPIC_SYSTEM, &user_prompt)
401 .await?;
402 parse_json_response::<TopicLabel>(&response)
403 }
404
405 pub async fn generate_community_summary(
407 &self,
408 category: &str,
409 entities: &[(String, String)], ) -> Result<CommunitySummary, LlmJudgeError> {
411 let entity_list = entities
412 .iter()
413 .map(|(name, ctx)| format!("- {} — {}", name, ctx))
414 .collect::<Vec<_>>()
415 .join("\n");
416
417 let user_prompt = format!(
418 "Category: {}\n\nEntities in this cluster:\n{}",
419 category, entity_list
420 );
421
422 let response = self
423 .judge
424 .complete(prompts::COMMUNITY_SUMMARY_SYSTEM, &user_prompt)
425 .await?;
426 parse_json_response::<CommunitySummary>(&response)
427 }
428
429 pub async fn generate_user_profile(
431 &self,
432 facts: &[String],
433 community_summaries: &[String],
434 ) -> Result<UserProfile, LlmJudgeError> {
435 let mut sections = Vec::new();
436
437 if !facts.is_empty() {
438 sections.push(format!(
439 "Known facts about the user:\n{}",
440 facts
441 .iter()
442 .map(|f| format!("- {}", f))
443 .collect::<Vec<_>>()
444 .join("\n")
445 ));
446 }
447
448 if !community_summaries.is_empty() {
449 sections.push(format!(
450 "Community summaries:\n{}",
451 community_summaries
452 .iter()
453 .map(|s| format!("- {}", s))
454 .collect::<Vec<_>>()
455 .join("\n")
456 ));
457 }
458
459 let user_prompt = sections.join("\n\n");
460
461 if user_prompt.is_empty() {
462 return Err(LlmJudgeError::ParseError(
463 "no facts or summaries provided for profile generation".into(),
464 ));
465 }
466
467 let response = self
468 .judge
469 .complete(prompts::USER_PROFILE_SYSTEM, &user_prompt)
470 .await?;
471 parse_json_response::<UserProfile>(&response)
472 }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct ClusterMember {
478 pub id: String,
479 pub content: String,
480 pub memory_type: String,
481 pub confidence: f32,
482 pub created_at: u64,
483}
484
485fn parse_json_response<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, LlmJudgeError> {
490 if let Ok(v) = serde_json::from_str::<T>(raw) {
492 return Ok(v);
493 }
494
495 let trimmed = raw.trim();
496
497 let stripped = if trimmed.starts_with("```json") {
499 trimmed
500 .strip_prefix("```json")
501 .and_then(|s| s.strip_suffix("```"))
502 .unwrap_or(trimmed)
503 .trim()
504 } else if trimmed.starts_with("```") {
505 trimmed
506 .strip_prefix("```")
507 .and_then(|s| s.strip_suffix("```"))
508 .unwrap_or(trimmed)
509 .trim()
510 } else {
511 trimmed
512 };
513
514 if let Ok(v) = serde_json::from_str::<T>(stripped) {
515 return Ok(v);
516 }
517
518 if let Some(start) = stripped.find('{')
521 && let Some(end) = rfind_matching_brace(stripped, start)
522 {
523 let candidate = &stripped[start..=end];
524 if let Ok(v) = serde_json::from_str::<T>(candidate) {
525 return Ok(v);
526 }
527 }
528
529 Err(LlmJudgeError::ParseError(format!(
530 "could not parse LLM response as expected type. Raw response: {raw}"
531 )))
532}
533
534fn rfind_matching_brace(s: &str, start: usize) -> Option<usize> {
536 let mut depth = 0i32;
537 let mut in_string = false;
538 let mut escape_next = false;
539
540 for (i, ch) in s[start..].char_indices() {
541 if escape_next {
542 escape_next = false;
543 continue;
544 }
545 match ch {
546 '\\' if in_string => escape_next = true,
547 '"' => in_string = !in_string,
548 '{' if !in_string => depth += 1,
549 '}' if !in_string => {
550 depth -= 1;
551 if depth == 0 {
552 return Some(start + i);
553 }
554 }
555 _ => {}
556 }
557 }
558 None
559}
560
561pub struct MockLlmJudge {
567 response: String,
568}
569
570impl MockLlmJudge {
571 pub fn new(response: impl Into<String>) -> Self {
572 Self {
573 response: response.into(),
574 }
575 }
576}
577
578impl LlmJudge for MockLlmJudge {
579 async fn complete(
580 &self,
581 _system_prompt: &str,
582 _user_prompt: &str,
583 ) -> Result<String, LlmJudgeError> {
584 Ok(self.response.clone())
585 }
586}
587
588#[cfg(test)]
590pub struct CapturingJudge {
591 response: String,
592 captured: std::sync::Mutex<Vec<(String, String)>>,
593}
594
595#[cfg(test)]
596impl CapturingJudge {
597 pub fn new(response: impl Into<String>) -> Self {
598 Self {
599 response: response.into(),
600 captured: std::sync::Mutex::new(Vec::new()),
601 }
602 }
603
604 pub fn calls(&self) -> Vec<(String, String)> {
605 self.captured.lock().unwrap().clone()
606 }
607}
608
609#[cfg(test)]
610impl LlmJudge for CapturingJudge {
611 async fn complete(
612 &self,
613 system_prompt: &str,
614 user_prompt: &str,
615 ) -> Result<String, LlmJudgeError> {
616 self.captured
617 .lock()
618 .unwrap()
619 .push((system_prompt.to_string(), user_prompt.to_string()));
620 Ok(self.response.clone())
621 }
622}
623
624#[cfg(test)]
629mod tests {
630 use super::*;
631 use mentedb_core::types::MemoryId;
632
633 fn test_id() -> MemoryId {
634 MemoryId::new()
635 }
636
637 fn mem(content: &str) -> MemorySummary {
638 MemorySummary {
639 id: test_id(),
640 content: content.to_string(),
641 memory_type: MemoryType::Semantic,
642 confidence: 0.9,
643 created_at: 1000,
644 }
645 }
646
647 #[allow(dead_code)]
648 fn mem_at(content: &str, created_at: u64) -> MemorySummary {
649 MemorySummary {
650 id: test_id(),
651 content: content.to_string(),
652 memory_type: MemoryType::Semantic,
653 confidence: 0.9,
654 created_at,
655 }
656 }
657
658 #[tokio::test]
659 async fn test_judge_invalidation_keep() {
660 let judge = MockLlmJudge::new(
661 r#"{"verdict": "keep", "reason": "These describe different people"}"#,
662 );
663 let svc = CognitiveLlmService::new(judge);
664
665 let result = svc
666 .judge_invalidation(&mem("Alice works at Acme"), &mem("Bob works at Google"))
667 .await
668 .unwrap();
669
670 assert!(matches!(result, InvalidationVerdict::Keep { .. }));
671 }
672
673 #[tokio::test]
674 async fn test_judge_invalidation_invalidate() {
675 let judge =
676 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
677 let svc = CognitiveLlmService::new(judge);
678
679 let result = svc
680 .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
681 .await
682 .unwrap();
683
684 assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
685 }
686
687 #[tokio::test]
688 async fn test_judge_invalidation_update() {
689 let judge = MockLlmJudge::new(
690 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "Temporal update"}"#,
691 );
692 let svc = CognitiveLlmService::new(judge);
693
694 let result = svc
695 .judge_invalidation(&mem("Project uses React"), &mem("Project migrated to Vue"))
696 .await
697 .unwrap();
698
699 match result {
700 InvalidationVerdict::Update { merged_content, .. } => {
701 assert!(merged_content.contains("React"));
702 assert!(merged_content.contains("Vue"));
703 }
704 other => panic!("Expected Update, got {:?}", other),
705 }
706 }
707
708 #[tokio::test]
709 async fn test_detect_contradiction_compatible() {
710 let judge = MockLlmJudge::new(
711 r#"{"verdict": "compatible", "reason": "Different topics entirely"}"#,
712 );
713 let svc = CognitiveLlmService::new(judge);
714
715 let result = svc
716 .detect_contradiction(&mem("Likes Python"), &mem("Uses PostgreSQL"))
717 .await
718 .unwrap();
719
720 assert!(matches!(result, ContradictionVerdict::Compatible { .. }));
721 }
722
723 #[tokio::test]
724 async fn test_detect_contradiction_contradicts() {
725 let judge =
726 MockLlmJudge::new(r#"{"verdict": "contradicts", "reason": "Cannot prefer both"}"#);
727 let svc = CognitiveLlmService::new(judge);
728
729 let result = svc
730 .detect_contradiction(&mem("Prefers tabs"), &mem("Prefers spaces"))
731 .await
732 .unwrap();
733
734 assert!(matches!(result, ContradictionVerdict::Contradicts { .. }));
735 }
736
737 #[tokio::test]
738 async fn test_detect_contradiction_supersedes() {
739 let id_b = test_id();
740 let response = format!(
741 r#"{{"verdict": "supersedes", "superseding_id": "{}", "reason": "B is newer"}}"#,
742 id_b
743 );
744 let judge = MockLlmJudge::new(response);
745 let svc = CognitiveLlmService::new(judge);
746
747 let mem_b = MemorySummary {
748 id: id_b,
749 content: "Migrated to Vue".into(),
750 memory_type: MemoryType::Semantic,
751 confidence: 0.9,
752 created_at: 2000,
753 };
754
755 let result = svc
756 .detect_contradiction(&mem("Uses React"), &mem_b)
757 .await
758 .unwrap();
759
760 assert!(matches!(result, ContradictionVerdict::Supersedes { .. }));
761 }
762
763 #[tokio::test]
764 async fn test_resolve_entities() {
765 let judge = MockLlmJudge::new(
766 r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.92}]}"#,
767 );
768 let svc = CognitiveLlmService::new(judge);
769
770 let candidates = vec![
771 EntityCandidate {
772 name: "Alice".into(),
773 context: None,
774 memory_id: None,
775 },
776 EntityCandidate {
777 name: "my manager".into(),
778 context: Some("my manager Alice told me".into()),
779 memory_id: None,
780 },
781 EntityCandidate {
782 name: "Alice Smith".into(),
783 context: None,
784 memory_id: None,
785 },
786 ];
787
788 let groups = svc.resolve_entities(&candidates).await.unwrap();
789 assert_eq!(groups.len(), 1);
790 assert_eq!(groups[0].canonical, "Alice Smith");
791 assert_eq!(groups[0].aliases.len(), 2);
792 }
793
794 #[tokio::test]
795 async fn test_resolve_entities_empty() {
796 let judge = MockLlmJudge::new("should not be called");
797 let svc = CognitiveLlmService::new(judge);
798
799 let groups = svc.resolve_entities(&[]).await.unwrap();
800 assert!(groups.is_empty());
801 }
802
803 #[tokio::test]
804 async fn test_consolidate_merge() {
805 let judge = MockLlmJudge::new(
806 r#"{"action": "merge", "merged_content": "User prefers Rust for systems programming due to safety and performance", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["a", "b"], "reason": "Complementary details"}"#,
807 );
808 let svc = CognitiveLlmService::new(judge);
809
810 let cluster = vec![
811 ClusterMember {
812 id: "a".into(),
813 content: "Uses Rust".into(),
814 memory_type: "Semantic".into(),
815 confidence: 0.9,
816 created_at: 1000,
817 },
818 ClusterMember {
819 id: "b".into(),
820 content: "Prefers Rust for safety".into(),
821 memory_type: "Semantic".into(),
822 confidence: 0.85,
823 created_at: 2000,
824 },
825 ];
826
827 let decision = svc.consolidate(&cluster).await.unwrap();
828 match decision {
829 ConsolidationDecision::Merge {
830 merged_content,
831 remove_ids,
832 ..
833 } => {
834 assert!(merged_content.contains("Rust"));
835 assert_eq!(remove_ids.len(), 2);
836 }
837 other => panic!("Expected Merge, got {:?}", other),
838 }
839 }
840
841 #[tokio::test]
842 async fn test_consolidate_empty_cluster() {
843 let judge = MockLlmJudge::new("should not be called");
844 let svc = CognitiveLlmService::new(judge);
845
846 let decision = svc.consolidate(&[]).await.unwrap();
847 assert!(matches!(decision, ConsolidationDecision::KeepAll { .. }));
848 }
849
850 #[tokio::test]
851 async fn test_canonicalize_topic_existing() {
852 let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
853 let svc = CognitiveLlmService::new(judge);
854
855 let label = svc
856 .canonicalize_topic(
857 "how do I configure the auth middleware",
858 &[
859 "database".into(),
860 "authentication".into(),
861 "deployment".into(),
862 ],
863 )
864 .await
865 .unwrap();
866
867 assert_eq!(label.topic, "authentication");
868 assert!(!label.is_new);
869 }
870
871 #[tokio::test]
872 async fn test_canonicalize_topic_new() {
873 let judge = MockLlmJudge::new(r#"{"topic": "caching", "is_new": true}"#);
874 let svc = CognitiveLlmService::new(judge);
875
876 let label = svc
877 .canonicalize_topic(
878 "should we add Redis for caching",
879 &["database".into(), "authentication".into()],
880 )
881 .await
882 .unwrap();
883
884 assert_eq!(label.topic, "caching");
885 assert!(label.is_new);
886 }
887
888 #[tokio::test]
889 async fn test_json_markdown_code_block_stripping() {
890 let judge = MockLlmJudge::new(
891 "```json\n{\"verdict\": \"keep\", \"reason\": \"wrapped in markdown\"}\n```",
892 );
893 let svc = CognitiveLlmService::new(judge);
894
895 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
896
897 assert!(matches!(result, InvalidationVerdict::Keep { .. }));
898 }
899
900 #[tokio::test]
901 async fn test_json_surrounded_by_text() {
902 let judge = MockLlmJudge::new(
903 "Here is my analysis:\n{\"verdict\": \"invalidate\", \"reason\": \"job changed\"}\nLet me know if you need more detail.",
904 );
905 let svc = CognitiveLlmService::new(judge);
906
907 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
908
909 assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
910 }
911
912 #[tokio::test]
913 async fn test_json_with_nested_braces_in_strings() {
914 let judge = MockLlmJudge::new(
915 "Sure! {\"verdict\": \"update\", \"merged_content\": \"uses {curly} braces\", \"reason\": \"test\"}",
916 );
917 let svc = CognitiveLlmService::new(judge);
918
919 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
920
921 match result {
922 InvalidationVerdict::Update { merged_content, .. } => {
923 assert!(merged_content.contains("{curly}"))
924 }
925 other => panic!("Expected Update, got {:?}", other),
926 }
927 }
928
929 #[tokio::test]
930 async fn test_provider_error_propagates() {
931 struct FailingJudge;
932 impl LlmJudge for FailingJudge {
933 async fn complete(&self, _: &str, _: &str) -> Result<String, LlmJudgeError> {
934 Err(LlmJudgeError::ProviderError("connection refused".into()))
935 }
936 }
937
938 let svc = CognitiveLlmService::new(FailingJudge);
939 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await;
940
941 assert!(result.is_err());
942 assert!(
943 result
944 .unwrap_err()
945 .to_string()
946 .contains("connection refused")
947 );
948 }
949
950 #[tokio::test]
951 async fn test_prompt_includes_memory_content() {
952 let judge = std::sync::Arc::new(CapturingJudge::new(
953 r#"{"verdict": "keep", "reason": "test"}"#,
954 ));
955 let svc = CognitiveLlmService::new(judge.clone());
956
957 let _ = svc
958 .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
959 .await;
960
961 let calls = judge.calls();
962 assert_eq!(calls.len(), 1);
963 let (system, user) = &calls[0];
964 assert!(system.contains("memory validity judge"));
965 assert!(user.contains("Alice works at Acme"));
966 assert!(user.contains("Alice joined Google"));
967 }
968}