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