1use std::collections::{HashMap, HashSet};
8
9fn 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#[derive(Debug, Clone)]
36pub struct TurnMetrics {
37 pub role: String,
39 pub word_count: usize,
41 pub char_count: usize,
43 pub question_count: usize,
45 pub response_time_ms: Option<u64>,
47 pub sentiment_score: f64,
49 pub topic_keywords: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct TopicDrift {
56 pub from_topic: String,
58 pub to_topic: String,
60 pub turn_index: usize,
62 pub drift_score: f64,
64}
65
66#[derive(Debug, Clone)]
68pub struct EngagementMetrics {
69 pub total_turns: usize,
71 pub avg_response_length: f64,
73 pub question_answer_ratio: f64,
75 pub topic_coherence: f64,
77 pub avg_sentiment: f64,
79 pub dominant_topics: Vec<String>,
81 pub user_engagement_score: f64,
83}
84
85#[derive(Debug, Clone)]
87pub struct ConversationQuality {
88 pub coherence: f64,
90 pub depth: f64,
92 pub breadth: f64,
94 pub resolution_rate: f64,
96 pub overall_score: f64,
98}
99
100pub struct ConversationAnalyzer;
106
107impl ConversationAnalyzer {
108 pub fn new() -> Self {
110 Self
111 }
112
113 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 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 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 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 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 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 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 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 pub fn identify_questions(&self, text: &str) -> usize {
370 let lower = text.to_lowercase();
371 let mut count = 0usize;
372
373 count += text.chars().filter(|&c| c == '?').count();
375
376 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 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 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 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
452fn 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}