active-call 0.3.83

A SIP/WebRTC voice agent
Documentation
# SIP 变量系统完整说明

## 概述

Active-Call 的变量系统现在明确区分 **SIP Headers** 和 **普通变量**,提供更清晰的变量管理和模板渲染机制。

## 核心特性

### 1. 变量分类

#### SIP Headers
- **识别规则**:通过 `sip.extract_headers` 配置提取的 headers
- **来源**  - 从 SIP INVITE 请求中提取(配置 `sip.extract_headers`  - 通过 `<set_var key="X-xxx" value="..."/>` 动态设置
- **访问方式**`{{ sip["X-Header-Name"] }}`(必须使用字典语法)
- **用途**:SIP 协议相关的元数据
- **重要**:Header 名称中的连字符会被 Jinja2 解析为减号,所以 `{{ X-Header }}` 会失败

#### 普通变量
- **识别规则**:不以 `X-``x-` 开头的变量
- **来源**  - 通过 `<set_var key="var_name" value="..."/>` 设置
  - 外部注入(API 调用时传入)
- **访问方式**`{{ var_name }}`
- **用途**:业务逻辑相关的数据

#### 内置会话变量(v0.3.38+)
- **识别规则**:系统自动注入,无需配置
- **来源**:通话建立时自动生成
- **访问方式**`{{ session_id }}``{{ call_type }}`- **变量列表**  - `session_id` — 通话会话唯一标识
  - `call_type` — 通话类型(`sip`/`websocket`/`webrtc`/`b2bua`  - `caller` — 主叫方 SIP URI(仅 SIP 通话)
  - `callee` — 被叫方 SIP URI(仅 SIP 通话)
  - `start_time` — 通话开始时间(RFC 3339)
- **特性**:不会覆盖外部传入的同名变量

### 2. 模板访问

在 Playbook 的 YAML 配置和 Prompt 中,可以使用以下语法:

```yaml
---
llm:
  greeting: "你好 {{ sip[\"X-Customer-Name\"] }},你的会员等级是 {{ member_level }}"
---
# Scene: main
客户编号:{{ sip["X-Customer-ID"] }}
业务类型:{{ business_type }}
```

**关键点**:
- ✅ SIP Headers 必须通过 `sip` 字典访问:`{{ sip["X-Header"] }}`
- ✅ 普通变量直接访问:`{{ variable_name }}`
- ❌ 错误:`{{ X-Header }}` 会被解析为减法运算

### 3. set_var 指令

LLM 可以在对话过程中动态设置变量:

#### 设置 SIP Header
```xml
<set_var key="X-Call-Status" value="completed" />
<set_var key="X-Ticket-ID" value="TKT-12345" />
```

#### 设置普通变量
```xml
<set_var key="user_name" value="张三" />
<set_var key="order_id" value="ORD-98765" />
```

#### 批量设置(不推荐)
```xml
<set_var key="_hangup_headers" value='{"X-Key1":"val1","X-Key2":"val2"}' />
```

### 4. BYE Headers 渲染

挂断时,`hangup_headers` 模板可以访问所有变量:

```yaml
---
sip:
  extract_headers:
    - "X-Customer-ID"
    - "X-Session-Type"
  hangup_headers:
    # 访问 SIP Headers
    X-Customer: "{{ sip[\"X-Customer-ID\"] }}"
    X-Type: "{{ sip[\"X-Session-Type\"] }}"
    
    # 访问普通变量
    X-Call-Result: "{{ call_result }}"
    X-Agent-Name: "{{ agent_name }}"
    X-Duration: "{{ call_duration }}"
---
```

在对话中设置这些变量:
```markdown
<set_var key="call_result" value="successful" />
<set_var key="agent_name" value="Alice" />
<set_var key="call_duration" value="180" />
```

## 动态场景 Prompt 渲染(v0.3.38+)

从 v0.3.38 开始,通过 `<set_var>` 设置的变量可以在场景切换时动态渲染到目标场景的 Prompt 中。

### 工作原理

1. 解析 Playbook 时,原始模板保存在 `Scene.raw_prompt`2. 每次 `<goto>` 切换场景时,使用当前 `extras` 重新渲染
3. 内置变量、SIP Headers、set_var 变量均可用
4. 渲染失败时自动回退到已有 Prompt

### 示例

```markdown
# Scene: collect
请收集用户意向。收集后输出 <set_var key="intent" value="..." /> 然后 <goto scene="confirm" />

# Scene: confirm
会话ID:{{ session_id }}
用户意向:{{ intent }}
请确认以上信息。
```

切换到 `confirm` 场景时,`{{ intent }}` 和 `{{ session_id }}` 会被动态替换为实际值。

详细说明参见 [Playbook Advanced Features](./playbook_advanced_features.md#动态场景-prompt-渲染)

## 实现细节

### 变量存储
所有变量(SIP Headers 和普通变量)都存储在 `ActiveCall.call_state.extras: HashMap<String, serde_json::Value>` 中。

### SIP Headers 识别

系统在源头(提取 headers 时)就标记哪些是 SIP Headers:

1. **配置驱动**:在 Playbook 中通过 `sip.extract_headers` 明确指定要提取的 SIP Headers
2. **自动标记**:提取后,系统自动在 `extras` 中存储一个 `_sip_header_keys` 列表
3. **为什么需要 `_sip_header_keys`**   - 问题根源:SIP Header 名称包含连字符(如 `X-Customer-ID`),Jinja2 会将 `{{ X-Customer-ID }}` 解析为 `X - Customer - ID`(减法运算)
   - 解决方案:将 SIP Headers 放入 `sip` 字典,使用 `{{ sip["X-Customer-ID"] }}` 访问
   - 实现关键:需要在渲染时知道哪些变量是 SIP Headers,因此在提取时存储 `_sip_header_keys` 列表
   - 运行时缓存:`_sip_header_keys` 是配置的运行时缓存,连接"配置"和"渲染"两个阶段

```rust
// 在 handler.rs 中过滤并标记 SIP headers
if let Some(allowed_headers) = &sip_config.extract_headers {
    let mut state = active_call.call_state.write().await;
    if let Some(extras) = &mut state.extras {
        filter_headers(extras, allowed_headers);
        // 存储 SIP header keys 列表
        let header_keys: Vec<String> = extras.keys()
            .filter(|k| !k.starts_with('_'))
            .cloned()
            .collect();
        extras.insert("_sip_header_keys", serde_json::to_value(&header_keys)?);
    }
}
```

### 模板渲染

#### Playbook 解析时
```rust
// 从存储的 keys 列表构建 sip 字典
let sip_header_keys: Vec<String> = vars
    .get("_sip_header_keys")
    .and_then(|v| serde_json::from_value(v.clone()).ok())
    .unwrap_or_default();  // 没有配置 extract_headers 时为空

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", serde_json::to_value(&sip_headers)?);
```

#### BYE Headers 渲染时
```rust
// 获取存储的 SIP header keys
let sip_header_keys: Vec<String> = state.extras
    .as_ref()
    .and_then(|e| e.get("_sip_header_keys"))
    .and_then(|v| serde_json::from_value(v.clone()).ok())
    .unwrap_or_default();  // 没有配置时为空

// sip 字典和所有变量都可访问
for (k, v) in extras {
    if k.starts_with('_') { continue; }  // 跳过内部 keys
    context.insert(k.clone(), v.clone());
    if sip_header_keys.contains(k) {
        sip_headers.insert(k.clone(), v.clone());
    }
}
context.insert("sip", serde_json::to_value(&sip_headers)?);
```

## 测试覆盖

### Playbook 模块测试
- `test_sip_dict_access_with_hyphens` - 测试 SIP Headers 通过 sip 字典访问
- `test_sip_dict_only_contains_sip_headers` - 验证 sip 字典只包含 SIP Headers
- `test_sip_dict_mixed_access` - 测试混合访问 SIP Headers 和普通变量
- `test_sip_dict_case_insensitive` - 测试大小写敏感性
- `test_sip_dict_empty_context` - 边界条件测试

### Handler 模块测试
- `test_set_var_with_sip_headers` - 测试批量设置 SIP Headers
- `test_set_var_individual_sip_header` - 测试单个 SIP Header 设置
- `test_bye_headers_with_all_variables` - 测试 BYE Headers 访问所有变量

## 使用场景

### 场景 1:呼入客户服务
```yaml
---
sip:
  extract_headers:
    - "X-Customer-ID"
    - "X-Customer-Level"
  hangup_headers:
    X-Call-Result: "{{ result }}"
    X-Satisfaction: "{{ satisfaction }}"
llm:
  greeting: "{{ sip[\"X-Customer-Level\"] }} 客户您好!"
---
# Scene: main
您的客户编号是 {{ sip["X-Customer-ID"] }}
```

### 场景 2:外呼营销
```yaml
---
sip:
  hangup_headers:
    X-Contact-Result: "{{ contact_result }}"
    X-Interest-Level: "{{ interest }}"
    X-Follow-Up: "{{ need_followup }}"
---
# Scene: main
<set_var key="contact_result" value="answered" />
客户对产品表示感兴趣 <set_var key="interest" value="high" />
需要后续跟进 <set_var key="need_followup" value="yes" />
```

### 场景 3:工单系统集成
```yaml
---
sip:
  extract_headers:
    - "X-Ticket-ID"
    - "X-Priority"
  hangup_headers:
    X-Resolution: "{{ resolution }}"
    X-Handled-By: "{{ agent }}"
---
# Scene: main
工单 {{ sip["X-Ticket-ID"] }} 优先级:{{ sip["X-Priority"] }}
问题已解决 <set_var key="resolution" value="solved" />
<set_var key="agent" value="AI-Agent-01" />
```

## 最佳实践

1. **命名约定**
   - SIP Headers:推荐使用 `X-` 前缀(RFC 规范),如 `X-Customer-ID`
   - 普通变量:使用下划线分隔,如 `customer_name``order_status`

2. **配置原则**
   -`sip.extract_headers` 中明确列出需要提取的 headers
   - 不要依赖命名约定(X- 前缀),让配置驱动一切

3. **访问模式**
   - 始终通过 `sip` 字典访问 SIP Headers:`{{ sip["X-Header"] }}`
   - 直接访问普通变量:`{{ variable_name }}`
   - **重要**:包含连字符的变量必须使用字典访问,否则会被解析为减法

3. **BYE Headers**
   -`hangup_headers` 中可以访问所有类型的变量
   - 优先使用普通变量存储业务逻辑数据
   - 使用 SIP Headers 传递协议层面的信息

4. **性能考虑**
   - `set_var` 是轻量级操作,可以频繁使用
   - 避免使用 `_hangup_headers` 批量设置,优先使用单个 `set_var`

5. **为什么之前没有发现问题**
   - 在引入 `sip` 字典之前,代码中从未直接在模板中使用过 `{{ X-Header-Name }}` 这样的语法
   - 实际使用时要么通过 API 参数传入(不经过模板),要么使用不含连字符的变量名
   - 当用户尝试在模板中使用 `{{ X-Header }}` 时才发现 Jinja2 会将其解析为减法运算

## 迁移指南

### 问题的由来

在 v0.3.38 之前,系统中虽然存在 SIP Headers(如 `X-Customer-ID`),但:
1. **从未在模板中直接引用**过这些包含连字符的变量
2. Headers 主要用于内部传递,不参与模板渲染
3. 当用户首次尝试在 Playbook 中使用 `{{ X-Header }}` 时,发现 Jinja2 将其解析为 `X - Header`(减法)

### 从旧版本迁移

**问题场景**:
```yaml
llm:
  greeting: "Hello {{ X-Customer-Name }}"  # ❌ 错误:被解析为 X - Customer - Name
```

**错误信息**:
```
Jinja2 template error: undefined variable 'X'
或
Jinja2 template error: unsupported operand type(s) for -: 'str' and 'str'
```

**解决方案**:
```yaml
sip:
  extract_headers:
    - "X-Customer-Name"  # 必须先配置提取
llm:
  greeting: "Hello {{ sip[\"X-Customer-Name\"] }}"  # ✅ 正确:字典访问
```

### 向后兼容性

- 普通变量(不含连字符)的访问方式保持不变
- 只有包含连字符的 SIP Headers 需要更新访问方式
- 所有测试已更新以覆盖新的访问模式

## 参考文档

- [Playbook Advanced Features (中文)]playbook_advanced_features.md
- [Playbook Advanced Features (English)]playbook_advanced_features.en.md
- [Template Syntax Comparison]template_syntax_comparison.md