1use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use tokio::sync::RwLock;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22 pub customer_id: String,
24 pub session_id: String,
26 pub role: String,
28 pub content: String,
30 pub timestamp: DateTime<Utc>,
32 pub metadata: HashMap<String, String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CustomerProfile {
39 pub customer_id: String,
41 pub total_sessions: usize,
43 pub total_turns: usize,
45 pub first_interaction: DateTime<Utc>,
47 pub last_interaction: DateTime<Utc>,
49 pub topics: Vec<String>,
51 pub sentiment_trend: String,
53}
54
55#[derive(Debug, Clone)]
60pub struct ConversationMemory {
61 turns: Arc<RwLock<HashMap<String, Vec<ConversationTurn>>>>,
63}
64
65impl Default for ConversationMemory {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl ConversationMemory {
72 pub fn new() -> Self {
74 Self {
75 turns: Arc::new(RwLock::new(HashMap::new())),
76 }
77 }
78
79 pub async fn record_turn(
81 &self,
82 customer_id: &str,
83 session_id: &str,
84 role: &str,
85 content: &str,
86 metadata: HashMap<String, String>,
87 ) {
88 let turn = ConversationTurn {
89 customer_id: customer_id.to_string(),
90 session_id: session_id.to_string(),
91 role: role.to_string(),
92 content: content.to_string(),
93 timestamp: Utc::now(),
94 metadata,
95 };
96 let mut store = self.turns.write().await;
97 store.entry(customer_id.to_string()).or_default().push(turn);
98 }
99
100 pub async fn record_turn_with_timestamp(
102 &self,
103 customer_id: &str,
104 session_id: &str,
105 role: &str,
106 content: &str,
107 metadata: HashMap<String, String>,
108 timestamp: DateTime<Utc>,
109 ) {
110 let turn = ConversationTurn {
111 customer_id: customer_id.to_string(),
112 session_id: session_id.to_string(),
113 role: role.to_string(),
114 content: content.to_string(),
115 timestamp,
116 metadata,
117 };
118 let mut store = self.turns.write().await;
119 store.entry(customer_id.to_string()).or_default().push(turn);
120 }
121
122 pub async fn get_context(&self, customer_id: &str, max_turns: usize) -> Vec<ConversationTurn> {
126 let store = self.turns.read().await;
127 match store.get(customer_id) {
128 Some(turns) => {
129 let len = turns.len();
130 if len <= max_turns {
131 turns.clone()
132 } else {
133 turns[len - max_turns..].to_vec()
134 }
135 }
136 None => Vec::new(),
137 }
138 }
139
140 pub async fn summarize_history(&self, customer_id: &str) -> String {
145 let store = self.turns.read().await;
146 let turns = match store.get(customer_id) {
147 Some(t) => t,
148 None => return format!("No history found for customer '{customer_id}'."),
149 };
150
151 if turns.is_empty() {
152 return format!("No history found for customer '{customer_id}'.");
153 }
154
155 let sessions: HashSet<&str> = turns.iter().map(|t| t.session_id.as_str()).collect();
156 let first = turns.first().map(|t| t.timestamp).unwrap_or_else(Utc::now);
157 let last = turns.last().map(|t| t.timestamp).unwrap_or_else(Utc::now);
158
159 let mut summary = format!(
160 "Customer: {customer_id}\nSessions: {}\nTotal turns: {}\nFirst interaction: {}\nLast interaction: {}\n\nTranscript:\n",
161 sessions.len(),
162 turns.len(),
163 first.format("%Y-%m-%d %H:%M UTC"),
164 last.format("%Y-%m-%d %H:%M UTC"),
165 );
166
167 for turn in turns {
168 summary.push_str(&format!(
169 "[{}] [{}] {}: {}\n",
170 turn.timestamp.format("%Y-%m-%d %H:%M"),
171 turn.session_id,
172 turn.role,
173 truncate_content(&turn.content, 200),
174 ));
175 }
176
177 summary
178 }
179
180 pub async fn get_sessions(&self, customer_id: &str) -> Vec<String> {
182 let store = self.turns.read().await;
183 match store.get(customer_id) {
184 Some(turns) => {
185 let mut seen = HashSet::new();
186 let mut sessions = Vec::new();
187 for turn in turns {
188 if seen.insert(turn.session_id.clone()) {
189 sessions.push(turn.session_id.clone());
190 }
191 }
192 sessions
193 }
194 None => Vec::new(),
195 }
196 }
197
198 pub async fn search_history(&self, customer_id: &str, query: &str) -> Vec<ConversationTurn> {
202 let store = self.turns.read().await;
203 let query_lower = query.to_lowercase();
204 match store.get(customer_id) {
205 Some(turns) => turns
206 .iter()
207 .filter(|t| t.content.to_lowercase().contains(&query_lower))
208 .cloned()
209 .collect(),
210 None => Vec::new(),
211 }
212 }
213
214 pub async fn build_profile(&self, customer_id: &str) -> Option<CustomerProfile> {
219 let store = self.turns.read().await;
220 let turns = store.get(customer_id)?;
221 if turns.is_empty() {
222 return None;
223 }
224
225 let sessions: HashSet<&str> = turns.iter().map(|t| t.session_id.as_str()).collect();
226 let first = turns.first().map(|t| t.timestamp).unwrap_or_else(Utc::now);
227 let last = turns.last().map(|t| t.timestamp).unwrap_or_else(Utc::now);
228 let topics = extract_topics(turns);
229 let sentiment = infer_sentiment(turns);
230
231 Some(CustomerProfile {
232 customer_id: customer_id.to_string(),
233 total_sessions: sessions.len(),
234 total_turns: turns.len(),
235 first_interaction: first,
236 last_interaction: last,
237 topics,
238 sentiment_trend: sentiment,
239 })
240 }
241
242 pub async fn customer_count(&self) -> usize {
244 let store = self.turns.read().await;
245 store.len()
246 }
247
248 pub async fn customer_ids(&self) -> Vec<String> {
250 let store = self.turns.read().await;
251 store.keys().cloned().collect()
252 }
253
254 pub async fn turn_count(&self, customer_id: &str) -> usize {
256 let store = self.turns.read().await;
257 store.get(customer_id).map_or(0, Vec::len)
258 }
259}
260
261pub struct ConversationSummarizer;
263
264impl ConversationSummarizer {
265 pub async fn build_context(
282 memory: &ConversationMemory,
283 customer_id: &str,
284 max_tokens: usize,
285 ) -> String {
286 let max_chars = max_tokens * 4;
287
288 let profile = memory.build_profile(customer_id).await;
289 let profile = match profile {
290 Some(p) => p,
291 None => {
292 return format!("[Customer History]\nNo prior interactions with '{customer_id}'.");
293 }
294 };
295
296 let mut result = String::from("[Customer History]\n");
297 result.push_str(&format!("Customer: {}\n", profile.customer_id));
298 result.push_str(&format!(
299 "Sessions: {} | Turns: {} | Since: {}\n",
300 profile.total_sessions,
301 profile.total_turns,
302 profile.first_interaction.format("%Y-%m-%d"),
303 ));
304
305 if !profile.topics.is_empty() {
306 result.push_str(&format!("Topics: {}\n", profile.topics.join(", ")));
307 }
308 result.push_str(&format!("Sentiment: {}\n", profile.sentiment_trend));
309
310 let header_len = result.len();
312 let remaining = max_chars.saturating_sub(header_len + 30); let max_recent = 10;
316 let recent = memory.get_context(customer_id, max_recent).await;
317
318 if recent.is_empty() {
319 return result;
320 }
321
322 result.push_str(&format!("\nLast {} interactions:\n", recent.len()));
323
324 let mut used = 0;
325 let mut included = Vec::new();
326 for turn in recent.iter().rev() {
327 let line = format!(
328 "- {}: {}\n",
329 turn.role,
330 truncate_content(&turn.content, 150),
331 );
332 if used + line.len() > remaining {
333 break;
334 }
335 used += line.len();
336 included.push(line);
337 }
338
339 included.reverse();
341 for line in &included {
342 result.push_str(line);
343 }
344
345 result
346 }
347}
348
349fn truncate_content(s: &str, max_len: usize) -> String {
355 if s.len() <= max_len {
356 s.to_string()
357 } else {
358 let mut truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
359 truncated.push_str("...");
360 truncated
361 }
362}
363
364const STOPWORDS: &[&str] = &[
366 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
367 "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "to",
368 "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during",
369 "before", "after", "and", "but", "or", "nor", "not", "so", "yet", "both", "either", "neither",
370 "each", "every", "all", "any", "few", "more", "most", "other", "some", "such", "no", "only",
371 "own", "same", "than", "too", "very", "just", "because", "about", "up", "out", "then", "them",
372 "these", "those", "this", "that", "it", "its", "i", "me", "my", "we", "our", "you", "your",
373 "he", "him", "his", "she", "her", "they", "their", "what", "which", "who", "whom", "how",
374 "when", "where", "why", "if", "while", "also", "like", "get", "got", "want", "need", "know",
375 "think",
376];
377
378fn extract_topics(turns: &[ConversationTurn]) -> Vec<String> {
381 let mut freq: HashMap<String, usize> = HashMap::new();
382 let stopwords: HashSet<&str> = STOPWORDS.iter().copied().collect();
383
384 for turn in turns {
385 for word in turn.content.split_whitespace() {
386 let clean: String = word
387 .chars()
388 .filter(|c| c.is_alphanumeric())
389 .collect::<String>()
390 .to_lowercase();
391 if clean.len() >= 3 && !stopwords.contains(clean.as_str()) {
392 *freq.entry(clean).or_insert(0) += 1;
393 }
394 }
395 }
396
397 let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
398 sorted.sort_by(|a, b| b.1.cmp(&a.1));
399 sorted.into_iter().take(10).map(|(word, _)| word).collect()
400}
401
402fn infer_sentiment(turns: &[ConversationTurn]) -> String {
407 let mut positive = 0i32;
408 let mut negative = 0i32;
409
410 let positive_words = [
411 "thanks",
412 "thank",
413 "great",
414 "good",
415 "excellent",
416 "perfect",
417 "happy",
418 "love",
419 "awesome",
420 "helpful",
421 "appreciate",
422 "wonderful",
423 "fantastic",
424 "pleased",
425 "satisfied",
426 ];
427 let negative_words = [
428 "bad",
429 "terrible",
430 "awful",
431 "horrible",
432 "hate",
433 "angry",
434 "frustrated",
435 "disappointed",
436 "worst",
437 "broken",
438 "fail",
439 "failed",
440 "issue",
441 "problem",
442 "error",
443 "bug",
444 "complaint",
445 ];
446
447 for turn in turns {
448 if let Some(sentiment) = turn.metadata.get("sentiment") {
450 match sentiment.to_lowercase().as_str() {
451 "positive" => positive += 2,
452 "negative" => negative += 2,
453 _ => {}
454 }
455 continue;
456 }
457
458 if turn.role == "user" {
460 let lower = turn.content.to_lowercase();
461 for &pw in &positive_words {
462 if lower.contains(pw) {
463 positive += 1;
464 }
465 }
466 for &nw in &negative_words {
467 if lower.contains(nw) {
468 negative += 1;
469 }
470 }
471 }
472 }
473
474 if positive > negative {
475 "positive".to_string()
476 } else if negative > positive {
477 "negative".to_string()
478 } else {
479 "neutral".to_string()
480 }
481}
482
483#[cfg(test)]
488#[allow(clippy::unwrap_used, clippy::expect_used)]
489mod tests {
490 use super::*;
491 use chrono::TimeZone;
492
493 fn meta() -> HashMap<String, String> {
494 HashMap::new()
495 }
496
497 fn meta_with(key: &str, val: &str) -> HashMap<String, String> {
498 let mut m = HashMap::new();
499 m.insert(key.to_string(), val.to_string());
500 m
501 }
502
503 #[test]
508 fn test_conversation_turn_creation() {
509 let turn = ConversationTurn {
510 customer_id: "c1".to_string(),
511 session_id: "s1".to_string(),
512 role: "user".to_string(),
513 content: "Hello".to_string(),
514 timestamp: Utc::now(),
515 metadata: meta(),
516 };
517 assert_eq!(turn.customer_id, "c1");
518 assert_eq!(turn.role, "user");
519 }
520
521 #[test]
522 fn test_conversation_turn_serialization() {
523 let turn = ConversationTurn {
524 customer_id: "c1".to_string(),
525 session_id: "s1".to_string(),
526 role: "assistant".to_string(),
527 content: "Hi there".to_string(),
528 timestamp: Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(),
529 metadata: meta_with("model", "gpt-4"),
530 };
531 let json = serde_json::to_string(&turn).unwrap();
532 let restored: ConversationTurn = serde_json::from_str(&json).unwrap();
533 assert_eq!(restored.content, "Hi there");
534 assert_eq!(restored.metadata.get("model").unwrap(), "gpt-4");
535 }
536
537 #[tokio::test]
542 async fn test_record_and_get_context() {
543 let mem = ConversationMemory::new();
544 mem.record_turn("c1", "s1", "user", "Hello", meta()).await;
545 mem.record_turn("c1", "s1", "assistant", "Hi!", meta())
546 .await;
547
548 let ctx = mem.get_context("c1", 10).await;
549 assert_eq!(ctx.len(), 2);
550 assert_eq!(ctx[0].role, "user");
551 assert_eq!(ctx[1].role, "assistant");
552 }
553
554 #[tokio::test]
555 async fn test_get_context_max_turns_limit() {
556 let mem = ConversationMemory::new();
557 for i in 0..10 {
558 mem.record_turn("c1", "s1", "user", &format!("msg {i}"), meta())
559 .await;
560 }
561
562 let ctx = mem.get_context("c1", 3).await;
563 assert_eq!(ctx.len(), 3);
564 assert_eq!(ctx[0].content, "msg 7");
566 assert_eq!(ctx[2].content, "msg 9");
567 }
568
569 #[tokio::test]
570 async fn test_get_context_unknown_customer() {
571 let mem = ConversationMemory::new();
572 let ctx = mem.get_context("unknown", 5).await;
573 assert!(ctx.is_empty());
574 }
575
576 #[tokio::test]
577 async fn test_get_sessions_single() {
578 let mem = ConversationMemory::new();
579 mem.record_turn("c1", "s1", "user", "a", meta()).await;
580 mem.record_turn("c1", "s1", "assistant", "b", meta()).await;
581
582 let sessions = mem.get_sessions("c1").await;
583 assert_eq!(sessions, vec!["s1"]);
584 }
585
586 #[tokio::test]
587 async fn test_get_sessions_multiple() {
588 let mem = ConversationMemory::new();
589 mem.record_turn("c1", "s1", "user", "a", meta()).await;
590 mem.record_turn("c1", "s2", "user", "b", meta()).await;
591 mem.record_turn("c1", "s1", "assistant", "c", meta()).await;
592 mem.record_turn("c1", "s3", "user", "d", meta()).await;
593
594 let sessions = mem.get_sessions("c1").await;
595 assert_eq!(sessions, vec!["s1", "s2", "s3"]);
596 }
597
598 #[tokio::test]
599 async fn test_get_sessions_unknown_customer() {
600 let mem = ConversationMemory::new();
601 let sessions = mem.get_sessions("ghost").await;
602 assert!(sessions.is_empty());
603 }
604
605 #[tokio::test]
606 async fn test_search_history_matches() {
607 let mem = ConversationMemory::new();
608 mem.record_turn("c1", "s1", "user", "How do I stake ETH?", meta())
609 .await;
610 mem.record_turn("c1", "s1", "assistant", "You can stake via...", meta())
611 .await;
612 mem.record_turn("c1", "s2", "user", "What about billing?", meta())
613 .await;
614
615 let results = mem.search_history("c1", "stake").await;
616 assert_eq!(results.len(), 2);
618 assert!(results
619 .iter()
620 .all(|r| r.content.to_lowercase().contains("stake")));
621
622 let billing = mem.search_history("c1", "billing").await;
624 assert_eq!(billing.len(), 1);
625 }
626
627 #[tokio::test]
628 async fn test_search_history_case_insensitive() {
629 let mem = ConversationMemory::new();
630 mem.record_turn("c1", "s1", "user", "BILLING question", meta())
631 .await;
632
633 let results = mem.search_history("c1", "billing").await;
634 assert_eq!(results.len(), 1);
635 }
636
637 #[tokio::test]
638 async fn test_search_history_no_match() {
639 let mem = ConversationMemory::new();
640 mem.record_turn("c1", "s1", "user", "Hello world", meta())
641 .await;
642
643 let results = mem.search_history("c1", "blockchain").await;
644 assert!(results.is_empty());
645 }
646
647 #[tokio::test]
648 async fn test_search_history_unknown_customer() {
649 let mem = ConversationMemory::new();
650 let results = mem.search_history("unknown", "anything").await;
651 assert!(results.is_empty());
652 }
653
654 #[tokio::test]
655 async fn test_summarize_history() {
656 let mem = ConversationMemory::new();
657 let ts = Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap();
658 mem.record_turn_with_timestamp("c1", "s1", "user", "Hello", meta(), ts)
659 .await;
660 mem.record_turn_with_timestamp("c1", "s1", "assistant", "Hi!", meta(), ts)
661 .await;
662
663 let summary = mem.summarize_history("c1").await;
664 assert!(summary.contains("Customer: c1"));
665 assert!(summary.contains("Sessions: 1"));
666 assert!(summary.contains("Total turns: 2"));
667 assert!(summary.contains("Transcript:"));
668 }
669
670 #[tokio::test]
671 async fn test_summarize_history_unknown() {
672 let mem = ConversationMemory::new();
673 let summary = mem.summarize_history("ghost").await;
674 assert!(summary.contains("No history found"));
675 }
676
677 #[tokio::test]
682 async fn test_build_profile() {
683 let mem = ConversationMemory::new();
684 let ts1 = Utc.with_ymd_and_hms(2025, 1, 10, 8, 0, 0).unwrap();
685 let ts2 = Utc.with_ymd_and_hms(2025, 3, 20, 14, 0, 0).unwrap();
686
687 mem.record_turn_with_timestamp("c1", "s1", "user", "billing question", meta(), ts1)
688 .await;
689 mem.record_turn_with_timestamp("c1", "s2", "user", "DeFi staking", meta(), ts2)
690 .await;
691
692 let profile = mem.build_profile("c1").await.unwrap();
693 assert_eq!(profile.customer_id, "c1");
694 assert_eq!(profile.total_sessions, 2);
695 assert_eq!(profile.total_turns, 2);
696 assert_eq!(profile.first_interaction, ts1);
697 assert_eq!(profile.last_interaction, ts2);
698 assert!(!profile.topics.is_empty());
699 }
700
701 #[tokio::test]
702 async fn test_build_profile_unknown() {
703 let mem = ConversationMemory::new();
704 assert!(mem.build_profile("unknown").await.is_none());
705 }
706
707 #[tokio::test]
708 async fn test_profile_sentiment_positive() {
709 let mem = ConversationMemory::new();
710 mem.record_turn("c1", "s1", "user", "Thanks, that was great!", meta())
711 .await;
712 mem.record_turn("c1", "s1", "user", "Excellent service, love it", meta())
713 .await;
714
715 let profile = mem.build_profile("c1").await.unwrap();
716 assert_eq!(profile.sentiment_trend, "positive");
717 }
718
719 #[tokio::test]
720 async fn test_profile_sentiment_negative() {
721 let mem = ConversationMemory::new();
722 mem.record_turn("c1", "s1", "user", "This is terrible and broken", meta())
723 .await;
724 mem.record_turn("c1", "s1", "user", "Awful experience, horrible bug", meta())
725 .await;
726
727 let profile = mem.build_profile("c1").await.unwrap();
728 assert_eq!(profile.sentiment_trend, "negative");
729 }
730
731 #[tokio::test]
732 async fn test_profile_sentiment_from_metadata() {
733 let mem = ConversationMemory::new();
734 mem.record_turn(
735 "c1",
736 "s1",
737 "user",
738 "neutral text",
739 meta_with("sentiment", "positive"),
740 )
741 .await;
742 mem.record_turn(
743 "c1",
744 "s1",
745 "user",
746 "more text",
747 meta_with("sentiment", "positive"),
748 )
749 .await;
750
751 let profile = mem.build_profile("c1").await.unwrap();
752 assert_eq!(profile.sentiment_trend, "positive");
753 }
754
755 #[tokio::test]
760 async fn test_summarizer_build_context() {
761 let mem = ConversationMemory::new();
762 let ts = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
763 mem.record_turn_with_timestamp("c1", "s1", "user", "How do I stake ETH?", meta(), ts)
764 .await;
765 mem.record_turn_with_timestamp(
766 "c1",
767 "s1",
768 "assistant",
769 "You can stake ETH through a validator.",
770 meta(),
771 ts,
772 )
773 .await;
774
775 let ctx = ConversationSummarizer::build_context(&mem, "c1", 500).await;
776 assert!(ctx.contains("[Customer History]"));
777 assert!(ctx.contains("Customer: c1"));
778 assert!(ctx.contains("Sessions: 1"));
779 assert!(ctx.contains("stake"));
780 }
781
782 #[tokio::test]
783 async fn test_summarizer_unknown_customer() {
784 let mem = ConversationMemory::new();
785 let ctx = ConversationSummarizer::build_context(&mem, "ghost", 500).await;
786 assert!(ctx.contains("[Customer History]"));
787 assert!(ctx.contains("No prior interactions"));
788 }
789
790 #[tokio::test]
791 async fn test_summarizer_respects_token_limit() {
792 let mem = ConversationMemory::new();
793 for i in 0..20 {
794 mem.record_turn(
795 "c1",
796 "s1",
797 "user",
798 &format!("This is a fairly long message number {i} about various topics"),
799 meta(),
800 )
801 .await;
802 }
803
804 let ctx = ConversationSummarizer::build_context(&mem, "c1", 100).await;
806 assert!(ctx.contains("[Customer History]"));
808 assert!(ctx.len() < 2000);
810 }
811
812 #[test]
817 fn test_truncate_content_short() {
818 assert_eq!(truncate_content("hello", 10), "hello");
819 }
820
821 #[test]
822 fn test_truncate_content_long() {
823 let long = "a".repeat(100);
824 let truncated = truncate_content(&long, 20);
825 assert!(truncated.ends_with("..."));
826 assert!(truncated.len() <= 20);
827 }
828
829 #[test]
830 fn test_extract_topics_basic() {
831 let turns = vec![ConversationTurn {
832 customer_id: "c1".to_string(),
833 session_id: "s1".to_string(),
834 role: "user".to_string(),
835 content: "billing billing billing staking staking DeFi".to_string(),
836 timestamp: Utc::now(),
837 metadata: meta(),
838 }];
839 let topics = extract_topics(&turns);
840 assert!(topics.contains(&"billing".to_string()));
841 assert!(topics.contains(&"staking".to_string()));
842 assert!(topics.contains(&"defi".to_string()));
843 }
844
845 #[test]
846 fn test_extract_topics_filters_stopwords() {
847 let turns = vec![ConversationTurn {
848 customer_id: "c1".to_string(),
849 session_id: "s1".to_string(),
850 role: "user".to_string(),
851 content: "the a is are were this that blockchain".to_string(),
852 timestamp: Utc::now(),
853 metadata: meta(),
854 }];
855 let topics = extract_topics(&turns);
856 assert!(topics.contains(&"blockchain".to_string()));
858 assert!(!topics.contains(&"the".to_string()));
859 }
860
861 #[test]
862 fn test_infer_sentiment_neutral() {
863 let turns = vec![ConversationTurn {
864 customer_id: "c1".to_string(),
865 session_id: "s1".to_string(),
866 role: "user".to_string(),
867 content: "What time does the store open?".to_string(),
868 timestamp: Utc::now(),
869 metadata: meta(),
870 }];
871 assert_eq!(infer_sentiment(&turns), "neutral");
872 }
873
874 #[tokio::test]
879 async fn test_multiple_customers_isolated() {
880 let mem = ConversationMemory::new();
881 mem.record_turn("c1", "s1", "user", "Hello from c1", meta())
882 .await;
883 mem.record_turn("c2", "s2", "user", "Hello from c2", meta())
884 .await;
885
886 assert_eq!(mem.customer_count().await, 2);
887 assert_eq!(mem.turn_count("c1").await, 1);
888 assert_eq!(mem.turn_count("c2").await, 1);
889
890 let ctx_c1 = mem.get_context("c1", 10).await;
891 assert_eq!(ctx_c1.len(), 1);
892 assert_eq!(ctx_c1[0].content, "Hello from c1");
893 }
894
895 #[tokio::test]
896 async fn test_default_constructor() {
897 let mem = ConversationMemory::default();
898 assert_eq!(mem.customer_count().await, 0);
899 }
900
901 #[tokio::test]
902 async fn test_customer_profile_serialization() {
903 let profile = CustomerProfile {
904 customer_id: "c1".to_string(),
905 total_sessions: 3,
906 total_turns: 15,
907 first_interaction: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
908 last_interaction: Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap(),
909 topics: vec!["billing".to_string(), "staking".to_string()],
910 sentiment_trend: "positive".to_string(),
911 };
912 let json = serde_json::to_string(&profile).unwrap();
913 let restored: CustomerProfile = serde_json::from_str(&json).unwrap();
914 assert_eq!(restored.total_sessions, 3);
915 assert_eq!(restored.topics.len(), 2);
916 }
917}