1use std::any::Any;
28use std::sync::Arc;
29use std::time::Duration;
30
31use async_trait::async_trait;
32
33use crate::error::Result;
34use crate::processor::frame::{CustomFrame, Frame, FrameClass};
35use crate::processor::{Envelope, FrameProcessor, Link};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Classification {
40 Conversation,
42 Voicemail,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DecisionReason {
49 GreetingPhrase,
51 LongFirstTurn,
53 HumanResponse,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct VoicemailDecision {
61 pub classification: Classification,
63 pub reason: DecisionReason,
65}
66
67impl CustomFrame for VoicemailDecision {
68 fn frame_class(&self) -> FrameClass {
69 FrameClass::Control
72 }
73
74 fn name(&self) -> &'static str {
75 "VoicemailDecision"
76 }
77
78 fn as_any(&self) -> &dyn Any {
79 self
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct VoicemailParams {
86 pub long_first_turn: Duration,
88 pub long_first_turn_words: usize,
91}
92
93impl Default for VoicemailParams {
94 fn default() -> Self {
95 Self {
96 long_first_turn: Duration::from_secs(5),
99 long_first_turn_words: 12,
100 }
101 }
102}
103
104const VOICEMAIL_PHRASES: &[&str] = &[
108 "please leave a message",
109 "leave a message",
110 "leave your name and number",
111 "leave your message after",
112 "after the tone",
113 "after the beep",
114 "you've reached",
115 "you have reached",
116 "the person you are trying to reach",
117 "is not available",
118 "not available right now",
119 "i'm not available",
120 "is unavailable",
121 "the number you have dialed",
122 "the number you dialed",
123 "is not in service",
124 "the mailbox",
125 "mailbox is full",
126 "has not been set up",
127 "voicemail",
128 "voice mail",
129 "all circuits are busy",
130 "record your message",
131 "i'll get back to you",
132 "call me back",
133 "our office is currently closed",
134 "please record",
135 "at the tone",
136];
137
138pub struct VoicemailDetector {
141 name: &'static str,
142 params: VoicemailParams,
143 transcript: String,
144 turn_started_secs: Option<f32>,
146 first_turn: bool,
147 decided: bool,
148}
149
150impl VoicemailDetector {
151 pub fn new(params: VoicemailParams) -> Self {
153 Self {
154 name: "voicemail-detector",
155 params,
156 transcript: String::new(),
157 turn_started_secs: None,
158 first_turn: true,
159 decided: false,
160 }
161 }
162
163 pub fn with_defaults() -> Self {
165 Self::new(VoicemailParams::default())
166 }
167
168 pub fn classify_transcript(transcript: &str) -> Option<DecisionReason> {
171 let lower = transcript.to_lowercase();
172 if VOICEMAIL_PHRASES.iter().any(|p| lower.contains(p)) {
173 Some(DecisionReason::GreetingPhrase)
174 } else {
175 None
176 }
177 }
178
179 fn decide(&mut self, classification: Classification, reason: DecisionReason) -> Frame {
181 self.decided = true;
182 Frame::Custom(Arc::new(VoicemailDecision {
183 classification,
184 reason,
185 }))
186 }
187}
188
189#[async_trait]
190impl FrameProcessor for VoicemailDetector {
191 fn name(&self) -> &str {
192 self.name
193 }
194
195 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
196 if self.decided {
197 link.push(env.meta, env.frame, env.direction).await;
199 return Ok(());
200 }
201
202 let mut decision: Option<Frame> = None;
203
204 match &env.frame {
205 Frame::Transcription { text, .. } | Frame::InterimTranscription { text, .. } => {
206 if !self.transcript.is_empty() {
207 self.transcript.push(' ');
208 }
209 self.transcript.push_str(text);
210 if let Some(reason) = Self::classify_transcript(&self.transcript) {
211 decision = Some(self.decide(Classification::Voicemail, reason));
212 } else if self.first_turn
213 && self.transcript.split_whitespace().count()
214 >= self.params.long_first_turn_words
215 {
216 decision =
218 Some(self.decide(Classification::Voicemail, DecisionReason::LongFirstTurn));
219 }
220 }
221 Frame::VadUserStartedSpeaking { start_secs } => {
222 if self.turn_started_secs.is_none() {
223 self.turn_started_secs = Some(*start_secs);
224 }
225 }
226 Frame::VadUserStoppedSpeaking { stop_secs } => {
227 if let Some(start) = self.turn_started_secs.take() {
229 let dur = Duration::from_secs_f32((*stop_secs - start).max(0.0));
230 if self.first_turn && dur >= self.params.long_first_turn {
231 decision = Some(
232 self.decide(Classification::Voicemail, DecisionReason::LongFirstTurn),
233 );
234 }
235 }
236 if decision.is_none() && self.first_turn {
239 decision = Some(
240 self.decide(Classification::Conversation, DecisionReason::HumanResponse),
241 );
242 }
243 self.first_turn = false;
244 }
245 _ => {}
246 }
247
248 let direction = env.direction;
250 link.push(env.meta, env.frame, direction).await;
251 if let Some(d) = decision {
252 link.push_down(d).await;
253 }
254 Ok(())
255 }
256}
257
258pub fn as_voicemail_decision(frame: &Frame) -> Option<&VoicemailDecision> {
260 match frame {
261 Frame::Custom(c) => c.as_any().downcast_ref::<VoicemailDecision>(),
262 _ => None,
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::agent::test_harness::drive;
270 use crate::processor::frame::Direction;
271
272 fn transcription(text: &str) -> Frame {
273 Frame::Transcription {
274 text: text.into(),
275 user_id: Arc::from("far-end"),
276 language: None,
277 final_: true,
278 }
279 }
280
281 #[test]
282 fn classify_transcript_matches_voicemail_phrases() {
283 assert_eq!(
284 VoicemailDetector::classify_transcript(
285 "Hi, you've reached Jane, please leave a message"
286 ),
287 Some(DecisionReason::GreetingPhrase)
288 );
289 assert_eq!(
290 VoicemailDetector::classify_transcript("The number you have dialed is not in service"),
291 Some(DecisionReason::GreetingPhrase)
292 );
293 assert_eq!(
294 VoicemailDetector::classify_transcript("Hello? Who is this?"),
295 None
296 );
297 }
298
299 #[tokio::test]
300 async fn detects_voicemail_from_greeting_phrase() {
301 let out = drive(
302 Box::new(VoicemailDetector::with_defaults()),
303 vec![transcription(
304 "Hi, you've reached the Smiths. Please leave a message after the beep.",
305 )],
306 Direction::Downstream,
307 )
308 .await;
309 let decision = out
310 .iter()
311 .find_map(as_voicemail_decision)
312 .expect("no decision emitted");
313 assert_eq!(decision.classification, Classification::Voicemail);
314 assert_eq!(decision.reason, DecisionReason::GreetingPhrase);
315 }
316
317 #[tokio::test]
318 async fn classifies_human_on_short_first_turn() {
319 let out = drive(
321 Box::new(VoicemailDetector::with_defaults()),
322 vec![
323 Frame::VadUserStartedSpeaking { start_secs: 0.0 },
324 transcription("Hello?"),
325 Frame::VadUserStoppedSpeaking { stop_secs: 0.3 },
326 ],
327 Direction::Downstream,
328 )
329 .await;
330 let decision = out
331 .iter()
332 .find_map(as_voicemail_decision)
333 .expect("no decision emitted");
334 assert_eq!(decision.classification, Classification::Conversation);
335 assert_eq!(decision.reason, DecisionReason::HumanResponse);
336 }
337
338 #[tokio::test]
339 async fn detects_voicemail_from_long_first_turn_vad() {
340 let out = drive(
342 Box::new(VoicemailDetector::with_defaults()),
343 vec![
344 Frame::VadUserStartedSpeaking { start_secs: 0.0 },
345 Frame::VadUserStoppedSpeaking { stop_secs: 6.0 },
346 ],
347 Direction::Downstream,
348 )
349 .await;
350 let decision = out
351 .iter()
352 .find_map(as_voicemail_decision)
353 .expect("no decision emitted");
354 assert_eq!(decision.classification, Classification::Voicemail);
355 assert_eq!(decision.reason, DecisionReason::LongFirstTurn);
356 }
357
358 #[tokio::test]
359 async fn decides_only_once_then_forwards() {
360 let out = drive(
361 Box::new(VoicemailDetector::with_defaults()),
362 vec![
363 transcription("please leave a message"), transcription("you've reached"), ],
366 Direction::Downstream,
367 )
368 .await;
369 let decisions = out.iter().filter_map(as_voicemail_decision).count();
370 assert_eq!(decisions, 1, "detector must decide exactly once");
371 let transcripts = out
373 .iter()
374 .filter(|f| matches!(f, Frame::Transcription { .. }))
375 .count();
376 assert_eq!(transcripts, 2);
377 }
378}