active_call/playbook/
mod.rs

1use crate::media::recorder::RecorderOption;
2use crate::media::vad::VADOption;
3use crate::synthesis::SynthesisOption;
4use crate::transcription::TranscriptionOption;
5use crate::{EouOption, RealtimeOption, media::ambiance::AmbianceOption};
6use anyhow::{Result, anyhow};
7use minijinja::Environment;
8use serde::{Deserialize, Serialize};
9use std::{collections::HashMap, path::Path};
10use tokio::fs;
11
12#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum InterruptionStrategy {
15    #[default]
16    Both,
17    Vad,
18    Asr,
19    None,
20}
21
22#[derive(Debug, Deserialize, Serialize, Clone, Default)]
23#[serde(rename_all = "camelCase")]
24pub struct InterruptionConfig {
25    pub strategy: InterruptionStrategy,
26    pub min_speech_ms: Option<u32>,
27    pub filler_word_filter: Option<bool>,
28    pub volume_fade_ms: Option<u32>,
29    pub ignore_first_ms: Option<u32>,
30}
31
32#[derive(Debug, Deserialize, Serialize, Clone, Default)]
33#[serde(rename_all = "camelCase")]
34pub struct PlaybookConfig {
35    pub asr: Option<TranscriptionOption>,
36    pub tts: Option<SynthesisOption>,
37    pub llm: Option<LlmConfig>,
38    pub vad: Option<VADOption>,
39    pub denoise: Option<bool>,
40    pub ambiance: Option<AmbianceOption>,
41    pub recorder: Option<RecorderOption>,
42    pub extra: Option<HashMap<String, String>>,
43    pub eou: Option<EouOption>,
44    pub greeting: Option<String>,
45    pub interruption: Option<InterruptionConfig>,
46    pub dtmf: Option<HashMap<String, DtmfAction>>,
47    pub realtime: Option<RealtimeOption>,
48    pub posthook: Option<PostHookConfig>,
49    pub follow_up: Option<FollowUpConfig>,
50}
51
52#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
53#[serde(rename_all = "camelCase")]
54pub struct FollowUpConfig {
55    pub timeout: u64,
56    pub max_count: u32,
57}
58
59#[derive(Debug, Deserialize, Serialize, Clone)]
60#[serde(rename_all = "lowercase")]
61pub enum SummaryType {
62    Short,
63    Detailed,
64    Intent,
65    Json,
66    #[serde(untagged)]
67    Custom(String),
68}
69
70impl SummaryType {
71    pub fn prompt(&self) -> &str {
72        match self {
73            Self::Short => "summarize the conversation in one or two sentences.",
74            Self::Detailed => {
75                "summarize the conversation in detail, including key points, decisions, and action items."
76            }
77            Self::Intent => "identify and summarize the user's main intent and needs.",
78            Self::Json => {
79                "output the conversation summary in JSON format with fields: intent, key_points, sentiment."
80            }
81            Self::Custom(p) => p,
82        }
83    }
84}
85
86#[derive(Debug, Deserialize, Serialize, Clone, Default)]
87#[serde(rename_all = "camelCase")]
88pub struct PostHookConfig {
89    pub url: String,
90    pub summary: Option<SummaryType>,
91    pub method: Option<String>,
92    pub headers: Option<HashMap<String, String>>,
93    pub include_history: Option<bool>,
94}
95
96#[derive(Debug, Deserialize, Serialize, Clone)]
97#[serde(tag = "action", rename_all = "lowercase")]
98pub enum DtmfAction {
99    Goto { scene: String },
100    Transfer { target: String },
101    Hangup,
102}
103
104#[derive(Debug, Deserialize, Serialize, Clone, Default)]
105#[serde(rename_all = "camelCase")]
106pub struct LlmConfig {
107    pub provider: String,
108    pub model: Option<String>,
109    pub base_url: Option<String>,
110    pub api_key: Option<String>,
111    pub prompt: Option<String>,
112    pub greeting: Option<String>,
113    pub language: Option<String>,
114    pub features: Option<Vec<String>>,
115    pub repair_window_ms: Option<u64>,
116    pub summary_limit: Option<usize>,
117}
118
119#[derive(Serialize, Deserialize, Clone, Debug)]
120pub struct ChatMessage {
121    pub role: String,
122    pub content: String,
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct Scene {
127    pub id: String,
128    pub prompt: String,
129    pub dtmf: Option<HashMap<String, DtmfAction>>,
130    pub play: Option<String>,
131    pub follow_up: Option<FollowUpConfig>,
132}
133
134#[derive(Debug, Clone)]
135pub struct Playbook {
136    pub config: PlaybookConfig,
137    pub scenes: HashMap<String, Scene>,
138    pub initial_scene_id: Option<String>,
139}
140
141impl Playbook {
142    pub async fn load<P: AsRef<Path>>(
143        path: P,
144        variables: Option<&HashMap<String, serde_json::Value>>,
145    ) -> Result<Self> {
146        let content = fs::read_to_string(path).await?;
147        Self::parse(&content, variables)
148    }
149
150    pub fn parse(
151        content: &str,
152        variables: Option<&HashMap<String, serde_json::Value>>,
153    ) -> Result<Self> {
154        let rendered_content = if let Some(vars) = variables {
155            let env = Environment::new();
156            env.render_str(content, vars)?
157        } else {
158            content.to_string()
159        };
160
161        if !rendered_content.starts_with("---") {
162            return Err(anyhow!("Missing front matter"));
163        }
164
165        let parts: Vec<&str> = rendered_content.splitn(3, "---").collect();
166        if parts.len() < 3 {
167            return Err(anyhow!("Invalid front matter format"));
168        }
169
170        let yaml_str = parts[1];
171        let prompt_section = parts[2].trim();
172
173        let mut config: PlaybookConfig = serde_yaml::from_str(yaml_str)?;
174
175        let mut scenes = HashMap::new();
176        let mut first_scene_id: Option<String> = None;
177
178        let dtmf_regex =
179            regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
180        let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
181        let followup_regex =
182            regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
183
184        let parse_scene = |id: String, content: String| -> Scene {
185            let mut dtmf_map = HashMap::new();
186            let mut play = None;
187            let mut follow_up = None;
188            let mut final_content = content.clone();
189
190            for cap in dtmf_regex.captures_iter(&content) {
191                let digit = cap.get(1).unwrap().as_str().to_string();
192                let action_type = cap.get(2).unwrap().as_str();
193
194                let action = match action_type {
195                    "goto" => {
196                        let scene = cap
197                            .get(3)
198                            .map(|m| m.as_str().to_string())
199                            .unwrap_or_default();
200                        DtmfAction::Goto { scene }
201                    }
202                    "transfer" => {
203                        let target = cap
204                            .get(4)
205                            .map(|m| m.as_str().to_string())
206                            .unwrap_or_default();
207                        DtmfAction::Transfer { target }
208                    }
209                    "hangup" => DtmfAction::Hangup,
210                    _ => continue,
211                };
212                dtmf_map.insert(digit, action);
213            }
214
215            if let Some(cap) = play_regex.captures(&content) {
216                play = Some(cap.get(1).unwrap().as_str().to_string());
217            }
218
219            if let Some(cap) = followup_regex.captures(&content) {
220                let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
221                let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
222                follow_up = Some(FollowUpConfig { timeout, max_count });
223            }
224
225            // Remove dtmf and play tags from the content
226            final_content = dtmf_regex.replace_all(&final_content, "").to_string();
227            final_content = play_regex.replace_all(&final_content, "").to_string();
228            final_content = followup_regex.replace_all(&final_content, "").to_string();
229            final_content = final_content.trim().to_string();
230
231            Scene {
232                id,
233                prompt: final_content,
234                dtmf: if dtmf_map.is_empty() {
235                    None
236                } else {
237                    Some(dtmf_map)
238                },
239                play,
240                follow_up,
241            }
242        };
243
244        // Parse scenes from markdown. Look for headers like "# Scene: <id>"
245        let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
246        let mut last_match_end = 0;
247        let mut last_scene_id: Option<String> = None;
248
249        for cap in scene_regex.captures_iter(prompt_section) {
250            let m = cap.get(0).unwrap();
251            let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
252
253            if first_scene_id.is_none() {
254                first_scene_id = Some(scene_id.clone());
255            }
256
257            if let Some(id) = last_scene_id {
258                let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
259                scenes.insert(id.clone(), parse_scene(id, scene_content));
260            } else {
261                // Content before the first scene header
262                let pre_content = prompt_section[..m.start()].trim();
263                if !pre_content.is_empty() {
264                    let id = "default".to_string();
265                    first_scene_id = Some(id.clone());
266                    scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
267                }
268            }
269
270            last_scene_id = Some(scene_id);
271            last_match_end = m.end();
272        }
273
274        if let Some(id) = last_scene_id {
275            let scene_content = prompt_section[last_match_end..].trim().to_string();
276            scenes.insert(id.clone(), parse_scene(id, scene_content));
277        } else if !prompt_section.is_empty() {
278            // No scene headers found, treat the whole prompt as "default"
279            let id = "default".to_string();
280            first_scene_id = Some(id.clone());
281            scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
282        }
283
284        if let Some(llm) = config.llm.as_mut() {
285            if llm.api_key.is_none() {
286                if let Ok(key) = std::env::var("OPENAI_API_KEY") {
287                    llm.api_key = Some(key);
288                }
289            }
290            if llm.base_url.is_none() {
291                if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
292                    llm.base_url = Some(url);
293                }
294            }
295            if llm.model.is_none() {
296                if let Ok(model) = std::env::var("OPENAI_MODEL") {
297                    llm.model = Some(model);
298                }
299            }
300
301            // Use the first scene found as the initial prompt
302            if let Some(initial_id) = first_scene_id.clone() {
303                if let Some(scene) = scenes.get(&initial_id) {
304                    llm.prompt = Some(scene.prompt.clone());
305                }
306            }
307        }
308
309        Ok(Self {
310            config,
311            scenes,
312            initial_scene_id: first_scene_id,
313        })
314    }
315}
316
317pub mod dialogue;
318pub mod handler;
319pub mod runner;
320
321pub use dialogue::DialogueHandler;
322pub use handler::{LlmHandler, RagRetriever};
323pub use runner::PlaybookRunner;
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn test_playbook_parsing_with_variables() {
332        let content = r#"---
333llm:
334  provider: openai
335  model: {{ model_name }}
336  greeting: Hello, {{ user_name }}!
337---
338# Scene: main
339You are an assistant for {{ company }}.
340"#;
341        let mut variables = HashMap::new();
342        variables.insert("model_name".to_string(), json!("gpt-4"));
343        variables.insert("user_name".to_string(), json!("Alice"));
344        variables.insert("company".to_string(), json!("RestSend"));
345
346        let playbook = Playbook::parse(content, Some(&variables)).unwrap();
347
348        assert_eq!(
349            playbook.config.llm.as_ref().unwrap().model,
350            Some("gpt-4".to_string())
351        );
352        assert_eq!(
353            playbook.config.llm.as_ref().unwrap().greeting,
354            Some("Hello, Alice!".to_string())
355        );
356
357        let scene = playbook.scenes.get("main").unwrap();
358        assert_eq!(scene.prompt, "You are an assistant for RestSend.");
359    }
360
361    #[test]
362    fn test_playbook_scene_dtmf_parsing() {
363        let content = r#"---
364llm:
365  provider: openai
366---
367# Scene: main
368<dtmf digit="1" action="goto" scene="product" />
369<dtmf digit="2" action="transfer" target="sip:123@domain" />
370<dtmf digit="0" action="hangup" />
371Welcome to our service.
372"#;
373        let playbook = Playbook::parse(content, None).unwrap();
374
375        let scene = playbook.scenes.get("main").unwrap();
376        assert_eq!(scene.prompt, "Welcome to our service.");
377
378        let dtmf = scene.dtmf.as_ref().unwrap();
379        assert_eq!(dtmf.len(), 3);
380
381        match dtmf.get("1").unwrap() {
382            DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
383            _ => panic!("Expected Goto action"),
384        }
385
386        match dtmf.get("2").unwrap() {
387            DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
388            _ => panic!("Expected Transfer action"),
389        }
390
391        match dtmf.get("0").unwrap() {
392            DtmfAction::Hangup => {}
393            _ => panic!("Expected Hangup action"),
394        }
395    }
396
397    #[test]
398    fn test_playbook_dtmf_priority() {
399        let content = r#"---
400llm:
401  provider: openai
402dtmf:
403  "1": { action: "goto", scene: "global_dest" }
404  "9": { action: "hangup" }
405---
406# Scene: main
407<dtmf digit="1" action="goto" scene="local_dest" />
408Welcome.
409"#;
410        let playbook = Playbook::parse(content, None).unwrap();
411
412        // Check global config
413        let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
414        assert_eq!(global_dtmf.len(), 2);
415
416        // Check scene config
417        let scene = playbook.scenes.get("main").unwrap();
418        let scene_dtmf = scene.dtmf.as_ref().unwrap();
419        assert_eq!(scene_dtmf.len(), 1);
420
421        // Verify scene has local_dest for "1"
422        match scene_dtmf.get("1").unwrap() {
423            DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
424            _ => panic!("Expected Local Goto action"),
425        }
426    }
427
428    #[test]
429    fn test_posthook_config_parsing() {
430        let content = r#"---
431posthook:
432  url: "http://test.com"
433  summary: "json"
434  includeHistory: true
435  headers:
436    X-API-Key: "secret"
437llm:
438  provider: openai
439---
440# Scene: main
441Hello
442"#;
443        let playbook = Playbook::parse(content, None).unwrap();
444        let posthook = playbook.config.posthook.unwrap();
445        assert_eq!(posthook.url, "http://test.com");
446        match posthook.summary.unwrap() {
447            SummaryType::Json => {}
448            _ => panic!("Expected Json summary type"),
449        }
450        assert_eq!(posthook.include_history, Some(true));
451        assert_eq!(
452            posthook.headers.unwrap().get("X-API-Key").unwrap(),
453            "secret"
454        );
455    }
456
457    #[test]
458    fn test_custom_summary_parsing() {
459        let content = r#"---
460posthook:
461  url: "http://test.com"
462  summary: "Please summarize customly"
463llm:
464  provider: openai
465---
466# Scene: main
467Hello
468"#;
469        let playbook = Playbook::parse(content, None).unwrap();
470        let posthook = playbook.config.posthook.unwrap();
471        match posthook.summary.unwrap() {
472            SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
473            _ => panic!("Expected Custom summary type"),
474        }
475    }
476}