define observed raw SIP response fields in final results
This commit is contained in:
@@ -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"}}}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user