Skip to main content

tokio_prompt_orchestrator/
conversation_analyzer.rs

1//! Conversation quality and engagement analysis.
2//!
3//! Provides metrics for analysing multi-turn conversations: turn-level
4//! statistics, topic drift detection, engagement scoring, and overall quality
5//! assessment.
6
7use std::collections::{HashMap, HashSet};
8
9// ---------------------------------------------------------------------------
10// Stopwords
11// ---------------------------------------------------------------------------
12
13fn stopwords() -> HashSet<&'static str> {
14    [
15        "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
16        "of", "with", "by", "from", "is", "it", "its", "be", "was", "are",
17        "were", "been", "has", "have", "had", "do", "does", "did", "will",
18        "would", "could", "should", "may", "might", "shall", "can", "this",
19        "that", "these", "those", "i", "me", "my", "we", "our", "you", "your",
20        "he", "she", "they", "them", "his", "her", "their", "what", "which",
21        "who", "whom", "not", "no", "so", "if", "as", "up", "out", "about",
22        "into", "than", "then", "there", "when", "where", "how", "all", "also",
23        "just", "more", "very", "s", "t", "re", "ll", "ve", "d", "m",
24    ]
25    .iter()
26    .copied()
27    .collect()
28}
29
30// ---------------------------------------------------------------------------
31// Structs and enums
32// ---------------------------------------------------------------------------
33
34/// Per-turn metrics for a single conversation turn.
35#[derive(Debug, Clone)]
36pub struct TurnMetrics {
37    /// Role of the speaker (e.g. `"user"` or `"assistant"`).
38    pub role: String,
39    /// Number of words in the turn.
40    pub word_count: usize,
41    /// Number of characters in the turn.
42    pub char_count: usize,
43    /// Number of interrogative patterns detected.
44    pub question_count: usize,
45    /// Time from the previous turn to this turn in milliseconds, if known.
46    pub response_time_ms: Option<u64>,
47    /// Sentiment score in the range `[-1.0, 1.0]`.
48    pub sentiment_score: f64,
49    /// Top keywords extracted from this turn.
50    pub topic_keywords: Vec<String>,
51}
52
53/// Represents a detected topic drift between two consecutive windows of turns.
54#[derive(Debug, Clone)]
55pub struct TopicDrift {
56    /// Topic label of the window before the drift.
57    pub from_topic: String,
58    /// Topic label of the window after the drift.
59    pub to_topic: String,
60    /// Index of the first turn in the later window.
61    pub turn_index: usize,
62    /// Jaccard distance (1 − similarity). Higher means more drift.
63    pub drift_score: f64,
64}
65
66/// Aggregate engagement metrics for a whole conversation.
67#[derive(Debug, Clone)]
68pub struct EngagementMetrics {
69    /// Total number of turns.
70    pub total_turns: usize,
71    /// Average word count per turn.
72    pub avg_response_length: f64,
73    /// Ratio of questions to total turns.
74    pub question_answer_ratio: f64,
75    /// Average Jaccard similarity between consecutive turns' keywords.
76    pub topic_coherence: f64,
77    /// Mean sentiment score across all turns.
78    pub avg_sentiment: f64,
79    /// Most frequent keywords across the conversation.
80    pub dominant_topics: Vec<String>,
81    /// Composite user engagement score in `[0.0, 1.0]`.
82    pub user_engagement_score: f64,
83}
84
85/// High-level quality assessment for a conversation.
86#[derive(Debug, Clone)]
87pub struct ConversationQuality {
88    /// How well-connected the topics are across turns.
89    pub coherence: f64,
90    /// Depth of content (approximated by average length and question density).
91    pub depth: f64,
92    /// Breadth of topics covered (unique keyword ratio).
93    pub breadth: f64,
94    /// Fraction of questions that appear to receive a follow-up response.
95    pub resolution_rate: f64,
96    /// Weighted combination of the other scores.
97    pub overall_score: f64,
98}
99
100// ---------------------------------------------------------------------------
101// ConversationAnalyzer
102// ---------------------------------------------------------------------------
103
104/// Stateless analyser for conversation quality and engagement.
105pub struct ConversationAnalyzer;
106
107impl ConversationAnalyzer {
108    /// Create a new `ConversationAnalyzer`.
109    pub fn new() -> Self {
110        Self
111    }
112
113    // -----------------------------------------------------------------------
114    // Public API
115    // -----------------------------------------------------------------------
116
117    /// Analyse a single turn and return its metrics.
118    ///
119    /// `prev_text` is the text of the immediately preceding turn and is used
120    /// only for future response-time calculations; the caller is responsible
121    /// for supplying `response_time_ms` externally if known.
122    pub fn analyze_turn(
123        &self,
124        text: &str,
125        role: &str,
126        _prev_text: Option<&str>,
127    ) -> TurnMetrics {
128        let words: Vec<&str> = text.split_whitespace().collect();
129        let word_count = words.len();
130        let char_count = text.len();
131        let question_count = self.identify_questions(text);
132        let sentiment_score = self.sentiment_score(text);
133        let topic_keywords = self.extract_keywords(text, 8);
134
135        TurnMetrics {
136            role: role.to_string(),
137            word_count,
138            char_count,
139            question_count,
140            response_time_ms: None,
141            sentiment_score,
142            topic_keywords,
143        }
144    }
145
146    /// Extract the top `top_n` keywords from `text` using TF weighting with
147    /// stopword removal.
148    pub fn extract_keywords(&self, text: &str, top_n: usize) -> Vec<String> {
149        let stops = stopwords();
150        let mut freq: HashMap<String, usize> = HashMap::new();
151
152        for word in text.split_whitespace() {
153            let clean: String = word
154                .chars()
155                .filter(|c| c.is_alphabetic())
156                .collect::<String>()
157                .to_lowercase();
158            if clean.len() > 2 && !stops.contains(clean.as_str()) {
159                *freq.entry(clean).or_insert(0) += 1;
160            }
161        }
162
163        let mut pairs: Vec<(String, usize)> = freq.into_iter().collect();
164        pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
165        pairs.into_iter().take(top_n).map(|(k, _)| k).collect()
166    }
167
168    /// Detect topic drift across `turns` using a sliding window of size
169    /// `window`.  Returns one [`TopicDrift`] entry per window boundary where
170    /// drift is detected (Jaccard similarity < 0.5).
171    pub fn detect_topic_drift(
172        &self,
173        turns: &[TurnMetrics],
174        window: usize,
175    ) -> Vec<TopicDrift> {
176        if turns.len() < window * 2 || window == 0 {
177            return Vec::new();
178        }
179
180        let mut drifts = Vec::new();
181
182        for i in window..turns.len() {
183            let prev_window = &turns[i.saturating_sub(window)..i];
184            let curr_start = i;
185            let curr_end = (i + window).min(turns.len());
186            let curr_window = &turns[curr_start..curr_end];
187
188            let prev_kw: HashSet<String> = prev_window
189                .iter()
190                .flat_map(|t| t.topic_keywords.iter().cloned())
191                .collect();
192            let curr_kw: HashSet<String> = curr_window
193                .iter()
194                .flat_map(|t| t.topic_keywords.iter().cloned())
195                .collect();
196
197            let similarity = jaccard_similarity(&prev_kw, &curr_kw);
198            let drift_score = 1.0 - similarity;
199
200            if drift_score > 0.5 {
201                let from_topic = prev_kw
202                    .iter()
203                    .next()
204                    .cloned()
205                    .unwrap_or_else(|| "unknown".to_string());
206                let to_topic = curr_kw
207                    .iter()
208                    .next()
209                    .cloned()
210                    .unwrap_or_else(|| "unknown".to_string());
211
212                drifts.push(TopicDrift {
213                    from_topic,
214                    to_topic,
215                    turn_index: i,
216                    drift_score,
217                });
218            }
219        }
220
221        drifts
222    }
223
224    /// Compute aggregate engagement metrics across all `turns`.
225    pub fn compute_engagement(&self, turns: &[TurnMetrics]) -> EngagementMetrics {
226        if turns.is_empty() {
227            return EngagementMetrics {
228                total_turns: 0,
229                avg_response_length: 0.0,
230                question_answer_ratio: 0.0,
231                topic_coherence: 0.0,
232                avg_sentiment: 0.0,
233                dominant_topics: Vec::new(),
234                user_engagement_score: 0.0,
235            };
236        }
237
238        let total_turns = turns.len();
239        let avg_response_length =
240            turns.iter().map(|t| t.word_count as f64).sum::<f64>() / total_turns as f64;
241        let total_questions: usize = turns.iter().map(|t| t.question_count).sum();
242        let question_answer_ratio = total_questions as f64 / total_turns as f64;
243        let topic_coherence = self.topic_coherence(turns);
244        let avg_sentiment =
245            turns.iter().map(|t| t.sentiment_score).sum::<f64>() / total_turns as f64;
246        let dominant_topics = self.dominant_topics(turns, 5);
247
248        // Engagement score: blend of length, coherence, and question density.
249        let length_score = (avg_response_length / 100.0).min(1.0);
250        let coherence_score = topic_coherence;
251        let question_score = (question_answer_ratio / 2.0).min(1.0);
252        let user_engagement_score =
253            (length_score * 0.4 + coherence_score * 0.4 + question_score * 0.2).min(1.0);
254
255        EngagementMetrics {
256            total_turns,
257            avg_response_length,
258            question_answer_ratio,
259            topic_coherence,
260            avg_sentiment,
261            dominant_topics,
262            user_engagement_score,
263        }
264    }
265
266    /// Assess the overall quality of a conversation.
267    pub fn assess_quality(
268        &self,
269        turns: &[TurnMetrics],
270        has_resolution: bool,
271    ) -> ConversationQuality {
272        if turns.is_empty() {
273            return ConversationQuality {
274                coherence: 0.0,
275                depth: 0.0,
276                breadth: 0.0,
277                resolution_rate: 0.0,
278                overall_score: 0.0,
279            };
280        }
281
282        let coherence = self.topic_coherence(turns);
283
284        let avg_words =
285            turns.iter().map(|t| t.word_count as f64).sum::<f64>() / turns.len() as f64;
286        let total_questions: usize = turns.iter().map(|t| t.question_count).sum();
287        let question_density = total_questions as f64 / turns.len() as f64;
288        let depth = ((avg_words / 150.0) * 0.7 + (question_density / 2.0) * 0.3).min(1.0);
289
290        let all_keywords: HashSet<String> = turns
291            .iter()
292            .flat_map(|t| t.topic_keywords.iter().cloned())
293            .collect();
294        let total_keywords: usize = turns.iter().map(|t| t.topic_keywords.len()).sum();
295        let breadth = if total_keywords > 0 {
296            (all_keywords.len() as f64 / total_keywords as f64).min(1.0)
297        } else {
298            0.0
299        };
300
301        let resolution_rate = if has_resolution { 1.0 } else { 0.0 };
302
303        let overall_score = coherence * 0.3 + depth * 0.3 + breadth * 0.2 + resolution_rate * 0.2;
304
305        ConversationQuality {
306            coherence,
307            depth,
308            breadth,
309            resolution_rate,
310            overall_score,
311        }
312    }
313
314    /// Compute a simple sentiment score in `[-1.0, 1.0]` by counting positive
315    /// and negative words.
316    pub fn sentiment_score(&self, text: &str) -> f64 {
317        let positive = [
318            "good", "great", "excellent", "amazing", "wonderful", "fantastic",
319            "helpful", "love", "like", "best", "perfect", "happy", "glad",
320            "pleased", "superb", "outstanding", "brilliant", "thank", "thanks",
321            "appreciate", "useful", "clear", "nice", "positive", "correct",
322        ];
323        let negative = [
324            "bad", "terrible", "awful", "horrible", "worst", "hate", "dislike",
325            "wrong", "error", "fail", "failed", "failure", "poor", "broken",
326            "confusing", "confused", "unclear", "problem", "issue", "bug",
327            "difficult", "hard", "annoying", "frustrating", "useless",
328        ];
329
330        let lower = text.to_lowercase();
331        let words: Vec<&str> = lower.split_whitespace().collect();
332        let total = words.len() as f64;
333
334        if total == 0.0 {
335            return 0.0;
336        }
337
338        let pos_count = words.iter().filter(|w| positive.contains(*w)).count() as f64;
339        let neg_count = words.iter().filter(|w| negative.contains(*w)).count() as f64;
340
341        ((pos_count - neg_count) / total * 10.0).clamp(-1.0, 1.0)
342    }
343
344    /// Compute average Jaccard similarity between consecutive turns' keyword
345    /// sets.  Returns `1.0` for single-turn conversations.
346    pub fn topic_coherence(&self, turns: &[TurnMetrics]) -> f64 {
347        if turns.len() < 2 {
348            return 1.0;
349        }
350
351        let mut total = 0.0;
352        let mut count = 0usize;
353
354        for pair in turns.windows(2) {
355            let a: HashSet<String> = pair[0].topic_keywords.iter().cloned().collect();
356            let b: HashSet<String> = pair[1].topic_keywords.iter().cloned().collect();
357            total += jaccard_similarity(&a, &b);
358            count += 1;
359        }
360
361        if count == 0 {
362            1.0
363        } else {
364            total / count as f64
365        }
366    }
367
368    /// Count interrogative patterns in `text`.
369    pub fn identify_questions(&self, text: &str) -> usize {
370        let lower = text.to_lowercase();
371        let mut count = 0usize;
372
373        // Count sentences ending with '?'
374        count += text.chars().filter(|&c| c == '?').count();
375
376        // Count interrogative starters (only if they don't already end with '?')
377        let starters = [
378            "what ", "why ", "how ", "when ", "where ", "who ", "which ",
379            "could you", "can you", "would you", "do you", "did you",
380            "have you", "is it", "are you", "is there",
381        ];
382        for line in lower.lines() {
383            let trimmed = line.trim();
384            if !trimmed.ends_with('?') {
385                for starter in &starters {
386                    if trimmed.starts_with(starter) {
387                        count += 1;
388                        break;
389                    }
390                }
391            }
392        }
393
394        count
395    }
396
397    /// Analyse a full conversation given as `(role, text)` pairs and return
398    /// engagement metrics, quality assessment, and detected topic drifts.
399    pub fn analyze_full(
400        &self,
401        conversation: &[(String, String)],
402    ) -> (EngagementMetrics, ConversationQuality, Vec<TopicDrift>) {
403        let turns: Vec<TurnMetrics> = conversation
404            .iter()
405            .enumerate()
406            .map(|(i, (role, text))| {
407                let prev = if i > 0 {
408                    Some(conversation[i - 1].1.as_str())
409                } else {
410                    None
411                };
412                self.analyze_turn(text, role, prev)
413            })
414            .collect();
415
416        let engagement = self.compute_engagement(&turns);
417        // Heuristic: assume the last turn resolves the conversation if the
418        // last speaker is the assistant.
419        let has_resolution = conversation
420            .last()
421            .map(|(role, _)| role == "assistant")
422            .unwrap_or(false);
423        let quality = self.assess_quality(&turns, has_resolution);
424        let drifts = self.detect_topic_drift(&turns, 2);
425
426        (engagement, quality, drifts)
427    }
428
429    // -----------------------------------------------------------------------
430    // Private helpers
431    // -----------------------------------------------------------------------
432
433    fn dominant_topics(&self, turns: &[TurnMetrics], top_n: usize) -> Vec<String> {
434        let mut freq: HashMap<String, usize> = HashMap::new();
435        for turn in turns {
436            for kw in &turn.topic_keywords {
437                *freq.entry(kw.clone()).or_insert(0) += 1;
438            }
439        }
440        let mut pairs: Vec<(String, usize)> = freq.into_iter().collect();
441        pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
442        pairs.into_iter().take(top_n).map(|(k, _)| k).collect()
443    }
444}
445
446impl Default for ConversationAnalyzer {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Free functions
454// ---------------------------------------------------------------------------
455
456fn jaccard_similarity(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
457    if a.is_empty() && b.is_empty() {
458        return 1.0;
459    }
460    let intersection = a.intersection(b).count() as f64;
461    let union = a.union(b).count() as f64;
462    if union == 0.0 {
463        1.0
464    } else {
465        intersection / union
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_sentiment_positive() {
475        let a = ConversationAnalyzer::new();
476        let score = a.sentiment_score("This is great and wonderful, thank you!");
477        assert!(score > 0.0, "expected positive sentiment, got {score}");
478    }
479
480    #[test]
481    fn test_sentiment_negative() {
482        let a = ConversationAnalyzer::new();
483        let score = a.sentiment_score("This is terrible and broken and awful");
484        assert!(score < 0.0, "expected negative sentiment, got {score}");
485    }
486
487    #[test]
488    fn test_identify_questions() {
489        let a = ConversationAnalyzer::new();
490        assert_eq!(a.identify_questions("How does this work? What is that?"), 2);
491    }
492
493    #[test]
494    fn test_extract_keywords() {
495        let a = ConversationAnalyzer::new();
496        let kw = a.extract_keywords("the quick brown fox jumps over the lazy dog", 3);
497        assert!(!kw.is_empty());
498    }
499
500    #[test]
501    fn test_analyze_full() {
502        let a = ConversationAnalyzer::new();
503        let conv = vec![
504            ("user".to_string(), "How do I install Rust?".to_string()),
505            (
506                "assistant".to_string(),
507                "You can install Rust using rustup. It is a great tool.".to_string(),
508            ),
509        ];
510        let (eng, qual, _drifts) = a.analyze_full(&conv);
511        assert_eq!(eng.total_turns, 2);
512        assert!(qual.overall_score >= 0.0);
513    }
514}