use std::any::Any;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use crate::error::Result;
use crate::processor::frame::{CustomFrame, Frame, FrameClass};
use crate::processor::{Envelope, FrameProcessor, Link};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Classification {
Conversation,
Voicemail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionReason {
GreetingPhrase,
LongFirstTurn,
HumanResponse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoicemailDecision {
pub classification: Classification,
pub reason: DecisionReason,
}
impl CustomFrame for VoicemailDecision {
fn frame_class(&self) -> FrameClass {
FrameClass::Control
}
fn name(&self) -> &'static str {
"VoicemailDecision"
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Debug, Clone)]
pub struct VoicemailParams {
pub long_first_turn: Duration,
pub long_first_turn_words: usize,
}
impl Default for VoicemailParams {
fn default() -> Self {
Self {
long_first_turn: Duration::from_secs(5),
long_first_turn_words: 12,
}
}
}
const VOICEMAIL_PHRASES: &[&str] = &[
"please leave a message",
"leave a message",
"leave your name and number",
"leave your message after",
"after the tone",
"after the beep",
"you've reached",
"you have reached",
"the person you are trying to reach",
"is not available",
"not available right now",
"i'm not available",
"is unavailable",
"the number you have dialed",
"the number you dialed",
"is not in service",
"the mailbox",
"mailbox is full",
"has not been set up",
"voicemail",
"voice mail",
"all circuits are busy",
"record your message",
"i'll get back to you",
"call me back",
"our office is currently closed",
"please record",
"at the tone",
];
pub struct VoicemailDetector {
name: &'static str,
params: VoicemailParams,
transcript: String,
turn_started_secs: Option<f32>,
first_turn: bool,
decided: bool,
}
impl VoicemailDetector {
pub fn new(params: VoicemailParams) -> Self {
Self {
name: "voicemail-detector",
params,
transcript: String::new(),
turn_started_secs: None,
first_turn: true,
decided: false,
}
}
pub fn with_defaults() -> Self {
Self::new(VoicemailParams::default())
}
pub fn classify_transcript(transcript: &str) -> Option<DecisionReason> {
let lower = transcript.to_lowercase();
if VOICEMAIL_PHRASES.iter().any(|p| lower.contains(p)) {
Some(DecisionReason::GreetingPhrase)
} else {
None
}
}
fn decide(&mut self, classification: Classification, reason: DecisionReason) -> Frame {
self.decided = true;
Frame::Custom(Arc::new(VoicemailDecision {
classification,
reason,
}))
}
}
#[async_trait]
impl FrameProcessor for VoicemailDetector {
fn name(&self) -> &str {
self.name
}
async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
if self.decided {
link.push(env.meta, env.frame, env.direction).await;
return Ok(());
}
let mut decision: Option<Frame> = None;
match &env.frame {
Frame::Transcription { text, .. } | Frame::InterimTranscription { text, .. } => {
if !self.transcript.is_empty() {
self.transcript.push(' ');
}
self.transcript.push_str(text);
if let Some(reason) = Self::classify_transcript(&self.transcript) {
decision = Some(self.decide(Classification::Voicemail, reason));
} else if self.first_turn
&& self.transcript.split_whitespace().count()
>= self.params.long_first_turn_words
{
decision =
Some(self.decide(Classification::Voicemail, DecisionReason::LongFirstTurn));
}
}
Frame::VadUserStartedSpeaking { start_secs } => {
if self.turn_started_secs.is_none() {
self.turn_started_secs = Some(*start_secs);
}
}
Frame::VadUserStoppedSpeaking { stop_secs } => {
if let Some(start) = self.turn_started_secs.take() {
let dur = Duration::from_secs_f32((*stop_secs - start).max(0.0));
if self.first_turn && dur >= self.params.long_first_turn {
decision = Some(
self.decide(Classification::Voicemail, DecisionReason::LongFirstTurn),
);
}
}
if decision.is_none() && self.first_turn {
decision = Some(
self.decide(Classification::Conversation, DecisionReason::HumanResponse),
);
}
self.first_turn = false;
}
_ => {}
}
let direction = env.direction;
link.push(env.meta, env.frame, direction).await;
if let Some(d) = decision {
link.push_down(d).await;
}
Ok(())
}
}
pub fn as_voicemail_decision(frame: &Frame) -> Option<&VoicemailDecision> {
match frame {
Frame::Custom(c) => c.as_any().downcast_ref::<VoicemailDecision>(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::test_harness::drive;
use crate::processor::frame::Direction;
fn transcription(text: &str) -> Frame {
Frame::Transcription {
text: text.into(),
user_id: Arc::from("far-end"),
language: None,
final_: true,
}
}
#[test]
fn classify_transcript_matches_voicemail_phrases() {
assert_eq!(
VoicemailDetector::classify_transcript(
"Hi, you've reached Jane, please leave a message"
),
Some(DecisionReason::GreetingPhrase)
);
assert_eq!(
VoicemailDetector::classify_transcript("The number you have dialed is not in service"),
Some(DecisionReason::GreetingPhrase)
);
assert_eq!(
VoicemailDetector::classify_transcript("Hello? Who is this?"),
None
);
}
#[tokio::test]
async fn detects_voicemail_from_greeting_phrase() {
let out = drive(
Box::new(VoicemailDetector::with_defaults()),
vec![transcription(
"Hi, you've reached the Smiths. Please leave a message after the beep.",
)],
Direction::Downstream,
)
.await;
let decision = out
.iter()
.find_map(as_voicemail_decision)
.expect("no decision emitted");
assert_eq!(decision.classification, Classification::Voicemail);
assert_eq!(decision.reason, DecisionReason::GreetingPhrase);
}
#[tokio::test]
async fn classifies_human_on_short_first_turn() {
let out = drive(
Box::new(VoicemailDetector::with_defaults()),
vec![
Frame::VadUserStartedSpeaking { start_secs: 0.0 },
transcription("Hello?"),
Frame::VadUserStoppedSpeaking { stop_secs: 0.3 },
],
Direction::Downstream,
)
.await;
let decision = out
.iter()
.find_map(as_voicemail_decision)
.expect("no decision emitted");
assert_eq!(decision.classification, Classification::Conversation);
assert_eq!(decision.reason, DecisionReason::HumanResponse);
}
#[tokio::test]
async fn detects_voicemail_from_long_first_turn_vad() {
let out = drive(
Box::new(VoicemailDetector::with_defaults()),
vec![
Frame::VadUserStartedSpeaking { start_secs: 0.0 },
Frame::VadUserStoppedSpeaking { stop_secs: 6.0 },
],
Direction::Downstream,
)
.await;
let decision = out
.iter()
.find_map(as_voicemail_decision)
.expect("no decision emitted");
assert_eq!(decision.classification, Classification::Voicemail);
assert_eq!(decision.reason, DecisionReason::LongFirstTurn);
}
#[tokio::test]
async fn decides_only_once_then_forwards() {
let out = drive(
Box::new(VoicemailDetector::with_defaults()),
vec![
transcription("please leave a message"), transcription("you've reached"), ],
Direction::Downstream,
)
.await;
let decisions = out.iter().filter_map(as_voicemail_decision).count();
assert_eq!(decisions, 1, "detector must decide exactly once");
let transcripts = out
.iter()
.filter(|f| matches!(f, Frame::Transcription { .. }))
.count();
assert_eq!(transcripts, 2);
}
}