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
13fn expand_env_vars(input: &str) -> String {
15 let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
16 re.replace_all(input, |caps: ®ex::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 realtime: Option<RealtimeOption>,
59 pub posthook: Option<PostHookConfig>,
60 pub follow_up: Option<FollowUpConfig>,
61 pub sip: Option<SipOption>,
62}
63
64#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
65#[serde(rename_all = "camelCase")]
66pub struct FollowUpConfig {
67 pub timeout: u64,
68 pub max_count: u32,
69}
70
71#[derive(Debug, Deserialize, Serialize, Clone)]
72#[serde(rename_all = "lowercase")]
73pub enum SummaryType {
74 Short,
75 Detailed,
76 Intent,
77 Json,
78 #[serde(untagged)]
79 Custom(String),
80}
81
82impl SummaryType {
83 pub fn prompt(&self) -> &str {
84 match self {
85 Self::Short => "summarize the conversation in one or two sentences.",
86 Self::Detailed => {
87 "summarize the conversation in detail, including key points, decisions, and action items."
88 }
89 Self::Intent => "identify and summarize the user's main intent and needs.",
90 Self::Json => {
91 "output the conversation summary in JSON format with fields: intent, key_points, sentiment."
92 }
93 Self::Custom(p) => p,
94 }
95 }
96}
97
98#[derive(Debug, Deserialize, Serialize, Clone, Default)]
99#[serde(rename_all = "camelCase")]
100pub struct PostHookConfig {
101 pub url: String,
102 pub summary: Option<SummaryType>,
103 pub method: Option<String>,
104 pub headers: Option<HashMap<String, String>>,
105 pub include_history: Option<bool>,
106}
107
108#[derive(Debug, Deserialize, Serialize, Clone)]
109#[serde(tag = "action", rename_all = "lowercase")]
110pub enum DtmfAction {
111 Goto { scene: String },
112 Transfer { target: String },
113 Hangup,
114}
115
116#[derive(Debug, Deserialize, Serialize, Clone, Default)]
117#[serde(rename_all = "camelCase")]
118pub struct LlmConfig {
119 pub provider: String,
120 pub model: Option<String>,
121 pub base_url: Option<String>,
122 pub api_key: Option<String>,
123 pub prompt: Option<String>,
124 pub greeting: Option<String>,
125 pub language: Option<String>,
126 pub features: Option<Vec<String>>,
127 pub repair_window_ms: Option<u64>,
128 pub summary_limit: Option<usize>,
129 pub tool_instructions: Option<String>,
132}
133
134#[derive(Serialize, Deserialize, Clone, Debug)]
135pub struct ChatMessage {
136 pub role: String,
137 pub content: String,
138}
139
140#[derive(Debug, Clone, Default)]
141pub struct Scene {
142 pub id: String,
143 pub prompt: String,
144 pub dtmf: Option<HashMap<String, DtmfAction>>,
145 pub play: Option<String>,
146 pub follow_up: Option<FollowUpConfig>,
147}
148
149#[derive(Debug, Clone)]
150pub struct Playbook {
151 pub raw_content: String,
152 pub config: PlaybookConfig,
153 pub scenes: HashMap<String, Scene>,
154 pub initial_scene_id: Option<String>,
155}
156
157impl Playbook {
158 pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
159 let content = fs::read_to_string(path).await?;
160 Self::parse(&content)
161 }
162
163 pub fn render(&self, vars: &HashMap<String, serde_json::Value>) -> Result<Self> {
164 let env = Environment::new();
165 let mut context = vars.clone();
166
167 let sip_header_keys: Vec<String> = vars
170 .get("_sip_header_keys")
171 .and_then(|v| serde_json::from_value(v.clone()).ok())
172 .unwrap_or_default();
173
174 let mut sip_headers = HashMap::new();
176 for key in &sip_header_keys {
177 if let Some(value) = vars.get(key) {
178 sip_headers.insert(key.clone(), value.clone());
179 }
180 }
181 context.insert(
182 "sip".to_string(),
183 serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
184 );
185
186 let rendered = env.render_str(&self.raw_content, &context)?;
187 let mut res = Self::parse(&rendered)?;
188 res.config.sip.as_mut().map(|sip| {
189 sip.hangup_headers = self
190 .config
191 .sip
192 .as_ref()
193 .and_then(|sip| sip.hangup_headers.clone());
194 });
195 Ok(res)
196 }
197
198 pub fn parse(content: &str) -> Result<Self> {
199 if !content.starts_with("---") {
200 return Err(anyhow!("Missing front matter"));
201 }
202
203 let parts: Vec<&str> = content.splitn(3, "---").collect();
204 if parts.len() < 3 {
205 return Err(anyhow!("Invalid front matter format"));
206 }
207
208 let yaml_str = parts[1];
209 let prompt_section = parts[2].trim();
210
211 let expanded_yaml = expand_env_vars(yaml_str);
214 let mut config: PlaybookConfig = serde_yaml::from_str(&expanded_yaml)?;
215
216 let mut scenes = HashMap::new();
217 let mut first_scene_id: Option<String> = None;
218
219 let dtmf_regex =
220 regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
221 let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
222 let followup_regex =
223 regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
224
225 let parse_scene = |id: String, content: String| -> Scene {
226 let mut dtmf_map = HashMap::new();
227 let mut play = None;
228 let mut follow_up = None;
229 let mut final_content = content.clone();
230
231 for cap in dtmf_regex.captures_iter(&content) {
232 let digit = cap.get(1).unwrap().as_str().to_string();
233 let action_type = cap.get(2).unwrap().as_str();
234
235 let action = match action_type {
236 "goto" => {
237 let scene = cap
238 .get(3)
239 .map(|m| m.as_str().to_string())
240 .unwrap_or_default();
241 DtmfAction::Goto { scene }
242 }
243 "transfer" => {
244 let target = cap
245 .get(4)
246 .map(|m| m.as_str().to_string())
247 .unwrap_or_default();
248 DtmfAction::Transfer { target }
249 }
250 "hangup" => DtmfAction::Hangup,
251 _ => continue,
252 };
253 dtmf_map.insert(digit, action);
254 }
255
256 if let Some(cap) = play_regex.captures(&content) {
257 play = Some(cap.get(1).unwrap().as_str().to_string());
258 }
259
260 if let Some(cap) = followup_regex.captures(&content) {
261 let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
262 let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
263 follow_up = Some(FollowUpConfig { timeout, max_count });
264 }
265
266 final_content = dtmf_regex.replace_all(&final_content, "").to_string();
268 final_content = play_regex.replace_all(&final_content, "").to_string();
269 final_content = followup_regex.replace_all(&final_content, "").to_string();
270 final_content = final_content.trim().to_string();
271
272 Scene {
273 id,
274 prompt: final_content,
275 dtmf: if dtmf_map.is_empty() {
276 None
277 } else {
278 Some(dtmf_map)
279 },
280 play,
281 follow_up,
282 }
283 };
284
285 let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
287 let mut last_match_end = 0;
288 let mut last_scene_id: Option<String> = None;
289
290 for cap in scene_regex.captures_iter(prompt_section) {
291 let m = cap.get(0).unwrap();
292 let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
293
294 if first_scene_id.is_none() {
295 first_scene_id = Some(scene_id.clone());
296 }
297
298 if let Some(id) = last_scene_id {
299 let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
300 scenes.insert(id.clone(), parse_scene(id, scene_content));
301 } else {
302 let pre_content = prompt_section[..m.start()].trim();
304 if !pre_content.is_empty() {
305 let id = "default".to_string();
306 first_scene_id = Some(id.clone());
307 scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
308 }
309 }
310
311 last_scene_id = Some(scene_id);
312 last_match_end = m.end();
313 }
314
315 if let Some(id) = last_scene_id {
316 let scene_content = prompt_section[last_match_end..].trim().to_string();
317 scenes.insert(id.clone(), parse_scene(id, scene_content));
318 } else if !prompt_section.is_empty() {
319 let id = "default".to_string();
321 first_scene_id = Some(id.clone());
322 scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
323 }
324
325 if let Some(llm) = config.llm.as_mut() {
326 if llm.api_key.is_none() {
328 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
329 llm.api_key = Some(key);
330 }
331 }
332 if llm.base_url.is_none() {
333 if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
334 llm.base_url = Some(url);
335 }
336 }
337 if llm.model.is_none() {
338 if let Ok(model) = std::env::var("OPENAI_MODEL") {
339 llm.model = Some(model);
340 }
341 }
342
343 if let Some(initial_id) = first_scene_id.clone() {
345 if let Some(scene) = scenes.get(&initial_id) {
346 llm.prompt = Some(scene.prompt.clone());
347 }
348 }
349 }
350
351 Ok(Self {
352 raw_content: content.to_string(),
353 config,
354 scenes,
355 initial_scene_id: first_scene_id,
356 })
357 }
358}
359
360pub mod dialogue;
361pub mod handler;
362pub mod runner;
363
364pub use dialogue::DialogueHandler;
365pub use handler::{LlmHandler, RagRetriever};
366pub use runner::PlaybookRunner;
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use serde_json::json;
372
373 #[test]
374 fn test_playbook_parsing_with_variables() {
375 let content = r#"---
376llm:
377 provider: openai
378 model: |-
379 {{ model_name }}
380 greeting: |-
381 Hello, {{ user_name }}!
382---
383# Scene: main
384You are an assistant for {{ company }}.
385"#;
386 let mut variables = HashMap::new();
387 variables.insert("model_name".to_string(), json!("gpt-4"));
388 variables.insert("user_name".to_string(), json!("Alice"));
389 variables.insert("company".to_string(), json!("RestSend"));
390
391 let playbook = Playbook::parse(content)
392 .unwrap()
393 .render(&variables)
394 .unwrap();
395 assert_eq!(
396 playbook.config.llm.as_ref().unwrap().model,
397 Some("gpt-4".to_string())
398 );
399 assert_eq!(
400 playbook.config.llm.as_ref().unwrap().greeting,
401 Some("Hello, Alice!".to_string())
402 );
403
404 let scene = playbook.scenes.get("main").unwrap();
405 assert_eq!(scene.prompt, "You are an assistant for RestSend.");
406 }
407
408 #[test]
409 fn test_playbook_scene_dtmf_parsing() {
410 let content = r#"---
411llm:
412 provider: openai
413---
414# Scene: main
415<dtmf digit="1" action="goto" scene="product" />
416<dtmf digit="2" action="transfer" target="sip:123@domain" />
417<dtmf digit="0" action="hangup" />
418Welcome to our service.
419"#;
420 let playbook = Playbook::parse(content).unwrap();
421
422 let scene = playbook.scenes.get("main").unwrap();
423 assert_eq!(scene.prompt, "Welcome to our service.");
424
425 let dtmf = scene.dtmf.as_ref().unwrap();
426 assert_eq!(dtmf.len(), 3);
427
428 match dtmf.get("1").unwrap() {
429 DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
430 _ => panic!("Expected Goto action"),
431 }
432
433 match dtmf.get("2").unwrap() {
434 DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
435 _ => panic!("Expected Transfer action"),
436 }
437
438 match dtmf.get("0").unwrap() {
439 DtmfAction::Hangup => {}
440 _ => panic!("Expected Hangup action"),
441 }
442 }
443
444 #[test]
445 fn test_playbook_dtmf_priority() {
446 let content = r#"---
447llm:
448 provider: openai
449dtmf:
450 "1": { action: "goto", scene: "global_dest" }
451 "9": { action: "hangup" }
452---
453# Scene: main
454<dtmf digit="1" action="goto" scene="local_dest" />
455Welcome.
456"#;
457 let playbook = Playbook::parse(content).unwrap();
458
459 let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
461 assert_eq!(global_dtmf.len(), 2);
462
463 let scene = playbook.scenes.get("main").unwrap();
465 let scene_dtmf = scene.dtmf.as_ref().unwrap();
466 assert_eq!(scene_dtmf.len(), 1);
467
468 match scene_dtmf.get("1").unwrap() {
470 DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
471 _ => panic!("Expected Local Goto action"),
472 }
473 }
474
475 #[test]
476 fn test_posthook_config_parsing() {
477 let content = r#"---
478posthook:
479 url: "http://test.com"
480 summary: "json"
481 includeHistory: true
482 headers:
483 X-API-Key: "secret"
484llm:
485 provider: openai
486---
487# Scene: main
488Hello
489"#;
490 let playbook = Playbook::parse(content).unwrap();
491 let posthook = playbook.config.posthook.unwrap();
492 assert_eq!(posthook.url, "http://test.com");
493 match posthook.summary.unwrap() {
494 SummaryType::Json => {}
495 _ => panic!("Expected Json summary type"),
496 }
497 assert_eq!(posthook.include_history, Some(true));
498 assert_eq!(
499 posthook.headers.unwrap().get("X-API-Key").unwrap(),
500 "secret"
501 );
502 }
503
504 #[test]
505 fn test_env_var_expansion() {
506 unsafe {
508 std::env::set_var("TEST_API_KEY", "sk-test-12345");
509 std::env::set_var("TEST_BASE_URL", "https://api.test.com");
510 }
511
512 let content = r#"---
513llm:
514 provider: openai
515 apiKey: "${TEST_API_KEY}"
516 baseUrl: "${TEST_BASE_URL}"
517 model: gpt-4
518---
519# Scene: main
520Test
521"#;
522 let playbook = Playbook::parse(content).unwrap();
523 let llm = playbook.config.llm.unwrap();
524
525 assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
526 assert_eq!(llm.base_url.unwrap(), "https://api.test.com");
527 assert_eq!(llm.model.unwrap(), "gpt-4");
528
529 unsafe {
531 std::env::remove_var("TEST_API_KEY");
532 std::env::remove_var("TEST_BASE_URL");
533 }
534 }
535
536 #[test]
537 fn test_env_var_expansion_missing() {
538 let content = r#"---
540llm:
541 provider: openai
542 apiKey: "${UNDEFINED_VAR}"
543---
544# Scene: main
545Test
546"#;
547 let playbook = Playbook::parse(content).unwrap();
548 let llm = playbook.config.llm.unwrap();
549
550 assert_eq!(llm.api_key.unwrap(), "${UNDEFINED_VAR}");
552 }
553
554 #[test]
555 fn test_custom_summary_parsing() {
556 let content = r#"---
557posthook:
558 url: "http://test.com"
559 summary: "Please summarize customly"
560llm:
561 provider: openai
562---
563# Scene: main
564Hello
565"#;
566 let playbook = Playbook::parse(content).unwrap();
567 let posthook = playbook.config.posthook.unwrap();
568 match posthook.summary.unwrap() {
569 SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
570 _ => panic!("Expected Custom summary type"),
571 }
572 }
573
574 #[test]
575 fn test_sip_dict_access_with_hyphens() {
576 let content = r#"---
578llm:
579 provider: openai
580 greeting: Hello {{ sip["X-Customer-Name"] }}!
581---
582# Scene: main
583Your ID is {{ sip["X-Customer-ID"] }}.
584Session type: {{ sip["X-Session-Type"] }}.
585"#;
586 let mut variables = HashMap::new();
587 variables.insert("X-Customer-Name".to_string(), json!("Alice"));
588 variables.insert("X-Customer-ID".to_string(), json!("CID-12345"));
589 variables.insert("X-Session-Type".to_string(), json!("inbound"));
590 variables.insert(
592 "_sip_header_keys".to_string(),
593 json!(["X-Customer-Name", "X-Customer-ID", "X-Session-Type"]),
594 );
595
596 let playbook = Playbook::parse(content)
597 .unwrap()
598 .render(&variables)
599 .unwrap();
600 assert_eq!(
601 playbook.config.llm.as_ref().unwrap().greeting,
602 Some("Hello Alice!".to_string())
603 );
604
605 let scene = playbook.scenes.get("main").unwrap();
606 assert_eq!(
607 scene.prompt,
608 "Your ID is CID-12345.\nSession type: inbound."
609 );
610 }
611
612 #[test]
613 fn test_sip_dict_only_contains_sip_headers() {
614 let content = r#"---
616llm:
617 provider: openai
618---
619# Scene: main
620SIP Header: {{ sip["X-Custom-Header"] }}
621Regular var: {{ regular_var }}
622"#;
623 let mut variables = HashMap::new();
624 variables.insert("X-Custom-Header".to_string(), json!("header_value"));
625 variables.insert("regular_var".to_string(), json!("regular_value"));
626 variables.insert("another_var".to_string(), json!("another"));
627 variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
629
630 let playbook = Playbook::parse(content)
631 .unwrap()
632 .render(&variables)
633 .unwrap();
634 let scene = playbook.scenes.get("main").unwrap();
635
636 assert!(scene.prompt.contains("SIP Header: header_value"));
638 assert!(scene.prompt.contains("Regular var: regular_value"));
639 }
640
641 #[test]
642 fn test_sip_dict_mixed_access() {
643 let content = r#"---
645llm:
646 provider: openai
647---
648# Scene: main
649Direct: {{ simple_var }}
650SIP Header: {{ sip["X-Custom-Header"] }}
651SIP via Direct: {{ X_Custom_Header2 }}
652"#;
653 let mut variables = HashMap::new();
654 variables.insert("simple_var".to_string(), json!("direct_value"));
655 variables.insert("X-Custom-Header".to_string(), json!("header_value"));
656 variables.insert("X_Custom_Header2".to_string(), json!("header2_value"));
657 variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
659
660 let playbook = Playbook::parse(content)
661 .unwrap()
662 .render(&variables)
663 .unwrap();
664 let scene = playbook.scenes.get("main").unwrap();
665
666 assert!(scene.prompt.contains("Direct: direct_value"));
667 assert!(scene.prompt.contains("SIP Header: header_value"));
668 assert!(scene.prompt.contains("SIP via Direct: header2_value"));
670 }
671
672 #[test]
673 fn test_sip_dict_empty_context() {
674 let content = r#"---
676llm:
677 provider: openai
678---
679# Scene: main
680No variables here.
681"#;
682 let playbook = Playbook::parse(content).unwrap();
683 let scene = playbook.scenes.get("main").unwrap();
684 assert_eq!(scene.prompt, "No variables here.");
685 }
686
687 #[test]
688 fn test_sip_dict_case_insensitive() {
689 let content = r#"---
691llm:
692 provider: openai
693---
694# Scene: main
695Upper: {{ sip["X-Header-Upper"] }}
696Lower: {{ sip["x-header-lower"] }}
697"#;
698 let mut variables = HashMap::new();
699 variables.insert("X-Header-Upper".to_string(), json!("UPPER"));
700 variables.insert("x-header-lower".to_string(), json!("lower"));
701 variables.insert(
702 "_sip_header_keys".to_string(),
703 json!(["X-Header-Upper", "x-header-lower"]),
704 );
705
706 let playbook = Playbook::parse(content)
707 .unwrap()
708 .render(&variables)
709 .unwrap();
710 let scene = playbook.scenes.get("main").unwrap();
711
712 assert!(scene.prompt.contains("Upper: UPPER"));
713 assert!(scene.prompt.contains("Lower: lower"));
714 }
715
716 #[test]
717 fn test_env_vars_in_all_fields() {
718 unsafe {
720 std::env::set_var("TEST_MODEL_ALL", "gpt-4o");
721 std::env::set_var("TEST_API_KEY_ALL", "sk-test-12345");
722 std::env::set_var("TEST_BASE_URL_ALL", "https://api.example.com");
723 std::env::set_var("TEST_SPEAKER_ALL", "F1");
724 std::env::set_var("TEST_LANGUAGE_ALL", "zh");
725 std::env::set_var("TEST_SPEED_ALL", "1.2");
726 }
727
728 let content = r#"---
729asr:
730 provider: "sensevoice"
731 language: "${TEST_LANGUAGE_ALL}"
732tts:
733 provider: "supertonic"
734 speaker: "${TEST_SPEAKER_ALL}"
735 speed: ${TEST_SPEED_ALL}
736llm:
737 provider: "openai"
738 model: "${TEST_MODEL_ALL}"
739 apiKey: "${TEST_API_KEY_ALL}"
740 baseUrl: "${TEST_BASE_URL_ALL}"
741---
742# Scene: main
743Test content
744"#;
745
746 let playbook = Playbook::parse(content).unwrap();
747
748 let asr = playbook.config.asr.unwrap();
750 assert_eq!(asr.language.unwrap(), "zh");
751
752 let tts = playbook.config.tts.unwrap();
754 assert_eq!(tts.speaker.unwrap(), "F1");
755 assert_eq!(tts.speed, Some(1.2));
756
757 let llm = playbook.config.llm.unwrap();
759 assert_eq!(llm.model.unwrap(), "gpt-4o");
760 assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
761 assert_eq!(llm.base_url.unwrap(), "https://api.example.com");
762
763 unsafe {
764 std::env::remove_var("TEST_MODEL_ALL");
765 std::env::remove_var("TEST_API_KEY_ALL");
766 std::env::remove_var("TEST_BASE_URL_ALL");
767 std::env::remove_var("TEST_SPEAKER_ALL");
768 std::env::remove_var("TEST_LANGUAGE_ALL");
769 std::env::remove_var("TEST_SPEED_ALL");
770 }
771 }
772
773 #[test]
774 fn test_sip_dict_with_http_command() {
775 let content = r#"---
777llm:
778 provider: openai
779---
780# Scene: main
781Querying API: <http url='https://api.example.com/customers/{{ sip["X-Customer-ID"] }}' method="GET" />
782"#;
783 let mut variables = HashMap::new();
784 variables.insert("X-Customer-ID".to_string(), json!("CUST12345"));
785 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
786
787 let playbook = Playbook::parse(content)
788 .unwrap()
789 .render(&variables)
790 .unwrap();
791 let scene = playbook.scenes.get("main").unwrap();
792
793 assert!(
795 scene
796 .prompt
797 .contains("https://api.example.com/customers/CUST12345")
798 );
799 }
800
801 #[test]
802 fn test_sip_dict_without_extract_config() {
803 let content = r#"---
805llm:
806 provider: openai
807---
808# Scene: main
809Regular var: {{ regular_var }}
810SIP dict should be empty.
811"#;
812 let mut variables = HashMap::new();
813 variables.insert("regular_var".to_string(), json!("regular_value"));
814 let playbook = Playbook::parse(content)
817 .unwrap()
818 .render(&variables)
819 .unwrap();
820 let scene = playbook.scenes.get("main").unwrap();
821
822 assert!(scene.prompt.contains("Regular var: regular_value"));
823 }
824
825 #[test]
826 fn test_sip_dict_with_multiple_headers_in_yaml() {
827 let content = r#"---
829llm:
830 provider: openai
831 greeting: 'Welcome {{ sip["X-Customer-Name"] }}! Your ID is {{ sip["X-Customer-ID"] }}.'
832---
833# Scene: main
834How can I help you today?
835"#;
836 let mut variables = HashMap::new();
837 variables.insert("X-Customer-Name".to_string(), json!("Alice"));
838 variables.insert("X-Customer-ID".to_string(), json!("CUST789"));
839 variables.insert(
840 "_sip_header_keys".to_string(),
841 json!(["X-Customer-Name", "X-Customer-ID"]),
842 );
843
844 let playbook = Playbook::parse(content).unwrap();
845 let playbook = playbook.render(&variables).unwrap();
846
847 assert_eq!(
848 playbook.config.llm.as_ref().unwrap().greeting,
849 Some("Welcome Alice! Your ID is CUST789.".to_string())
850 );
851 }
852
853 #[test]
854 fn test_wrong_syntax_should_fail() {
855 let content = r#"---
857llm:
858 provider: openai
859---
860# Scene: main
861This will fail: {{ X-Customer-ID }}
862"#;
863 let mut variables = HashMap::new();
864 variables.insert("X-Customer-ID".to_string(), json!("CUST123"));
865 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
866
867 let playbook = Playbook::parse(content).unwrap();
870 let result = playbook.render(&variables);
871 assert!(result.is_err());
873 }
874
875 #[test]
876 fn test_sip_dict_with_set_var() {
877 let content = r#"---
879llm:
880 provider: openai
881---
882# Scene: main
883<set_var key="X-Call-Status" value="active" />
884Customer: {{ sip["X-Customer-ID"] }}
885Status set successfully.
886"#;
887 let mut variables = HashMap::new();
888 variables.insert("X-Customer-ID".to_string(), json!("CUST456"));
889 variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
890
891 let playbook = Playbook::parse(content)
892 .unwrap()
893 .render(&variables)
894 .unwrap();
895 let scene = playbook.scenes.get("main").unwrap();
896
897 assert!(scene.prompt.contains("Customer: CUST456"));
898 assert!(scene.prompt.contains("<set_var"));
899 }
900
901 #[test]
902 fn test_sip_dict_mixed_with_regular_vars_in_complex_scenario() {
903 let content = r#"---
905llm:
906 provider: openai
907 greeting: 'Hello {{ sip["X-Customer-Name"] }}, member level: {{ member_level }}'
908---
909# Scene: main
910Your ID: {{ sip["X-Customer-ID"] }}
911Your status: {{ account_status }}
912Your priority: {{ sip["X-Priority"] }}
913Order count: {{ order_count }}
914"#;
915 let mut variables = HashMap::new();
916 variables.insert("X-Customer-Name".to_string(), json!("Bob"));
918 variables.insert("X-Customer-ID".to_string(), json!("CUST999"));
919 variables.insert("X-Priority".to_string(), json!("VIP"));
920 variables.insert("member_level".to_string(), json!("Gold"));
922 variables.insert("account_status".to_string(), json!("Active"));
923 variables.insert("order_count".to_string(), json!(5));
924 variables.insert(
926 "_sip_header_keys".to_string(),
927 json!(["X-Customer-Name", "X-Customer-ID", "X-Priority"]),
928 );
929
930 let playbook = Playbook::parse(content)
931 .unwrap()
932 .render(&variables)
933 .unwrap();
934
935 assert_eq!(
937 playbook.config.llm.as_ref().unwrap().greeting,
938 Some("Hello Bob, member level: Gold".to_string())
939 );
940
941 let scene = playbook.scenes.get("main").unwrap();
943 assert!(scene.prompt.contains("Your ID: CUST999"));
944 assert!(scene.prompt.contains("Your status: Active"));
945 assert!(scene.prompt.contains("Your priority: VIP"));
946 assert!(scene.prompt.contains("Order count: 5"));
947 }
948}