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