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