diff --git a/contracts/local/examples/mq-result-no-recording.json b/contracts/local/examples/mq-result-no-recording.json index 527fb72..d994d49 100644 --- a/contracts/local/examples/mq-result-no-recording.json +++ b/contracts/local/examples/mq-result-no-recording.json @@ -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":{}}} diff --git a/contracts/local/examples/mq-result-uploaded.json b/contracts/local/examples/mq-result-uploaded.json index 8e81c17..c179448 100644 --- a/contracts/local/examples/mq-result-uploaded.json +++ b/contracts/local/examples/mq-result-uploaded.json @@ -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"}}} diff --git a/contracts/local/manifest.json b/contracts/local/manifest.json index 1176a3a..bdff5cf 100644 --- a/contracts/local/manifest.json +++ b/contracts/local/manifest.json @@ -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" } diff --git a/contracts/local/mq.schema.json b/contracts/local/mq.schema.json index 1a47293..f1e11f8 100644 --- a/contracts/local/mq.schema.json +++ b/contracts/local/mq.schema.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} }}}, diff --git a/docs/thirds/saas-dispatcher.md b/docs/thirds/saas-dispatcher.md index c85e9a1..85faa7e 100644 --- a/docs/thirds/saas-dispatcher.md +++ b/docs/thirds/saas-dispatcher.md @@ -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) diff --git a/internal/callflow/result_payload.go b/internal/callflow/result_payload.go index 5d49f29..a501ad3 100644 --- a/internal/callflow/result_payload.go +++ b/internal/callflow/result_payload.go @@ -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{}, }) } diff --git a/internal/callflow/result_payload_test.go b/internal/callflow/result_payload_test.go index b737158..2b9af4e 100644 --- a/internal/callflow/result_payload_test.go +++ b/internal/callflow/result_payload_test.go @@ -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