Skip to main content

active_call/playbook/
mod.rs

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