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}
116
117#[derive(Serialize, Deserialize, Clone, Debug)]
118pub struct ChatMessage {
119 pub role: String,
120 pub content: String,
121}
122
123#[derive(Debug, Clone, Default)]
124pub struct Scene {
125 pub id: String,
126 pub prompt: String,
127 pub dtmf: Option<HashMap<String, DtmfAction>>,
128 pub play: Option<String>,
129 pub follow_up: Option<FollowUpConfig>,
130}
131
132#[derive(Debug, Clone)]
133pub struct Playbook {
134 pub config: PlaybookConfig,
135 pub scenes: HashMap<String, Scene>,
136 pub initial_scene_id: Option<String>,
137}
138
139impl Playbook {
140 pub async fn load<P: AsRef<Path>>(
141 path: P,
142 variables: Option<&HashMap<String, serde_json::Value>>,
143 ) -> Result<Self> {
144 let content = fs::read_to_string(path).await?;
145 Self::parse(&content, variables)
146 }
147
148 pub fn parse(
149 content: &str,
150 variables: Option<&HashMap<String, serde_json::Value>>,
151 ) -> Result<Self> {
152 let rendered_content = if let Some(vars) = variables {
153 let env = Environment::new();
154 env.render_str(content, vars)?
155 } else {
156 content.to_string()
157 };
158
159 if !rendered_content.starts_with("---") {
160 return Err(anyhow!("Missing front matter"));
161 }
162
163 let parts: Vec<&str> = rendered_content.splitn(3, "---").collect();
164 if parts.len() < 3 {
165 return Err(anyhow!("Invalid front matter format"));
166 }
167
168 let yaml_str = parts[1];
169 let prompt_section = parts[2].trim();
170
171 let mut config: PlaybookConfig = serde_yaml::from_str(yaml_str)?;
172
173 let mut scenes = HashMap::new();
174 let mut first_scene_id: Option<String> = None;
175
176 let dtmf_regex =
177 regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
178 let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
179 let followup_regex =
180 regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
181
182 let parse_scene = |id: String, content: String| -> Scene {
183 let mut dtmf_map = HashMap::new();
184 let mut play = None;
185 let mut follow_up = None;
186 let mut final_content = content.clone();
187
188 for cap in dtmf_regex.captures_iter(&content) {
189 let digit = cap.get(1).unwrap().as_str().to_string();
190 let action_type = cap.get(2).unwrap().as_str();
191
192 let action = match action_type {
193 "goto" => {
194 let scene = cap
195 .get(3)
196 .map(|m| m.as_str().to_string())
197 .unwrap_or_default();
198 DtmfAction::Goto { scene }
199 }
200 "transfer" => {
201 let target = cap
202 .get(4)
203 .map(|m| m.as_str().to_string())
204 .unwrap_or_default();
205 DtmfAction::Transfer { target }
206 }
207 "hangup" => DtmfAction::Hangup,
208 _ => continue,
209 };
210 dtmf_map.insert(digit, action);
211 }
212
213 if let Some(cap) = play_regex.captures(&content) {
214 play = Some(cap.get(1).unwrap().as_str().to_string());
215 }
216
217 if let Some(cap) = followup_regex.captures(&content) {
218 let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
219 let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
220 follow_up = Some(FollowUpConfig { timeout, max_count });
221 }
222
223 final_content = dtmf_regex.replace_all(&final_content, "").to_string();
225 final_content = play_regex.replace_all(&final_content, "").to_string();
226 final_content = followup_regex.replace_all(&final_content, "").to_string();
227 final_content = final_content.trim().to_string();
228
229 Scene {
230 id,
231 prompt: final_content,
232 dtmf: if dtmf_map.is_empty() {
233 None
234 } else {
235 Some(dtmf_map)
236 },
237 play,
238 follow_up,
239 }
240 };
241
242 let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
244 let mut last_match_end = 0;
245 let mut last_scene_id: Option<String> = None;
246
247 for cap in scene_regex.captures_iter(prompt_section) {
248 let m = cap.get(0).unwrap();
249 let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
250
251 if first_scene_id.is_none() {
252 first_scene_id = Some(scene_id.clone());
253 }
254
255 if let Some(id) = last_scene_id {
256 let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
257 scenes.insert(id.clone(), parse_scene(id, scene_content));
258 } else {
259 let pre_content = prompt_section[..m.start()].trim();
261 if !pre_content.is_empty() {
262 let id = "default".to_string();
263 first_scene_id = Some(id.clone());
264 scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
265 }
266 }
267
268 last_scene_id = Some(scene_id);
269 last_match_end = m.end();
270 }
271
272 if let Some(id) = last_scene_id {
273 let scene_content = prompt_section[last_match_end..].trim().to_string();
274 scenes.insert(id.clone(), parse_scene(id, scene_content));
275 } else if !prompt_section.is_empty() {
276 let id = "default".to_string();
278 first_scene_id = Some(id.clone());
279 scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
280 }
281
282 if let Some(llm) = config.llm.as_mut() {
283 if llm.api_key.is_none() {
284 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
285 llm.api_key = Some(key);
286 }
287 }
288 if llm.base_url.is_none() {
289 if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
290 llm.base_url = Some(url);
291 }
292 }
293 if llm.model.is_none() {
294 if let Ok(model) = std::env::var("OPENAI_MODEL") {
295 llm.model = Some(model);
296 }
297 }
298
299 if let Some(initial_id) = first_scene_id.clone() {
301 if let Some(scene) = scenes.get(&initial_id) {
302 llm.prompt = Some(scene.prompt.clone());
303 }
304 }
305 }
306
307 Ok(Self {
308 config,
309 scenes,
310 initial_scene_id: first_scene_id,
311 })
312 }
313}
314
315pub mod dialogue;
316pub mod handler;
317pub mod runner;
318
319pub use dialogue::DialogueHandler;
320pub use handler::{LlmHandler, RagRetriever};
321pub use runner::PlaybookRunner;
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use serde_json::json;
327
328 #[test]
329 fn test_playbook_parsing_with_variables() {
330 let content = r#"---
331llm:
332 provider: openai
333 model: {{ model_name }}
334 greeting: Hello, {{ user_name }}!
335---
336# Scene: main
337You are an assistant for {{ company }}.
338"#;
339 let mut variables = HashMap::new();
340 variables.insert("model_name".to_string(), json!("gpt-4"));
341 variables.insert("user_name".to_string(), json!("Alice"));
342 variables.insert("company".to_string(), json!("RestSend"));
343
344 let playbook = Playbook::parse(content, Some(&variables)).unwrap();
345
346 assert_eq!(
347 playbook.config.llm.as_ref().unwrap().model,
348 Some("gpt-4".to_string())
349 );
350 assert_eq!(
351 playbook.config.llm.as_ref().unwrap().greeting,
352 Some("Hello, Alice!".to_string())
353 );
354
355 let scene = playbook.scenes.get("main").unwrap();
356 assert_eq!(scene.prompt, "You are an assistant for RestSend.");
357 }
358
359 #[test]
360 fn test_playbook_scene_dtmf_parsing() {
361 let content = r#"---
362llm:
363 provider: openai
364---
365# Scene: main
366<dtmf digit="1" action="goto" scene="product" />
367<dtmf digit="2" action="transfer" target="sip:123@domain" />
368<dtmf digit="0" action="hangup" />
369Welcome to our service.
370"#;
371 let playbook = Playbook::parse(content, None).unwrap();
372
373 let scene = playbook.scenes.get("main").unwrap();
374 assert_eq!(scene.prompt, "Welcome to our service.");
375
376 let dtmf = scene.dtmf.as_ref().unwrap();
377 assert_eq!(dtmf.len(), 3);
378
379 match dtmf.get("1").unwrap() {
380 DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
381 _ => panic!("Expected Goto action"),
382 }
383
384 match dtmf.get("2").unwrap() {
385 DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
386 _ => panic!("Expected Transfer action"),
387 }
388
389 match dtmf.get("0").unwrap() {
390 DtmfAction::Hangup => {}
391 _ => panic!("Expected Hangup action"),
392 }
393 }
394
395 #[test]
396 fn test_playbook_dtmf_priority() {
397 let content = r#"---
398llm:
399 provider: openai
400dtmf:
401 "1": { action: "goto", scene: "global_dest" }
402 "9": { action: "hangup" }
403---
404# Scene: main
405<dtmf digit="1" action="goto" scene="local_dest" />
406Welcome.
407"#;
408 let playbook = Playbook::parse(content, None).unwrap();
409
410 let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
412 assert_eq!(global_dtmf.len(), 2);
413
414 let scene = playbook.scenes.get("main").unwrap();
416 let scene_dtmf = scene.dtmf.as_ref().unwrap();
417 assert_eq!(scene_dtmf.len(), 1);
418
419 match scene_dtmf.get("1").unwrap() {
421 DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
422 _ => panic!("Expected Local Goto action"),
423 }
424 }
425
426 #[test]
427 fn test_posthook_config_parsing() {
428 let content = r#"---
429posthook:
430 url: "http://test.com"
431 summary: "json"
432 includeHistory: true
433 headers:
434 X-API-Key: "secret"
435llm:
436 provider: openai
437---
438# Scene: main
439Hello
440"#;
441 let playbook = Playbook::parse(content, None).unwrap();
442 let posthook = playbook.config.posthook.unwrap();
443 assert_eq!(posthook.url, "http://test.com");
444 match posthook.summary.unwrap() {
445 SummaryType::Json => {}
446 _ => panic!("Expected Json summary type"),
447 }
448 assert_eq!(posthook.include_history, Some(true));
449 assert_eq!(
450 posthook.headers.unwrap().get("X-API-Key").unwrap(),
451 "secret"
452 );
453 }
454
455 #[test]
456 fn test_custom_summary_parsing() {
457 let content = r#"---
458posthook:
459 url: "http://test.com"
460 summary: "Please summarize customly"
461llm:
462 provider: openai
463---
464# Scene: main
465Hello
466"#;
467 let playbook = Playbook::parse(content, None).unwrap();
468 let posthook = playbook.config.posthook.unwrap();
469 match posthook.summary.unwrap() {
470 SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
471 _ => panic!("Expected Custom summary type"),
472 }
473 }
474}