Skip to main content

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