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