Skip to main content

flowcat_core/agent/
ivr.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! IVR-navigator state machine (pure logic, no network).
4//!
5//! pipecat's `IVRNavigator` drives menu traversal with an *LLM* (it asks a model
6//! which key to press). That needs a network LLM, so it is out of this pure-logic
7//! scope. The flowcat navigator is the **deterministic** half: a scripted
8//! state machine that matches each incoming transcript ([`Frame::Transcription`])
9//! against a configured target path and emits the matching action — DTMF
10//! ([`Frame::OutputDtmf`]) or a spoken reply ([`Frame::TtsSpeak`]) — to navigate
11//! the menu, advancing step by step until a terminal state.
12//!
13//! A [`Script`] is an ordered list of [`IvrStep`]s. Each step has prompt
14//! *triggers* (substrings to look for in the menu's transcript) and an
15//! [`IvrAction`] to take when triggered. After acting on a step the navigator
16//! advances to the next; when it runs off the end it reports
17//! [`IvrStatus::Completed`]. If a configured number of unmatched prompts pass with
18//! no progress it reports [`IvrStatus::Stuck`]. Status changes are emitted as a
19//! [`Frame::Custom`] carrying an [`IvrStatusChanged`] (no `Frame` variant is
20//! promoted at this layer).
21
22use std::any::Any;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::error::Result;
28use crate::processor::frame::{CustomFrame, Frame, FrameClass, KeypadEntry};
29use crate::processor::{Envelope, FrameProcessor, Link};
30
31/// The action a matched [`IvrStep`] takes.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum IvrAction {
34    /// Press these keypad digits in order (DTMF).
35    Dtmf(Vec<KeypadEntry>),
36    /// Speak this verbal response (for "Say yes or no"-style prompts).
37    Say(String),
38    /// No output — the step only marks that this prompt was reached (e.g. an
39    /// acknowledgement before the next menu).
40    Wait,
41}
42
43/// One step in an IVR [`Script`]: when any `triggers` substring appears in the
44/// menu transcript, take `action` and advance.
45#[derive(Debug, Clone)]
46pub struct IvrStep {
47    /// Case-insensitive substrings; any one present in the prompt fires the step.
48    pub triggers: Vec<String>,
49    /// What to do when this step's prompt is reached.
50    pub action: IvrAction,
51}
52
53impl IvrStep {
54    /// A step that presses `digits` when any of `triggers` is heard.
55    pub fn press(triggers: &[&str], digits: Vec<KeypadEntry>) -> Self {
56        Self {
57            triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
58            action: IvrAction::Dtmf(digits),
59        }
60    }
61
62    /// A step that speaks `reply` when any of `triggers` is heard.
63    pub fn say(triggers: &[&str], reply: impl Into<String>) -> Self {
64        Self {
65            triggers: triggers.iter().map(|s| s.to_lowercase()).collect(),
66            action: IvrAction::Say(reply.into()),
67        }
68    }
69
70    fn matches(&self, prompt_lower: &str) -> bool {
71        self.triggers.iter().any(|t| prompt_lower.contains(t))
72    }
73}
74
75/// An ordered IVR traversal plan.
76#[derive(Debug, Clone, Default)]
77pub struct Script {
78    steps: Vec<IvrStep>,
79    /// How many consecutive unmatched prompts before declaring [`IvrStatus::Stuck`].
80    stuck_after: usize,
81}
82
83impl Script {
84    /// A new script from `steps`, declaring "stuck" after `stuck_after`
85    /// consecutive prompts that match no remaining step.
86    pub fn new(steps: Vec<IvrStep>, stuck_after: usize) -> Self {
87        Self { steps, stuck_after }
88    }
89}
90
91/// IVR navigation status (mirrors pipecat `IVRStatus`, minus the LLM-only `Wait`
92/// which is handled inline as a no-op step).
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum IvrStatus {
95    /// Navigation finished — every step in the path was taken.
96    Completed,
97    /// No remaining step matched after `stuck_after` prompts.
98    Stuck,
99}
100
101/// Status-change signal emitted as a [`Frame::Custom`].
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct IvrStatusChanged {
104    /// The new status.
105    pub status: IvrStatus,
106}
107
108impl CustomFrame for IvrStatusChanged {
109    fn frame_class(&self) -> FrameClass {
110        FrameClass::Control
111    }
112    fn name(&self) -> &'static str {
113        "IvrStatusChanged"
114    }
115    fn as_any(&self) -> &dyn Any {
116        self
117    }
118}
119
120/// A scripted IVR navigator (see module docs). Stateless across calls but holds
121/// per-call traversal position.
122pub struct IvrNavigator {
123    name: &'static str,
124    script: Script,
125    cursor: usize,
126    unmatched_streak: usize,
127    finished: bool,
128}
129
130impl IvrNavigator {
131    /// A navigator that follows `script`.
132    pub fn new(script: Script) -> Self {
133        Self {
134            name: "ivr-navigator",
135            script,
136            cursor: 0,
137            unmatched_streak: 0,
138            finished: false,
139        }
140    }
141
142    /// Whether the navigator has reached a terminal state.
143    pub fn is_finished(&self) -> bool {
144        self.finished
145    }
146
147    /// Turn an [`IvrAction`] into the output frame it emits, if any.
148    fn action_frame(action: &IvrAction) -> Option<Frame> {
149        match action {
150            IvrAction::Dtmf(digits) => Some(Frame::OutputDtmf(digits.clone())),
151            IvrAction::Say(reply) => Some(Frame::TtsSpeak {
152                text: reply.clone(),
153                append_to_context: Some(false),
154            }),
155            IvrAction::Wait => None,
156        }
157    }
158}
159
160#[async_trait]
161impl FrameProcessor for IvrNavigator {
162    fn name(&self) -> &str {
163        self.name
164    }
165
166    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
167        let Frame::Transcription { ref text, .. } = env.frame else {
168            link.push(env.meta, env.frame, env.direction).await;
169            return Ok(());
170        };
171
172        // Forward the prompt transcript itself, then act on it.
173        let direction = env.direction;
174        let prompt_lower = text.to_lowercase();
175        link.push(env.meta, env.frame, direction).await;
176
177        if self.finished {
178            return Ok(());
179        }
180
181        // Does the current step match this prompt? (We only ever try the step at
182        // the cursor; menus are visited in order.)
183        if let Some(step) = self.script.steps.get(self.cursor) {
184            if step.matches(&prompt_lower) {
185                self.unmatched_streak = 0;
186                if let Some(frame) = Self::action_frame(&step.action) {
187                    link.push_down(frame).await;
188                }
189                self.cursor += 1;
190                if self.cursor >= self.script.steps.len() {
191                    self.finished = true;
192                    link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
193                        status: IvrStatus::Completed,
194                    })))
195                    .await;
196                }
197                return Ok(());
198            }
199        }
200
201        // No match for the current step on this prompt.
202        self.unmatched_streak += 1;
203        if self.script.stuck_after > 0 && self.unmatched_streak >= self.script.stuck_after {
204            self.finished = true;
205            link.push_down(Frame::Custom(Arc::new(IvrStatusChanged {
206                status: IvrStatus::Stuck,
207            })))
208            .await;
209        }
210        Ok(())
211    }
212}
213
214/// Downcast helper: pull an [`IvrStatusChanged`] out of a [`Frame::Custom`].
215pub fn as_ivr_status(frame: &Frame) -> Option<&IvrStatusChanged> {
216    match frame {
217        Frame::Custom(c) => c.as_any().downcast_ref::<IvrStatusChanged>(),
218        _ => None,
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::agent::test_harness::drive;
226    use crate::processor::frame::Direction;
227
228    fn prompt(text: &str) -> Frame {
229        Frame::Transcription {
230            text: text.into(),
231            user_id: Arc::from("ivr"),
232            language: None,
233            final_: true,
234        }
235    }
236
237    fn dtmf_of(frame: &Frame) -> Option<Vec<KeypadEntry>> {
238        match frame {
239            Frame::OutputDtmf(d) => Some(d.clone()),
240            _ => None,
241        }
242    }
243
244    #[tokio::test]
245    async fn navigates_a_menu_path_and_completes() {
246        // Goal: reach billing → press 2, then enter account "1" via DTMF, then
247        // answer a verbal confirmation.
248        let script = Script::new(
249            vec![
250                IvrStep::press(
251                    &["press 2 for billing", "for billing press 2"],
252                    vec![KeypadEntry::Two],
253                ),
254                IvrStep::press(&["enter your account number"], vec![KeypadEntry::One]),
255                IvrStep::say(&["say yes or no", "is this correct"], "Yes."),
256            ],
257            3,
258        );
259        let out = drive(
260            Box::new(IvrNavigator::new(script)),
261            vec![
262                prompt("Thank you for calling. Press 1 for sales, press 2 for billing."),
263                prompt("Please enter your account number followed by pound."),
264                prompt("I heard account one. Is this correct? Say yes or no."),
265            ],
266            Direction::Downstream,
267        )
268        .await;
269
270        // First step pressed 2.
271        let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
272        assert_eq!(dtmfs, vec![vec![KeypadEntry::Two], vec![KeypadEntry::One]]);
273        // Third step spoke "Yes."
274        let said = out
275            .iter()
276            .any(|f| matches!(f, Frame::TtsSpeak { text, .. } if text == "Yes."));
277        assert!(said, "verbal step did not speak: {out:?}");
278        // Completed status emitted.
279        let status = out.iter().find_map(as_ivr_status).expect("no status");
280        assert_eq!(status.status, IvrStatus::Completed);
281    }
282
283    #[tokio::test]
284    async fn waits_through_irrelevant_prompts_then_matches() {
285        let script = Script::new(
286            vec![IvrStep::press(
287                &["press 0 for an agent"],
288                vec![KeypadEntry::Zero],
289            )],
290            5,
291        );
292        let out = drive(
293            Box::new(IvrNavigator::new(script)),
294            vec![
295                prompt("Your call is important to us."), // no match → wait
296                prompt("Please continue to hold."),      // no match → wait
297                prompt("Or press 0 for an agent."),      // match → press 0
298            ],
299            Direction::Downstream,
300        )
301        .await;
302        let dtmfs: Vec<Vec<KeypadEntry>> = out.iter().filter_map(dtmf_of).collect();
303        assert_eq!(dtmfs, vec![vec![KeypadEntry::Zero]]);
304        let status = out.iter().find_map(as_ivr_status).expect("no status");
305        assert_eq!(status.status, IvrStatus::Completed);
306    }
307
308    #[tokio::test]
309    async fn reports_stuck_after_unmatched_streak() {
310        let script = Script::new(
311            vec![IvrStep::press(
312                &["press 9 for spanish"],
313                vec![KeypadEntry::Nine],
314            )],
315            2, // stuck after 2 consecutive unmatched prompts
316        );
317        let out = drive(
318            Box::new(IvrNavigator::new(script)),
319            vec![
320                prompt("Welcome to the help line."), // unmatched 1
321                prompt("All agents are busy."),      // unmatched 2 → stuck
322                prompt("press 9 for spanish"),       // already finished → ignored
323            ],
324            Direction::Downstream,
325        )
326        .await;
327        let status = out.iter().find_map(as_ivr_status).expect("no status");
328        assert_eq!(status.status, IvrStatus::Stuck);
329        // No DTMF after stuck.
330        assert!(out.iter().filter_map(dtmf_of).next().is_none());
331    }
332
333    #[tokio::test]
334    async fn non_transcription_frames_pass_through() {
335        let script = Script::new(vec![IvrStep::press(&["x"], vec![KeypadEntry::One])], 3);
336        let out = drive(
337            Box::new(IvrNavigator::new(script)),
338            vec![Frame::Text("hi".into())],
339            Direction::Downstream,
340        )
341        .await;
342        assert!(matches!(out.first(), Some(Frame::Text(t)) if t == "hi"));
343    }
344}