use crate::media::recorder::RecorderOption;
use crate::media::vad::VADOption;
use crate::synthesis::SynthesisOption;
use crate::transcription::TranscriptionOption;
use crate::{EouOption, RealtimeOption, RingbackDetectionOption, SipOption, media::ambiance::AmbianceOption};
use anyhow::{Result, anyhow};
use minijinja::Environment;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, path::Path};
use tokio::fs;
fn expand_env_vars(input: &str) -> String {
let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
re.replace_all(input, |caps: ®ex::Captures| {
let var_name = &caps[1];
std::env::var(var_name).unwrap_or_else(|_| format!("${{{}}}", var_name))
})
.to_string()
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum InterruptionStrategy {
#[default]
Both,
Vad,
Asr,
None,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct InterruptionConfig {
pub strategy: InterruptionStrategy,
pub min_speech_ms: Option<u32>,
pub filler_word_filter: Option<bool>,
pub volume_fade_ms: Option<u32>,
pub ignore_first_ms: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlaybookConfig {
pub asr: Option<TranscriptionOption>,
pub tts: Option<SynthesisOption>,
pub llm: Option<LlmConfig>,
pub vad: Option<VADOption>,
pub denoise: Option<bool>,
pub ambiance: Option<AmbianceOption>,
pub recorder: Option<RecorderOption>,
pub extra: Option<HashMap<String, String>>,
pub eou: Option<EouOption>,
pub greeting: Option<String>,
pub interruption: Option<InterruptionConfig>,
pub dtmf: Option<HashMap<String, DtmfAction>>,
pub dtmf_collectors: Option<HashMap<String, DtmfCollectorConfig>>,
pub realtime: Option<RealtimeOption>,
pub ringback_detection: Option<RingbackDetectionOption>,
pub posthook: Option<PostHookConfig>,
pub follow_up: Option<FollowUpConfig>,
pub sip: Option<SipOption>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
#[serde(rename_all = "camelCase")]
pub struct FollowUpConfig {
pub timeout: u64,
pub max_count: u32,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SummaryType {
Short,
Detailed,
Intent,
Json,
#[serde(untagged)]
Custom(String),
}
impl SummaryType {
pub fn prompt(&self) -> &str {
match self {
Self::Short => "summarize the conversation in one or two sentences.",
Self::Detailed => {
"summarize the conversation in detail, including key points, decisions, and action items."
}
Self::Intent => "identify and summarize the user's main intent and needs.",
Self::Json => {
"output the conversation summary in JSON format with fields: intent, key_points, sentiment."
}
Self::Custom(p) => p,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct PostHookConfig {
pub url: String,
pub summary: Option<SummaryType>,
pub method: Option<String>,
pub headers: Option<HashMap<String, String>>,
pub include_history: Option<bool>,
pub timeout: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(tag = "action", rename_all = "lowercase")]
pub enum DtmfAction {
Goto { scene: String },
Transfer { target: String },
Hangup,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct DtmfValidation {
pub pattern: String,
pub error_message: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct DtmfCollectorConfig {
pub description: Option<String>,
pub digits: Option<u32>,
pub min_digits: Option<u32>,
pub max_digits: Option<u32>,
pub finish_key: Option<String>,
pub timeout: Option<u32>,
pub inter_digit_timeout: Option<u32>,
pub validation: Option<DtmfValidation>,
pub retry_times: Option<u32>,
pub interruptible: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct LlmConfig {
pub provider: String,
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub prompt: Option<String>,
pub greeting: Option<String>,
pub language: Option<String>,
pub features: Option<Vec<String>>,
pub repair_window_ms: Option<u64>,
pub summary_limit: Option<usize>,
pub tool_instructions: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Default)]
pub struct Scene {
pub id: String,
pub prompt: String,
pub raw_prompt: Option<String>,
pub dtmf: Option<HashMap<String, DtmfAction>>,
pub play: Option<String>,
pub follow_up: Option<FollowUpConfig>,
}
pub const BUILTIN_SESSION_ID: &str = "session_id";
pub const BUILTIN_CALL_TYPE: &str = "call_type";
pub const BUILTIN_CALLER: &str = "caller";
pub const BUILTIN_CALLEE: &str = "callee";
pub const BUILTIN_START_TIME: &str = "start_time";
pub fn render_scene_prompt(scene: &Scene, vars: &HashMap<String, serde_json::Value>) -> String {
let template = match &scene.raw_prompt {
Some(t) if t.contains("{{") => t,
_ => return scene.prompt.clone(),
};
let env = Environment::new();
let mut context = vars.clone();
let sip_header_keys: Vec<String> = vars
.get("_sip_header_keys")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut sip_headers = HashMap::new();
for key in &sip_header_keys {
if let Some(value) = vars.get(key) {
sip_headers.insert(key.clone(), value.clone());
}
}
context.insert(
"sip".to_string(),
serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
);
context.retain(|k, _| !k.starts_with('_'));
match env.render_str(template, &context) {
Ok(rendered) => rendered,
Err(_) => scene.prompt.clone(),
}
}
#[derive(Debug, Clone)]
pub struct Playbook {
pub raw_content: String,
pub config: PlaybookConfig,
pub scenes: HashMap<String, Scene>,
pub initial_scene_id: Option<String>,
}
impl Playbook {
pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path).await?;
Self::parse(&content)
}
pub fn render(&self, vars: &HashMap<String, serde_json::Value>) -> Result<Self> {
let env = Environment::new();
let mut context = vars.clone();
let sip_header_keys: Vec<String> = vars
.get("_sip_header_keys")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut sip_headers = HashMap::new();
for key in &sip_header_keys {
if let Some(value) = vars.get(key) {
sip_headers.insert(key.clone(), value.clone());
}
}
context.insert(
"sip".to_string(),
serde_json::to_value(&sip_headers).unwrap_or(Value::Null),
);
let rendered = env.render_str(&self.raw_content, &context)?;
let mut res = Self::parse(&rendered)?;
res.raw_content = self.raw_content.clone();
for (scene_id, scene) in &self.scenes {
if let Some(res_scene) = res.scenes.get_mut(scene_id) {
res_scene.raw_prompt = scene.raw_prompt.clone();
}
}
res.config.sip.as_mut().map(|sip| {
sip.hangup_headers = self
.config
.sip
.as_ref()
.and_then(|sip| sip.hangup_headers.clone());
});
Ok(res)
}
pub fn parse(content: &str) -> Result<Self> {
if !content.starts_with("---") {
return Err(anyhow!("Missing front matter"));
}
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(anyhow!("Invalid front matter format"));
}
let yaml_str = parts[1];
let prompt_section = parts[2].trim();
let expanded_yaml = expand_env_vars(yaml_str);
let mut config: PlaybookConfig = serde_yaml::from_str(&expanded_yaml)?;
let mut scenes = HashMap::new();
let mut first_scene_id: Option<String> = None;
let dtmf_regex =
regex::Regex::new(r#"<dtmf\s+digit="([^"]+)"\s+action="([^"]+)"(?:\s+scene="([^"]+)")?(?:\s+target="([^"]+)")?\s*/>"#).unwrap();
let play_regex = regex::Regex::new(r#"<play\s+file="([^"]+)"\s*/>"#).unwrap();
let followup_regex =
regex::Regex::new(r#"<followup\s+timeout="(\d+)"\s+max="(\d+)"\s*/>"#).unwrap();
let parse_scene = |id: String, content: String| -> Scene {
let mut dtmf_map = HashMap::new();
let mut play = None;
let mut follow_up = None;
let mut final_content = content.clone();
for cap in dtmf_regex.captures_iter(&content) {
let digit = cap.get(1).unwrap().as_str().to_string();
let action_type = cap.get(2).unwrap().as_str();
let action = match action_type {
"goto" => {
let scene = cap
.get(3)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
DtmfAction::Goto { scene }
}
"transfer" => {
let target = cap
.get(4)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
DtmfAction::Transfer { target }
}
"hangup" => DtmfAction::Hangup,
_ => continue,
};
dtmf_map.insert(digit, action);
}
if let Some(cap) = play_regex.captures(&content) {
play = Some(cap.get(1).unwrap().as_str().to_string());
}
if let Some(cap) = followup_regex.captures(&content) {
let timeout = cap.get(1).unwrap().as_str().parse().unwrap_or(0);
let max_count = cap.get(2).unwrap().as_str().parse().unwrap_or(0);
follow_up = Some(FollowUpConfig { timeout, max_count });
}
final_content = dtmf_regex.replace_all(&final_content, "").to_string();
final_content = play_regex.replace_all(&final_content, "").to_string();
final_content = followup_regex.replace_all(&final_content, "").to_string();
final_content = final_content.trim().to_string();
Scene {
id,
raw_prompt: Some(final_content.clone()),
prompt: final_content,
dtmf: if dtmf_map.is_empty() {
None
} else {
Some(dtmf_map)
},
play,
follow_up,
}
};
let scene_regex = regex::Regex::new(r"(?m)^# Scene:\s*(.+)$").unwrap();
let mut last_match_end = 0;
let mut last_scene_id: Option<String> = None;
for cap in scene_regex.captures_iter(prompt_section) {
let m = cap.get(0).unwrap();
let scene_id = cap.get(1).unwrap().as_str().trim().to_string();
if first_scene_id.is_none() {
first_scene_id = Some(scene_id.clone());
}
if let Some(id) = last_scene_id {
let scene_content = prompt_section[last_match_end..m.start()].trim().to_string();
scenes.insert(id.clone(), parse_scene(id, scene_content));
} else {
let pre_content = prompt_section[..m.start()].trim();
if !pre_content.is_empty() {
let id = "default".to_string();
first_scene_id = Some(id.clone());
scenes.insert(id.clone(), parse_scene(id, pre_content.to_string()));
}
}
last_scene_id = Some(scene_id);
last_match_end = m.end();
}
if let Some(id) = last_scene_id {
let scene_content = prompt_section[last_match_end..].trim().to_string();
scenes.insert(id.clone(), parse_scene(id, scene_content));
} else if !prompt_section.is_empty() {
let id = "default".to_string();
first_scene_id = Some(id.clone());
scenes.insert(id.clone(), parse_scene(id, prompt_section.to_string()));
}
if let Some(llm) = config.llm.as_mut() {
if llm.api_key.is_none() {
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
llm.api_key = Some(key);
}
}
if llm.base_url.is_none() {
if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
llm.base_url = Some(url);
}
}
if llm.model.is_none() {
if let Ok(model) = std::env::var("OPENAI_MODEL") {
llm.model = Some(model);
}
}
if let Some(initial_id) = first_scene_id.clone() {
if let Some(scene) = scenes.get(&initial_id) {
llm.prompt = Some(scene.prompt.clone());
}
}
}
Ok(Self {
raw_content: content.to_string(),
config,
scenes,
initial_scene_id: first_scene_id,
})
}
}
pub mod dialogue;
pub mod handler;
pub mod runner;
pub use dialogue::DialogueHandler;
pub use handler::{LlmHandler, RagRetriever};
pub use runner::PlaybookRunner;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_playbook_parsing_with_variables() {
let content = r#"---
llm:
provider: openai
model: |-
{{ model_name }}
greeting: |-
Hello, {{ user_name }}!
---
# Scene: main
You are an assistant for {{ company }}.
"#;
let mut variables = HashMap::new();
variables.insert("model_name".to_string(), json!("gpt-4"));
variables.insert("user_name".to_string(), json!("Alice"));
variables.insert("company".to_string(), json!("RestSend"));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
assert_eq!(
playbook.config.llm.as_ref().unwrap().model,
Some("gpt-4".to_string())
);
assert_eq!(
playbook.config.llm.as_ref().unwrap().greeting,
Some("Hello, Alice!".to_string())
);
let scene = playbook.scenes.get("main").unwrap();
assert_eq!(scene.prompt, "You are an assistant for RestSend.");
}
#[test]
fn test_playbook_scene_dtmf_parsing() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
<dtmf digit="1" action="goto" scene="product" />
<dtmf digit="2" action="transfer" target="sip:123@domain" />
<dtmf digit="0" action="hangup" />
Welcome to our service.
"#;
let playbook = Playbook::parse(content).unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert_eq!(scene.prompt, "Welcome to our service.");
let dtmf = scene.dtmf.as_ref().unwrap();
assert_eq!(dtmf.len(), 3);
match dtmf.get("1").unwrap() {
DtmfAction::Goto { scene } => assert_eq!(scene, "product"),
_ => panic!("Expected Goto action"),
}
match dtmf.get("2").unwrap() {
DtmfAction::Transfer { target } => assert_eq!(target, "sip:123@domain"),
_ => panic!("Expected Transfer action"),
}
match dtmf.get("0").unwrap() {
DtmfAction::Hangup => {}
_ => panic!("Expected Hangup action"),
}
}
#[test]
fn test_playbook_dtmf_priority() {
let content = r#"---
llm:
provider: openai
dtmf:
"1": { action: "goto", scene: "global_dest" }
"9": { action: "hangup" }
---
# Scene: main
<dtmf digit="1" action="goto" scene="local_dest" />
Welcome.
"#;
let playbook = Playbook::parse(content).unwrap();
let global_dtmf = playbook.config.dtmf.as_ref().unwrap();
assert_eq!(global_dtmf.len(), 2);
let scene = playbook.scenes.get("main").unwrap();
let scene_dtmf = scene.dtmf.as_ref().unwrap();
assert_eq!(scene_dtmf.len(), 1);
match scene_dtmf.get("1").unwrap() {
DtmfAction::Goto { scene } => assert_eq!(scene, "local_dest"),
_ => panic!("Expected Local Goto action"),
}
}
#[test]
fn test_posthook_config_parsing() {
let content = r#"---
posthook:
url: "http://test.com"
summary: "json"
includeHistory: true
headers:
X-API-Key: "secret"
llm:
provider: openai
---
# Scene: main
Hello
"#;
let playbook = Playbook::parse(content).unwrap();
let posthook = playbook.config.posthook.unwrap();
assert_eq!(posthook.url, "http://test.com");
match posthook.summary.unwrap() {
SummaryType::Json => {}
_ => panic!("Expected Json summary type"),
}
assert_eq!(posthook.include_history, Some(true));
assert_eq!(
posthook.headers.unwrap().get("X-API-Key").unwrap(),
"secret"
);
}
#[test]
fn test_env_var_expansion() {
unsafe {
std::env::set_var("TEST_API_KEY", "sk-test-12345");
std::env::set_var("TEST_BASE_URL", "https://api.test.com");
}
let content = r#"---
llm:
provider: openai
apiKey: "${TEST_API_KEY}"
baseUrl: "${TEST_BASE_URL}"
model: gpt-4
---
# Scene: main
Test
"#;
let playbook = Playbook::parse(content).unwrap();
let llm = playbook.config.llm.unwrap();
assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
assert_eq!(llm.base_url.unwrap(), "https://api.test.com");
assert_eq!(llm.model.unwrap(), "gpt-4");
unsafe {
std::env::remove_var("TEST_API_KEY");
std::env::remove_var("TEST_BASE_URL");
}
}
#[test]
fn test_env_var_expansion_missing() {
let content = r#"---
llm:
provider: openai
apiKey: "${UNDEFINED_VAR}"
---
# Scene: main
Test
"#;
let playbook = Playbook::parse(content).unwrap();
let llm = playbook.config.llm.unwrap();
assert_eq!(llm.api_key.unwrap(), "${UNDEFINED_VAR}");
}
#[test]
fn test_custom_summary_parsing() {
let content = r#"---
posthook:
url: "http://test.com"
summary: "Please summarize customly"
llm:
provider: openai
---
# Scene: main
Hello
"#;
let playbook = Playbook::parse(content).unwrap();
let posthook = playbook.config.posthook.unwrap();
match posthook.summary.unwrap() {
SummaryType::Custom(s) => assert_eq!(s, "Please summarize customly"),
_ => panic!("Expected Custom summary type"),
}
}
#[test]
fn test_sip_dict_access_with_hyphens() {
let content = r#"---
llm:
provider: openai
greeting: Hello {{ sip["X-Customer-Name"] }}!
---
# Scene: main
Your ID is {{ sip["X-Customer-ID"] }}.
Session type: {{ sip["X-Session-Type"] }}.
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-Name".to_string(), json!("Alice"));
variables.insert("X-Customer-ID".to_string(), json!("CID-12345"));
variables.insert("X-Session-Type".to_string(), json!("inbound"));
variables.insert(
"_sip_header_keys".to_string(),
json!(["X-Customer-Name", "X-Customer-ID", "X-Session-Type"]),
);
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
assert_eq!(
playbook.config.llm.as_ref().unwrap().greeting,
Some("Hello Alice!".to_string())
);
let scene = playbook.scenes.get("main").unwrap();
assert_eq!(
scene.prompt,
"Your ID is CID-12345.\nSession type: inbound."
);
}
#[test]
fn test_sip_dict_only_contains_sip_headers() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
SIP Header: {{ sip["X-Custom-Header"] }}
Regular var: {{ regular_var }}
"#;
let mut variables = HashMap::new();
variables.insert("X-Custom-Header".to_string(), json!("header_value"));
variables.insert("regular_var".to_string(), json!("regular_value"));
variables.insert("another_var".to_string(), json!("another"));
variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("SIP Header: header_value"));
assert!(scene.prompt.contains("Regular var: regular_value"));
}
#[test]
fn test_sip_dict_mixed_access() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
Direct: {{ simple_var }}
SIP Header: {{ sip["X-Custom-Header"] }}
SIP via Direct: {{ X_Custom_Header2 }}
"#;
let mut variables = HashMap::new();
variables.insert("simple_var".to_string(), json!("direct_value"));
variables.insert("X-Custom-Header".to_string(), json!("header_value"));
variables.insert("X_Custom_Header2".to_string(), json!("header2_value"));
variables.insert("_sip_header_keys".to_string(), json!(["X-Custom-Header"]));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("Direct: direct_value"));
assert!(scene.prompt.contains("SIP Header: header_value"));
assert!(scene.prompt.contains("SIP via Direct: header2_value"));
}
#[test]
fn test_sip_dict_empty_context() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
No variables here.
"#;
let playbook = Playbook::parse(content).unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert_eq!(scene.prompt, "No variables here.");
}
#[test]
fn test_sip_dict_case_insensitive() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
Upper: {{ sip["X-Header-Upper"] }}
Lower: {{ sip["x-header-lower"] }}
"#;
let mut variables = HashMap::new();
variables.insert("X-Header-Upper".to_string(), json!("UPPER"));
variables.insert("x-header-lower".to_string(), json!("lower"));
variables.insert(
"_sip_header_keys".to_string(),
json!(["X-Header-Upper", "x-header-lower"]),
);
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("Upper: UPPER"));
assert!(scene.prompt.contains("Lower: lower"));
}
#[test]
fn test_env_vars_in_all_fields() {
unsafe {
std::env::set_var("TEST_MODEL_ALL", "gpt-4o");
std::env::set_var("TEST_API_KEY_ALL", "sk-test-12345");
std::env::set_var("TEST_BASE_URL_ALL", "https://api.example.com");
std::env::set_var("TEST_SPEAKER_ALL", "F1");
std::env::set_var("TEST_LANGUAGE_ALL", "zh");
std::env::set_var("TEST_SPEED_ALL", "1.2");
}
let content = r#"---
asr:
provider: "sensevoice"
language: "${TEST_LANGUAGE_ALL}"
tts:
provider: "supertonic"
speaker: "${TEST_SPEAKER_ALL}"
speed: ${TEST_SPEED_ALL}
llm:
provider: "openai"
model: "${TEST_MODEL_ALL}"
apiKey: "${TEST_API_KEY_ALL}"
baseUrl: "${TEST_BASE_URL_ALL}"
---
# Scene: main
Test content
"#;
let playbook = Playbook::parse(content).unwrap();
let asr = playbook.config.asr.unwrap();
assert_eq!(asr.language.unwrap(), "zh");
let tts = playbook.config.tts.unwrap();
assert_eq!(tts.speaker.unwrap(), "F1");
assert_eq!(tts.speed, Some(1.2));
let llm = playbook.config.llm.unwrap();
assert_eq!(llm.model.unwrap(), "gpt-4o");
assert_eq!(llm.api_key.unwrap(), "sk-test-12345");
assert_eq!(llm.base_url.unwrap(), "https://api.example.com");
unsafe {
std::env::remove_var("TEST_MODEL_ALL");
std::env::remove_var("TEST_API_KEY_ALL");
std::env::remove_var("TEST_BASE_URL_ALL");
std::env::remove_var("TEST_SPEAKER_ALL");
std::env::remove_var("TEST_LANGUAGE_ALL");
std::env::remove_var("TEST_SPEED_ALL");
}
}
#[test]
fn test_sip_dict_with_http_command() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
Querying API: <http url='https://api.example.com/customers/{{ sip["X-Customer-ID"] }}' method="GET" />
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-ID".to_string(), json!("CUST12345"));
variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(
scene
.prompt
.contains("https://api.example.com/customers/CUST12345")
);
}
#[test]
fn test_sip_dict_without_extract_config() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
Regular var: {{ regular_var }}
SIP dict should be empty.
"#;
let mut variables = HashMap::new();
variables.insert("regular_var".to_string(), json!("regular_value"));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("Regular var: regular_value"));
}
#[test]
fn test_sip_dict_with_multiple_headers_in_yaml() {
let content = r#"---
llm:
provider: openai
greeting: 'Welcome {{ sip["X-Customer-Name"] }}! Your ID is {{ sip["X-Customer-ID"] }}.'
---
# Scene: main
How can I help you today?
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-Name".to_string(), json!("Alice"));
variables.insert("X-Customer-ID".to_string(), json!("CUST789"));
variables.insert(
"_sip_header_keys".to_string(),
json!(["X-Customer-Name", "X-Customer-ID"]),
);
let playbook = Playbook::parse(content).unwrap();
let playbook = playbook.render(&variables).unwrap();
assert_eq!(
playbook.config.llm.as_ref().unwrap().greeting,
Some("Welcome Alice! Your ID is CUST789.".to_string())
);
}
#[test]
fn test_wrong_syntax_should_fail() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
This will fail: {{ X-Customer-ID }}
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-ID".to_string(), json!("CUST123"));
variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
let playbook = Playbook::parse(content).unwrap();
let result = playbook.render(&variables);
assert!(result.is_err());
}
#[test]
fn test_sip_dict_with_set_var() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
<set_var key="X-Call-Status" value="active" />
Customer: {{ sip["X-Customer-ID"] }}
Status set successfully.
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-ID".to_string(), json!("CUST456"));
variables.insert("_sip_header_keys".to_string(), json!(["X-Customer-ID"]));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("Customer: CUST456"));
assert!(scene.prompt.contains("<set_var"));
}
#[test]
fn test_sip_dict_mixed_with_regular_vars_in_complex_scenario() {
let content = r#"---
llm:
provider: openai
greeting: 'Hello {{ sip["X-Customer-Name"] }}, member level: {{ member_level }}'
---
# Scene: main
Your ID: {{ sip["X-Customer-ID"] }}
Your status: {{ account_status }}
Your priority: {{ sip["X-Priority"] }}
Order count: {{ order_count }}
"#;
let mut variables = HashMap::new();
variables.insert("X-Customer-Name".to_string(), json!("Bob"));
variables.insert("X-Customer-ID".to_string(), json!("CUST999"));
variables.insert("X-Priority".to_string(), json!("VIP"));
variables.insert("member_level".to_string(), json!("Gold"));
variables.insert("account_status".to_string(), json!("Active"));
variables.insert("order_count".to_string(), json!(5));
variables.insert(
"_sip_header_keys".to_string(),
json!(["X-Customer-Name", "X-Customer-ID", "X-Priority"]),
);
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
assert_eq!(
playbook.config.llm.as_ref().unwrap().greeting,
Some("Hello Bob, member level: Gold".to_string())
);
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.prompt.contains("Your ID: CUST999"));
assert!(scene.prompt.contains("Your status: Active"));
assert!(scene.prompt.contains("Your priority: VIP"));
assert!(scene.prompt.contains("Order count: 5"));
}
#[test]
fn test_raw_prompt_preserved_after_render() {
let content = r#"---
llm:
provider: openai
---
# Scene: greeting
您好,{{ customer_name }}!您的意图是:{{ intent }}
# Scene: detail
客户意图:{{ intent }}
详细信息在此。
"#;
let mut variables = HashMap::new();
variables.insert("customer_name".to_string(), json!("张三"));
variables.insert("intent".to_string(), json!("咨询"));
let playbook = Playbook::parse(content)
.unwrap()
.render(&variables)
.unwrap();
let greeting = playbook.scenes.get("greeting").unwrap();
assert!(greeting.prompt.contains("您好,张三"));
assert!(greeting.prompt.contains("您的意图是:咨询"));
assert!(greeting.raw_prompt.is_some());
let raw = greeting.raw_prompt.as_ref().unwrap();
assert!(raw.contains("{{ customer_name }}"));
assert!(raw.contains("{{ intent }}"));
let detail = playbook.scenes.get("detail").unwrap();
assert!(detail.raw_prompt.is_some());
assert!(detail.raw_prompt.as_ref().unwrap().contains("{{ intent }}"));
}
#[test]
fn test_render_scene_prompt_with_dynamic_vars() {
let scene = Scene {
id: "main".to_string(),
raw_prompt: Some("客户意图:{{ intent }}\n客户ID:{{ sip[\"X-Jobid\"] }}".to_string()),
prompt: "客户意图:\n客户ID:JOB123".to_string(), ..Default::default()
};
let mut vars = HashMap::new();
vars.insert("intent".to_string(), json!("买零食"));
vars.insert("X-Jobid".to_string(), json!("JOB123"));
vars.insert("_sip_header_keys".to_string(), json!(["X-Jobid"]));
let rendered = render_scene_prompt(&scene, &vars);
assert!(rendered.contains("客户意图:买零食"));
assert!(rendered.contains("客户ID:JOB123"));
}
#[test]
fn test_render_scene_prompt_fallback_without_template() {
let scene = Scene {
id: "simple".to_string(),
raw_prompt: Some("你好,欢迎光临".to_string()),
prompt: "你好,欢迎光临".to_string(),
..Default::default()
};
let vars = HashMap::new();
let rendered = render_scene_prompt(&scene, &vars);
assert_eq!(rendered, "你好,欢迎光临");
}
#[test]
fn test_render_scene_prompt_fallback_no_raw_prompt() {
let scene = Scene {
id: "legacy".to_string(),
prompt: "Hello world".to_string(),
..Default::default()
};
let vars = HashMap::new();
let rendered = render_scene_prompt(&scene, &vars);
assert_eq!(rendered, "Hello world");
}
#[test]
fn test_render_scene_prompt_with_builtin_vars() {
let scene = Scene {
id: "main".to_string(),
raw_prompt: Some(
"会话ID:{{ session_id }}\n呼叫类型:{{ call_type }}\n主叫:{{ caller }}\n被叫:{{ callee }}\n开始时间:{{ start_time }}"
.to_string(),
),
prompt: String::new(),
..Default::default()
};
let mut vars = HashMap::new();
vars.insert(BUILTIN_SESSION_ID.to_string(), json!("sess-12345"));
vars.insert(BUILTIN_CALL_TYPE.to_string(), json!("sip"));
vars.insert(BUILTIN_CALLER.to_string(), json!("sip:alice@example.com"));
vars.insert(BUILTIN_CALLEE.to_string(), json!("sip:bob@example.com"));
vars.insert(
BUILTIN_START_TIME.to_string(),
json!("2026-02-14T10:00:00Z"),
);
let rendered = render_scene_prompt(&scene, &vars);
assert!(rendered.contains("会话ID:sess-12345"));
assert!(rendered.contains("呼叫类型:sip"));
assert!(rendered.contains("主叫:sip:alice@example.com"));
assert!(rendered.contains("被叫:sip:bob@example.com"));
assert!(rendered.contains("开始时间:2026-02-14T10:00:00Z"));
}
#[test]
fn test_render_scene_prompt_mixed_sip_and_set_var() {
let scene = Scene {
id: "main".to_string(),
raw_prompt: Some(
"客户:{{ sip[\"X-Customer-Name\"] }}\n意图:{{ intent }}\n会话:{{ session_id }}"
.to_string(),
),
prompt: String::new(),
..Default::default()
};
let mut vars = HashMap::new();
vars.insert("X-Customer-Name".to_string(), json!("王五"));
vars.insert("_sip_header_keys".to_string(), json!(["X-Customer-Name"]));
vars.insert("intent".to_string(), json!("退货"));
vars.insert(BUILTIN_SESSION_ID.to_string(), json!("sess-99"));
let rendered = render_scene_prompt(&scene, &vars);
assert!(rendered.contains("客户:王五"));
assert!(rendered.contains("意图:退货"));
assert!(rendered.contains("会话:sess-99"));
}
#[test]
fn test_render_scene_prompt_graceful_on_missing_vars() {
let scene = Scene {
id: "main".to_string(),
raw_prompt: Some("意图:{{ intent }}".to_string()),
prompt: "意图:(未知)".to_string(), ..Default::default()
};
let vars = HashMap::new(); let rendered = render_scene_prompt(&scene, &vars);
assert_eq!(rendered, "意图:");
}
#[test]
fn test_raw_prompt_set_on_parse() {
let content = r#"---
llm:
provider: openai
---
# Scene: main
Hello {{ name }}!
"#;
let playbook = Playbook::parse(content).unwrap();
let scene = playbook.scenes.get("main").unwrap();
assert!(scene.raw_prompt.is_some());
assert!(scene.raw_prompt.as_ref().unwrap().contains("{{ name }}"));
}
#[test]
fn test_builtin_var_constants() {
assert_eq!(BUILTIN_SESSION_ID, "session_id");
assert_eq!(BUILTIN_CALL_TYPE, "call_type");
assert_eq!(BUILTIN_CALLER, "caller");
assert_eq!(BUILTIN_CALLEE, "callee");
assert_eq!(BUILTIN_START_TIME, "start_time");
}
}