Skip to main content

argentor_memory/
conversation.rs

1//! Conversation memory system for cross-session customer context.
2//!
3//! Enables agents to recall context from previous sessions with the same customer,
4//! building rich customer profiles and injecting relevant history into agent prompts.
5//!
6//! # Main types
7//!
8//! - [`ConversationMemory`] — Thread-safe store for conversation turns, keyed by customer.
9//! - [`ConversationTurn`] — A single turn (user/assistant/tool) in a conversation.
10//! - [`CustomerProfile`] — Aggregated customer context across all sessions.
11//! - [`ConversationSummarizer`] — Builds context strings for agent system-prompt injection.
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use tokio::sync::RwLock;
18
19/// A single turn in a conversation.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22    /// The customer this turn belongs to.
23    pub customer_id: String,
24    /// The session in which this turn occurred.
25    pub session_id: String,
26    /// The role of the speaker: "user", "assistant", or "tool".
27    pub role: String,
28    /// The textual content of the turn.
29    pub content: String,
30    /// When this turn was recorded.
31    pub timestamp: DateTime<Utc>,
32    /// Arbitrary metadata (agent_role, model, tokens, sentiment, etc.).
33    pub metadata: HashMap<String, String>,
34}
35
36/// Aggregated customer context built from conversation history.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CustomerProfile {
39    /// The customer identifier.
40    pub customer_id: String,
41    /// How many distinct sessions this customer has had.
42    pub total_sessions: usize,
43    /// Total number of conversation turns across all sessions.
44    pub total_turns: usize,
45    /// Timestamp of the first recorded interaction.
46    pub first_interaction: DateTime<Utc>,
47    /// Timestamp of the most recent interaction.
48    pub last_interaction: DateTime<Utc>,
49    /// Topics extracted from conversation content (deduplicated keywords).
50    pub topics: Vec<String>,
51    /// Overall sentiment trend: "positive", "neutral", or "negative".
52    pub sentiment_trend: String,
53}
54
55/// Thread-safe conversation memory that stores and retrieves turns per customer.
56///
57/// Internally uses `Arc<RwLock<HashMap<String, Vec<ConversationTurn>>>>` so it can
58/// be shared across async tasks and agent threads.
59#[derive(Debug, Clone)]
60pub struct ConversationMemory {
61    /// customer_id -> ordered list of turns (oldest first).
62    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    /// Create an empty conversation memory.
73    pub fn new() -> Self {
74        Self {
75            turns: Arc::new(RwLock::new(HashMap::new())),
76        }
77    }
78
79    /// Record a conversation turn for a customer.
80    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    /// Record a turn with an explicit timestamp (useful for tests and imports).
101    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    /// Retrieve the last `max_turns` turns for a customer across all sessions.
123    ///
124    /// Returns turns in chronological order (oldest first among the returned set).
125    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    /// Build a text summary of all interactions for a customer.
141    ///
142    /// Returns a human-readable multi-line summary including session count,
143    /// turn count, time range, and a condensed transcript.
144    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    /// Get a list of distinct session IDs for a customer.
181    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    /// Case-insensitive keyword search across all turns for a customer.
199    ///
200    /// Returns turns whose content contains the query substring.
201    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    /// Build a [`CustomerProfile`] from the stored conversation history.
215    ///
216    /// Topics are extracted as the most frequent non-stopword tokens.
217    /// Sentiment is inferred from metadata if present, otherwise defaults to "neutral".
218    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    /// Return the total number of customers tracked.
243    pub async fn customer_count(&self) -> usize {
244        let store = self.turns.read().await;
245        store.len()
246    }
247
248    /// Return all customer IDs.
249    pub async fn customer_ids(&self) -> Vec<String> {
250        let store = self.turns.read().await;
251        store.keys().cloned().collect()
252    }
253
254    /// Return total turn count for a customer.
255    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
261/// Builds context strings suitable for injection into an agent's system prompt.
262pub struct ConversationSummarizer;
263
264impl ConversationSummarizer {
265    /// Build a context string for agent injection, respecting an approximate token limit.
266    ///
267    /// The output looks like:
268    /// ```text
269    /// [Customer History]
270    /// Customer: cust_123
271    /// Sessions: 3 | Turns: 15 | Since: 2025-01-15
272    /// Topics: billing, DeFi, staking
273    /// Sentiment: positive
274    ///
275    /// Last 5 interactions:
276    /// - user: How do I stake ETH?
277    /// - assistant: You can stake ETH via ...
278    /// ```
279    ///
280    /// `max_tokens` is an approximate character budget (1 token ~ 4 chars).
281    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        // Determine how many turns we can fit
311        let header_len = result.len();
312        let remaining = max_chars.saturating_sub(header_len + 30); // 30 chars for the section heading
313
314        // Fetch recent turns, starting with 10 and trimming if needed
315        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        // Reverse to chronological order
340        included.reverse();
341        for line in &included {
342            result.push_str(line);
343        }
344
345        result
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Internal helpers
351// ---------------------------------------------------------------------------
352
353/// Truncate a string to `max_len` characters, appending "..." if truncated.
354fn 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
364/// Simple stopword list for topic extraction.
365const 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
378/// Extract the most frequent non-stopword tokens from conversation content.
379/// Returns up to 10 topics sorted by frequency (descending).
380fn 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
402/// Infer overall sentiment from metadata tags or simple keyword heuristics.
403///
404/// If turns carry a "sentiment" metadata key, we tally positive/negative/neutral.
405/// Otherwise, falls back to a naive keyword scan.
406fn 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        // Check metadata first
449        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        // Fallback: keyword scan on user turns
459        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// ===========================================================================
484// Tests
485// ===========================================================================
486
487#[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    // -----------------------------------------------------------------------
504    // ConversationTurn basic tests
505    // -----------------------------------------------------------------------
506
507    #[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    // -----------------------------------------------------------------------
538    // ConversationMemory tests
539    // -----------------------------------------------------------------------
540
541    #[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        // Should be the last 3 turns
565        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        // Both the user question and assistant reply contain "stake"
617        assert_eq!(results.len(), 2);
618        assert!(results
619            .iter()
620            .all(|r| r.content.to_lowercase().contains("stake")));
621
622        // "billing" only appears once
623        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    // -----------------------------------------------------------------------
678    // CustomerProfile tests
679    // -----------------------------------------------------------------------
680
681    #[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    // -----------------------------------------------------------------------
756    // ConversationSummarizer tests
757    // -----------------------------------------------------------------------
758
759    #[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        // Very small token budget
805        let ctx = ConversationSummarizer::build_context(&mem, "c1", 100).await;
806        // Should have some content but be truncated
807        assert!(ctx.contains("[Customer History]"));
808        // With 100 tokens * 4 = 400 chars, not all 20 turns should fit
809        assert!(ctx.len() < 2000);
810    }
811
812    // -----------------------------------------------------------------------
813    // Helper function tests
814    // -----------------------------------------------------------------------
815
816    #[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        // Stopwords should be filtered; "blockchain" should remain
857        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    // -----------------------------------------------------------------------
875    // Thread safety / multi-customer tests
876    // -----------------------------------------------------------------------
877
878    #[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}