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