1use crate::media::recorder::RecorderOption;
2use crate::media::vad::VADOption;
3use crate::synthesis::SynthesisOption;
4use crate::transcription::TranscriptionOption;
5use crate::{EouOption, RealtimeOption, RingbackDetectionOption, SipOption, media::ambiance::AmbianceOption};
6use anyhow::{Result, anyhow};
7use minijinja::Environment;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::{collections::HashMap, path::Path};
11use tokio::fs;
12
13fn expand_env_vars(input: &str) -> String {
15 let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
16 re.replace_all(input, |caps: ®ex::Captures| {
17 let var_name = &caps[1];
18 std::env::var(var_name).unwrap_or_else(|_| format!("${{{}}}", var_name))
19 })
20 .to_string()
21}
22
23#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum InterruptionStrategy {
26 #[default]
27 Both,
28 Vad,
29 Asr,
30 None,
31}
32
33#[derive(Debug, Deserialize, Serialize, Clone, Default)]
34#[serde(rename_all = "camelCase")]
35pub struct InterruptionConfig {
36 pub strategy: InterruptionStrategy,
37 pub min_speech_ms: Option<u32>,
38 pub filler_word_filter: Option<bool>,
39 pub volume_fade_ms: Option<u32>,
40 pub ignore_first_ms: Option<u32>,
41}
42
43#[derive(Debug, Deserialize, Serialize, Clone, Default)]
44#[serde(rename_all = "camelCase")]
45pub struct PlaybookConfig {
46 pub asr: Option<TranscriptionOption>,
47 pub tts: Option<SynthesisOption>,
48 pub llm: Option<LlmConfig>,
49 pub vad: Option<VADOption>,
50 pub denoise: Option<bool>,
51 pub ambiance: Option<AmbianceOption>,
52 pub recorder: Option<RecorderOption>,
53 pub extra: Option<HashMap<String, String>>,
54 pub eou: Option<EouOption>,
55 pub greeting: Option<String>,
56 pub interruption: Option<InterruptionConfig>,
57 pub dtmf: Option<HashMap<String, DtmfAction>>,
58 pub dtmf_collectors: Option<HashMap<String, DtmfCollectorConfig>>,
59 pub realtime: Option<RealtimeOption>,
60 pub ringback_detection: Option<RingbackDetectionOption>,
61 pub posthook: Option<PostHookConfig>,
62 pub follow_up: Option<FollowUpConfig>,
63 pub sip: Option<SipOption>,
64}
65
66#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
67#[serde(rename_all = "camelCase")]
68pub struct FollowUpConfig {
69 pub timeout: u64,
70 pub max_count: u32,
71}
72
73#[derive(Debug, Deserialize, Serialize, Clone)]
74#[serde(rename_all = "lowercase")]
75pub enum SummaryType {
76 Short,
77 Detailed,
78 Intent,
79 Json,
80 #[serde(untagged)]
81 Custom(String),
82}
83
84impl SummaryType {
85 pub fn prompt(&self) -> &str {
86 match self {
87 Self::Short => "summarize the conversation in one or two sentences.",
88 Self::Detailed => {
89 "summarize the conversation in detail, including key points, decisions, and action items."
90 }
91 Self::Intent => "identify and summarize the user's main intent and needs.",
92 Self::Json => {
93 "output the conversation summary in JSON format with fields: intent, key_points, sentiment."
94 }
95 Self::Custom(p) => p,
96 }
97 }
98}
99
100#[derive(Debug, Deserialize, Serialize, Clone, Default)]
101#[serde(rename_all = "camelCase")]
102pub struct PostHookConfig {
103 pub url: String,
104 pub summary: Option<SummaryType>,
105 pub method: Option<String>,
106 pub headers: Option<HashMap<String, String>>,
107 pub include_history: Option<bool>,
108 pub timeout: Option<u32>,
109}
110
111#[derive(Debug, Deserialize, Serialize, Clone)]
112#[serde(tag = "action", rename_all = "lowercase")]
113pub enum DtmfAction {
114 Goto { scene: String },
115 Transfer { target: String },
116 Hangup,
117}
118
119#[derive(Debug, Deserialize, Serialize, Clone, Default)]
121#[serde(rename_all = "camelCase")]
122pub struct DtmfValidation {
123 pub pattern: String,
125 pub error_message: Option<String>,
127}
128
129#[derive(Debug, Deserialize, Serialize, Clone, Default)]
131#[serde(rename_all = "camelCase")]
132pub struct DtmfCollectorConfig {
133 pub description: Option<String>,
135 pub digits: Option<u32>,
137 pub min_digits: Option<u32>,
139 pub max_digits: Option<u32>,
141 pub finish_key: Option<String>,
143 pub timeout: Option<u32>,
145 pub inter_digit_timeout: Option<u32>,
147 pub validation: Option<DtmfValidation>,
149 pub retry_times: Option<u32>,
151 pub interruptible: Option<bool>,
153}
154
155#[derive(Debug, Deserialize, Serialize, Clone, Default)]
156#[serde(rename_all = "camelCase")]
157pub struct LlmConfig {
158 pub provider: String,
159 pub model: Option<String>,
160 pub base_url: Option<String>,
161 pub api_key: Option<String>,
162 pub prompt: Option<String>,
163 pub greeting: Option<String>,
164 pub language: Option<String>,
165 pub features: Option<Vec<String>>,
166 pub repair_window_ms: Option<u64>,
167 pub summary_limit: Option<usize>,
168 pub tool_instructions: Option<String>,
171}
172
173#[derive(Serialize, Deserialize, Clone, Debug)]
174pub struct ChatMessage {
175 pub role: String,
176 pub content: String,
177}
178
179#[derive(Debug, Clone, Default)]
180pub struct Scene {
181 pub id: String,
182 pub prompt: String,
183 pub raw_prompt: Option<String>,
186 pub dtmf: Option<HashMap<String, DtmfAction>>,
187 pub play: Option<String>,
188 pub follow_up: Option<FollowUpConfig>,
189}
190
191pub const BUILTIN_SESSION_ID: &str = "session_id";
195pub const BUILTIN_CALL_TYPE: &str = "call_type";
196pub const BUILTIN_CALLER: &str = "caller";
197pub const BUILTIN_CALLEE: &str = "callee";
198pub const BUILTIN_START_TIME: &str = "start_time";
199
200pub fn render_scene_prompt(scene: &Scene, vars: &HashMap<String, serde_json::Value>) -> String {
205 let template = match &scene.raw_prompt {
206 Some(t) if t.contains("{{") => t,
207 _ => return scene.prompt.clone(),
208 };
209
210 let env = Environment::new();
211 let mut context = vars.clone();
212
213 let sip_header_keys: Vec<String> = vars
215 .get("_sip_header_keys")
216 .and_then(|v| serde_json::from_value(v.clone()).ok())
217 .unwrap_or_default();
218
219 let mut sip_headers = HashMap::new();
220 for key in &sip_header_keys {
221 if let Some(value) = vars.get(key) {
222 sip_headers.insert(key.clone(), value.clone());
223 }
224 }
225 context.insert(
226 "sip".to_string(),
227 serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
228 );
229
230 context.retain(|k, _| !k.starts_with('_'));
232
233 match env.render_str(template, &context) {
234 Ok(rendered) => rendered,
235 Err(_) => scene.prompt.clone(),
236 }
237}
238
239#[derive(Debug, Clone)]
240pub struct Playbook {
241 pub raw_content: String,
242 pub config: PlaybookConfig,
243 pub scenes: HashMap<String, Scene>,
244 pub initial_scene_id: Option<String>,
245}
246
247impl Playbook {
248 pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
249 let content = fs::read_to_string(path).await?;
250 Self::parse(&content)
251 }
252
253 pub fn render(&self, vars: &HashMap<String, serde_json::Value>) -> Result<Self> {
254 let env = Environment::new();
255 let mut context = vars.clone();
256
257 let sip_header_keys: Vec<String> = vars
260 .get("_sip_header_keys")
261 .and_then(|v| serde_json::from_value(v.clone()).ok())
262 .unwrap_or_default();
263
264 let mut sip_headers = HashMap::new();
266 for key in &sip_header_keys {
267 if let Some(value) = vars.get(key) {
268 sip_headers.insert(key.clone(), value.clone());
269 }
270 }
271 context.insert(
272 "sip".to_string(),
273 serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
274 );
275
276 let rendered = env.render_str(&self.raw_content, &context)?;
277 let mut res = Self::parse(&rendered)?;
278 res.raw_content = self.raw_content.clone();
280 for (scene_id, scene) in &self.scenes {
282 if let Some(res_scene) = res.scenes.get_mut(scene_id) {
283 res_scene.raw_prompt = scene.raw_prompt.clone();
284 }
285 }
286 res.config.sip.as_mut().map(|sip| {
287 sip.hangup_headers = self
288 .config
289 .sip
290 .as_ref()
291 .and_then(|sip| sip.hangup_headers.clone());
292 });
293 Ok(res)
294 }
295
296 pub fn parse(content: &str) -> Result<Self> {
297 if !content.starts_with("---") {
298 return Err(anyhow!("Missing front matter"));
299 }
300
301 let parts: Vec<&str> = content.splitn(3, "---").collect();
302 if parts.len() < 3 {
303 return Err(anyhow!("Invalid front matter format"));
304 }
305
306 let yaml_str = parts[1];
307 let prompt_section = parts[2].trim();
308
309 let expanded_yaml = expand_env_vars(yaml_str);
312 let mut config: PlaybookConfig = serde_yaml::from_str(&expanded_yaml)?;
313
314 let mut scenes = HashMap::new();
315 let mut first_scene_id: Option<String> = None;
316
317 let dtmf_regex =
318 regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
319 let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
320 let followup_regex =
321 regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
322
323 let parse_scene = |id: String, content: String| -> Scene {
324 let mut dtmf_map = HashMap::new();
325 let mut play = None;
326 let mut follow_up = None;
327 let mut final_content = content.clone();
328
329 for cap in dtmf_regex.captures_iter(&content) {
330 let digit = cap.get(1).unwrap().as_str().to_string();
331 let action_type = cap.get(2).unwrap().as_str();
332
333 let action = match action_type {
334 "goto" => {
335 let scene = cap
336 .get(3)
337 .map(|m| m.as_str().to_string())
338 .unwrap_or_default();
339 DtmfAction::Goto { scene }
340 }
341 "transfer" => {
342 let target = cap
343 .get(4)
344 .map(|m| m.as_str().to_string())
345 .unwrap_or_default();
346 DtmfAction::Transfer { target }
347 }
348 "hangup" => DtmfAction::Hangup,
349 _ => continue,
350 };
351 dtmf_map.insert(digit, action);
352 }
353
354 if let Some(cap) = play_regex.captures(&content) {
355 play = Some(cap.get(1).unwrap().as_str().to_string());
356 }
357
358 if let Some(cap) = followup_regex.captures(&content) {
359 let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
360 let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
361 follow_up = Some(FollowUpConfig { timeout, max_count });
362 }
363
364 final_content = dtmf_regex.replace_all(&final_content, "").to_string();
366 final_content = play_regex.replace_all(&final_content, "").to_string();
367 final_content = followup_regex.replace_all(&final_content, "").to_string();
368 final_content = final_content.trim().to_string();
369
370 Scene {
371 id,
372 raw_prompt: Some(final_content.clone()),
373 prompt: final_content,
374 dtmf: if dtmf_map.is_empty() {
375 None
376 } else {
377 Some(dtmf_map)
378 },
379 play,
380 follow_up,
381 }
382 };
383
384 let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
386 let mut last_match_end = 0;
387 let mut last_scene_id: Option<String> = None;
388
389 for cap in scene_regex.captures_iter(prompt_section) {
390 let m = cap.get(0).unwrap();
391 let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
392
393 if first_scene_id.is_none() {
394 first_scene_id = Some(scene_id.clone());
395 }
396
397 if let Some(id) = last_scene_id {
398 let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
399 scenes.insert(id.clone(), parse_scene(id, scene_content));
400 } else {
401 let pre_content = prompt_section[..m.start()].trim();
403 if !pre_content.is_empty() {
404 let id = "default".to_string();
405 first_scene_id = Some(id.clone());
406 scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
407 }
408 }
409
410 last_scene_id = Some(scene_id);
411 last_match_end = m.end();
412 }
413
414 if let Some(id) = last_scene_id {
415 let scene_content = prompt_section[last_match_end..].trim().to_string();
416 scenes.insert(id.clone(), parse_scene(id, scene_content));
417 } else if !prompt_section.is_empty() {
418 let id = "default".to_string();
420 first_scene_id = Some(id.clone());
421 scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
422 }
423
424 if let Some(llm) = config.llm.as_mut() {
425 if llm.api_key.is_none() {
427 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
428 llm.api_key = Some(key);
429 }
430 }
431 if llm.base_url.is_none() {
432 if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
433 llm.base_url = Some(url);
434 }
435 }
436 if llm.model.is_none() {
437 if let Ok(model) = std::env::var("OPENAI_MODEL") {
438 llm.model = Some(model);
439 }
440 }
441
442 if let Some(initial_id) = first_scene_id.clone() {
444 if let Some(scene) = scenes.get(&initial_id) {
445 llm.prompt = Some(scene.prompt.clone());
446 }
447 }
448 }
449
450 Ok(Self {
451 raw_content: content.to_string(),
452 config,
453 scenes,
454 initial_scene_id: first_scene_id,
455 })
456 }
457}
458
459pub mod dialogue;
460pub mod handler;
461pub mod runner;
462
463pub use dialogue::DialogueHandler;
464pub use handler::{LlmHandler, RagRetriever};
465pub use runner::PlaybookRunner;
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use serde_json::json;
471
472 #[test]
473 fn test_playbook_parsing_with_variables() {
474 let content = r#"---
475llm:
476 provider: openai
477 model: |-
478 {{ model_name }}
479 greeting: |-
480 Hello, {{ user_name }}!
481---
482# Scene: main
483You are an assistant for {{ company }}.
484"#;
485 let mut variables = HashMap::new();
486 variables.insert("model_name".to_string(), json!("gpt-4"));
487 variables.insert("user_name".to_string(), json!("Alice"));
488 variables.insert("company".to_string(), json!("RestSend"));
489
490 let playbook = Playbook::parse(content)
491 .unwrap()
492 .render(&variables)
493 .unwrap();
494 assert_eq!(
495 playbook.config.llm.as_ref().unwrap().model,
496 Some("gpt-4".to_string())
497 );
498 assert_eq!(
499 playbook.config.llm.as_ref().unwrap().greeting,
500 Some("Hello, Alice!".to_string())
501 );
502
503 let scene = playbook.scenes.get("main").unwrap();
504 assert_eq!(scene.prompt, "You are an assistant for RestSend.");
505 }
506
507 #[test]
508 fn test_playbook_scene_dtmf_parsing() {
509 let content = r#"---
510llm:
511 provider: openai
512---
513# Scene: main
514<dtmf digit="1" action="goto" scene="product" />
515<dtmf digit="2" action="transfer" target="sip:123@domain" />
516<dtmf digit="0" action="hangup" />
517Welcome to our service.
518"#;
519 let playbook = Playbook::parse(content).unwrap();
520
521 let scene = playbook.scenes.get("main").unwrap();
522 assert_eq!(scene.prompt, "Welcome to our service.");
523
524 let dtmf = scene.dtmf.as_ref().unwrap();
525 assert_eq!(dtmf.len(), 3);
526
527 match dtmf.get("1").unwrap() {
528 DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
529 _ => panic!("Expected Goto action"),
530 }
531
532 match dtmf.get("2").unwrap() {
533 DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
534 _ => panic!("Expected Transfer action"),
535 }
536
537 match dtmf.get("0").unwrap() {
538 DtmfAction::Hangup => {}
539 _ => panic!("Expected Hangup action"),
540 }
541 }
542
543 #[test]
544 fn test_playbook_dtmf_priority() {
545 let content = r#"---
546llm:
547 provider: openai
548dtmf:
549 "1": { action: "goto", scene: "global_dest" }
550 "9": { action: "hangup" }
551---
552# Scene: main
553<dtmf digit="1" action="goto" scene="local_dest" />
554Welcome.
555"#;
556 let playbook = Playbook::parse(content).unwrap();
557
558 let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
560 assert_eq!(global_dtmf.len(), 2);
561
562 let scene = playbook.scenes.get("main").unwrap();
564 let scene_dtmf = scene.dtmf.as_ref().unwrap();
565 assert_eq!(scene_dtmf.len(), 1);
566
567 match scene_dtmf.get("1").unwrap() {
569 DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
570 _ => panic!("Expected Local Goto action"),
571 }
572 }
573
574 #[test]
575 fn test_posthook_config_parsing() {
576 let content = r#"---
577posthook:
578 url: "http://test.com"
579 summary: "json"
580 includeHistory: true
581 headers:
582 X-API-Key: "secret"
583llm:
584 provider: openai
585---
586# Scene: main
587Hello
588"#;
589 let playbook = Playbook::parse(content).unwrap();
590 let posthook = playbook.config.posthook.unwrap();
591 assert_eq!(posthook.url, "http://test.com");
592 match posthook.summary.unwrap() {
593 SummaryType::Json => {}
594 _ => panic!("Expected Json summary type"),
595 }
596 assert_eq!(posthook.include_history, Some(true));
597 assert_eq!(
598 posthook.headers.unwrap().get("X-API-Key").unwrap(),
599 "secret"
600 );
601 }
602
603 #[test]
604 fn test_env_var_expansion() {
605 unsafe {
607 std::env::set_var("TEST_API_KEY", "sk-test-12345");
608 std::env::set_var("TEST_BASE_URL", "https://api.test.com");
609 }
610
611 let content = r#"---
612llm:
613 provider: openai
614 apiKey: "${TEST_API_KEY}"
615 baseUrl: "${TEST_BASE_URL}"
616 model: gpt-4
617---
618# Scene: main
619Test
620"#;
621 let playbook = Playbook::parse(content).unwrap();
622 let llm = playbook.config.llm.unwrap();
623
624 assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
625 assert_eq!(llm.base_url.unwrap(), "https://api.test.com");
626 assert_eq!(llm.model.unwrap(), "gpt-4");
627
628 unsafe {
630 std::env::remove_var("TEST_API_KEY");
631 std::env::remove_var("TEST_BASE_URL");
632 }
633 }
634
635 #[test]
636 fn test_env_var_expansion_missing() {
637 let content = r#"---
639llm:
640 provider: openai
641 apiKey: "${UNDEFINED_VAR}"
642---
643# Scene: main
644Test
645"#;
646 let playbook = Playbook::parse(content).unwrap();
647 let llm = playbook.config.llm.unwrap();
648
649 assert_eq!(llm.api_key.unwrap(), "${UNDEFINED_VAR}");
651 }
652
653 #[test]
654 fn test_custom_summary_parsing() {
655 let content = r#"---
656posthook:
657 url: "http://test.com"
658 summary: "Please summarize customly"
659llm:
660 provider: openai
661---
662# Scene: main
663Hello
664"#;
665 let playbook = Playbook::parse(content).unwrap();
666 let posthook = playbook.config.posthook.unwrap();
667 match posthook.summary.unwrap() {
668 SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
669 _ => panic!("Expected Custom summary type"),
670 }
671 }
672
673 #[test]
674 fn test_sip_dict_access_with_hyphens() {
675 let content = r#"---
677llm:
678 provider: openai
679 greeting: Hello {{ sip["X-Customer-Name"] }}!
680---
681# Scene: main
682Your ID is {{ sip["X-Customer-ID"] }}.
683Session type: {{ sip["X-Session-Type"] }}.
684"#;
685 let mut variables = HashMap::new();
686 variables.insert("X-Customer-Name".to_string(), json!("Alice"));
687 variables.insert("X-Customer-ID".to_string(), json!("CID-12345"));
688 variables.insert("X-Session-Type".to_string(), json!("inbound"));
689 variables.insert(
691 "_sip_header_keys".to_string(),
692 json!(["X-Customer-Name", "X-Customer-ID", "X-Session-Type"]),
693 );
694
695 let playbook = Playbook::parse(content)
696 .unwrap()
697 .render(&variables)
698 .unwrap();
699 assert_eq!(
700 playbook.config.llm.as_ref().unwrap().greeting,
701 Some("Hello Alice!".to_string())
702 );
703
704 let scene = playbook.scenes.get("main").unwrap();
705 assert_eq!(
706 scene.prompt,
707 "Your ID is CID-12345.\nSession type: inbound."
708 );
709 }
710
711 #[test]
712 fn test_sip_dict_only_contains_sip_headers() {
713 let content = r#"---
715llm:
716 provider: openai
717---
718# Scene: main
719SIP Header: {{ sip["X-Custom-Header"] }}
720Regular var: {{ regular_var }}
721"#;
722 let mut variables = HashMap::new();
723 variables.insert("X-Custom-Header".to_string(), json!("header_value"));
724 variables.insert("regular_var".to_string(), json!("regular_value"));
725 variables.insert("another_var".to_string(), json!("another"));
726 variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
728
729 let playbook = Playbook::parse(content)
730 .unwrap()
731 .render(&variables)
732 .unwrap();
733 let scene = playbook.scenes.get("main").unwrap();
734
735 assert!(scene.prompt.contains("SIP Header: header_value"));
737 assert!(scene.prompt.contains("Regular var: regular_value"));
738 }
739
740 #[test]
741 fn test_sip_dict_mixed_access() {
742 let content = r#"---
744llm:
745 provider: openai
746---
747# Scene: main
748Direct: {{ simple_var }}
749SIP Header: {{ sip["X-Custom-Header"] }}
750SIP via Direct: {{ X_Custom_Header2 }}
751"#;
752 let mut variables = HashMap::new();
753 variables.insert("simple_var".to_string(), json!("direct_value"));
754 variables.insert("X-Custom-Header".to_string(), json!("header_value"));
755 variables.insert("X_Custom_Header2".to_string(), json!("header2_value"));
756 variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
758
759 let playbook = Playbook::parse(content)
760 .unwrap()
761 .render(&variables)
762 .unwrap();
763 let scene = playbook.scenes.get("main").unwrap();
764
765 assert!(scene.prompt.contains("Direct: direct_value"));
766 assert!(scene.prompt.contains("SIP Header: header_value"));
767 assert!(scene.prompt.contains("SIP via Direct: header2_value"));
769 }
770
771 #[test]
772 fn test_sip_dict_empty_context() {
773 let content = r#"---
775llm:
776 provider: openai
777---
778# Scene: main
779No variables here.
780"#;
781 let playbook = Playbook::parse(content).unwrap();
782 let scene = playbook.scenes.get("main").unwrap();
783 assert_eq!(scene.prompt, "No variables here.");
784 }
785
786 #[test]
787 fn test_sip_dict_case_insensitive() {
788 let content = r#"---
790llm:
791 provider: openai
792---
793# Scene: main
794Upper: {{ sip["X-Header-Upper"] }}
795Lower: {{ sip["x-header-lower"] }}
796"#;
797 let mut variables = HashMap::new();
798 variables.insert("X-Header-Upper".to_string(), json!("UPPER"));
799 variables.insert("x-header-lower".to_string(), json!("lower"));
800 variables.insert(
801 "_sip_header_keys".to_string(),
802 json!(["X-Header-Upper", "x-header-lower"]),
803 );
804
805 let playbook = Playbook::parse(content)
806 .unwrap()
807 .render(&variables)
808 .unwrap();
809 let scene = playbook.scenes.get("main").unwrap();
810
811 assert!(scene.prompt.contains("Upper: UPPER"));
812 assert!(scene.prompt.contains("Lower: lower"));
813 }
814
815 #[test]
816 fn test_env_vars_in_all_fields() {
817 unsafe {
819 std::env::set_var("TEST_MODEL_ALL", "gpt-4o");
820 std::env::set_var("TEST_API_KEY_ALL", "sk-test-12345");
821 std::env::set_var("TEST_BASE_URL_ALL", "https://api.example.com");
822 std::env::set_var("TEST_SPEAKER_ALL", "F1");
823 std::env::set_var("TEST_LANGUAGE_ALL", "zh");
824 std::env::set_var("TEST_SPEED_ALL", "1.2");
825 }
826
827 let content = r#"---
828asr:
829 provider: "sensevoice"
830 language: "${TEST_LANGUAGE_ALL}"
831tts:
832 provider: "supertonic"
833 speaker: "${TEST_SPEAKER_ALL}"
834 speed: ${TEST_SPEED_ALL}
835llm:
836 provider: "openai"
837 model: "${TEST_MODEL_ALL}"
838 apiKey: "${TEST_API_KEY_ALL}"
839 baseUrl: "${TEST_BASE_URL_ALL}"
840---
841# Scene: main
842Test content
843"#;
844
845 let playbook = Playbook::parse(content).unwrap();
846
847 let asr = playbook.config.asr.unwrap();
849 assert_eq!(asr.language.unwrap(), "zh");
850
851 let tts = playbook.config.tts.unwrap();
853 assert_eq!(tts.speaker.unwrap(), "F1");
854 assert_eq!(tts.speed, Some(1.2));
855
856 let llm = playbook.config.llm.unwrap();
858 assert_eq!(llm.model.unwrap(), "gpt-4o");
859 assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
860 assert_eq!(llm.base_url.unwrap(), "https://api.example.com");
861
862 unsafe {
863 std::env::remove_var("TEST_MODEL_ALL");
864 std::env::remove_var("TEST_API_KEY_ALL");
865 std::env::remove_var("TEST_BASE_URL_ALL");
866 std::env::remove_var("TEST_SPEAKER_ALL");
867 std::env::remove_var("TEST_LANGUAGE_ALL");
868 std::env::remove_var("TEST_SPEED_ALL");
869 }
870 }
871
872 #[test]
873 fn test_sip_dict_with_http_command() {
874 let content = r#"---
876llm:
877 provider: openai
878---
879# Scene: main
880Querying API: <http url='https://api.example.com/customers/{{ sip["X-Customer-ID"] }}' method="GET" />
881"#;
882 let mut variables = HashMap::new();
883 variables.insert("X-Customer-ID".to_string(), json!("CUST12345"));
884 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
885
886 let playbook = Playbook::parse(content)
887 .unwrap()
888 .render(&variables)
889 .unwrap();
890 let scene = playbook.scenes.get("main").unwrap();
891
892 assert!(
894 scene
895 .prompt
896 .contains("https://api.example.com/customers/CUST12345")
897 );
898 }
899
900 #[test]
901 fn test_sip_dict_without_extract_config() {
902 let content = r#"---
904llm:
905 provider: openai
906---
907# Scene: main
908Regular var: {{ regular_var }}
909SIP dict should be empty.
910"#;
911 let mut variables = HashMap::new();
912 variables.insert("regular_var".to_string(), json!("regular_value"));
913 let playbook = Playbook::parse(content)
916 .unwrap()
917 .render(&variables)
918 .unwrap();
919 let scene = playbook.scenes.get("main").unwrap();
920
921 assert!(scene.prompt.contains("Regular var: regular_value"));
922 }
923
924 #[test]
925 fn test_sip_dict_with_multiple_headers_in_yaml() {
926 let content = r#"---
928llm:
929 provider: openai
930 greeting: 'Welcome {{ sip["X-Customer-Name"] }}! Your ID is {{ sip["X-Customer-ID"] }}.'
931---
932# Scene: main
933How can I help you today?
934"#;
935 let mut variables = HashMap::new();
936 variables.insert("X-Customer-Name".to_string(), json!("Alice"));
937 variables.insert("X-Customer-ID".to_string(), json!("CUST789"));
938 variables.insert(
939 "_sip_header_keys".to_string(),
940 json!(["X-Customer-Name", "X-Customer-ID"]),
941 );
942
943 let playbook = Playbook::parse(content).unwrap();
944 let playbook = playbook.render(&variables).unwrap();
945
946 assert_eq!(
947 playbook.config.llm.as_ref().unwrap().greeting,
948 Some("Welcome Alice! Your ID is CUST789.".to_string())
949 );
950 }
951
952 #[test]
953 fn test_wrong_syntax_should_fail() {
954 let content = r#"---
956llm:
957 provider: openai
958---
959# Scene: main
960This will fail: {{ X-Customer-ID }}
961"#;
962 let mut variables = HashMap::new();
963 variables.insert("X-Customer-ID".to_string(), json!("CUST123"));
964 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
965
966 let playbook = Playbook::parse(content).unwrap();
969 let result = playbook.render(&variables);
970 assert!(result.is_err());
972 }
973
974 #[test]
975 fn test_sip_dict_with_set_var() {
976 let content = r#"---
978llm:
979 provider: openai
980---
981# Scene: main
982<set_var key="X-Call-Status" value="active" />
983Customer: {{ sip["X-Customer-ID"] }}
984Status set successfully.
985"#;
986 let mut variables = HashMap::new();
987 variables.insert("X-Customer-ID".to_string(), json!("CUST456"));
988 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
989
990 let playbook = Playbook::parse(content)
991 .unwrap()
992 .render(&variables)
993 .unwrap();
994 let scene = playbook.scenes.get("main").unwrap();
995
996 assert!(scene.prompt.contains("Customer: CUST456"));
997 assert!(scene.prompt.contains("<set_var"));
998 }
999
1000 #[test]
1001 fn test_sip_dict_mixed_with_regular_vars_in_complex_scenario() {
1002 let content = r#"---
1004llm:
1005 provider: openai
1006 greeting: 'Hello {{ sip["X-Customer-Name"] }}, member level: {{ member_level }}'
1007---
1008# Scene: main
1009Your ID: {{ sip["X-Customer-ID"] }}
1010Your status: {{ account_status }}
1011Your priority: {{ sip["X-Priority"] }}
1012Order count: {{ order_count }}
1013"#;
1014 let mut variables = HashMap::new();
1015 variables.insert("X-Customer-Name".to_string(), json!("Bob"));
1017 variables.insert("X-Customer-ID".to_string(), json!("CUST999"));
1018 variables.insert("X-Priority".to_string(), json!("VIP"));
1019 variables.insert("member_level".to_string(), json!("Gold"));
1021 variables.insert("account_status".to_string(), json!("Active"));
1022 variables.insert("order_count".to_string(), json!(5));
1023 variables.insert(
1025 "_sip_header_keys".to_string(),
1026 json!(["X-Customer-Name", "X-Customer-ID", "X-Priority"]),
1027 );
1028
1029 let playbook = Playbook::parse(content)
1030 .unwrap()
1031 .render(&variables)
1032 .unwrap();
1033
1034 assert_eq!(
1036 playbook.config.llm.as_ref().unwrap().greeting,
1037 Some("Hello Bob, member level: Gold".to_string())
1038 );
1039
1040 let scene = playbook.scenes.get("main").unwrap();
1042 assert!(scene.prompt.contains("Your ID: CUST999"));
1043 assert!(scene.prompt.contains("Your status: Active"));
1044 assert!(scene.prompt.contains("Your priority: VIP"));
1045 assert!(scene.prompt.contains("Order count: 5"));
1046 }
1047
1048 #[test]
1049 fn test_raw_prompt_preserved_after_render() {
1050 let content = r#"---
1052llm:
1053 provider: openai
1054---
1055# Scene: greeting
1056您好,{{ customer_name }}!您的意图是:{{ intent }}
1057# Scene: detail
1058客户意图:{{ intent }}
1059详细信息在此。
1060"#;
1061 let mut variables = HashMap::new();
1062 variables.insert("customer_name".to_string(), json!("张三"));
1063 variables.insert("intent".to_string(), json!("咨询"));
1064
1065 let playbook = Playbook::parse(content)
1066 .unwrap()
1067 .render(&variables)
1068 .unwrap();
1069
1070 let greeting = playbook.scenes.get("greeting").unwrap();
1072 assert!(greeting.prompt.contains("您好,张三"));
1073 assert!(greeting.prompt.contains("您的意图是:咨询"));
1074
1075 assert!(greeting.raw_prompt.is_some());
1077 let raw = greeting.raw_prompt.as_ref().unwrap();
1078 assert!(raw.contains("{{ customer_name }}"));
1079 assert!(raw.contains("{{ intent }}"));
1080
1081 let detail = playbook.scenes.get("detail").unwrap();
1083 assert!(detail.raw_prompt.is_some());
1084 assert!(detail.raw_prompt.as_ref().unwrap().contains("{{ intent }}"));
1085 }
1086
1087 #[test]
1088 fn test_render_scene_prompt_with_dynamic_vars() {
1089 let scene = Scene {
1091 id: "main".to_string(),
1092 raw_prompt: Some("客户意图:{{ intent }}\n客户ID:{{ sip[\"X-Jobid\"] }}".to_string()),
1093 prompt: "客户意图:\n客户ID:JOB123".to_string(), ..Default::default()
1095 };
1096
1097 let mut vars = HashMap::new();
1099 vars.insert("intent".to_string(), json!("买零食"));
1100 vars.insert("X-Jobid".to_string(), json!("JOB123"));
1101 vars.insert("_sip_header_keys".to_string(), json!(["X-Jobid"]));
1102
1103 let rendered = render_scene_prompt(&scene, &vars);
1104 assert!(rendered.contains("客户意图:买零食"));
1105 assert!(rendered.contains("客户ID:JOB123"));
1106 }
1107
1108 #[test]
1109 fn test_render_scene_prompt_fallback_without_template() {
1110 let scene = Scene {
1112 id: "simple".to_string(),
1113 raw_prompt: Some("你好,欢迎光临".to_string()),
1114 prompt: "你好,欢迎光临".to_string(),
1115 ..Default::default()
1116 };
1117
1118 let vars = HashMap::new();
1119 let rendered = render_scene_prompt(&scene, &vars);
1120 assert_eq!(rendered, "你好,欢迎光临");
1121 }
1122
1123 #[test]
1124 fn test_render_scene_prompt_fallback_no_raw_prompt() {
1125 let scene = Scene {
1127 id: "legacy".to_string(),
1128 prompt: "Hello world".to_string(),
1129 ..Default::default()
1130 };
1131
1132 let vars = HashMap::new();
1133 let rendered = render_scene_prompt(&scene, &vars);
1134 assert_eq!(rendered, "Hello world");
1135 }
1136
1137 #[test]
1138 fn test_render_scene_prompt_with_builtin_vars() {
1139 let scene = Scene {
1141 id: "main".to_string(),
1142 raw_prompt: Some(
1143 "会话ID:{{ session_id }}\n呼叫类型:{{ call_type }}\n主叫:{{ caller }}\n被叫:{{ callee }}\n开始时间:{{ start_time }}"
1144 .to_string(),
1145 ),
1146 prompt: String::new(),
1147 ..Default::default()
1148 };
1149
1150 let mut vars = HashMap::new();
1151 vars.insert(BUILTIN_SESSION_ID.to_string(), json!("sess-12345"));
1152 vars.insert(BUILTIN_CALL_TYPE.to_string(), json!("sip"));
1153 vars.insert(BUILTIN_CALLER.to_string(), json!("sip:alice@example.com"));
1154 vars.insert(BUILTIN_CALLEE.to_string(), json!("sip:bob@example.com"));
1155 vars.insert(
1156 BUILTIN_START_TIME.to_string(),
1157 json!("2026-02-14T10:00:00Z"),
1158 );
1159
1160 let rendered = render_scene_prompt(&scene, &vars);
1161 assert!(rendered.contains("会话ID:sess-12345"));
1162 assert!(rendered.contains("呼叫类型:sip"));
1163 assert!(rendered.contains("主叫:sip:alice@example.com"));
1164 assert!(rendered.contains("被叫:sip:bob@example.com"));
1165 assert!(rendered.contains("开始时间:2026-02-14T10:00:00Z"));
1166 }
1167
1168 #[test]
1169 fn test_render_scene_prompt_mixed_sip_and_set_var() {
1170 let scene = Scene {
1172 id: "main".to_string(),
1173 raw_prompt: Some(
1174 "客户:{{ sip[\"X-Customer-Name\"] }}\n意图:{{ intent }}\n会话:{{ session_id }}"
1175 .to_string(),
1176 ),
1177 prompt: String::new(),
1178 ..Default::default()
1179 };
1180
1181 let mut vars = HashMap::new();
1182 vars.insert("X-Customer-Name".to_string(), json!("王五"));
1184 vars.insert("_sip_header_keys".to_string(), json!(["X-Customer-Name"]));
1185 vars.insert("intent".to_string(), json!("退货"));
1187 vars.insert(BUILTIN_SESSION_ID.to_string(), json!("sess-99"));
1189
1190 let rendered = render_scene_prompt(&scene, &vars);
1191 assert!(rendered.contains("客户:王五"));
1192 assert!(rendered.contains("意图:退货"));
1193 assert!(rendered.contains("会话:sess-99"));
1194 }
1195
1196 #[test]
1197 fn test_render_scene_prompt_graceful_on_missing_vars() {
1198 let scene = Scene {
1201 id: "main".to_string(),
1202 raw_prompt: Some("意图:{{ intent }}".to_string()),
1203 prompt: "意图:(未知)".to_string(), ..Default::default()
1205 };
1206
1207 let vars = HashMap::new(); let rendered = render_scene_prompt(&scene, &vars);
1209 assert_eq!(rendered, "意图:");
1211 }
1212
1213 #[test]
1214 fn test_raw_prompt_set_on_parse() {
1215 let content = r#"---
1217llm:
1218 provider: openai
1219---
1220# Scene: main
1221Hello {{ name }}!
1222"#;
1223 let playbook = Playbook::parse(content).unwrap();
1224 let scene = playbook.scenes.get("main").unwrap();
1225 assert!(scene.raw_prompt.is_some());
1226 assert!(scene.raw_prompt.as_ref().unwrap().contains("{{ name }}"));
1227 }
1228
1229 #[test]
1230 fn test_builtin_var_constants() {
1231 assert_eq!(BUILTIN_SESSION_ID, "session_id");
1233 assert_eq!(BUILTIN_CALL_TYPE, "call_type");
1234 assert_eq!(BUILTIN_CALLER, "caller");
1235 assert_eq!(BUILTIN_CALLEE, "callee");
1236 assert_eq!(BUILTIN_START_TIME, "start_time");
1237 }
1238}