Freeze current project-local SaaS Dispatcher contract and examples

This commit is contained in:
2026-09-29 18:50:29 +08:00
parent bb694395cf
commit 1d385e0c4a
46 changed files with 1409 additions and 1 deletions
+12 -1
View File
@@ -11,11 +11,22 @@ import (
// Files contains pinned upstream schemas and the active local contract versions.
// Runtime code never reads a checkout or resolves schema refs online.
//
//go:embed upstream local/v0.1 local/v0.2 local/v0.3 local/v0.4
//go:embed upstream local/v0.1 local/v0.2 local/v0.3 local/v0.4 local/*.json
var Files embed.FS
const SourceCommit = "v1"
// ReadCurrent reads the sole active project-local contract, with no fallback
// to a historical bundle if a name is missing or invalid.
func ReadCurrent(name string) ([]byte, error) {
switch name {
case "config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json":
return Files.ReadFile(path.Join("local", name))
default:
return nil, fmt.Errorf("invalid current contract %q", name)
}
}
func ReadLocal(version, name string) ([]byte, error) {
if (version != "v0.1" && version != "v0.2" && version != "v0.3" && version != "v0.4") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") {
return nil, fmt.Errorf("invalid project-local schema %q/%q", version, name)
+68
View File
@@ -0,0 +1,68 @@
package contracts_test
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// The current, project-owned contracts have one schema per transport surface.
// Historical upstream bundles are not fallback validators for these inputs.
func TestCurrentContractExamples(t *testing.T) {
for _, surface := range []string{"config-read", "task-discovery", "mq"} {
t.Run(surface, func(t *testing.T) {
root := filepath.Join("local", surface+".schema.json")
raw, err := os.ReadFile(root)
if err != nil {
t.Fatal(err)
}
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
const base = "https://go-sip.local/contracts/current/"
if err := compiler.AddResource(base+surface+".schema.json", value); err != nil {
t.Fatal(err)
}
schema, err := compiler.Compile(base + surface + ".schema.json")
if err != nil {
t.Fatal(err)
}
positives, err := filepath.Glob(filepath.Join("local", "examples", surface+"-*.json"))
if err != nil || len(positives) == 0 {
t.Fatalf("missing positive examples: %v", err)
}
negatives, err := filepath.Glob(filepath.Join("local", "examples", "invalid", surface+"-*.json"))
if err != nil || len(negatives) == 0 {
t.Fatalf("missing negative examples: %v", err)
}
for _, tc := range []struct {
files []string
valid bool
}{{positives, true}, {negatives, false}} {
for _, path := range tc.files {
t.Run(strings.TrimSuffix(filepath.Base(path), ".json"), func(t *testing.T) {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatal(err)
}
if err := schema.Validate(value); (err == nil) != tc.valid {
t.Fatalf("valid=%v, validation error=%v", tc.valid, err)
}
})
}
}
})
}
}
+23
View File
@@ -0,0 +1,23 @@
package contracts_test
import (
"strings"
"testing"
"git.ipao.vip/rogee/go-sip/contracts"
)
func TestCurrentContractRead(t *testing.T) {
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json"} {
data, err := contracts.ReadCurrent(name)
if err != nil || len(data) == 0 {
t.Fatalf("read %s: %v", name, err)
}
}
for _, name := range []string{"", "v0.4/task-discovery-v0.4-proposal.schema.json", "../upstream/v1/mq.schema.json", "examples/mq-execute.json", "arbitrary.json"} {
_, err := contracts.ReadCurrent(name)
if err == nil || !strings.Contains(err.Error(), "invalid") {
t.Fatalf("unexpected read of %q: %v", name, err)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package contracts_test
import (
"encoding/json"
"os"
"strings"
"testing"
)
func TestCurrentContractTopology(t *testing.T) {
type route struct {
RoutingKey string `json:"routing_key"`
BindingKey string `json:"binding_key"`
Queue string `json:"queue"`
}
var topology struct {
Ownership string `json:"ownership"`
Dispatcher struct {
Exchange string `json:"exchange"`
Control route `json:"control"`
Task route `json:"task"`
} `json:"dispatcher"`
SaaS struct {
Exchange string `json:"exchange"`
Result route `json:"result"`
} `json:"saas"`
}
data, err := os.ReadFile("local/mq-topology.json")
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &topology); err != nil {
t.Fatal(err)
}
if topology.Ownership != "saas" || topology.Dispatcher.Exchange != "agent-call.dispatchers.v1" || topology.SaaS.Exchange != "agent-call.saas.v1" {
t.Fatalf("unapproved owner/exchanges: %+v", topology)
}
if topology.Dispatcher.Control.Queue != "agent-call.d.<dispatcher_id>.control.v1" || topology.Dispatcher.Control.BindingKey != "d.<dispatcher_id>.control.in" {
t.Fatalf("wrong control queue or exact binding: %+v", topology.Dispatcher.Control)
}
if topology.Dispatcher.Task.Queue != "agent-call.d.<dispatcher_id>.task.<task_id>.v1" || topology.Dispatcher.Task.BindingKey != "d.<dispatcher_id>.task.<task_id>.in" {
t.Fatalf("wrong per-task queue or binding: %+v", topology.Dispatcher.Task)
}
result := topology.SaaS.Result
if result.Queue != "agent-call.saas.events.v1" || result.RoutingKey != "d.<dispatcher_id>.out" || result.BindingKey != result.RoutingKey {
t.Fatalf("SaaS must consume a shared queue bound exactly to each D: %+v", result)
}
for _, r := range []route{topology.Dispatcher.Control, topology.Dispatcher.Task, result} {
if r.RoutingKey != r.BindingKey || strings.ContainsAny(r.BindingKey, "*#") {
t.Fatalf("route must have exact binding: %+v", r)
}
}
for _, id := range []string{"d-1", "d-2"} {
key := strings.ReplaceAll(result.RoutingKey, "<dispatcher_id>", id)
if len(key) > 255 || key == result.BindingKey {
t.Fatalf("invalid dispatcher-specific binding: %q", key)
}
}
}
+190
View File
@@ -0,0 +1,190 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/current/config-read.schema.json",
"title": "Project-local read-only Dispatcher configuration; external SaaS compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/sip"},
{"$ref": "#/$defs/providers"},
{"$ref": "#/$defs/task"},
{"$ref": "#/$defs/quota"},
{"$ref": "#/$defs/error"}
],
"$defs": {
"dispatcher_id": {"type": "string", "format": "uuid"},
"tenant_id": {"type": "integer", "minimum": 1},
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"sip": {
"type": "object", "additionalProperties": false,
"required": ["resource", "dispatcher_id", "revision", "trunks"],
"properties": {
"resource": {"const": "sip_config"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"revision": {"type": "integer", "minimum": 1},
"trunks": {"type": "array", "items": {"$ref": "#/$defs/trunk"}}
}
},
"trunk": {
"type": "object", "additionalProperties": false,
"required": ["trunk_id", "provider_id", "codec", "dial_prefix", "enabled", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_profiles", "schedule"],
"properties": {
"trunk_id": {"type": "string", "minLength": 1},
"provider_id": {"type": "string", "minLength": 1},
"codec": {"const": "PCMA"},
"dial_prefix": {"type": "string"},
"enabled": {"type": "boolean"},
"server_host": {"type": "string", "minLength": 1},
"server_port": {"type": "integer", "minimum": 1, "maximum": 65535},
"transport": {"enum": ["udp", "tcp", "tls", null]},
"auth_mode": {"enum": ["ip", "digest", "none", null]},
"registration_required": {"type": ["boolean", "null"]},
"max_concurrent_calls": {"type": ["integer", "null"], "minimum": 1},
"caller_profiles": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["caller_profile_id", "caller_id"], "properties": {"caller_profile_id": {"type": "string", "minLength": 1}, "caller_id": {"type": "string", "minLength": 1}}}},
"schedule": {"$ref": "#/$defs/weekly_schedule"}
}
},
"providers": {
"type": "object", "additionalProperties": false,
"required": ["resource", "dispatcher_id", "providers"],
"properties": {
"resource": {"const": "ai_providers"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}}
}
},
"provider": {
"type": "object", "additionalProperties": false,
"required": ["provider_ref", "role", "enabled", "adapter", "endpoint", "credential"],
"properties": {
"provider_ref": {"type": "string", "minLength": 1},
"role": {"enum": ["asr", "llm", "tts"]},
"enabled": {"type": "boolean"},
"adapter": {"type": "string", "minLength": 1},
"endpoint": {"type": "string", "minLength": 1},
"credential": {"type": "string", "minLength": 1, "$comment": "Value is supplied verbatim to the provider SDK. Never log its content."}
}
},
"task": {
"type": "object", "additionalProperties": false,
"required": ["resource", "dispatcher_id", "tenant_id", "task_id", "task_revision", "status", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "caller_profile_id", "allowed_trunk_ids", "schedule", "agent"],
"properties": {
"resource": {"const": "task_config"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"tenant_id": {"$ref": "#/$defs/tenant_id"},
"task_id": {"$ref": "#/$defs/task_id"},
"task_revision": {"type": "integer", "minimum": 1},
"status": {"enum": ["running", "paused", "stopped"]},
"name": {"type": "string", "minLength": 1},
"max_concurrent_calls": {"type": "integer", "minimum": 1},
"ring_timeout_ms": {"type": "integer", "minimum": 1},
"max_call_duration_ms": {"type": "integer", "minimum": 1},
"route_policy_id": {"type": "string", "minLength": 1},
"caller_profile_id": {"type": "string", "minLength": 1},
"allowed_trunk_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"schedule": {"$ref": "#/$defs/task_schedule"},
"agent": {"$ref": "#/$defs/agent"}
}
},
"quota": {
"type": "object", "additionalProperties": false,
"required": ["resource", "dispatcher_id", "tenant_id", "quota_revision", "max_concurrent_calls"],
"properties": {
"resource": {"const": "tenant_quota"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"tenant_id": {"$ref": "#/$defs/tenant_id"},
"quota_revision": {"type": "integer", "minimum": 1},
"max_concurrent_calls": {"type": "integer", "minimum": 0}
}
},
"error": {
"type": "object", "additionalProperties": false,
"required": ["resource", "error"],
"properties": {
"resource": {"const": "error"},
"error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}}
}
},
"task_schedule": {
"type": "object", "additionalProperties": false,
"required": ["time_zone", "starts_at", "ends_at", "weekly_windows", "excluded_dates"],
"properties": {
"time_zone": {"const": "Asia/Shanghai"},
"starts_at": {"type": ["string", "null"], "format": "date-time"},
"ends_at": {"type": ["string", "null"], "format": "date-time"},
"weekly_windows": {"$ref": "#/$defs/weekly_windows"},
"excluded_dates": {"type": "array", "uniqueItems": true, "items": {"type": "string", "format": "date"}}
}
},
"weekly_schedule": {
"type": "object", "additionalProperties": false,
"required": ["time_zone", "weekly_windows"],
"properties": {"time_zone": {"const": "Asia/Shanghai"}, "weekly_windows": {"$ref": "#/$defs/weekly_windows"}}
},
"weekly_windows": {
"type": "object", "additionalProperties": false,
"required": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
"properties": {
"monday": {"$ref": "#/$defs/windows"}, "tuesday": {"$ref": "#/$defs/windows"}, "wednesday": {"$ref": "#/$defs/windows"}, "thursday": {"$ref": "#/$defs/windows"}, "friday": {"$ref": "#/$defs/windows"}, "saturday": {"$ref": "#/$defs/windows"}, "sunday": {"$ref": "#/$defs/windows"}
}
},
"windows": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"type": "string", "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}, "end": {"type": "string", "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"}}}},
"agent": {
"type": "object", "additionalProperties": false,
"required": ["immutable", "mode", "asr"],
"properties": {
"immutable": {"const": true},
"mode": {"enum": ["asr_only", "full_ai"]},
"asr": {"$ref": "#/$defs/asr"},
"llm": {"$ref": "#/$defs/llm"},
"tts": {"$ref": "#/$defs/tts"},
"prompt": {"$ref": "#/$defs/prompt"},
"conversation": {"$ref": "#/$defs/conversation"}
},
"allOf": [
{"if": {"properties": {"mode": {"const": "full_ai"}}}, "then": {"required": ["llm", "tts", "prompt", "conversation"]}},
{"if": {"properties": {"mode": {"const": "asr_only"}}}, "then": {"not": {"anyOf": [{"required": ["llm"]}, {"required": ["tts"]}, {"required": ["prompt"]}]}}}
]
},
"asr": {
"type": "object", "additionalProperties": false,
"required": ["provider_ref", "language", "input"],
"properties": {
"provider_ref": {"type": "string", "minLength": 1},
"model": {"type": "string", "minLength": 1},
"language": {"type": "string", "minLength": 1},
"interim": {"type": "boolean"},
"timeout_ms": {"type": "integer", "minimum": 1},
"input": {"$ref": "#/$defs/audio_input"}
}
},
"audio_input": {
"type": "object", "additionalProperties": false,
"required": ["encoding", "sample_rate_hz", "channels", "sample_width_bytes"],
"properties": {
"encoding": {"const": "pcm_s16le"}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}, "sample_width_bytes": {"const": 2}
}
},
"llm": {
"type": "object", "additionalProperties": false,
"required": ["provider_ref", "model"],
"properties": {
"provider_ref": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "temperature": {"type": "number", "minimum": 0, "maximum": 2}, "max_tokens": {"type": "integer", "minimum": 1}, "timeout_ms": {"type": "integer", "minimum": 1}
}
},
"tts": {
"type": "object", "additionalProperties": false,
"required": ["provider_ref", "model", "voice", "format"],
"properties": {
"provider_ref": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "voice": {"type": "string", "minLength": 1}, "speed": {"type": "number", "minimum": 0.25, "maximum": 3}, "timeout_ms": {"type": "integer", "minimum": 1}, "format": {"type": "object", "additionalProperties": false, "required": ["encoding", "sample_rate_hz", "channels"], "properties": {"encoding": {"enum": ["pcm_s16le", "pcma"]}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}}}
}
},
"prompt": {
"type": "object", "additionalProperties": false,
"required": ["text", "allowed_variables"],
"properties": {"text": {"type": "string", "minLength": 1}, "allowed_variables": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "max_bytes": {"type": "integer", "minimum": 1}}
},
"conversation": {
"type": "object", "additionalProperties": false,
"properties": {"opening": {"type": "string"}, "hangup_keywords": {"type": "array", "items": {"type": "string", "minLength": 1}}, "allow_interrupt": {"type": "boolean"}, "silence_timeout_ms": {"type": "integer", "minimum": 1}, "max_duration_ms": {"type": "integer", "minimum": 1}, "max_turns": {"type": "integer", "minimum": 1}, "sentence_max_chars": {"type": "integer", "minimum": 1}, "max_pending_audio_chunks": {"type": "integer", "minimum": 1}}
}
}
}
@@ -0,0 +1 @@
{"resource":"error","error":{"code":"resource_not_found","message":"The requested resource is not available"}}
@@ -0,0 +1,9 @@
{
"resource":"ai_providers",
"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6",
"providers":[
{"provider_ref":"asr-example","role":"asr","enabled":true,"adapter":"volcengine_asr","endpoint":"https://asr.example.invalid","credential":"example-only-not-a-real-secret"},
{"provider_ref":"llm-example","role":"llm","enabled":true,"adapter":"openai_compatible","endpoint":"https://llm.example.invalid/v1","credential":"example-only-not-a-real-secret"},
{"provider_ref":"tts-example","role":"tts","enabled":true,"adapter":"volcengine_tts","endpoint":"https://tts.example.invalid","credential":"example-only-not-a-real-secret"}
]
}
@@ -0,0 +1 @@
{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"quota_revision":1,"max_concurrent_calls":3}
@@ -0,0 +1,12 @@
{
"resource":"sip_config",
"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6",
"revision":8,
"trunks":[{
"trunk_id":"trunk-mock","provider_id":"provider-mock","codec":"PCMA","dial_prefix":"","enabled":true,
"server_host":"sip.example.invalid","server_port":5060,
"transport":null,"auth_mode":null,"registration_required":null,"max_concurrent_calls":null,
"caller_profiles":[{"caller_profile_id":"caller-profile-mock","caller_id":"BD00000000"}],
"schedule":{"time_zone":"Asia/Shanghai","weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]}}
}]
}
@@ -0,0 +1,8 @@
{
"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,
"task_id":"task-asr","task_revision":1,"status":"running","name":"ASR example",
"max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,
"route_policy_id":"route-mock","caller_profile_id":"caller-profile-mock","allowed_trunk_ids":["trunk-mock"],
"schedule":{"time_zone":"Asia/Shanghai","starts_at":"2026-09-21T00:00:00+08:00","ends_at":null,"weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]},
"agent":{"immutable":true,"mode":"asr_only","asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":false,"timeout_ms":5000}}
}
@@ -0,0 +1,15 @@
{
"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,
"task_id":"task-full","task_revision":2,"status":"running","name":"Full AI example",
"max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,
"route_policy_id":"route-mock","caller_profile_id":"caller-profile-mock","allowed_trunk_ids":["trunk-mock"],
"schedule":{"time_zone":"Asia/Shanghai","starts_at":"2026-09-21T00:00:00+08:00","ends_at":null,"weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":["2026-10-01"]},
"agent":{
"immutable":true,"mode":"full_ai",
"asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":true,"timeout_ms":5000},
"llm":{"provider_ref":"llm-example","model":"example-chat","temperature":0,"max_tokens":256,"timeout_ms":5000},
"tts":{"provider_ref":"tts-example","model":"example-tts","voice":"example-neutral","speed":1,"format":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1},"timeout_ms":5000},
"prompt":{"text":"Example only","allowed_variables":[],"max_bytes":32768},
"conversation":{"opening":"Example greeting","hangup_keywords":["不用了"],"allow_interrupt":false,"silence_timeout_ms":3000,"max_duration_ms":120000,"max_turns":20,"sentence_max_chars":80,"max_pending_audio_chunks":32}
}
}
@@ -0,0 +1 @@
{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"quota_revision":1,"max_concurrent_calls":3,"schema_version":"v0.5"}
@@ -0,0 +1 @@
{"resource":"ai_providers","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","providers":[{"provider_ref":"asr-example","role":"asr","enabled":true,"adapter":"volcengine_asr","endpoint":"https://asr.example.invalid","credential_ref":"must-not-be-referenced"}]}
@@ -0,0 +1 @@
{"resource":"sip_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","trunks":[]}
@@ -0,0 +1 @@
{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":"1001","quota_revision":1,"max_concurrent_calls":3}
@@ -0,0 +1 @@
{"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"task_id":"task-asr","task_revision":1,"status":"running","max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,"route_policy_id":"route-mock","caller_profile_id":"caller-profile-mock","allowed_trunk_ids":["trunk-mock"],"schedule":{"time_zone":"Asia/Shanghai","starts_at":null,"ends_at":null,"weekly_windows":{"monday":[],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]},"agent":{"immutable":true,"mode":"asr_only","asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2}},"llm":{"provider_ref":"llm-example","model":"must-not-be-used"}}}
@@ -0,0 +1 @@
{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"pause","reason":"operator","options":{"active_call_policy":"ignore"}}}
@@ -0,0 +1 @@
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","schema_version":"v0.5","payload":{"task_id":"task-asr","callee":"15003164745"}}
@@ -0,0 +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,"transcript":[],"opt_out":false,"recording":{}}}
@@ -0,0 +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","call_id":"legacy-call-id","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":"storage unavailable","transcript":[],"opt_out":false,"recording":{}}}
@@ -0,0 +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":"storage unavailable","transcript":[],"opt_out":false,"recording":{"status":"unavailable"}}}
@@ -0,0 +1 @@
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":"1001","issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","callee":"15003164745"}}
@@ -0,0 +1 @@
{"event_id":"call-result-example","event_type":"call.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{}}
@@ -0,0 +1 @@
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","snapshot_id":"legacy","tasks":[]}
@@ -0,0 +1 @@
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","tasks":[{"task_id":"task-asr","tenant_id":"1001","status":"running","task_revision":1}]}
@@ -0,0 +1 @@
{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"payload":{"status":"applied"}}
+1
View File
@@ -0,0 +1 @@
{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"pause","reason":"operator","options":{"active_call_policy":"drain"}}}
@@ -0,0 +1 @@
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"dispatched"}}
@@ -0,0 +1 @@
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"rejected","reason_code":null,"reason_message":"outside permitted time"}}
+1
View File
@@ -0,0 +1 @@
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","callee":"15003164745"}}
@@ -0,0 +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":{}}}
@@ -0,0 +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"}}}
@@ -0,0 +1 @@
{"event_id":"sip-change-example","event_type":"sip.config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","payload":{"trunk_id":"trunk-mock","change_type":"disabled","revision":8}}
@@ -0,0 +1 @@
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-end-token","tasks":[]}
@@ -0,0 +1 @@
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","tasks":[{"task_id":"task-asr","tenant_id":1001,"status":"running","task_revision":1}]}
+11
View File
@@ -0,0 +1,11 @@
{
"scope": "project-local-current",
"status": "isolated-mock-only-external-unverified",
"sources": {
"docs/thirds/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada",
"docs/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87",
"docs/thirds/saas-dispatcher.md": "0ee54323ac815ced2327dc8f37140fca53605466e8f78cf8183223326075ca03"
},
"bundle_sha256": "4ff0afffa2c865050091c042d8f98bbe344ba9a4f4c3652e721a5077217722ed",
"bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json"
}
+30
View File
@@ -0,0 +1,30 @@
{
"ownership": "saas",
"dispatcher": {
"exchange": "agent-call.dispatchers.v1",
"exchange_type": "topic",
"durable": true,
"control": {
"routing_key": "d.<dispatcher_id>.control.in",
"binding_key": "d.<dispatcher_id>.control.in",
"queue": "agent-call.d.<dispatcher_id>.control.v1"
},
"task": {
"routing_key": "d.<dispatcher_id>.task.<task_id>.in",
"binding_key": "d.<dispatcher_id>.task.<task_id>.in",
"queue": "agent-call.d.<dispatcher_id>.task.<task_id>.v1"
}
},
"saas": {
"exchange": "agent-call.saas.v1",
"exchange_type": "topic",
"durable": true,
"result": {
"routing_key": "d.<dispatcher_id>.out",
"binding_key": "d.<dispatcher_id>.out",
"queue": "agent-call.saas.events.v1"
}
},
"dead_letter_exchange": "agent-call.dead-letter.v1",
"provisioning_note": "SaaS owns exchanges, queues and exact bindings. For every active dispatcher bind its exact d.<dispatcher_id>.out key to the same SaaS result queue; Dispatcher has no configure permission. The queue name is the isolated Mock fixture, not a requirement on SaaS deployment naming."
}
+105
View File
@@ -0,0 +1,105 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/current/mq.schema.json",
"title": "Project-local Dispatcher MQ messages; external SaaS compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/sip_change"},
{"$ref": "#/$defs/control_in"},
{"$ref": "#/$defs/control_ack"},
{"$ref": "#/$defs/execute_in"},
{"$ref": "#/$defs/execute_ack"},
{"$ref": "#/$defs/execute_result"}
],
"$defs": {
"event_id": {"type": "string", "minLength": 1},
"dispatcher_id": {"type": "string", "format": "uuid"},
"tenant_id": {"type": "integer", "minimum": 1},
"issued_at": {"type": "string", "format": "date-time"},
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"sip_change": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "payload"],
"properties": {
"event_id": {"$ref": "#/$defs/event_id"},
"event_type": {"const": "sip.config"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"payload": {"type": "object", "additionalProperties": false, "required": ["trunk_id", "change_type", "revision"], "properties": {
"trunk_id": {"type": "string", "minLength": 1}, "change_type": {"enum": ["enabled", "disabled", "removed", "created"]}, "revision": {"type": "integer", "minimum": 1}
}}
}
},
"control_in": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
"properties": {
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "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", "action", "reason"], "properties": {
"task_id": {"$ref": "#/$defs/task_id"}, "action": {"enum": ["pause", "resume", "stop"]}, "reason": {"type": "string", "minLength": 1},
"options": {"type": "object", "additionalProperties": false, "properties": {"active_call_policy": {"enum": ["drain", "hangup"]}}}
}}
}
},
"control_ack": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "payload"],
"properties": {
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"},
"payload": {"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"type": "string", "minLength": 1}}}
}
},
"execute_in": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
"properties": {
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "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", "callee"], "properties": {"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1}}}
}
},
"execute_ack": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
"properties": {
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
"payload": {"oneOf": [
{"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"const": "dispatched"}}},
{"type": "object", "additionalProperties": false, "required": ["status", "reason_code", "reason_message"], "properties": {"status": {"const": "rejected"}, "reason_code": {"type": "null"}, "reason_message": {"type": "string", "minLength": 1}}}
]}
}
},
"execute_result": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
"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"],
"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},
"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}
}}},
"opt_out": {"type": "boolean"},
"recording": {"oneOf": [
{"type": "object", "additionalProperties": false, "maxProperties": 0},
{"$ref": "#/$defs/recording_uploaded"}
]}
},
"allOf": [
{"if": {"properties": {"reason_code": {"type": "null"}}, "required": ["reason_code"]}, "then": {"required": ["reason_message"]}}
]
}
}
},
"recording_uploaded": {
"type": "object", "additionalProperties": false,
"required": ["status", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"],
"properties": {
"status": {"const": "uploaded"}, "bucket": {"type": "string", "minLength": 1}, "object_key": {"type": "string", "minLength": 1}, "format": {"type": "string", "minLength": 1}, "channels": {"type": "integer", "minimum": 1}, "sample_rate_hz": {"type": "integer", "minimum": 1}, "duration_ms": {"type": "integer", "minimum": 0}, "size_bytes": {"type": "integer", "minimum": 0}, "checksum_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
}
}
}
}
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/current/task-discovery.schema.json",
"title": "Project-local Dispatcher task list; external SaaS compatibility unverified",
"oneOf": [{"$ref": "#/$defs/page"}, {"$ref": "#/$defs/error"}],
"$defs": {
"page": {
"type": "object", "additionalProperties": false,
"required": ["dispatcher_id", "cursor", "tasks"],
"properties": {
"dispatcher_id": {"type": "string", "format": "uuid"},
"cursor": {"type": "string", "minLength": 1},
"tasks": {"type": "array", "items": {"$ref": "#/$defs/task"}}
},
"$comment": "A page containing zero tasks and a cursor ends enumeration. Non-empty short pages never end enumeration. Persist non-empty pages before using their returned cursor."
},
"task": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "tenant_id", "status", "task_revision"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "integer", "minimum": 1},
"status": {"enum": ["running", "paused", "stopped"]},
"task_revision": {"type": "integer", "minimum": 1}
}
},
"error": {
"type": "object", "additionalProperties": false,
"required": ["resource", "error"],
"properties": {
"resource": {"const": "error"},
"error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}}
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
# SaaS↔Dispatcher 通信调整与去版本标识计划
## 1. 已确认范围与完成定义
### 1.1 依据及优先级
1. 用户已确认 [`docs/thirds/v0.5-proposal.md`](thirds/v0.5-proposal.md) 是与 SaaS 负责人最终协商的数据通信结构。文件名中的 `proposal` 不再表示整个方向待批准;现有实现、历史计划和旧 Schema 与之冲突时,以该文档为准。本轮逐项补充确认以 §3.1–§3.2 为最新规则;原始通信文档本轮不修改,后续由 P01 同步这些已确认内容,不重新审批已解决问题。
2. 用户补充确认:**项目尚未发版,移除自有代码、文件名及目录名中用于区分实现代次的 Vx 标识,禁止版本迭代。** 不再新增另一代实现、另一套版本化契约、兼容入口或自动回退;保留唯一现行实现。
3. **通信名称与实现代次分开处理**:MQ exchange、queue、routing/binding key 中已约定的版本标识固定为 `v1`,HTTP `/internal/v1/dispatcher/...` 路径保持双方约定。它们不随文件、代码或 Schema 的变化递增,也不能在清理代码名称时被误删。
4. 第三方依赖及其 module/import 路径版本、Go/工具链版本、HTTP/AMQP/UUID 等标准中的版本标识,以及业务 `revision`、配置身份、消息身份和数据迁移顺序号,不属于实现代次清理。保留这些值不等于允许项目版本迭代。
5. 文档内部真正互相矛盾或不足以确定执行行为的部分,按 §3 定点确认;不能以“当前代码需要”为由要求 SaaS 恢复已取消字段,也不重新审批已确认方向。
### 1.2 本轮与后续实施的边界
- **本轮仅审查、修订本计划**;不修改代码、Schema、样例、配置、其他文档或已有数据,不执行任何重命名。当前计划文件名保留,后续去版本标识时统一改名。
- 后续实施覆盖 MQ、五类只读 HTTP、任务/控制/结果处理、Agent 执行快照,以及所有相关自有文件和标识;不是只替换 MQ 字符串。
- 单节点、单 Agent、单 Cell、单租户仍是本地验收范围。真实 SaaS/management、真实 MQ、供应商、ECS、生产切换和真实拨号分别授权、验收,不能用 Mock 结果代替。
- 后续完成条件:受影响的协议歧义已有明确答案,唯一现行契约与实现一致,§5 工作包完成,§6 验收通过。已读文档、已有 Mock、改完名称均不等于完成通信调整。
## 2. 审查结论与计划纠正
以下是对原计划的审查结果;“纠正”指本计划已修订,**不表示实现已修复**。
| 编号 | 级别 | 原计划问题 | 本计划的纠正 |
| --- | --- | --- | --- |
| R01 | 高 | §1/P01 仍要求“独立版本”“版本化机器契约”,并讨论将来版本升级,与未发版、禁止版本迭代冲突。 | 使用唯一无代次名称的当前契约、类型、校验器和清单;直接修正当前内容并更新来源/hash,不创建下一版本,不按版本号分支解析。 |
| R02 | 高 | 只列 MQ `.v3`→`.v1`,没有覆盖 Go 符号、文件/目录、Proto、生成物、Schema 引用、SQL、脚本及文档名称。 | 增加 §4 全范围清理和 §6 残留检查;通信固定 `v1` 是明确例外,不是保留 `V3Broker` 等代码名的理由。 |
| R03 | 高 | C02–C05/P03/P05 将旧实现要求的 `tenant_key`、`agent_version_id`、SIP revision、授权期限等缺失一概当成对方应补齐的字段;还将明文 `credential` 误设计为凭据引用。 | 按最终字段重整解析、持久绑定和 Agent 交付。用户已确认 `credential` 直接返回明文凭据,按供应商 SDK 要求使用,不需要 ref、引用解析、凭据交换服务或额外安全架构。旧字段不再是隐含入站必填项;既有归属与实际加载检查不变,不伪造缺失值或新增对外字段。 |
| R04 | 高 | 对 `call.execute` 回执、`call.execute.result`、`recording.uploaded` 的处理仍偏向保留旧路径,缺少明确的对外事件替换边界。 | 以最终文档确定唯一对外事件清单,调整信封及关联方式;旧 `command.result`、`call.result` 和分散事件不能作为别名或并行通知继续外发。录音上传与待交付事实仍须可靠保存。 |
| R05 | 中 | C01–C09 全部绑定“双方确认”,把 JSON 排版错误、Mock 引用值不一致、旧字段删除,与真实协议歧义混为同一总阻塞。 | 已确认规则列入 §3.1–§3.2,不再列为待批准;本轮待确认问题已按 §3.3 关闭,不将整项工作重新置于待批准状态;未来发现新的真实缺口才定点确认。 |
| R06 | 中 | P02 把启动被动检查描述成可以核验全部绑定。当前 AMQP 被动声明不能枚举或证明完整绑定关系。 | 区分 exchange/queue 存在性检查、实际消息路由测试和 mandatory/return/confirm;不能把 exchange 存在或 confirm 成功写成指定队列已收到。 |
| R07 | 高 | 未处理改名碰撞、嵌入/生成/hash 引用及持久数据,直接去后缀可能覆盖现有同名实现或使恢复入口失效。 | 大规模清理前建分支、做旧→新映射并按职责合并;不覆盖同名文件,不自动清库,不删除未交付执行/上传/outbox 记录;见 §4.3。 |
## 3. 已确认规则与确认状态
### 3.1 已确认,不再重复询问
| 编号 | 事项 | 明确规则及实现边界 |
| --- | --- | --- |
| K01 队列组织 | 外呼队列与原来一致;SaaS 共用一个结果队列。 | 沿用现有每 D 独立控制队列、按任务消费外呼的组织方式,外呼路由 `d.<D>.task.<task_id>.in`、队列 `agent-call.d.<D>.task.<task_id>.v1`;不改成按租户混合消费。取消“每 D 独立结果队列”的目标,SaaS 将结果收进同一个队列。D 身份、任务归属和精确路由仍保留;SaaS 管理队列/绑定,D 不建队。所有版本后缀固定 v1。 |
| K02 身份与结构字段 | tenant_id 使用数字;schema_version 删除。 | HTTP/MQ 相应字段统一校验数字类型,不再接受字符串租户 ID 作为新接口格式;移除 schema_version 字段及其版本选择分支,不替换成另一个固定版本值。消息身份仍保留,不能因删除结构版本字段而破坏内部幂等。 |
| K03 任务发现 | 逐页读取返回的 cursor;带 cursor 且任务数量为零的响应表示已取全。 | 不因非空页数量较少就判断结束;非空页持久成功后才使用返回 cursor 继续查询。首次/重启沿用全量初始化和控制积压优先的流程,不恢复旧代次持久游标;失败关新准入,控制和结果恢复照常处理。不强加旧 snapshot_id/watermark/mode=snapshot。 |
| K04 任务范围 | 任务不会删除,只会启用、停用、暂停;不考虑切换 Dispatcher。停用对应终止 stop,同一 task_id 不允许再次启用;需要恢复的任务使用 pause/resume。 | 不设计任务删除通知、移出 tombstone、归属迁移、跨 D 接管或终止后恢复。状态变化持久保存,HTTP 旧 running 或 resume 不能解除已终止状态;暂停才可经最新配置核验后恢复。 |
| K05 控制缺省与回执 | active_call_policy 未提供时默认 hangup;回执在调度到 Agent 后返回。 | 缺省挂断不再当成非法配置;显式不合法值仍拒绝。回执表示已调度,不等同于活动通话已全部挂断/排空;未成功调度不能回成功。实际通话终结与释放资源仍独立核实。 |
| K06 外呼回执与关联 | dispatched 表示已发出呼叫指令;最终结果 payload 中 task_id+手机号即可对应。 | 对外按 task_id/callee 关联,不强加 call_id/source_command_id 等新字段。内部继续保留每次执行的事件身份、消息身份和幂等记录,不把 task_id+callee 擅自改成永久禁止同号码再次执行的唯一键。回执不表示已接通。 |
| K07 SIP 变更 | 全量 SIP 接口补充 revision 字段。 | 读取和加载核验使用该 revision,与 sip.config 通知对齐;不再以“全量缺少 revision”阻塞计划。业务 revision 保留,不属于自有实现代次清理;仍须证明实际加载,不能只更新数据库中的编号。 |
| K08 凭据 | credential 直接返回明文,不需要 ref。 | D 将凭据交给 Agent 的对应 SDK,不引入凭据引用解析、交换服务或额外安全架构。provider_ref 仅用于选择 provider。真实凭据不写日志/样例的既有要求不变。 |
| K09 挂断关键词 | 仅用户侧 ASR 最终识别文本包含任意配置关键词时挂断。 | 按字面包含匹配,等最终识别文本,不使用中间结果触发;不引入语义模型或额外归一化。助手回复、提示词、开场白和 TTS 内容不得触发;重复识别/通知不得造成重复执行终结操作。 |
| K10 录音与最终结果 | OSS 只上传录音。正常上传成功即回报,不生成本地录音或外呼记录文件;只有 OSS 上传失败,才把录音及结果恢复信息保存本地。48 小时从首次失败后的两类恢复文件均保存完成时起算;重试间隔 1 分钟起、逐次翻倍、最长每小时一次。 | 有有效录音时,上传成功后才回最终结果;录音生成失败按 K15 的明确例外回报。48 小时耗尽仍上传失败则停止自动重试,保留文件,暂不回报,等待人工处理。本地结果信息只用于恢复 MQ 回报,不上传 OSS。此最新确认取代“所有通话先落盘”的旧表述;完整流程见 §3.2。 |
| K11 错误码表达 | 有真实 SIP 状态码则 reason_code 返回原数字;没有 SIP 状态码时 reason_code=null,reason_message 说明原因。 | 不新建本地数字错误码,不把本地拒绝、SDK HTTP 错误、录音或 OSS 错误伪装成 SIP 状态码;文字说明保留必要原因,但不带真实凭据或原始敏感报文。通话事件/状态见 K13–K14,录音生成失败见 K15,失败恢复文件无法保存见 K16;这些处理规则均已确认。 |
| K12 规则不满足时等待 | 不在允许时段等调度规则不满足时,暂停该任务的呼叫调度,直到规则允许后继续;不把暂时不能调度直接判成呼叫失败。 | 保留尚未执行的外呼,不因等待而丢弃、伪造结果或重复拨号;恢复前重新检查规则。规则等待与人工 pause/stop 分开,不能自动解除人工暂停或终止。单号码问题按 K13 处理;本规则不授权真实拨号或放宽现行真实门禁。 |
| K13 单号码错误 | 某号码不在白名单或格式错误,返回 call.execute 拒绝回执:status=rejected、reason_code=null、reason_message 说明原因;不发 call.execute.result,不暂停整个任务。 | 不拨号、不创建录音/上传任务,不发 dispatched;继续处理其他正常号码。保留处理与消息身份记录,不无记录丢弃,不通过清洗/替换号码绕过校验。此规则不用于 K12 的时段/额度等待。 |
| K14 通话结果状态 | 已发出指令后的最终 outcome 使用 answered、no_answer、failed:确实接通过为 answered;已发起但忙线/拒接/无人接听且确认结束为 no_answer;Agent/Asterisk 等执行故障、已确认未接通且执行结束为 failed。 | 后续异常不抹掉已接通事实;SIP 码/文字原因按 K11。仍不能确认是否接通或结束时继续核实,不伪造最终结果、不重拨、不释放未知占用。录音上传失败不改变通话 outcome,按 K10 等待上传/人工处理。 |
| K15 录音生成失败例外 | 已确认通话结束,但预期录音无法生成时,仍回报真实通话结果,recording={},reason_message 明确说明录音生成失败。 | 不把正常接通改成 failed,不伪造录音/OSS 路径或 SIP 错误码;没有可上传内容,不进入 OSS 上传或 48 小时重试,不为此新建本地业务文件。此例外只针对录音生成失败,不用于已有录音的 OSS 上传失败或重试超期。 |
| K16 上传失败且无法保存恢复文件 | 已有录音,但 OSS 上传失败后本地恢复文件也无法完整保存时,明确报错,暂不返回最终结果,等待人工修复。 | 保留已有内容,不伪称已有完整恢复副本;恢复文件完整保存后才能进入 K10 的重试流程并开始计时。未落盘内容不能保证跨进程退出恢复;不得擅自套用 K15 的空录音回报例外或无副本地宣称已开始可靠重试。 |
### 3.2 录音上传、失败落盘与回报顺序
1. **正常路径不落业务文件**:OSS 只上传录音内容;正常情况下直接上传,成功后回报单份最终通话结果,不生成本地录音文件或外呼记录信息文件。本地结果 JSON 不上传 OSS,也不新增该类资产/通知。不得先写临时文件再删除,并声称“没有落盘”。
2. **仅上传失败才落盘**:OSS 上传失败后,将录音和用于恢复 MQ 回报的外呼记录信息一起保存本地。录音路径与原 OSS bucket/对象路径对应,结果恢复信息关联同一执行和目标;保持原文件、原对象目标和原消息身份,不因重试新建资产。若恢复文件无法完整保存,按 K16 明确报错、保留已有内容、暂不返回最终结果,等待人工修复;不能声称已有完整可恢复副本,也不启动依赖该副本的 48 小时重试。
3. **确认通话终结**:释放已经确认结束的通话资源及占用,不等待 OSS 或最终结果发布。仍未确认终结的执行不能因上传窗口到期而释放;派发回执按 K05/K06 正常发送,不跟随最终结果等待录音。
4. **失败上传重试**:间隔依次为 **1、2、4、8、16、32、60 分钟,之后保持每 60 分钟一次**;**从首次 OSS 上传失败后,录音和结果恢复信息均保存完成的时刻起算 48 小时**。起点固定记录一次,后续失败、修改重试进度、等待或重启均不延长截止时间。恢复记录保存原目标、尝试进度、窗口时间和下次尝试信息;不能用 SDK 默认重试覆盖此节奏。
5. **上传成功后回报**:经 D 的既有可靠 MQ 交付返回最终结果;D 的 SQLite 状态/outbox 持久化不因“正常路径不落业务文件”而取消。MQ 发送失败只恢复原消息的交付,不再次上传已确认成功的录音、不重新拨号,也不把 MQ 故障当成 OSS 上传失败来生成录音缓存。48 小时不是删除待交付 MQ 结果的期限。
6. **满 48 小时仍未成功**:停止自动上传重试;本地录音、结果恢复信息及进度继续保留,标记待人工处理;**不返回最终通话结果、不伪造 uploaded、不自动发 unavailable、不删除文件、不自动开启下一个 48 小时窗口**。SaaS 在人工处理并确认上传成功前收不到该最终结果。人工处置入口另行明确,不自动创建管理后台或补传协议。
7. **取代旧行为**:删除“所有通话先写录音/结果文件”的前提;对已失败落盘的上传,取代旧 `call.ended_at + 15m` 自动收口/返回 unavailable 的逻辑,不留旧超时兜底。上传授权的有效期与重试窗口分开;Agent 仍经现有 D↔A RPC 显式取得有效上传授权,目标不变,不向 SaaS 申请 OSS TOKEN。
8. **分清失败来源**:SIP 响应不能替录音生成、失败落盘或 OSS 上传报告状态。上传失败不把正常接通的通话改成呼叫失败;没有真实 SIP 状态码时按 K11 使用 reason_code=null 并说明原因,不编造状态码。录音无法生成按 K15 返回真实通话结果、recording={},并说明生成失败;上传失败后的恢复文件也写不出时按 K16 报错待人工、暂不回报,不套用空录音例外。
9. **无录音路径与恢复边界**:单号码白名单/格式错误只按 K13 回拒绝回执,不进入最终通话结果或录音流程。已发起但无应答且未生成录音,按 recording 空对象返回最终结果,不等待不存在的文件上传,也不生成本地业务文件。正常路径不落盘意味着在上传成功或失败恢复文件保存完成前,进程异常退出时不能保证恢复内存中的录音;文档和验收不得承诺此阶段录音零丢失,也不能为掩盖限制偷偷恢复预写盘。上传重试不授权重拨、换线或真实测试。
### 3.3 本轮确认已完成
本轮列出的待确认问题已经逐项答复,当前清单没有剩余待用户确认项。K01–K16 为本轮明确规则,不再重复审批。
待完成的是 P01–P08 的契约同步、代码调整和验收,不是再次确认这些业务方向;本轮仍只修改计划,原始通信文档、Schema 和代码尚未同步。人工处理是明确报错并保留现有内容,不表示已实现新的人工恢复工具、后台或额外补传协议。
后续实施若发现新的真实字段冲突或现有组件无法满足要求,应带证据单独确认,只阻塞依赖该答案的部分;不能猜默认值、静默降级或重新启用废弃路径。
实施处理规则:
- 注释、Markdown 粗体键、重复冒号和缺逗号等先整理为合法 JSON,保持原业务含义,保留来源记录;这些排版问题不各自形成外部审批门槛。本轮不修改原文。
- `provider_ref: "mock"` 与 provider 清单示例不匹配时,在隔离样例中提供一致且按角色可解析的引用;不能把一个示例值硬编码为生产默认值。
- `hangup_keywords` 按 K09 检查用户最终识别文本是否包含关键词;对象、方式和时机均已确认,不再列为待批准。必须验证真实控制行为,不把“字段能解析”当成“功能已实现”。
- 最终文档没有列出的旧对外事件不默认保留。确有额外必需事件时,必须明确修正同一份当前约定,不能通过兼容层暗中外发。
- 每项答复直接修正唯一现行契约和测试;不产生下一版文件,不扩展为对已确认方向的再次审批。
### 3.4 已确定的字段变化,不再列为待批准事项
| 部分 | 必须调整的内容 |
| --- | --- |
| MQ 信封 | 按消息类型使用最终文档的 event_id/event_type、身份与时间字段;tenant_id 统一为数字,schema_version 删除。不能沿用旧信封统一要求的 tenant_key/trace_id/aggregate_*。例如 sip.config 示例没有 tenant_id/issued_at,不能因旧解码器需要而强迫添加。外呼最终 payload 按 task_id/callee 对应,不强加新的执行关联字段;内部追踪和幂等身份仍保留,但不擅自外发。 |
| 任务与额度 | 删除对外 `agent_version_id` 等已移除字段依赖;保留真实业务 `task_revision/quota_revision`,它们不是实现代次。`agent.immutable` 及任务内完整 AI 配置必须实际绑定;ASR-only 不能被旧 full_ai 必填字段拒绝。 |
| AI provider | 新增全量 `providers` 读取,按 `provider_ref/role/enabled/adapter/endpoint/credential` 解析;`credential` 是 SaaS 直接返回的明文凭据字符串,传给对应供应商 SDK,不改成对象或 `credential_ref`,不增加引用解析、凭据交换或密钥管理服务。`provider_ref` 仍仅用于选择 provider,不是凭据引用。禁用、缺失或角色不符的 provider 不可执行;真实凭据不写日志/样例的既有要求不变。 |
| SIP 读取与未知值 | 全量响应新增业务 revision,与变更通知及实际加载核验对应。transport/auth_mode/registration_required/max_concurrent_calls 的 null 按原文表示尚未确认;读取可以表达未知,执行不能据此默认 UDP、免鉴权、不注册或无限并发。 |
| 回执与最终事件 | 任务回执仍是 `task.control`,外呼开始回执仍是 `call.execute`,结果是 `call.execute.result`;保留各自事件 ID 语义,不沿用旧 `command.result/call.result` 信封。最终时间字段为 `issued_at`,不是旧 `occurred_at`。 |
| 结果字段 | 当前 `call_result_v01.go` 使用字符串 `reason_code`,最终文档为 `null` 或数值(无应答示例为 `480`),并出现 `reason_message`;须调整类型及校验。无应答按例发送空 `transcript`、`opt_out: false`、`recording: {}`,不能强制套旧 `not_created` 结构;上传成功按约定输出 OSS 字段及哈希,旧本地执行/录音 ID 不强加到对外消息。 |
## 4. 去版本标识的完整范围
### 4.1 清理对象与改名原则
初筛已发现 235 个含代次式路径的候选文件,分布在 `internal/`、`contracts/`、`docs/`、`proto/`、生成目录等;这是候选清单,不能据此机械删除。还须检查无版本文件名内部的 Go 符号、JSON 标识、嵌入路径、SQL 和脚本引用。
| 范围 | 当前已核实的例子 | 目标及同步检查 |
| --- | --- | --- |
| Go 源码、测试及符号 | `internal/mq/amqp_v3.go`、`V3Broker`、`internal/dispatcher/task_queue_v3.go`、`task_control_v3.go`、`task_runtime_v3.go`、`LocalV01Runtime`、`call_result_v01.go`、`internal/configread/discovery_v04.go`;store 的代次式方法和测试 | 使用职责名,如 `Broker`、`TaskRuntime`、`task_queue.go`、`task_control.go`、`call_result.go`、`discovery.go`。先处理现存同名文件/符号,再统一调用点、测试、错误/日志标签;不保留类型别名或转发包装。 |
| 当前契约与加载入口 | `contracts/upstream/v1/`、`docs/contracts/*-v0.x*.schema.json`、`local-contract-manifest-v0.x.json`、`contracts/contracts.go`、`internal/contract/contract.go` 的加载/校验路径 | 按领域维护唯一无代次文件,例如 `call-result.schema.json`、`config-read.schema.json`、`local-contract-manifest.json`。同步 `$id/$ref`、示例、Schema 选择器、编译缓存键、`go:embed` 和来源/hash;删除按旧版本加载的分支。来源事实中的原始外部版本不伪造、不改写。 |
| 自有 Proto 与生成物 | `proto/agent/v1/agent.proto`、`gen/go/agent/v1/` | 目标为 `proto/agent/agent.proto`、`gen/go/agent/` 等无代次路径;同步自有 package、`go_package`、服务全名、导入、生成配置和 `scripts/check-proto.sh`。必须重新生成,不手改生成文件;D/A 同步构建,不能保留旧服务别名。去名本身不随意改变字段号及业务语义。 |
| SQLite、恢复文件与检查脚本 | `internal/store/migrations/019_local_v04_discovery.sql`、相关 store 方法、状态/结果恢复入口,以及引用这些路径的脚本 | 清理迁移文件名称、确含实现代次的自有表/列/索引/记录类型及测试标识;保留有实际含义的迁移顺序号、业务 revision 和幂等身份。不得因为改名而丢失已执行事实或重做拨号/上传。 |
| 部署、构建及验收入口 | Makefile、脚本、CI、配置样例、夹具、测试选择条件、hash 清单中的旧路径/命令 | 同步更新可执行引用;普通构建、检查和隔离验收不读取旧代次路径,不因缺文件跳过检查。各模式必须显式,不用改名引入 Mock/real 回退。 |
| 文档、计划和归档文件名 | 本计划、`docs/thirds/v0.5-proposal.md`、相关计划、契约、验收证据和 `AGENTS.md` 中的引用 | 本计划后续改为 `docs/plan-saas-dispatcher.md`,当前通信标准改为 `docs/thirds/saas-dispatcher.md`;同步所有引用及权威入口。多份同主题材料只保留一个当前入口;有必要的历史证据按主题/事实日期归档,不再按版本并行维护,也不再参与运行校验。 |
### 4.2 必须保留的例外
- 双方已确认的 MQ 固定 `v1` 名称、HTTP 固定 `/internal/v1/...` 路径。它们是外部通信约定;自有 Go 类型/函数和文件不得因此命名为 `V1...`。
- 第三方依赖、标准协议和工具链的合法版本,例如 SDK module 路径中的 `/v2`、Go 1.27.1、UUID v4、IPv4/IPv6;真实供应商 endpoint 和 model ID 也必须保留原值。禁止为了通过名称扫描而改依赖路径、供应商请求参数、协议行为或工具链基线。自有 Mock 的 `mock-chat-v1/mock-tts-v1` 等代次式名称不借此豁免。
- 真实业务 revision、配置/任务身份、哈希、消息 ID、迁移顺序号,以及外部原始来源记录。它们不得成为“仍保留多代实现”的借口;自有历史实现代次不能冒充业务字段。
- 例外必须按具体用途核实;不能将整个目录、所有 `v1` 字符串或所有测试一律放行。
### 4.3 改名与数据保留边界
1. 大规模清理前新建分支,记录工作树和可追溯基线;不得暂存、提交、移动或清理与本任务无关的用户改动。
2. 建立路径/符号的旧→新映射,覆盖大小写、`V01/V04`、`v0_1`、`v0.1` 等形式。当前同时有 `amqp.go` 与 `amqp_v3.go`,不能简单去后缀覆盖;先保留必要行为、移除废弃路径,再统一职责名。
3. 每批改名同步修正代码调用、嵌入、生成、构建和测试引用;改路径后的 hash 清单必须重新核验,不能只改条目文字。
4. 不为旧实现保留兼容层、备用消费路径、旧 Schema 解析器或自动切换机制。有效的可靠性测试转到唯一现行实现,不能通过删测试消除失败。
5. **不自动清空或重建现有 SQLite/spool/outbox。** 新结构先在独立空目录中验收;若现有持久记录受名称变化影响,须在受控停机前明确其状态及处置。存在未完成执行、未知占用、待交付结果或上传恢复记录时,未有获授权且验证可行的处理方式不得切换;不以项目未发版为由假定数据可丢。
6. 当前源码、文件名、目录名和运行入口清理结束后,再做全仓残留检查;仅搜 MQ 后缀或仅看 `git diff` 不能作为完成证明。
## 5. 调整工作包与逐项验收
| 工作包 | 调整内容和依赖 | 验收标准 |
| --- | --- | --- |
| P01 唯一通信契约 | 将原始通信文档与 §3.1–§3.2 的最新确认同步到唯一字段/事件/路由清单;规范化 JSON,形成无代次 Schema、正反例和来源/hash。采用共享结果队列、数字 tenant_id、删除 schema_version、全量 SIP revision 及已确认回报顺序;不新增下一版本。 | 已确认事项不再列为待批准;旧必填字段不混入新接口;未知/非法字段拒绝;K01–K16 同步到当前唯一契约和测试;实现中新发现的问题按 §3.3 带证据单独确认,不能猜默认值。 |
| P02 无代次骨架与引用整理 | 建分支,完成 §4 路径/符号清单及碰撞处理;按职责统一核心接口、存储调用和 Proto/生成路径。依赖已明确的契约部分;大范围改动须分批回归。 | 无覆盖同名文件、旧类型别名、重复服务或失效导入;生成物可重复生成,内部 RPC 参数/语义检查通过;旧运行代次入口关闭而非自动回退。 |
| P03 五类 HTTP 与新数据模型 | 调整 SIP、任务、任务发现、租户额度和 ai-providers 五类读取;tenant_id 使用数字,去掉 schema_version/agent_version_id 的旧要求;SIP 全量读取增加 revision。按 agent/provider_ref/明文 credential 交付,保持 HTTP Header 大小写无关语义。 | 五类读取均有正反例;字符串租户 ID 不再作为新格式接纳;schema_version 不再是选择器或必填项;全量与通知 revision 能核对;0/false、数组保真;错误归属、HTTP/结构错误不能回退旧 MQ 配置或过期数据。 |
| P04 MQ、任务发现与控制接纳 | 保留原外呼队列并改用 SaaS 共享结果队列;SaaS 建队,D 只消费/发布。发现翻页到带 cursor 的空清单;不开发删除/跨 D 迁移。按 K12 对规则不满足的任务暂停调度、保留待执行呼叫,条件允许后继续;K13 的单号码错误只回 call.execute rejected,不发最终结果、不暂停任务。policy 缺省 hangup,业务回执在调度 Agent 后返回,dispatched 须已发出呼叫指令。 | 持久 inbox 后才做 RabbitMQ ACK;规则等待不能借 ACK 丢掉未执行呼叫,业务回执不冒充已拨出。控制积压未清不准入,非空短页不提前结束;恢复前核对当前规则,不用过期许可放行。自动恢复只针对规则等待,不能覆盖人工 pause/stop;已发出或未知的执行不再次拨号。停用同 ID 不可再启用,不擅加 CAS/控制去重。 |
| P05 Agent 配置与实际执行 | 明文 credential 经 D 交付 Agent 并用于 SDK,无 ref 查找/凭据交换;适配新快照,移除旧实现版本依赖。ASR-only 不强迫带 LLM/TTS;完整 AI 配置实传 SDK/控制器;opening 与 hangup_keywords 有实际行为,后者仅响应用户文本。SIP 变更关准入、排空并核验 revision 对应的实际加载。 | 两种 AI 场景运行;已接纳呼叫固定任务/provider 快照;同 task_revision 异内容拒绝;缺失/禁用/角色错误 provider 或无效凭据拒绝;只有用户最终识别文本包含关键词才触发挂断;中间文本、TTS/助手文字不触发;无真实加载证据不放行真实执行。 |
| P06 正常直传、失败落盘与结果 | OSS 只接收录音;正常上传成功直接回报,不写录音/结果业务文件;调整现有录音及上传入口对文件路径的依赖,复用现有库/SDK,不另写协议。仅 OSS 上传失败时保存录音和结果恢复信息,路径对应原目标,并按 §3.2 启动 48 小时重试。窗口耗尽保留待人工,不发最终结果。D 状态/outbox 同事务;移除旧事件别名、总是预写盘及旧 15 分钟 unavailable 路径。 | 正常路径与无录音路径无业务文件写入,不能靠先写后删达标;OSS 不接收结果 JSON。失败落盘后可重启恢复原目标/消息/窗口;MQ 失败不重新 PUT。满 48 小时停止自动重试,不删文件、不自动回失败结果;资源按实际通话结束释放,派发回执不等上传。最终 outcome 按 K14 区分,未知执行不伪造结果;录音生成失败按 K15 回真实结果、空录音和明确原因;恢复文件写入失败按 K16 报错、保留内容、待人工且不回最终结果;不宣称未落盘录音能跨崩溃恢复。 |
| P07 全仓清理与当前入口更新 | 随 P02–P06 每批处理 §4 改名/引用;删除废弃运行路径、旧 Schema 选择器和重复清单;同步当前契约、AGENTS、脚本、Makefile、嵌入与 hash,特别纠正旧“每 D 结果队列”和“15 分钟自动收口”等冲突规则。 | 当前入口唯一,无未批准代次残留;合法例外明确;普通构建/测试不读旧路径。记录已确认的新规则,不把本地实现或文档更新写成真实外部联调通过。 |
| P08 端到端及交付验证 | 按已确认规则更新隔离 SaaS/MQ Mock 与验收入口,执行 §6;使用独立目录、可控时间和既有测试资源,不修改现存数据。 | 正常直传回报、录音生成失败空录音回报、OSS 失败落盘后重试成功、48 小时耗尽待人工不回报、恢复文件写入失败待人工不回报五条路径均通过;不靠真实等待 48 小时或真实 OSS 消费验收。部署/诊断门禁照常;区分本地通过、外部未验证及阻塞。 |
依赖顺序:P01 明确契约 → P02 建立无代次入口 → P03/P04/P05/P06 按接口依赖逐项完成 → P07 全仓收口 → P08 总验收。P07 的引用修正随每批工作同步完成,不留到最后才修编译和测试。本轮业务确认已完成;未来确有新增问题时按 §3.3 定点确认,不因已解决的问题重复停工,也不开不确定的执行准入。
## 6. 验收清单与完成证据
以下均为后续实施要求;本轮文档修改不将任何一项标成实现通过。
| 编号 | 检查 | 必须留存的结果 |
| --- | --- | --- |
| A01 契约一致性 | 对照最终正文逐项检查五类 HTTP、MQ 命令、控制/执行回执、最终结果、provider/agent 新字段;Schema 正反例全部执行。 | 字段/消息→Schema→测试映射;JSON 可解析;没有仅因旧代码需要而保留的必填字段、未经约定的新字段或版本分支。 |
| A02 无代次名称 | 扫描全部自有源码、符号、注释/日志标签、文件/目录、SQL、脚本、样例和当前文档引用,覆盖 `V1/V2/V3/V01/V04/v0.x/v0_1` 等形式;人工核对例外与误报。 | 候选清单、旧→新映射、删除/保留理由及复扫结果;未批准的实现代次残留为零。不能靠把命名改成另一种代次格式或放行整个目录达标。 |
| A03 固定通信名称 | 检查实际 publish/consume 的 exchange、queue、routing/binding key 和 HTTP 路径;以不同内部配置/Schema 内容运行同一流程。 | 始终使用约定固定 `v1`;不从代码/文件/Schema 版本推导拓扑,不存在 `.v3` 等旧拓扑的自动回退。 |
| A04 引用及生成一致性 | 检查 import、Proto package/服务名、go_package、嵌入、Schema 引用、生成配置、脚本、文档链接和 manifest hash。 | 构建和重新生成成功;生成后无意外差异;无悬空引用、覆盖冲突或旧路径依赖。外部来源事实与本地整理产物的 hash 各自可核验。 |
| A05 配置读取与失效 | 覆盖数字/字符串 tenant_id、删除 schema_version、SIP 全量 revision 与通知对应、错误归属/字段、未知或禁用 provider、角色错配、无效凭据、网络失败、0/false 和合法 null。用虚构 credential 验证原值直达 SDK,无 ref 查找服务。 | 合法且满足准入条件的新结构可执行;字符串租户 ID、旧 schema_version 等不作为兼容格式放行;未知配置不默认可执行;无旧 MQ/过期缓存回退;日志/样例/证据不含真实密钥、TOKEN 或完整音频/对话。 |
| A06 队列所有权与路由 | 验证原外呼/控制队列组织及 SaaS 单个共享结果队列;D 无 configure 权限;故障覆盖缺资源、错绑定、不可路由、return、confirm 丢失和重连。 | D 不建队/绑定/删除,不要求每 D 独立结果队列;来源身份仍能区分;以实际接收证明路由,不把 exchange 存在或 confirm 当目的队列收到。D1/D2 仅作隔离夹具,不冒充多 D 业务运行。 |
| A07 控制与发现竞态 | 覆盖带 cursor 的空页完成、非空短页继续、读取/持久化失败、重启全量与控制积压;覆盖 policy 缺省挂断、显式 drain/hangup、业务回执调度时机、旧 running 页、重投/乱序及 SIP revision 变更。 | 无任务删除/跨 D 迁移流程;非空页不能提前完成;未调度 Agent 不回成功,回执不冒充活动通话已结束;HTTP 不覆盖已应用控制,不删 SaaS 队列。stop 后同 ID resume 或 HTTP running 均不能恢复,只有暂停任务可以恢复。 |
| A08 执行与 AI 行为 | 覆盖重复/历史执行消息、同号码不同呼叫、白名单/时段/额度/期限、两类 AI 场景、开场白及 SDK 实参;挂断词分别输入用户最终文本、仅中间结果命中、助手文本和 TTS 内容,核对字面包含规则与重复通知行为。 | 接纳与实际发出前门禁有效;任务级规则不满足时等待而非丢弃,恢复时重新校验,不自动解除人工 pause/stop;单号码白名单/格式错误只回 call.execute rejected、不发最终结果,不阻塞后续正常号码;dispatched 确已发出呼叫指令但不代表接通。已发出/未知执行不重拨,无自动换线或旧音频重播;仅用户最终文本可触发关键词。真实路径仍受 09:00(含)–20:00(不含)Asia/Shanghai 限制,Mock 不授权真实拨号。 |
| A09 结果与录音 | 分别验证正常上传成功不写业务文件、OSS 失败才保存录音/结果恢复信息、无录音直接回报;OSS 接收端断言只有录音内容。用可控时间核对 1、2、4、8、16、32、60 分钟节奏及封顶,48 小时从首次失败后的两类恢复文件保存完成起算。验证 K14 的 answered/no_answer/failed 及未知时不发最终结果;验证 K15 录音生成失败仍回真实结果、recording={} 并注明原因;验证 K16 恢复文件写入失败明确报错、保留内容、待人工且不发最终结果,不假装已开始可靠重试。 | 文件写入观测证明正常路径没有先写后删;有有效录音时上传成功后才发最终结果,无录音及 K15 生成失败例外不等待上传;派发回执不等上传;满 48 小时停止自动上传、保留待人工、不发 unavailable/失败结果,不恢复旧 15 分钟逻辑。MQ 失败不触发录音落盘/重复 PUT;无应答无录音不等待上传。 |
| A10 数据保护与恢复 | 对失败后已落盘的录音/结果信息、重试窗口/进度,以及既有 inbox、未知占用、上传成功事实和 outbox 注入故障;检查 D 的可靠消息持久化没有被正常路径不落业务文件的规则取消。 | 失败落盘后重启不重置 48 小时、不换对象、不重拨;授权过期经现有 D 接口取有效授权且目标不变。已持久到 D 的状态/outbox 同事务,MQ 失败恢复原消息。满 48 小时不删缓存,不给 MQ 交付加删除期限;未落盘录音的进程崩溃不可恢复边界明确,不伪称零丢失。资源按通话终结释放,现有数据未经明确处置不清库。 |
| A11 测试与构建 | 按 TDD 开发;执行格式化检查、现有 contract/proto 检查、`go vet ./...`、`go test -race ./...`、构建及隔离端到端。 | 核验 Go 1.27.1;单元测试覆盖率 ≥65%,报告注明范围,不靠排除修改代码达标;每批合并后回归,无跳过必需部署/诊断步骤。 |
| A12 交付边界 | 对照 P01–P08、§3 答复和上述检查逐项填写证据,不沿用旧版本通过记录代签。 | 当前文档/代码入口唯一;本地验收、真实 SaaS/MQ、真实 Agent/Asterisk 加载及供应商/生产验证分开列明。未获授权不执行真实切换、消费或拨号。 |
本轮文档交付核验:只修改本文件;检查文字规则、表格、现有引用及工作树差异。不运行或宣称完成上述代码测试、部署和通信验收。
+50
View File
@@ -0,0 +1,50 @@
# SaaS ↔ Dispatcher:项目内唯一现行通信约定
> 本文根据用户提供的 `v0.5-proposal.md` 及已确认的 K01–K16 整理为可校验的项目内合同;原提案含注释、排版错误及被后续确认取代的旧队列/租户字段。项目内 Schema 与合法/非法 JSON 的唯一机器来源为 [`contracts/local/`](../../contracts/local/);不得从本 Markdown 复制第二套手写 Schema。**本地隔离 Mock 可验收,不等于 SaaS/management 已签收或真实外呼获授权。**
## HTTP:五类只读配置
只由归属 D 使用 `X-DISPATCHER-ID` 和 `X-DISPATCHER-SECRET-KEY` 读取;HTTP header 名大小写无关。返回中的 `dispatcher_id` 须等于本 D,任务/配额的 `tenant_id` 是正整数;不接受旧字符串租户 ID。不在日志、样例或证据中打印真实凭据。业务正文只使用当前 Schema,不接受 `schema_version`、旧版 `tenant_key`、`agent_version_id`、授权期限或内容摘要等删除字段;也不根据字段做版本分支或从 HTTP 失败回退旧 MQ 配置。
| 资源 | 方法和路径 | 应用规则 | 正例 |
| --- | --- | --- | --- |
| SIP 全量 | `GET /internal/v1/dispatcher/sip` | `revision` 是实际加载版本核对依据;未知 `transport/auth_mode/registration_required/max_concurrent_calls` 为 `null`,不能作为可执行线路默认值;变更时关准入、排空并核验 Agent/Asterisk 实际加载 | [`sip`](../../contracts/local/examples/config-read-sip.json) |
| AI provider 全量 | `GET /internal/v1/dispatcher/ai-providers` | `provider_ref` 只标识供应商;将明文 `credential` 原值交给选定角色的 SDK,不引入 ref 查找或交换;缺失、禁用、角色不匹配、无效凭据拒绝执行 | [`providers`](../../contracts/local/examples/config-read-providers.json) |
| 任务 | `GET /internal/v1/dispatcher/task/{task_id}` | 归属、状态和 `task_revision` 校验后持久绑定同一 `agent`/provider 快照;不可变摘要由本项目计算。同一 revision 内容不同拒绝;ASR-only 只需 ASR,不强制 LLM/TTS;0 和 false 原样保留 | [`ASR-only`](../../contracts/local/examples/config-read-task-asr.json) · [`full AI`](../../contracts/local/examples/config-read-task-full.json) |
| 任务列表 | `GET /internal/v1/dispatcher/tasks`,后续 `?after=<cursor>` | 首次/重启完整读到 **带 cursor 且 tasks=[]** 的终止页;非空短页不能提前结束。非空页持久成功后才使用下一 cursor;控制队列积压处理前不开新任务准入。不使用旧 snapshot_id/watermark/mode=snapshot | [`page`](../../contracts/local/examples/task-discovery-page.json) · [`end`](../../contracts/local/examples/task-discovery-end.json) |
| 租户额度 | `GET /internal/v1/dispatcher/tenant/{tenant_id}/quota` | `quota_revision` 为业务修订号;未知占用不得算成已释放 | [`quota`](../../contracts/local/examples/config-read-quota.json) |
HTTP 非 200、正文不合法、缺字段、归属冲突、缓存失效或读取失败时拒绝新的相关准入并记录脱敏错误;已持久接纳的执行继续使用原快照。错误体示例 [`error`](../../contracts/local/examples/config-read-error.json),不能当成功配置解析。任务仅有运行/暂停/终止;终止是不可逆 stop,同一 `task_id` 不再启用。HTTP 旧 running 不得覆盖已持久的 pause/stop;resume 经最新任务配置核验后只能解除人工暂停。
## MQ:固定 v1 传输,唯一事件清单
RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D 只消费/发布,无 configure 权限**。机器拓扑见 [`mq-topology.json`](../../contracts/local/mq-topology.json)。外呼维持每 D、每任务独立的 `d.<dispatcher_id>.task.<task_id>.in` 路由及 `agent-call.d.<dispatcher_id>.task.<task_id>.v1` 队列;每 D 单独控制路由 `d.<dispatcher_id>.control.in` 及队列 `agent-call.d.<dispatcher_id>.control.v1`。SaaS 将所有 D 的输出 `d.<dispatcher_id>.out` **精确绑定至同一个结果队列**(本地 Mock 名 `agent-call.saas.events.v1`,不是强制 SaaS 实际队列名)。入站 exchange `agent-call.dispatchers.v1`;出站 exchange `agent-call.saas.v1`;死信 exchange `agent-call.dead-letter.v1`。不广播后靠正文过滤,不使用独立通配段,不把被动声明/发布 confirm 冒充指定队列已收到:须以实际路由、mandatory return 与 confirm 联合验证。
`event_id` 是入站/回执关联和内部防重复处理的消息身份;`dispatcher_id` 是 D 身份,`tenant_id` 是正整数。**不是所有事件共用一个统一必填信封**:`sip.config` 没有 tenant_id/issued_at;回执按其示例字段,最终结果使用 `issued_at` 而非 `occurred_at`。正文的 `schema_version` 已移除。只接受以下外发事件及必要入站事件;所有字段直接按 [`mq.schema.json`](../../contracts/local/mq.schema.json) 和正例校验:
| 事件 | 方向和处理规则 | 正例 |
| --- | --- | --- |
| `sip.config` | SaaS→D;`revision` 触发全量重新读取和实际加载核验,不用通知正文代替全量 | [`notification`](../../contracts/local/examples/mq-sip-change.json) |
| `task.control` | SaaS→D;pause/resume/stop,无控制去重/CAS 字段;省略 `active_call_policy` 默认 **hangup**,显式仅 drain/hangup。成功将操作发给 Agent 后才回应用回执,回执不是已完成排空/挂断的证据;stop 同 ID 不可恢复 | [`control`](../../contracts/local/examples/mq-control.json) · [`ack`](../../contracts/local/examples/mq-control-ack.json) |
| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/主叫/AI/时限从绑定任务读取。`dispatched` 表示已发出呼叫指令,**不表示接通**;白名单/单号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) |
| `call.execute.result` | D→SaaS;按 `task_id` + 原号码关联,每次呼叫仅一份最终结果;不新增外部 call_id/source_command_id;真正终结且录音成功上传、无录音或预期录音生成失败后才发送 | [`uploaded`](../../contracts/local/examples/mq-result-uploaded.json) · [`empty`](../../contracts/local/examples/mq-result-no-recording.json) |
旧 `command.result`、`call.result`、分散通话/转写/拒联事件、`recording.uploaded` 不再作为对外并行通知或兼容别名。D 在 inbox 持久后 ACK;状态/outbox 同事务;结果发布使用原消息身份可靠交付;confirm 不是 SaaS 应用收讫。消息年龄不让旧命令绕过准入;未来 issued_at 不提前接纳。重复投递/未知执行不触发再次拨号。
## 调度、AI 与真实结果(K01–K09、K11–K14)
- 白名单仅含 `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={}`。
- 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。
## 录音与最终结果(K10、K15、K16)
1. 有效录音先直接 PUT 至 OSS,**只上传录音**;成功即持久入 D 的最终结果/outbox,正常路径不生成录音或结果业务文件(不得先写临时文件再删)。无应答无录音直接发真实结果,`recording={}`;录音**生成失败**仍报告真实通话 outcome、空录音与明确 `reason_message`,不走 OSS/重试,不伪造 SIP 码。
2. OSS 上传首次失败时,**录音和结果恢复信息两者均保存成功**后才建立固定重试起点/48 小时截止,保持原 bucket、object_key 和消息身份。本地结果文件仅用于恢复 MQ 回报,不上传 OSS。任一恢复文件不能完整保存:显式报错,保留已有内容,暂不发送最终结果、也不宣称有可恢复副本,等待人工修复。
3. 从首次两文件完整保存起重试:间隔为 1、2、4、8、16、32、60 分钟,其后每 60 分钟一次;重启/失败不重置起点,SDK 默认自动重试不得改变节奏。每次经 D↔A RPC 显式领取有效上传授权,不换对象/不向 SaaS 申请 TOKEN。48 小时仍不成功:停止自动重试、保留两文件和进度、标记待人工,**不发**伪造 uploaded/unavailable 或最终结果,也不自动重开窗口。
4. 上传成功后仅恢复原最终结果消息的 MQ 可靠交付,MQ 失败不重新 PUT、不新建资产、不重拨;48 小时是 OSS 重试窗口,**不是** MQ outbox 的清除期限。已确认结束的通话资源及时释放,不等待 OSS/MQ;未知执行不释放。进程在正常上传尚未成功、失败恢复两文件尚未完整保存前退出,内存录音可能丢失,不能声称零丢失,也不能为了隐藏限制悄悄预写盘。
## 校验、来源与界限
- 字段及结构的唯一机器契约:[`config-read.schema.json`](../../contracts/local/config-read.schema.json)、[`task-discovery.schema.json`](../../contracts/local/task-discovery.schema.json)、[`mq.schema.json`](../../contracts/local/mq.schema.json);正反例在 `contracts/local/examples/`,来源/hash 在 `contracts/local/manifest.json`,检查入口 `go test ./contracts -run TestCurrentContractExamples` 及 `scripts/check-current-contracts.sh`。外部供应商仍未签收。
- JSON 样例是隔离 Mock 虚构数据;`example-only-not-a-real-secret` **不是凭据**。严禁将真实凭据、完整用户音频或完整对话放入源码/日志/证据。运行时须按接入方权限与实际加载事实再核验,不以机器 Schema 通过取代拨号授权。
+446
View File
@@ -0,0 +1,446 @@
SaaS Dispatcher 对接文档 v0.5
相关占位说明:
`/internal/v1/dispatcher/xxxxx` 路由按系统当前架构路由进行定义即可
`X-DISPATCHER-ID`/`X-DISPATCHER-SECRET-KEY` HEADER 头KEY 获取忽略大小写
Dispatcher 角色下称 D
触发顺序
1.1 MQ 发布消费规则
RabbitMQ 有发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费四步;
```Plain Text
SaaS→D exchange: agent-call.dispatchers.v1
routing key: d.<dispatcher_id>.t.<tenant_id>.in
binding key: d.<dispatcher_id>.t.<tenant_id>.in
queue: agent-call.d.<dispatcher_id>.t.<tenant_id>.v1
consumer: 对应 Dispatcher
D→SaaS exchange: agent-call.saas.v1
routing key: d.<dispatcher_id>.t.<tenant_id>.out
queue: agent-call.saas.events.v1
consumer: SaaS
```
1.2 本轮任务队列与事件路由
硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。
```Plain Text
SaaS 创建并绑定:
exchange: agent-call.dispatchers.v1 (topic, durable)
task routing: d.<dispatcher_id>.task.<task_id>.in
task queue: agent-call.d.<dispatcher_id>.task.<task_id>.v1
control route: d.<dispatcher_id>.control.in
control queue: agent-call.d.<dispatcher_id>.control.v1
dead-letter: agent-call.dead-letter.v1
D -> SaaS exchange: agent-call.saas.v1 (topic, durable)
result route: d.<dispatcher_id>.out
SaaS result queue: agent-call.saas.d.<dispatcher_id>.v1
```
HTTP请求
SIP列表配置
```HTTP
GET /internal/v1/dispatcher/sip HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
响应:
```JSON
{
"resource": "sip_config", // 资源类型:SIP 配置
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", // 这份配置所属的 Dispatcher
"trunks": [ // 该 Dispatcher 获批的线路列表
{
"trunk_id": "trunk-mock", // 线路的唯一标识
"provider_id": "provider-mock", // SIP 服务商标识
"codec": "PCMA", // 线路使用的语音编码
"dial_prefix": "", // 该线路拨号时添加的被叫前缀;空串表示不添加
"enabled": true, // 是否启用这条线路
"server_host": "sip.example.invalid", // SIP 服务端地址;此处是 Mock 地址
"server_port": 5060, // SIP 服务端端口
"transport": null, // 传输方式:udp、tcp、tls;null 表示尚未确认
"auth_mode": null, // 鉴权方式:ip、digest、none;null 表示尚未确认
"registration_required": null, // 是否需要 SIP 注册;null 表示尚未确认
"max_concurrent_calls": null, // 分配给该 Dispatcher 的线路并发上限;null 表示尚未确认
"caller_profiles": [ // 这条线路可用的主叫配置
{
"caller_profile_id": "caller-profile-mock", // 主叫配置标识,供任务引用
"caller_id": "BD00000000" // 实际主叫标识,保留原值及字母
}
],
"schedule": { // 这条线路允许外呼的每周时段
"time_zone": "Asia/Shanghai", // 判断时段使用的时区
"weekly_windows": { // 每天可配置多个时段;空数组表示当天不允许外呼
"monday": [{"start": "09:00", "end": "20:00"}], // 周一
"tuesday": [{"start": "09:00", "end": "20:00"}], // 周二
"wednesday": [{"start": "09:00", "end": "20:00"}], // 周三
"thursday": [{"start": "09:00", "end": "20:00"}], // 周四
"friday": [{"start": "09:00", "end": "20:00"}], // 周五
"saturday": [], // 周六不允许外呼
"sunday": [] // 周日不允许外呼
} }
} ]
}
```
AI 服务商全量读取
`GET /internal/v1/dispatcher/ai-providers`
```JSON
{
"resource": "ai_providers",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"providers": [
{
"provider_ref": "asr-provider-a",
"role": "asr",
"enabled": true,
"adapter": "volcengine_asr",
"endpoint": "https://asr.example.invalid",
"credential": "managed-asr-a"
},
{
"provider_ref": "llm-provider-a",
"role": "llm",
"enabled": true,
"adapter": "openai_compatible",
"endpoint": "https://llm.example.invalid/v1",
"credential": "managed-llm-a"
},
{
"provider_ref": "tts-provider-a",
"role": "tts",
"enabled": true,
"adapter": "volcengine_tts",
"endpoint": "https://tts.example.invalid",
"credential": "managed-tts-a"
}
]
}
```
任务配置
```HTTP
GET /internal/v1/dispatcher/task/{task_id} HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
响应体:
仅 ASR 模式(Agent配置仅返回 ASR即可)
ASR + LLM + TTS 模式
```JSON
{
**"resource"**: **"task_config"**,
**"dispatcher_id"**: **"c046b893-8628-4589-ae50-619d049248a6"**,
**"tenant_id"**: **"tenant-id-mock"**,
**"task_id"**: **"task-mock"**,
**"task_revision"**: **2**,
**"status"**: **"running"**,
**"name"**: **"Mock task"**,
**"max_concurrent_calls"**: **2**,
**"ring_timeout_ms"**: **30000**,
**"max_call_duration_ms"**: **120000**,
**"route_policy_id"**: **"route-mock"**,
**"caller_profile_id"**: **"caller-profile-mock"**,
**"allowed_trunk_ids"**: [
**"trunk-mock"**
],
**"schedule"**: {
**"time_zone"**: **"Asia/Shanghai"**,
**"starts_at"**: **"2026-09-21T00:00:00+08:00"**,
**"ends_at"**: **null**,
**"weekly_windows"**: {
**"monday"**: [
{
**"start"**: **"09:00"**,
**"end"**: **"11:00"**
},
{
**"start"**: **"14:00"**,
**"end"**: **"18:00"**
}
],
**"tuesday"**: [
{
**"start"**: **"09:00"**,
**"end"**: **"18:00"**
}
],
**"wednesday"**: [
{
**"start"**: **"09:00"**,
**"end"**: **"18:00"**
}
],
**"thursday"**: [
{
**"start"**: **"09:00"**,
**"end"**: **"18:00"**
}
],
**"friday"**: [
{
**"start"**: **"09:00"**,
**"end"**: **"18:00"**
}
],
**"saturday"**: [
],
**"sunday"**: [
]
},
**"excluded_dates"**: [
**"2026-10-01"**,
**"2026-10-02"**
]
},
**"agent"**: {
**"immutable"**: **true**,
**"mode"**: **"full_ai"**,
**"llm"**: {
**"provider_ref"**: **"mock"**,
**"model"**: **"mock-chat-v1"**,
**"temperature"**: **0.2**,
**"max_tokens"**: **256**,
**"timeout_ms"**: **5000**
},
**"prompt"**: {
**"text"**: **"Mock prompt for an isolated test."**,
**"allowed_variables"**: [
],
**"max_bytes"**: **32768**
},
**"tts"**: {
**"provider_ref"**: **"mock"**,
**"model"**: **"mock-tts-v1"**,
**"voice"**: **"mock-neutral"**,
**"speed"**: **1**,
**"format"**: {
**"encoding"**: **"pcm_s16le"**,
**"sample_rate_hz"**: **16000**,
**"channels"**: **1**
},
**"timeout_ms"**: **5000**
},
**"asr"**: {
**"provider_ref"**: **"mock"**,
**"language"**: **"zh-CN"**,
**"input"**: {
**"encoding"**: **"pcm_s16le"**,
**"sample_rate_hz"**: **16000**,
**"channels"**: **1**,
**"sample_width_bytes"**: **2**
},
**"interim"**: **true**,
**"timeout_ms"**: **5000**
},
**"conversation"**: {
**"opening"**: **"开场白"**,
**"hangup_keywords"**: [
**"不用了"**,
**"请挂机"**
],
**"allow_interrupt"**: **true**,
**"silence_timeout_ms"**: **3000**,
**"max_duration_ms"**: **120000**,
**"max_turns"**: **20**,
**"sentence_max_chars"**: **80**,
**"max_pending_audio_chunks"**: **32**
}
}
}
```
任务列表
```HTTP
GET /internal/v1/dispatcher/tasks HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
增量请求
```HTTP
GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
响应:
```JSON
{
"schema_version": "task-discovery.v0.2-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"cursor": "1042",
"tasks": [
{
"task_id": "task-a",
"tenant_id": 1001,
"status": "running",
"task_revision": 1
},
{
"task_id": "task-old",
"tenant_id": 1001,
"status": "stopped",
"task_revision": 3
}
]
}
```
按租户 ID 获取配额信息
```HTTP
GET /internal/v1/dispatcher/tenant/{tenant-id}/quota HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
```JSON
{
"resource": "tenant_quota",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"quota_revision": 1,
"max_concurrent_calls": 3
}
```
控制事件
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.control\.v1
>
>
SIP 线路变更
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type": "sip.config",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"payload": {
"trunk_id": "trunk-mock",
"change_type": "disabled", // enabled/removed/created
"revision": 8
}
}
```
任务控制
```JSON
{
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-21T00:00:00Z",
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "task.control",
"payload": {
"task_id": "task-a",
"action": "pause", // resume , stop
"reason": "local-test",
"options": { // 附加控制参数
"active_call_policy": "drain", // 活跃通话策略,仅stop,pause事件生效 [drain|hangup]
}
}
}
```
任务控制处理回执
> 队列 agent\-call\.saas\.v1
>
>
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "task.control",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"payload": {
"status": "applied"
}
}
```
外呼任务队列
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.t\.\<tenant\_id\>\.v1
>
>
呼出
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "call.execute",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:00:00+08:00"
"payload": {
"task_id": "task-a",
"callee": "15003164745"
}
}
```
回执
> 队列 agent\-call\.saas\.v1
>
>
开始调度执行
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "call.execute",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:00:00+08:00"
"payload": {
"status": "dispatched",
}
}
```
完成结果上报
成功
```JSON
{
"event_id": "call-result-001",
"event_type": "call.execute.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:10:15+08:00",
"payload": {
"task_id": "task-a",
"caller_profile_id": "caller-a",
"callee": "15003164745",
"trunk_id": "trunk-a",
"started_at": "2026-09-18T10:00:00+08:00",
"ended_at": "2026-09-18T10:10:00+08:00",
"duration_ms": 600000,
"outcome": "answered",
"reason_code": null,
"transcript": [
{
"turn_id": "turn-1",
"segment_id": "segment-1",
"role": "user",
"text": "示例转写内容",
"start_ms": 1000,
"end_ms": 2500
}
],
"opt_out": false,
"recording": {
"status": "uploaded",
"bucket": "example-bucket",
"object_key": "calls/tenant-a/call-a.wav",
"format": "wav",
"channels": 1,
"sample_rate_hz": 8000,
"duration_ms": 600000,
"size_bytes": 9600000,
"checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}
```
无应答
```JSON
{
// ...
"payload": {
//...
"outcome": "no_answer",
"reason_code": 480,
"reason_message": "SIP ring timeout Reason",
"transcript": [],
"opt_out": false,
"recording": {}
}
}
```
+62
View File
@@ -0,0 +1,62 @@
package contract
import (
"bytes"
"errors"
"fmt"
"strings"
"git.ipao.vip/rogee/go-sip/contracts"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// ValidateCurrent checks only the current project-local HTTP/MQ contract.
// An absent schema is an error; it never falls back to historical bundles.
func ValidateCurrent(surface string, raw []byte) error {
var name string
switch surface {
case "config-read", "task-discovery", "mq":
name = surface + ".schema.json"
default:
return fmt.Errorf("invalid current contract surface %q", surface)
}
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("decode %s body: %w", surface, err)
}
key := "current/" + name
cached, ok := compiledSchemas.Load(key)
var schema *jsonschema.Schema
if ok {
schema = cached.(*jsonschema.Schema)
} else {
data, err := contracts.ReadCurrent(name)
if err != nil {
return err
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("decode current schema %s: %w", name, err)
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
resource := "https://go-sip.local/contracts/current/" + name
if err := compiler.AddResource(resource, doc); err != nil {
return fmt.Errorf("register %s: %w", name, err)
}
schema, err = compiler.Compile(resource)
if err != nil {
return fmt.Errorf("compile %s: %w", name, err)
}
actual, _ := compiledSchemas.LoadOrStore(key, schema)
schema = actual.(*jsonschema.Schema)
}
if err := schema.Validate(value); err != nil {
var validation *jsonschema.ValidationError
if errors.As(err, &validation) {
return fmt.Errorf("%s validation failed at /%s", surface, strings.Join(validation.InstanceLocation, "/"))
}
return fmt.Errorf("%s validation failed", surface)
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package contract
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateCurrent(t *testing.T) {
for _, tc := range []struct {
name string
file string
valid bool
}{
{"config-read", "config-read-task-asr.json", true},
{"config-read", "config-read-task-full.json", true},
{"config-read", "config-read-sip.json", true},
{"config-read", "config-read-providers.json", true},
{"config-read", "config-read-quota.json", true},
{"task-discovery", "task-discovery-end.json", true},
{"mq", "mq-execute.json", true},
{"config-read", "invalid/config-read-string-tenant.json", false},
{"mq", "invalid/mq-legacy-schema-version.json", false},
} {
t.Run(tc.file, func(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", tc.file))
if err != nil {
t.Fatal(err)
}
if err := ValidateCurrent(tc.name, raw); (err == nil) != tc.valid {
t.Fatalf("valid=%v, err=%v", tc.valid, err)
}
})
}
for _, name := range []string{"", "config-read-v0.3", "../upstream/v1/mq"} {
if err := ValidateCurrent(name, []byte(`{}`)); err == nil || !strings.Contains(err.Error(), "invalid") {
t.Fatalf("accepted unknown contract %q: %v", name, err)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Check provenance and reproducible digests of the sole current local bundle."""
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUNDLE = ROOT / "contracts" / "local"
MANIFEST = BUNDLE / "manifest.json"
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> None:
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
for relative, expected in manifest["sources"].items():
path = ROOT / relative
actual = digest(path)
if actual != expected:
raise SystemExit(f"source hash mismatch: {relative}: {actual} != {expected}")
paths = sorted(
(p for p in BUNDLE.rglob("*.json")
if p != MANIFEST and (p.parent == BUNDLE or p.relative_to(BUNDLE).parts[0] == "examples")),
key=lambda p: p.relative_to(BUNDLE).as_posix(),
)
if len(paths) < 10:
raise SystemExit("missing current contract examples or schemas")
listing = "".join(f"{p.relative_to(BUNDLE).as_posix()} {digest(p)}\n" for p in paths)
actual = hashlib.sha256(listing.encode("utf-8")).hexdigest()
if actual != manifest["bundle_sha256"]:
raise SystemExit(f"contract bundle hash mismatch: {actual} != {manifest['bundle_sha256']}")
print(f"current local contract: {len(paths)} JSON files, source and bundle hashes valid")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 scripts/check-current-contracts.py
go test ./contracts -run 'TestCurrentContract' -count=1