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