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