Skip to main content

active_call/playbook/
mod.rs

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
14/// Expand environment variables in the format ${VAR_NAME}
15fn expand_env_vars(input: &str) -> String {
16    let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
17    re.replace_all(input, |caps: &regex::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/// Validation rule for DTMF digit collection
122#[derive(Debug, Deserialize, Serialize, Clone, Default)]
123#[serde(rename_all = "camelCase")]
124pub struct DtmfValidation {
125    /// Regex pattern for validation, e.g. "^1[3-9]\\d{9}$" for Chinese phone numbers
126    pub pattern: String,
127    /// Error message shown when validation fails
128    pub error_message: Option<String>,
129}
130
131/// Configuration for a DTMF digit collector template
132#[derive(Debug, Deserialize, Serialize, Clone, Default)]
133#[serde(rename_all = "camelCase")]
134pub struct DtmfCollectorConfig {
135    /// Human-readable description of this collector (used in LLM prompt generation)
136    pub description: Option<String>,
137    /// Exact expected digit count (shorthand for min_digits == max_digits)
138    pub digits: Option<u32>,
139    /// Minimum digits required
140    pub min_digits: Option<u32>,
141    /// Maximum digits allowed
142    pub max_digits: Option<u32>,
143    /// Key that terminates collection: "#" or "*"
144    pub finish_key: Option<String>,
145    /// Overall timeout in seconds (default: 15)
146    pub timeout: Option<u32>,
147    /// Max seconds between consecutive key presses (default: 5)
148    pub inter_digit_timeout: Option<u32>,
149    /// Validation rule (regex + error message)
150    pub validation: Option<DtmfValidation>,
151    /// Max retry attempts when validation fails (default: 3)
152    pub retry_times: Option<u32>,
153    /// Whether voice input (ASR) can interrupt collection (default: false)
154    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    /// Custom tool instructions. If not set, default tool instructions based on language will be used.
171    /// Set this to override the built-in tool usage instructions completely.
172    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    /// The original unrendered prompt template, preserved for dynamic re-rendering
186    /// with updated variables (e.g., after set_var during conversation).
187    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
193/// Built-in session variable key constants.
194/// These are automatically injected into `extras` so they can be referenced
195/// in playbook templates using `{{ session_id }}`, `{{ call_type }}`, etc.
196pub 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
202/// Render a scene prompt template dynamically using the current variables.
203/// This allows `set_var` values set during conversation to be used in scene prompts.
204///
205/// If `raw_prompt` is `None` or rendering fails, falls back to the pre-rendered `prompt`.
206pub 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    // Build sip dictionary from _sip_header_keys (same logic as Playbook::render)
216    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    // Remove internal keys from context
233    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        // Get the list of SIP header keys stored by extract_headers processing
260        // If not present, sip dict will be empty (no headers were configured for extraction)
261        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        // Separate SIP headers into sip dictionary based on stored keys
267        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        // Preserve the original raw_content (with templates) for dynamic re-rendering
281        res.raw_content = self.raw_content.clone();
282        // Preserve original raw_prompts from the unrendered playbook for dynamic re-rendering
283        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        // Expand environment variables in YAML configuration
312        // This allows ALL fields to use ${VAR_NAME} syntax
313        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            // Remove dtmf and play tags from the content
367            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        // Parse scenes from markdown. Look for headers like "# Scene: <id>"
387        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                // Content before the first scene header
404                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            // No scene headers found, treat the whole prompt as "default"
421            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            // Fallback to direct env var if not set
428            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            // Use the first scene found as the initial prompt
445            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        // Check global config
561        let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
562        assert_eq!(global_dtmf.len(), 2);
563
564        // Check scene config
565        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        // Verify scene has local_dest for "1"
570        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        // Set test env vars
608        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        // Clean up
631        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        // Test with undefined var
640        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        // Should keep the placeholder if env var not found
652        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        // Test accessing SIP headers with hyphens via sip dictionary
678        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        // Simulate extract_headers processing
692        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        // Test that sip dict only contains SIP headers from extract_headers, not other variables
716        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        // Only X-Custom-Header is extracted
729        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        // Both should work - SIP header via sip dict, regular var via direct access
738        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        // Test that both direct access and sip dict access work together
745        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        // Only X-Custom-Header is in extract_headers
759        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        // X_Custom_Header2 doesn't start with X-, so won't be in sip dict
770        assert!(scene.prompt.contains("SIP via Direct: header2_value"));
771    }
772
773    #[test]
774    fn test_sip_dict_empty_context() {
775        // Test that sip dict works with no variables
776        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        // Test that extract_headers can include headers with different cases
791        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        // Test that ${VAR} works in all configuration fields
820        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        // Verify ASR fields
850        let asr = playbook.config.asr.unwrap();
851        assert_eq!(asr.language.unwrap(), "zh");
852
853        // Verify TTS fields
854        let tts = playbook.config.tts.unwrap();
855        assert_eq!(tts.speaker.unwrap(), "F1");
856        assert_eq!(tts.speed, Some(1.2));
857
858        // Verify LLM fields
859        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        // Test that SIP headers work correctly in HTTP command URLs
877        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        // The HTTP tag should be preserved in the prompt with the variable expanded
895        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        // Test that sip dict is empty when no _sip_header_keys is present
905        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        // No _sip_header_keys, so sip dict should be empty
916
917        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        // Test SIP headers used in YAML configuration section
929        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        // Test that using {{ X-Header }} (without sip dict) should fail
957        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        // This should fail because X-Customer-ID is not in the direct context
969        // It's only in sip dict
970        let playbook = Playbook::parse(content).unwrap();
971        let result = playbook.render(&variables);
972        // Templates are not checked during parsing anymore
973        assert!(result.is_err());
974    }
975
976    #[test]
977    fn test_sip_dict_with_set_var() {
978        // Test that SIP headers work in set_var commands
979        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        // Test complex scenario with both SIP headers and regular variables
1005        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        // SIP headers
1018        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        // Regular variables
1022        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        // Mark SIP headers
1026        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        // Check greeting has both types
1037        assert_eq!(
1038            playbook.config.llm.as_ref().unwrap().greeting,
1039            Some("Hello Bob, member level: Gold".to_string())
1040        );
1041
1042        // Check scene has all variables correctly rendered
1043        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        // Test that raw_prompt is preserved after rendering so it can be re-rendered later
1053        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        // Verify rendered prompts
1073        let greeting = playbook.scenes.get("greeting").unwrap();
1074        assert!(greeting.prompt.contains("您好,张三"));
1075        assert!(greeting.prompt.contains("您的意图是:咨询"));
1076
1077        // Verify raw_prompt still has the template
1078        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        // Verify detail scene too
1084        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        // Test render_scene_prompt: simulates set_var updating variables mid-conversation
1092        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(), // initially rendered
1096            ..Default::default()
1097        };
1098
1099        // Simulate variables after set_var has been called
1100        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        // When raw_prompt has no template markers, should return prompt as-is
1113        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        // When raw_prompt is None, should return prompt
1128        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        // Test that built-in session variables work in scene prompts
1142        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        // Test mixed SIP headers and set_var variables in dynamic rendering
1173        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        // SIP header
1185        vars.insert("X-Customer-Name".to_string(), json!("王五"));
1186        vars.insert("_sip_header_keys".to_string(), json!(["X-Customer-Name"]));
1187        // set_var variable
1188        vars.insert("intent".to_string(), json!("退货"));
1189        // Built-in variable
1190        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        // When a referenced variable is missing, MiniJinja renders it as empty string
1201        // The render still succeeds but with empty values
1202        let scene = Scene {
1203            id: "main".to_string(),
1204            raw_prompt: Some("意图:{{ intent }}".to_string()),
1205            prompt: "意图:(未知)".to_string(), // fallback (not used since rendering succeeds)
1206            ..Default::default()
1207        };
1208
1209        let vars = HashMap::new(); // no intent variable
1210        let rendered = render_scene_prompt(&scene, &vars);
1211        // MiniJinja renders missing vars as empty string
1212        assert_eq!(rendered, "意图:");
1213    }
1214
1215    #[test]
1216    fn test_raw_prompt_set_on_parse() {
1217        // Verify that raw_prompt is set during initial parsing
1218        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        // Verify the built-in variable constant values
1234        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}