Skip to main content

active_call/playbook/
mod.rs

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
13/// Expand environment variables in the format ${VAR_NAME}
14fn expand_env_vars(input: &str) -> String {
15    let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
16    re.replace_all(input, |caps: &regex::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 realtime: Option<RealtimeOption>,
59    pub posthook: Option<PostHookConfig>,
60    pub follow_up: Option<FollowUpConfig>,
61    pub sip: Option<SipOption>,
62}
63
64#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
65#[serde(rename_all = "camelCase")]
66pub struct FollowUpConfig {
67    pub timeout: u64,
68    pub max_count: u32,
69}
70
71#[derive(Debug, Deserialize, Serialize, Clone)]
72#[serde(rename_all = "lowercase")]
73pub enum SummaryType {
74    Short,
75    Detailed,
76    Intent,
77    Json,
78    #[serde(untagged)]
79    Custom(String),
80}
81
82impl SummaryType {
83    pub fn prompt(&self) -> &str {
84        match self {
85            Self::Short => "summarize the conversation in one or two sentences.",
86            Self::Detailed => {
87                "summarize the conversation in detail, including key points, decisions, and action items."
88            }
89            Self::Intent => "identify and summarize the user's main intent and needs.",
90            Self::Json => {
91                "output the conversation summary in JSON format with fields: intent, key_points, sentiment."
92            }
93            Self::Custom(p) => p,
94        }
95    }
96}
97
98#[derive(Debug, Deserialize, Serialize, Clone, Default)]
99#[serde(rename_all = "camelCase")]
100pub struct PostHookConfig {
101    pub url: String,
102    pub summary: Option<SummaryType>,
103    pub method: Option<String>,
104    pub headers: Option<HashMap<String, String>>,
105    pub include_history: Option<bool>,
106}
107
108#[derive(Debug, Deserialize, Serialize, Clone)]
109#[serde(tag = "action", rename_all = "lowercase")]
110pub enum DtmfAction {
111    Goto { scene: String },
112    Transfer { target: String },
113    Hangup,
114}
115
116#[derive(Debug, Deserialize, Serialize, Clone, Default)]
117#[serde(rename_all = "camelCase")]
118pub struct LlmConfig {
119    pub provider: String,
120    pub model: Option<String>,
121    pub base_url: Option<String>,
122    pub api_key: Option<String>,
123    pub prompt: Option<String>,
124    pub greeting: Option<String>,
125    pub language: Option<String>,
126    pub features: Option<Vec<String>>,
127    pub repair_window_ms: Option<u64>,
128    pub summary_limit: Option<usize>,
129    /// Custom tool instructions. If not set, default tool instructions based on language will be used.
130    /// Set this to override the built-in tool usage instructions completely.
131    pub tool_instructions: Option<String>,
132}
133
134#[derive(Serialize, Deserialize, Clone, Debug)]
135pub struct ChatMessage {
136    pub role: String,
137    pub content: String,
138}
139
140#[derive(Debug, Clone, Default)]
141pub struct Scene {
142    pub id: String,
143    pub prompt: String,
144    pub dtmf: Option<HashMap<String, DtmfAction>>,
145    pub play: Option<String>,
146    pub follow_up: Option<FollowUpConfig>,
147}
148
149#[derive(Debug, Clone)]
150pub struct Playbook {
151    pub raw_content: String,
152    pub config: PlaybookConfig,
153    pub scenes: HashMap<String, Scene>,
154    pub initial_scene_id: Option<String>,
155}
156
157impl Playbook {
158    pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
159        let content = fs::read_to_string(path).await?;
160        Self::parse(&content)
161    }
162
163    pub fn render(&self, vars: &HashMap<String, serde_json::Value>) -> Result<Self> {
164        let env = Environment::new();
165        let mut context = vars.clone();
166
167        // Get the list of SIP header keys stored by extract_headers processing
168        // If not present, sip dict will be empty (no headers were configured for extraction)
169        let sip_header_keys: Vec<String> = vars
170            .get("_sip_header_keys")
171            .and_then(|v| serde_json::from_value(v.clone()).ok())
172            .unwrap_or_default();
173
174        // Separate SIP headers into sip dictionary based on stored keys
175        let mut sip_headers = HashMap::new();
176        for key in &sip_header_keys {
177            if let Some(value) = vars.get(key) {
178                sip_headers.insert(key.clone(), value.clone());
179            }
180        }
181        context.insert(
182            "sip".to_string(),
183            serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
184        );
185
186        let rendered = env.render_str(&self.raw_content, &context)?;
187        let mut res = Self::parse(&rendered)?;
188        res.config.sip.as_mut().map(|sip| {
189            sip.hangup_headers = self
190                .config
191                .sip
192                .as_ref()
193                .and_then(|sip| sip.hangup_headers.clone());
194        });
195        Ok(res)
196    }
197
198    pub fn parse(content: &str) -> Result<Self> {
199        if !content.starts_with("---") {
200            return Err(anyhow!("Missing front matter"));
201        }
202
203        let parts: Vec<&str> = content.splitn(3, "---").collect();
204        if parts.len() < 3 {
205            return Err(anyhow!("Invalid front matter format"));
206        }
207
208        let yaml_str = parts[1];
209        let prompt_section = parts[2].trim();
210
211        // Expand environment variables in YAML configuration
212        // This allows ALL fields to use ${VAR_NAME} syntax
213        let expanded_yaml = expand_env_vars(yaml_str);
214        let mut config: PlaybookConfig = serde_yaml::from_str(&expanded_yaml)?;
215
216        let mut scenes = HashMap::new();
217        let mut first_scene_id: Option<String> = None;
218
219        let dtmf_regex =
220            regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
221        let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
222        let followup_regex =
223            regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
224
225        let parse_scene = |id: String, content: String| -> Scene {
226            let mut dtmf_map = HashMap::new();
227            let mut play = None;
228            let mut follow_up = None;
229            let mut final_content = content.clone();
230
231            for cap in dtmf_regex.captures_iter(&content) {
232                let digit = cap.get(1).unwrap().as_str().to_string();
233                let action_type = cap.get(2).unwrap().as_str();
234
235                let action = match action_type {
236                    "goto" => {
237                        let scene = cap
238                            .get(3)
239                            .map(|m| m.as_str().to_string())
240                            .unwrap_or_default();
241                        DtmfAction::Goto { scene }
242                    }
243                    "transfer" => {
244                        let target = cap
245                            .get(4)
246                            .map(|m| m.as_str().to_string())
247                            .unwrap_or_default();
248                        DtmfAction::Transfer { target }
249                    }
250                    "hangup" => DtmfAction::Hangup,
251                    _ => continue,
252                };
253                dtmf_map.insert(digit, action);
254            }
255
256            if let Some(cap) = play_regex.captures(&content) {
257                play = Some(cap.get(1).unwrap().as_str().to_string());
258            }
259
260            if let Some(cap) = followup_regex.captures(&content) {
261                let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
262                let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
263                follow_up = Some(FollowUpConfig { timeout, max_count });
264            }
265
266            // Remove dtmf and play tags from the content
267            final_content = dtmf_regex.replace_all(&final_content, "").to_string();
268            final_content = play_regex.replace_all(&final_content, "").to_string();
269            final_content = followup_regex.replace_all(&final_content, "").to_string();
270            final_content = final_content.trim().to_string();
271
272            Scene {
273                id,
274                prompt: final_content,
275                dtmf: if dtmf_map.is_empty() {
276                    None
277                } else {
278                    Some(dtmf_map)
279                },
280                play,
281                follow_up,
282            }
283        };
284
285        // Parse scenes from markdown. Look for headers like "# Scene: <id>"
286        let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
287        let mut last_match_end = 0;
288        let mut last_scene_id: Option<String> = None;
289
290        for cap in scene_regex.captures_iter(prompt_section) {
291            let m = cap.get(0).unwrap();
292            let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
293
294            if first_scene_id.is_none() {
295                first_scene_id = Some(scene_id.clone());
296            }
297
298            if let Some(id) = last_scene_id {
299                let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
300                scenes.insert(id.clone(), parse_scene(id, scene_content));
301            } else {
302                // Content before the first scene header
303                let pre_content = prompt_section[..m.start()].trim();
304                if !pre_content.is_empty() {
305                    let id = "default".to_string();
306                    first_scene_id = Some(id.clone());
307                    scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
308                }
309            }
310
311            last_scene_id = Some(scene_id);
312            last_match_end = m.end();
313        }
314
315        if let Some(id) = last_scene_id {
316            let scene_content = prompt_section[last_match_end..].trim().to_string();
317            scenes.insert(id.clone(), parse_scene(id, scene_content));
318        } else if !prompt_section.is_empty() {
319            // No scene headers found, treat the whole prompt as "default"
320            let id = "default".to_string();
321            first_scene_id = Some(id.clone());
322            scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
323        }
324
325        if let Some(llm) = config.llm.as_mut() {
326            // Fallback to direct env var if not set
327            if llm.api_key.is_none() {
328                if let Ok(key) = std::env::var("OPENAI_API_KEY") {
329                    llm.api_key = Some(key);
330                }
331            }
332            if llm.base_url.is_none() {
333                if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
334                    llm.base_url = Some(url);
335                }
336            }
337            if llm.model.is_none() {
338                if let Ok(model) = std::env::var("OPENAI_MODEL") {
339                    llm.model = Some(model);
340                }
341            }
342
343            // Use the first scene found as the initial prompt
344            if let Some(initial_id) = first_scene_id.clone() {
345                if let Some(scene) = scenes.get(&initial_id) {
346                    llm.prompt = Some(scene.prompt.clone());
347                }
348            }
349        }
350
351        Ok(Self {
352            raw_content: content.to_string(),
353            config,
354            scenes,
355            initial_scene_id: first_scene_id,
356        })
357    }
358}
359
360pub mod dialogue;
361pub mod handler;
362pub mod runner;
363
364pub use dialogue::DialogueHandler;
365pub use handler::{LlmHandler, RagRetriever};
366pub use runner::PlaybookRunner;
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use serde_json::json;
372
373    #[test]
374    fn test_playbook_parsing_with_variables() {
375        let content = r#"---
376llm:
377  provider: openai
378  model: |-
379    {{ model_name }}
380  greeting: |-
381    Hello, {{ user_name }}!
382---
383# Scene: main
384You are an assistant for {{ company }}.
385"#;
386        let mut variables = HashMap::new();
387        variables.insert("model_name".to_string(), json!("gpt-4"));
388        variables.insert("user_name".to_string(), json!("Alice"));
389        variables.insert("company".to_string(), json!("RestSend"));
390
391        let playbook = Playbook::parse(content)
392            .unwrap()
393            .render(&variables)
394            .unwrap();
395        assert_eq!(
396            playbook.config.llm.as_ref().unwrap().model,
397            Some("gpt-4".to_string())
398        );
399        assert_eq!(
400            playbook.config.llm.as_ref().unwrap().greeting,
401            Some("Hello, Alice!".to_string())
402        );
403
404        let scene = playbook.scenes.get("main").unwrap();
405        assert_eq!(scene.prompt, "You are an assistant for RestSend.");
406    }
407
408    #[test]
409    fn test_playbook_scene_dtmf_parsing() {
410        let content = r#"---
411llm:
412  provider: openai
413---
414# Scene: main
415<dtmf digit="1" action="goto" scene="product" />
416<dtmf digit="2" action="transfer" target="sip:123@domain" />
417<dtmf digit="0" action="hangup" />
418Welcome to our service.
419"#;
420        let playbook = Playbook::parse(content).unwrap();
421
422        let scene = playbook.scenes.get("main").unwrap();
423        assert_eq!(scene.prompt, "Welcome to our service.");
424
425        let dtmf = scene.dtmf.as_ref().unwrap();
426        assert_eq!(dtmf.len(), 3);
427
428        match dtmf.get("1").unwrap() {
429            DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
430            _ => panic!("Expected Goto action"),
431        }
432
433        match dtmf.get("2").unwrap() {
434            DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
435            _ => panic!("Expected Transfer action"),
436        }
437
438        match dtmf.get("0").unwrap() {
439            DtmfAction::Hangup => {}
440            _ => panic!("Expected Hangup action"),
441        }
442    }
443
444    #[test]
445    fn test_playbook_dtmf_priority() {
446        let content = r#"---
447llm:
448  provider: openai
449dtmf:
450  "1": { action: "goto", scene: "global_dest" }
451  "9": { action: "hangup" }
452---
453# Scene: main
454<dtmf digit="1" action="goto" scene="local_dest" />
455Welcome.
456"#;
457        let playbook = Playbook::parse(content).unwrap();
458
459        // Check global config
460        let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
461        assert_eq!(global_dtmf.len(), 2);
462
463        // Check scene config
464        let scene = playbook.scenes.get("main").unwrap();
465        let scene_dtmf = scene.dtmf.as_ref().unwrap();
466        assert_eq!(scene_dtmf.len(), 1);
467
468        // Verify scene has local_dest for "1"
469        match scene_dtmf.get("1").unwrap() {
470            DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
471            _ => panic!("Expected Local Goto action"),
472        }
473    }
474
475    #[test]
476    fn test_posthook_config_parsing() {
477        let content = r#"---
478posthook:
479  url: "http://test.com"
480  summary: "json"
481  includeHistory: true
482  headers:
483    X-API-Key: "secret"
484llm:
485  provider: openai
486---
487# Scene: main
488Hello
489"#;
490        let playbook = Playbook::parse(content).unwrap();
491        let posthook = playbook.config.posthook.unwrap();
492        assert_eq!(posthook.url, "http://test.com");
493        match posthook.summary.unwrap() {
494            SummaryType::Json => {}
495            _ => panic!("Expected Json summary type"),
496        }
497        assert_eq!(posthook.include_history, Some(true));
498        assert_eq!(
499            posthook.headers.unwrap().get("X-API-Key").unwrap(),
500            "secret"
501        );
502    }
503
504    #[test]
505    fn test_env_var_expansion() {
506        // Set test env vars
507        unsafe {
508            std::env::set_var("TEST_API_KEY", "sk-test-12345");
509            std::env::set_var("TEST_BASE_URL", "https://api.test.com");
510        }
511
512        let content = r#"---
513llm:
514  provider: openai
515  apiKey: "${TEST_API_KEY}"
516  baseUrl: "${TEST_BASE_URL}"
517  model: gpt-4
518---
519# Scene: main
520Test
521"#;
522        let playbook = Playbook::parse(content).unwrap();
523        let llm = playbook.config.llm.unwrap();
524
525        assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
526        assert_eq!(llm.base_url.unwrap(), "https://api.test.com");
527        assert_eq!(llm.model.unwrap(), "gpt-4");
528
529        // Clean up
530        unsafe {
531            std::env::remove_var("TEST_API_KEY");
532            std::env::remove_var("TEST_BASE_URL");
533        }
534    }
535
536    #[test]
537    fn test_env_var_expansion_missing() {
538        // Test with undefined var
539        let content = r#"---
540llm:
541  provider: openai
542  apiKey: "${UNDEFINED_VAR}"
543---
544# Scene: main
545Test
546"#;
547        let playbook = Playbook::parse(content).unwrap();
548        let llm = playbook.config.llm.unwrap();
549
550        // Should keep the placeholder if env var not found
551        assert_eq!(llm.api_key.unwrap(), "${UNDEFINED_VAR}");
552    }
553
554    #[test]
555    fn test_custom_summary_parsing() {
556        let content = r#"---
557posthook:
558  url: "http://test.com"
559  summary: "Please summarize customly"
560llm:
561  provider: openai
562---
563# Scene: main
564Hello
565"#;
566        let playbook = Playbook::parse(content).unwrap();
567        let posthook = playbook.config.posthook.unwrap();
568        match posthook.summary.unwrap() {
569            SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
570            _ => panic!("Expected Custom summary type"),
571        }
572    }
573
574    #[test]
575    fn test_sip_dict_access_with_hyphens() {
576        // Test accessing SIP headers with hyphens via sip dictionary
577        let content = r#"---
578llm:
579  provider: openai
580  greeting: Hello {{ sip["X-Customer-Name"] }}!
581---
582# Scene: main
583Your ID is {{ sip["X-Customer-ID"] }}.
584Session type: {{ sip["X-Session-Type"] }}.
585"#;
586        let mut variables = HashMap::new();
587        variables.insert("X-Customer-Name".to_string(), json!("Alice"));
588        variables.insert("X-Customer-ID".to_string(), json!("CID-12345"));
589        variables.insert("X-Session-Type".to_string(), json!("inbound"));
590        // Simulate extract_headers processing
591        variables.insert(
592            "_sip_header_keys".to_string(),
593            json!(["X-Customer-Name", "X-Customer-ID", "X-Session-Type"]),
594        );
595
596        let playbook = Playbook::parse(content)
597            .unwrap()
598            .render(&variables)
599            .unwrap();
600        assert_eq!(
601            playbook.config.llm.as_ref().unwrap().greeting,
602            Some("Hello Alice!".to_string())
603        );
604
605        let scene = playbook.scenes.get("main").unwrap();
606        assert_eq!(
607            scene.prompt,
608            "Your ID is CID-12345.\nSession type: inbound."
609        );
610    }
611
612    #[test]
613    fn test_sip_dict_only_contains_sip_headers() {
614        // Test that sip dict only contains SIP headers from extract_headers, not other variables
615        let content = r#"---
616llm:
617  provider: openai
618---
619# Scene: main
620SIP Header: {{ sip["X-Custom-Header"] }}
621Regular var: {{ regular_var }}
622"#;
623        let mut variables = HashMap::new();
624        variables.insert("X-Custom-Header".to_string(), json!("header_value"));
625        variables.insert("regular_var".to_string(), json!("regular_value"));
626        variables.insert("another_var".to_string(), json!("another"));
627        // Only X-Custom-Header is extracted
628        variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
629
630        let playbook = Playbook::parse(content)
631            .unwrap()
632            .render(&variables)
633            .unwrap();
634        let scene = playbook.scenes.get("main").unwrap();
635
636        // Both should work - SIP header via sip dict, regular var via direct access
637        assert!(scene.prompt.contains("SIP Header: header_value"));
638        assert!(scene.prompt.contains("Regular var: regular_value"));
639    }
640
641    #[test]
642    fn test_sip_dict_mixed_access() {
643        // Test that both direct access and sip dict access work together
644        let content = r#"---
645llm:
646  provider: openai
647---
648# Scene: main
649Direct: {{ simple_var }}
650SIP Header: {{ sip["X-Custom-Header"] }}
651SIP via Direct: {{ X_Custom_Header2 }}
652"#;
653        let mut variables = HashMap::new();
654        variables.insert("simple_var".to_string(), json!("direct_value"));
655        variables.insert("X-Custom-Header".to_string(), json!("header_value"));
656        variables.insert("X_Custom_Header2".to_string(), json!("header2_value"));
657        // Only X-Custom-Header is in extract_headers
658        variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
659
660        let playbook = Playbook::parse(content)
661            .unwrap()
662            .render(&variables)
663            .unwrap();
664        let scene = playbook.scenes.get("main").unwrap();
665
666        assert!(scene.prompt.contains("Direct: direct_value"));
667        assert!(scene.prompt.contains("SIP Header: header_value"));
668        // X_Custom_Header2 doesn't start with X-, so won't be in sip dict
669        assert!(scene.prompt.contains("SIP via Direct: header2_value"));
670    }
671
672    #[test]
673    fn test_sip_dict_empty_context() {
674        // Test that sip dict works with no variables
675        let content = r#"---
676llm:
677  provider: openai
678---
679# Scene: main
680No variables here.
681"#;
682        let playbook = Playbook::parse(content).unwrap();
683        let scene = playbook.scenes.get("main").unwrap();
684        assert_eq!(scene.prompt, "No variables here.");
685    }
686
687    #[test]
688    fn test_sip_dict_case_insensitive() {
689        // Test that extract_headers can include headers with different cases
690        let content = r#"---
691llm:
692  provider: openai
693---
694# Scene: main
695Upper: {{ sip["X-Header-Upper"] }}
696Lower: {{ sip["x-header-lower"] }}
697"#;
698        let mut variables = HashMap::new();
699        variables.insert("X-Header-Upper".to_string(), json!("UPPER"));
700        variables.insert("x-header-lower".to_string(), json!("lower"));
701        variables.insert(
702            "_sip_header_keys".to_string(),
703            json!(["X-Header-Upper", "x-header-lower"]),
704        );
705
706        let playbook = Playbook::parse(content)
707            .unwrap()
708            .render(&variables)
709            .unwrap();
710        let scene = playbook.scenes.get("main").unwrap();
711
712        assert!(scene.prompt.contains("Upper: UPPER"));
713        assert!(scene.prompt.contains("Lower: lower"));
714    }
715
716    #[test]
717    fn test_env_vars_in_all_fields() {
718        // Test that ${VAR} works in all configuration fields
719        unsafe {
720            std::env::set_var("TEST_MODEL_ALL", "gpt-4o");
721            std::env::set_var("TEST_API_KEY_ALL", "sk-test-12345");
722            std::env::set_var("TEST_BASE_URL_ALL", "https://api.example.com");
723            std::env::set_var("TEST_SPEAKER_ALL", "F1");
724            std::env::set_var("TEST_LANGUAGE_ALL", "zh");
725            std::env::set_var("TEST_SPEED_ALL", "1.2");
726        }
727
728        let content = r#"---
729asr:
730  provider: "sensevoice"
731  language: "${TEST_LANGUAGE_ALL}"
732tts:
733  provider: "supertonic"
734  speaker: "${TEST_SPEAKER_ALL}"
735  speed: ${TEST_SPEED_ALL}
736llm:
737  provider: "openai"
738  model: "${TEST_MODEL_ALL}"
739  apiKey: "${TEST_API_KEY_ALL}"
740  baseUrl: "${TEST_BASE_URL_ALL}"
741---
742# Scene: main
743Test content
744"#;
745
746        let playbook = Playbook::parse(content).unwrap();
747
748        // Verify ASR fields
749        let asr = playbook.config.asr.unwrap();
750        assert_eq!(asr.language.unwrap(), "zh");
751
752        // Verify TTS fields
753        let tts = playbook.config.tts.unwrap();
754        assert_eq!(tts.speaker.unwrap(), "F1");
755        assert_eq!(tts.speed, Some(1.2));
756
757        // Verify LLM fields
758        let llm = playbook.config.llm.unwrap();
759        assert_eq!(llm.model.unwrap(), "gpt-4o");
760        assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
761        assert_eq!(llm.base_url.unwrap(), "https://api.example.com");
762
763        unsafe {
764            std::env::remove_var("TEST_MODEL_ALL");
765            std::env::remove_var("TEST_API_KEY_ALL");
766            std::env::remove_var("TEST_BASE_URL_ALL");
767            std::env::remove_var("TEST_SPEAKER_ALL");
768            std::env::remove_var("TEST_LANGUAGE_ALL");
769            std::env::remove_var("TEST_SPEED_ALL");
770        }
771    }
772
773    #[test]
774    fn test_sip_dict_with_http_command() {
775        // Test that SIP headers work correctly in HTTP command URLs
776        let content = r#"---
777llm:
778  provider: openai
779---
780# Scene: main
781Querying API: <http url='https://api.example.com/customers/{{ sip["X-Customer-ID"] }}' method="GET" />
782"#;
783        let mut variables = HashMap::new();
784        variables.insert("X-Customer-ID".to_string(), json!("CUST12345"));
785        variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
786
787        let playbook = Playbook::parse(content)
788            .unwrap()
789            .render(&variables)
790            .unwrap();
791        let scene = playbook.scenes.get("main").unwrap();
792
793        // The HTTP tag should be preserved in the prompt with the variable expanded
794        assert!(
795            scene
796                .prompt
797                .contains("https://api.example.com/customers/CUST12345")
798        );
799    }
800
801    #[test]
802    fn test_sip_dict_without_extract_config() {
803        // Test that sip dict is empty when no _sip_header_keys is present
804        let content = r#"---
805llm:
806  provider: openai
807---
808# Scene: main
809Regular var: {{ regular_var }}
810SIP dict should be empty.
811"#;
812        let mut variables = HashMap::new();
813        variables.insert("regular_var".to_string(), json!("regular_value"));
814        // No _sip_header_keys, so sip dict should be empty
815
816        let playbook = Playbook::parse(content)
817            .unwrap()
818            .render(&variables)
819            .unwrap();
820        let scene = playbook.scenes.get("main").unwrap();
821
822        assert!(scene.prompt.contains("Regular var: regular_value"));
823    }
824
825    #[test]
826    fn test_sip_dict_with_multiple_headers_in_yaml() {
827        // Test SIP headers used in YAML configuration section
828        let content = r#"---
829llm:
830  provider: openai
831  greeting: 'Welcome {{ sip["X-Customer-Name"] }}! Your ID is {{ sip["X-Customer-ID"] }}.'
832---
833# Scene: main
834How can I help you today?
835"#;
836        let mut variables = HashMap::new();
837        variables.insert("X-Customer-Name".to_string(), json!("Alice"));
838        variables.insert("X-Customer-ID".to_string(), json!("CUST789"));
839        variables.insert(
840            "_sip_header_keys".to_string(),
841            json!(["X-Customer-Name", "X-Customer-ID"]),
842        );
843
844        let playbook = Playbook::parse(content).unwrap();
845        let playbook = playbook.render(&variables).unwrap();
846
847        assert_eq!(
848            playbook.config.llm.as_ref().unwrap().greeting,
849            Some("Welcome Alice! Your ID is CUST789.".to_string())
850        );
851    }
852
853    #[test]
854    fn test_wrong_syntax_should_fail() {
855        // Test that using {{ X-Header }} (without sip dict) should fail
856        let content = r#"---
857llm:
858  provider: openai
859---
860# Scene: main
861This will fail: {{ X-Customer-ID }}
862"#;
863        let mut variables = HashMap::new();
864        variables.insert("X-Customer-ID".to_string(), json!("CUST123"));
865        variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
866
867        // This should fail because X-Customer-ID is not in the direct context
868        // It's only in sip dict
869        let playbook = Playbook::parse(content).unwrap();
870        let result = playbook.render(&variables);
871        // Templates are not checked during parsing anymore
872        assert!(result.is_err());
873    }
874
875    #[test]
876    fn test_sip_dict_with_set_var() {
877        // Test that SIP headers work in set_var commands
878        let content = r#"---
879llm:
880  provider: openai
881---
882# Scene: main
883<set_var key="X-Call-Status" value="active" />
884Customer: {{ sip["X-Customer-ID"] }}
885Status set successfully.
886"#;
887        let mut variables = HashMap::new();
888        variables.insert("X-Customer-ID".to_string(), json!("CUST456"));
889        variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
890
891        let playbook = Playbook::parse(content)
892            .unwrap()
893            .render(&variables)
894            .unwrap();
895        let scene = playbook.scenes.get("main").unwrap();
896
897        assert!(scene.prompt.contains("Customer: CUST456"));
898        assert!(scene.prompt.contains("<set_var"));
899    }
900
901    #[test]
902    fn test_sip_dict_mixed_with_regular_vars_in_complex_scenario() {
903        // Test complex scenario with both SIP headers and regular variables
904        let content = r#"---
905llm:
906  provider: openai
907  greeting: 'Hello {{ sip["X-Customer-Name"] }}, member level: {{ member_level }}'
908---
909# Scene: main
910Your ID: {{ sip["X-Customer-ID"] }}
911Your status: {{ account_status }}
912Your priority: {{ sip["X-Priority"] }}
913Order count: {{ order_count }}
914"#;
915        let mut variables = HashMap::new();
916        // SIP headers
917        variables.insert("X-Customer-Name".to_string(), json!("Bob"));
918        variables.insert("X-Customer-ID".to_string(), json!("CUST999"));
919        variables.insert("X-Priority".to_string(), json!("VIP"));
920        // Regular variables
921        variables.insert("member_level".to_string(), json!("Gold"));
922        variables.insert("account_status".to_string(), json!("Active"));
923        variables.insert("order_count".to_string(), json!(5));
924        // Mark SIP headers
925        variables.insert(
926            "_sip_header_keys".to_string(),
927            json!(["X-Customer-Name", "X-Customer-ID", "X-Priority"]),
928        );
929
930        let playbook = Playbook::parse(content)
931            .unwrap()
932            .render(&variables)
933            .unwrap();
934
935        // Check greeting has both types
936        assert_eq!(
937            playbook.config.llm.as_ref().unwrap().greeting,
938            Some("Hello Bob, member level: Gold".to_string())
939        );
940
941        // Check scene has all variables correctly rendered
942        let scene = playbook.scenes.get("main").unwrap();
943        assert!(scene.prompt.contains("Your ID: CUST999"));
944        assert!(scene.prompt.contains("Your status: Active"));
945        assert!(scene.prompt.contains("Your priority: VIP"));
946        assert!(scene.prompt.contains("Order count: 5"));
947    }
948}