define observed raw SIP response fields in final results

This commit is contained in:
2026-10-05 21:00:24 +08:00
parent 915d95a504
commit c3ecf4ad67
7 changed files with 80 additions and 7 deletions
@@ -1 +1 @@
{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"reason_message":"recording storage unavailable","transcript":[],"opt_out":false,"recording":{}}}
{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"status_line":null,"raw":null,"sip_capture_error":null,"reason_message":"recording storage unavailable","transcript":[],"opt_out":false,"recording":{}}}
@@ -1 +1 @@
{"event_id":"call-result-example-2","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"answered","reason_code":null,"reason_message":"answered, SIP status unavailable in isolated Mock","transcript":[{"turn_id":"turn-1","segment_id":"segment-1","role":"user","text":"Example utterance","start_ms":0,"end_ms":1000}],"opt_out":true,"recording":{"status":"uploaded","bucket":"example-bucket","object_key":"example/recording.wav","format":"wav","channels":1,"sample_rate_hz":16000,"duration_ms":60000,"size_bytes":64000,"checksum_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}
{"event_id":"call-result-example-2","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"answered","reason_code":null,"status_line":null,"raw":null,"sip_capture_error":null,"reason_message":"answered, SIP status unavailable in isolated Mock","transcript":[{"turn_id":"turn-1","segment_id":"segment-1","role":"user","text":"Example utterance","start_ms":0,"end_ms":1000}],"opt_out":true,"recording":{"status":"uploaded","bucket":"example-bucket","object_key":"example/recording.wav","format":"wav","channels":1,"sample_rate_hz":16000,"duration_ms":60000,"size_bytes":64000,"checksum_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}
+2 -2
View File
@@ -4,8 +4,8 @@
"sources": {
"docs/archive/sources/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada",
"docs/archive/sources/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87",
"docs/thirds/saas-dispatcher.md": "dfa9d264d48d2c2641a06c54d4d6f077e7ecea90a7274b967b179428fbc35ddc"
"docs/thirds/saas-dispatcher.md": "c4fad50a3eac37a4cae3440f70f0a374d00e392372893946b3085f84abc2541c"
},
"bundle_sha256": "130e8ba1b1d87f38265b4125451c01cb0ef0b4b90e108040891aa41d9f0ed473",
"bundle_sha256": "4cda02cdab7659c278ff22af45f4ab2190da9d97846b7f5a8a64bb2ffccb7e3c",
"bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json"
}
+4 -1
View File
@@ -72,13 +72,16 @@
"properties": {
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute.result"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
"payload": {"type": "object", "additionalProperties": false,
"required": ["task_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "transcript", "opt_out", "recording"],
"required": ["task_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "status_line", "raw", "sip_capture_error", "transcript", "opt_out", "recording"],
"properties": {
"task_id": {"$ref": "#/$defs/task_id"}, "caller_profile_id": {"type": "string", "minLength": 1}, "callee": {"type": "string", "minLength": 1}, "trunk_id": {"type": "string", "minLength": 1},
"started_at": {"$ref": "#/$defs/issued_at"}, "ended_at": {"$ref": "#/$defs/issued_at"}, "duration_ms": {"type": "integer", "minimum": 0},
"outcome": {"enum": ["answered", "no_answer", "failed"]},
"reason_code": {"type": ["integer", "null"], "minimum": 100, "maximum": 699},
"reason_message": {"type": "string", "minLength": 1},
"status_line": {"type": ["string", "null"], "pattern": "^SIP/2\\.0 [1-6][0-9]{2} .+", "maxLength": 256},
"raw": {"type": ["string", "null"], "minLength": 12, "maxLength": 65535},
"sip_capture_error": {"type": ["string", "null"], "minLength": 1, "maxLength": 256},
"transcript": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"], "properties": {
"turn_id": {"type": "string", "minLength": 1}, "segment_id": {"type": "string", "minLength": 1}, "role": {"enum": ["user", "assistant"]}, "text": {"type": "string"}, "start_ms": {"type": "integer", "minimum": 0}, "end_ms": {"type": "integer", "minimum": 0}
}}},
+1 -1
View File
@@ -35,7 +35,7 @@ RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D
- 当前 TTS 唯一获批适配器是 `bailian_tts`:任务快照须明确提供 `qwen3-tts-flash`、`Cherry`、`Chinese`、速度 `1` 和单声道 16 kHz PCM16 目标格式;使用已核验的 provider 凭据及生成端点。每段仅发起一次生成请求,下载返回的短期音频引用后转换为电话可用的 PCM16;不可用、超时、缺少转换工具或参数不支持时显式失败,不回退旧火山 TTS、不隐式重试或记录签名音频 URL。历史测试凭据不是任务授权,本地转换 Mock 不构成真实百炼/通话验收。
- 白名单仅含 `15003164745`、`15830461047` 原值;已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁或授权真实拨号。
- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。
- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。`call.execute.result.payload` 的 `status_line`、`raw` 与 `sip_capture_error` 始终存在:仅将经同一 ARI 通道拨号前取得的 SIP Call-ID 与 HEP INVITE 事务严格关联的最终响应写入原样状态行、完整原样报文与状态码;`raw` 不拼装、不截断,不能从目标号码、时间、挂断原因或 ARI HTTP 状态猜测。无 SIP 响应时前两项为 `null`;若已发起 SIP 但镜像/关联/解码失败,第三项须写明确错误,已确认结束仍报告真实结果并释放额度,不以原文缺失伪装为通话未知。原始报文只进入受控结果通道,不写日志、仓库或长期测试证据。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。
- 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。
## 录音与最终结果(K10、K15、K16)
+28 -1
View File
@@ -4,8 +4,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// FinalCallFacts are observed or approved call facts, never defaults inferred
@@ -20,6 +22,9 @@ type FinalCallFacts struct {
Outcome string
ReasonCode *int
ReasonMessage string
SIPStatusLine string
SIPRaw string
SIPCaptureError string
}
// FinalResultPayload creates the one current MQ result body. Only final
@@ -41,6 +46,16 @@ func FinalResultPayload(facts FinalCallFacts, call Result) ([]byte, error) {
if facts.ReasonCode != nil && (*facts.ReasonCode < 100 || *facts.ReasonCode > 699) {
return nil, errors.New("final call result has an invalid SIP response code")
}
if facts.SIPRaw != "" || facts.SIPStatusLine != "" {
fields := strings.SplitN(facts.SIPStatusLine, " ", 3)
if facts.SIPCaptureError != "" || facts.ReasonCode == nil || len(fields) != 3 || fields[0] != "SIP/2.0" || !strings.HasPrefix(facts.SIPRaw, facts.SIPStatusLine+"\r\n") || !strings.Contains(facts.SIPRaw, "\r\n\r\n") || len(facts.SIPRaw) > 65535 || !utf8.ValidString(facts.SIPRaw) {
return nil, errors.New("final call result contains incomplete or contradictory SIP evidence")
}
code, err := strconv.Atoi(fields[1])
if err != nil || code != *facts.ReasonCode {
return nil, errors.New("final call result SIP status disagrees with the original response")
}
}
if len(call.CaptureWindows) < len(call.Turns) {
return nil, errors.New("final ASR text has no corresponding observed media window")
}
@@ -77,6 +92,14 @@ func FinalResultPayload(facts FinalCallFacts, call Result) ([]byte, error) {
})
}
}
var statusLine, raw, captureError *string
if facts.SIPStatusLine != "" {
statusLine = &facts.SIPStatusLine
raw = &facts.SIPRaw
}
if facts.SIPCaptureError != "" {
captureError = &facts.SIPCaptureError
}
return json.Marshal(struct {
TaskID string `json:"task_id"`
CallerProfileID string `json:"caller_profile_id"`
@@ -88,6 +111,9 @@ func FinalResultPayload(facts FinalCallFacts, call Result) ([]byte, error) {
Outcome string `json:"outcome"`
ReasonCode *int `json:"reason_code"`
ReasonMessage string `json:"reason_message"`
StatusLine *string `json:"status_line"`
Raw *string `json:"raw"`
SIPCaptureError *string `json:"sip_capture_error"`
Transcript []transcriptSegment `json:"transcript"`
OptOut bool `json:"opt_out"`
Recording map[string]any `json:"recording"`
@@ -95,6 +121,7 @@ func FinalResultPayload(facts FinalCallFacts, call Result) ([]byte, error) {
TaskID: facts.TaskID, CallerProfileID: facts.CallerProfileID, Callee: facts.Callee, TrunkID: facts.TrunkID,
StartedAt: facts.StartedAt.UTC().Format(time.RFC3339Nano), EndedAt: facts.EndedAt.UTC().Format(time.RFC3339Nano),
DurationMS: callDuration, Outcome: facts.Outcome, ReasonCode: facts.ReasonCode,
ReasonMessage: facts.ReasonMessage, Transcript: segments, OptOut: optOut, Recording: map[string]any{},
ReasonMessage: facts.ReasonMessage, StatusLine: statusLine, Raw: raw, SIPCaptureError: captureError,
Transcript: segments, OptOut: optOut, Recording: map[string]any{},
})
}
+43
View File
@@ -73,6 +73,49 @@ func TestFinalResultPayloadUsesOnlyObservedUserASRAndMediaTimes(t *testing.T) {
}
}
func TestFinalResultPayloadPreservesOriginalSIPResponse(t *testing.T) {
facts, _ := approvedResultFixture()
code := 480
raw := "SIP/2.0 480 Temporarily Unavailable\r\nVia: SIP/2.0/UDP 127.0.0.1:5060;branch=z9hG4bK-unique\r\nCall-ID: isolated-123\r\nCSeq: 1 INVITE\r\nReason: Q.850;cause=19;text=\"No answer\"\r\nContent-Length: 0\r\n\r\n"
facts.Outcome, facts.ReasonMessage, facts.ReasonCode = "failed", "call ended before answer", &code
facts.SIPStatusLine, facts.SIPRaw = "SIP/2.0 480 Temporarily Unavailable", raw
payload, err := FinalResultPayload(facts, Result{})
if err != nil {
t.Fatal(err)
}
var result struct {
ReasonCode *int `json:"reason_code"`
StatusLine string `json:"status_line"`
Raw string `json:"raw"`
}
if err := json.Unmarshal(payload, &result); err != nil || result.ReasonCode == nil || *result.ReasonCode != code || result.StatusLine != facts.SIPStatusLine || result.Raw != raw {
t.Fatalf("exact SIP response lost: %+v err=%v", result, err)
}
}
func TestFinalResultPayloadRejectsContradictorySIPResponse(t *testing.T) {
for _, tc := range []struct {
name string
change func(*FinalCallFacts)
}{
{"code_mismatch", func(f *FinalCallFacts) { n := 486; f.ReasonCode = &n }},
{"status_line_mismatch", func(f *FinalCallFacts) { f.SIPStatusLine = "SIP/2.0 486 Busy Here" }},
{"truncated_raw", func(f *FinalCallFacts) { f.SIPRaw = "SIP/2.0 480 Temporarily Unavailable\r\nVia: x" }},
{"raw_and_capture_error", func(f *FinalCallFacts) { f.SIPCaptureError = "mirror lost" }},
} {
t.Run(tc.name, func(t *testing.T) {
facts, _ := approvedResultFixture()
code := 480
facts.ReasonCode, facts.SIPStatusLine = &code, "SIP/2.0 480 Temporarily Unavailable"
facts.SIPRaw = "SIP/2.0 480 Temporarily Unavailable\r\nContent-Length: 0\r\n\r\n"
tc.change(&facts)
if payload, err := FinalResultPayload(facts, Result{}); err == nil || len(payload) != 0 {
t.Fatalf("unverified SIP response became a result: size=%d err=%v", len(payload), err)
}
})
}
}
func TestFinalResultPayloadRejectsMissingOrInventedCallFacts(t *testing.T) {
for _, tc := range []struct {
name string