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 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 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 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 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 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 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 let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
418 assert_eq!(global_dtmf.len(), 2);
419
420 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 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}