Skip to main content

flowcat_core/agent/
voicemail.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Voicemail / answering-machine detection (pure logic, no network).
4//!
5//! pipecat's `VoicemailDetector` is a *parallel LLM classifier* (it asks a text
6//! LLM "CONVERSATION or VOICEMAIL?"). That needs a network LLM, so it is out of
7//! this pure-logic scope. The flowcat detector is the **heuristic** half:
8//! a [`FrameProcessor`] that classifies the far end from the existing frame
9//! stream alone, with two signals (either fires a decision):
10//!
11//! 1. **Phrase heuristic** — accumulate the far-end transcript
12//!    ([`Frame::Transcription`]/[`Frame::InterimTranscription`]) and match it
13//!    against a curated set of voicemail-greeting phrases (ported from pipecat's
14//!    `VoicemailDetector.DEFAULT_SYSTEM_PROMPT` VOICEMAIL list — "please leave a
15//!    message", "you've reached", "not available", …).
16//! 2. **Long-speech VAD heuristic** — if the *first* turn is one long
17//!    uninterrupted stretch of speech (a [`Frame::VadUserStartedSpeaking`] with no
18//!    stop for longer than a threshold, or a single turn whose transcript exceeds
19//!    a word count), it is far more likely a recorded greeting than a human's
20//!    "Hello?" — mirrors pipecat's `long_speech_timeout` early-trigger.
21//!
22//! A decision is emitted **once** as a [`Frame::Custom`] carrying a
23//! [`VoicemailDecision`] (no `Frame` variant is promoted at this layer); the
24//! downstream brain downcasts it. The detector then
25//! goes inert (it never re-decides) and forwards every frame unchanged.
26
27use 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/// The classification the detector reaches.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Classification {
40    /// A live human answered.
41    Conversation,
42    /// The call reached a voicemail / answering machine / carrier recording.
43    Voicemail,
44}
45
46/// Why the detector decided as it did (for tracing / brain logic).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DecisionReason {
49    /// A voicemail greeting phrase was matched in the transcript.
50    GreetingPhrase,
51    /// The first turn was an unusually long uninterrupted stretch of speech.
52    LongFirstTurn,
53    /// No voicemail signal was seen by the time the user stopped speaking.
54    HumanResponse,
55}
56
57/// The decision frame the detector emits (carried in [`Frame::Custom`]). The
58/// brain downcasts this via [`CustomFrame::as_any`].
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct VoicemailDecision {
61    /// The classification reached.
62    pub classification: Classification,
63    /// Why (which heuristic fired).
64    pub reason: DecisionReason,
65}
66
67impl CustomFrame for VoicemailDecision {
68    fn frame_class(&self) -> FrameClass {
69        // A control-plane signal for the brain — ordered, survives nothing
70        // special; Control is the closest match.
71        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/// Tunables for [`VoicemailDetector`].
84#[derive(Debug, Clone)]
85pub struct VoicemailParams {
86    /// First-turn speech longer than this is treated as a recorded greeting.
87    pub long_first_turn: Duration,
88    /// A first-turn transcript with at least this many words is treated as a
89    /// greeting even before the turn ends (a belt-and-braces word-count signal).
90    pub long_first_turn_words: usize,
91}
92
93impl Default for VoicemailParams {
94    fn default() -> Self {
95        Self {
96            // A human's first utterance ("Hello?", "Who's this?") is short; a
97            // recorded greeting runs for seconds.
98            long_first_turn: Duration::from_secs(5),
99            long_first_turn_words: 12,
100        }
101    }
102}
103
104/// The curated voicemail-greeting phrases (lowercased), ported from pipecat's
105/// `VoicemailDetector.DEFAULT_SYSTEM_PROMPT` VOICEMAIL bullet list. A match in the
106/// accumulated transcript classifies the call as voicemail.
107const 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
138/// Heuristic voicemail detector (see module docs). Emits a single
139/// [`VoicemailDecision`] then forwards everything unchanged.
140pub struct VoicemailDetector {
141    name: &'static str,
142    params: VoicemailParams,
143    transcript: String,
144    /// Set on the first `VadUserStartedSpeaking`; cleared on stop.
145    turn_started_secs: Option<f32>,
146    first_turn: bool,
147    decided: bool,
148}
149
150impl VoicemailDetector {
151    /// A detector with the given params.
152    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    /// A detector with default params.
164    pub fn with_defaults() -> Self {
165        Self::new(VoicemailParams::default())
166    }
167
168    /// Pure classifier over an accumulated transcript: matches against the
169    /// voicemail phrase set (exposed for unit testing without the frame loop).
170    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    /// Build the decision frame and mark the detector inert.
180    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            // Inert: forward unchanged.
198            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                    // A long first-turn transcript with no human cue → greeting.
217                    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                // Evaluate the just-finished turn's duration.
228                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 the first turn ended without any voicemail signal, the far end
237                // behaved like a human (short turn) → classify conversation.
238                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        // Forward the original frame first (the decision is sideband signalling).
249        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
258/// Downcast helper: pull a [`VoicemailDecision`] out of a [`Frame::Custom`].
259pub 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        // VAD edges with a short turn (0.3s) and a non-voicemail transcript.
320        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        // A long uninterrupted first turn (6s) with no human cue → voicemail.
341        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"), // decides voicemail
364                transcription("you've reached"),         // must NOT decide again
365            ],
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        // Both original transcription frames still forwarded.
372        let transcripts = out
373            .iter()
374            .filter(|f| matches!(f, Frame::Transcription { .. }))
375            .count();
376        assert_eq!(transcripts, 2);
377    }
378}