Require one-shot opening before full-AI turns

This commit is contained in:
2026-09-29 21:37:41 +08:00
parent 7371fb1e3e
commit d9ad420480
4 changed files with 194 additions and 7 deletions
@@ -50,7 +50,7 @@
- Dispatcher 在启动、增量发现、恢复任务、SIP 更新及发指令前对完整 AI/provider 快照执行能力校验;不支持的任务关闭准入而不占额度或呼叫。已按用户确认保留现行 TTS Schema:火山 TTS V2 SDK 无法表达的 PCMA、0.5–2 倍以外或整数 speech_rate 无法精确表达的速度均明确拒绝,不静默修改配置;不支持的插话配置同样拒绝。
- `ExecuteApproved` 只在隔离 Mock 中签发:携带任务原始 JSON、引用的 provider 明文凭据、SIP revision、选定路由/主叫/原始被叫、独立的拨号期限及最大通话时限;任务+provider 原始字节+SIP revision 计算绑定摘要。Agent 对照已激活的 D 会话身份、绑定摘要、SDK 能力及**实际观察到的** SIP 加载结果;发出 Mock 指令前仅持久保存摘要和未知占用,失败/重复/重启不自动重拨,文件中不留明文凭据。当前 `LoadedSIP` 为可注入的隔离 Mock 观察器,**不等于真实 Asterisk 已加载核验**。
- ASR-only 不启动 LLM/TTS;full-AI 使用冻结的 model、显式 temperature=0/max_tokens、TTS voice/format/speech_rate 与 provider 原值凭据。真实 ASR SDK WebSocket 发出的音频输入/识别配置、LLM 与 TTS SDK 向隔离 HTTP Mock 发出的实际参数均已捕获核对;关键词仅匹配用户侧最终 ASR,失败/未知挂断不自动重试。`ApprovedOriginator` 经生成的 Unary gRPC Stub 交付完整快照,SIP 全量 revision 不吻合即拒绝。
- ASR-only 不启动 LLM/TTS;full-AI 使用冻结的 model、显式 temperature=0/max_tokens、TTS voice/format/speech_rate 与 provider 原值凭据。真实 ASR SDK WebSocket 发出的音频输入/识别配置、LLM 与 TTS SDK 向隔离 HTTP Mock 发出的实际参数均已捕获核对;已配置的开场白由 TTS 单次合成,失败不自动重播且未完成时不进入对话轮次;空开场白不调用 TTS。关键词仅匹配用户侧最终 ASR,失败/未知挂断不自动重试。`ApprovedOriginator` 经生成的 Unary gRPC Stub 交付完整快照,SIP 全量 revision 不吻合即拒绝。
- 验证:`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`go test -race ./internal/ai ./internal/configread ./internal/dispatcher ./internal/rpc -count=1`、`go vet ./...`、`go build ./...`、`git diff --check` 均通过。
- **尚未完成:** 生产 CLI 接入、会话期间全部对话控制参数的实际媒体行为、完整录音/OSS/最终结果及真实 Agent/Asterisk、SaaS、MQ、AI 供应商联调。这些不能被本地 Mock 结果代签,分别属于 P05 收尾、P06–P08 或第二阶段外部验收。
+138
View File
@@ -0,0 +1,138 @@
package ai
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestCurrentCallOpeningUsesApprovedTTSOnce(t *testing.T) {
task, providers := currentFixture(t, "full_ai")
var calls atomic.Int32
text := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
var payload struct {
Params struct {
Text string `json:"text"`
} `json:"req_params"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid SDK request", http.StatusBadRequest)
return
}
text <- payload.Params.Text
_, _ = fmt.Fprintf(w, `{"code":0,"data":%q}`+"\n", base64.StdEncoding.EncodeToString([]byte{1, 0, 2, 0}))
_, _ = fmt.Fprintln(w, `{"code":20000000,"message":"ok","data":null}`)
}))
defer server.Close()
p := providers["tts-example"]
p.Endpoint = server.URL
providers[p.ProviderRef] = p
bound, err := BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
if err != nil {
t.Fatal(err)
}
audio, err := call.Open(context.Background())
if err != nil || len(audio) != 4 || <-text != bound.Opening {
t.Fatalf("approved opening was not synthesized once: len=%d err=%v", len(audio), err)
}
if _, err := call.Open(context.Background()); err == nil || !strings.Contains(err.Error(), "already") || calls.Load() != 1 {
t.Fatalf("opening cannot be replayed: requests=%d err=%v", calls.Load(), err)
}
}
func TestCurrentCallOpeningFailureIsVisibleAndNeverRetried(t *testing.T) {
task, providers := currentFixture(t, "full_ai")
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, "mock provider unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
p := providers["tts-example"]
p.Endpoint = server.URL
providers[p.ProviderRef] = p
bound, err := BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
if err != nil {
t.Fatal(err)
}
if _, err := call.Open(context.Background()); err == nil {
t.Fatal("provider failure must be visible")
}
if _, err := call.Open(context.Background()); err == nil || calls.Load() != 1 {
t.Fatalf("uncertain opening must not replay: requests=%d err=%v", calls.Load(), err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := call.RunTurn(ctx, []byte{1, 0}); err == nil || !strings.Contains(err.Error(), "opening") {
t.Fatalf("an unsuccessful opening cannot be silently skipped: %v", err)
}
}
func TestCurrentCallASROnlyNeverRequestsOpeningTTS(t *testing.T) {
task, providers := currentFixture(t, "asr_only")
bound, err := BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
if err != nil {
t.Fatal(err)
}
if _, err := call.Open(context.Background()); err == nil {
t.Fatal("ASR-only call cannot synthesize an opening")
}
}
func TestCurrentCallOptionalEmptyOpeningMakesNoTTSRequest(t *testing.T) {
task, providers := currentFixture(t, "full_ai")
task = changeCurrentAgent(t, task, func(agent map[string]any) {
agent["conversation"].(map[string]any)["opening"] = ""
})
bound, err := BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
if err != nil {
t.Fatal(err)
}
if audio, err := call.Open(context.Background()); err != nil || len(audio) != 0 {
t.Fatalf("an approved empty opening must not call TTS: len=%d err=%v", len(audio), err)
}
if _, err := call.Open(context.Background()); err == nil {
t.Fatal("opening stage cannot run twice even if no audio was configured")
}
}
func TestCurrentCallRequiresApprovedOpeningBeforeFullAITurn(t *testing.T) {
task, providers := currentFixture(t, "full_ai")
bound, err := BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := call.RunTurn(ctx, []byte{1, 0}); err == nil || !strings.Contains(err.Error(), "opening") {
t.Fatalf("a full-AI turn cannot skip its configured opening: %v", err)
}
}
+43 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/GizClaw/doubao-speech-go"
@@ -18,8 +19,11 @@ import (
// CurrentCall owns the keyword action for exactly one call. A failed or
// uncertain hangup is never attempted again for that call.
type CurrentCall struct {
bound CurrentBound
keyword *KeywordHangup
bound CurrentBound
keyword *KeywordHangup
mu sync.Mutex
openingRequested bool
openingReady bool
}
func NewCurrentCall(bound CurrentBound, hangup func(context.Context) error) (*CurrentCall, error) {
@@ -30,6 +34,35 @@ func NewCurrentCall(bound CurrentBound, hangup func(context.Context) error) (*Cu
return &CurrentCall{bound: bound, keyword: keyword}, nil
}
// Open synthesizes the approved opening at most once per call. An ambiguous
// synthesis result is visible to the caller and never replayed automatically.
func (c *CurrentCall) Open(ctx context.Context) ([]byte, error) {
if c == nil || c.bound.Mode != "full_ai" {
return nil, errors.New("ASR-only call has no opening TTS")
}
c.mu.Lock()
if c.openingRequested {
c.mu.Unlock()
return nil, errors.New("opening already requested; outcome unknown")
}
c.openingRequested = true
c.mu.Unlock()
if c.bound.Opening == "" {
c.mu.Lock()
c.openingReady = true
c.mu.Unlock()
return nil, nil
}
audio, err := c.bound.Synthesize(ctx, c.bound.Opening)
if err != nil {
return nil, err
}
c.mu.Lock()
c.openingReady = true
c.mu.Unlock()
return audio, nil
}
func (c *CurrentCall) HandleFinalASR(ctx context.Context, segment ASRSegment) (bool, error) {
if c == nil {
return false, errors.New("AI call is unavailable")
@@ -43,6 +76,14 @@ func (c *CurrentCall) RunTurn(ctx context.Context, pcm16 []byte) (TurnResult, er
if c == nil {
return TurnResult{}, errors.New("AI call is unavailable")
}
if c.bound.Mode == "full_ai" && c.bound.Opening != "" {
c.mu.Lock()
ready := c.openingReady
c.mu.Unlock()
if !ready {
return TurnResult{}, errors.New("approved opening was not completed")
}
}
text, err := c.bound.Recognize(ctx, pcm16)
if err != nil {
return TurnResult{}, fmt.Errorf("ASR failed: %w", err)
@@ -39,7 +39,7 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
req.TaskId = task.TaskID
req.SourceEventId, req.CallId = "event-full", "event-full"
llmObserved := make(chan approvedSDKObservation, 1)
ttsObserved := make(chan approvedSDKObservation, 1)
ttsObserved := make(chan approvedSDKObservation, 2)
mockSDKs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -89,6 +89,13 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
if err != nil {
return err
}
openingAudio, err := call.Open(ctx)
if err != nil {
return fmt.Errorf("synthesize approved opening: %w", err)
}
if len(openingAudio) != 4 {
t.Fatal("Agent did not synthesize the approved opening audio")
}
for _, segment := range []ai.ASRSegment{
{Source: "user", Text: "不用了", Final: false},
{Source: "assistant", Text: "不用了", Final: true},
@@ -136,7 +143,8 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
if err := orig.Originate(context.Background(), spec); err != nil || calls != 1 || hangups != 1 {
t.Fatalf("full-AI isolated Agent call failed: err=%v calls=%d hangups=%d", err, calls, hangups)
}
llmCall, ttsCall := <-llmObserved, <-ttsObserved
llmCall := <-llmObserved
openingCall, ttsCall := <-ttsObserved, <-ttsObserved
if llmCall.Authorization != "Bearer "+llm.Credential || llmCall.Body["model"] != "example-chat" || llmCall.Body["temperature"] != float64(0) || llmCall.Body["max_tokens"] != float64(256) {
t.Fatal("Agent did not send approved LLM values through SDK")
}
@@ -144,7 +152,7 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
t.Fatal("Agent did not use approved TTS resource and credential")
}
params := ttsCall.Body["req_params"].(map[string]any)
if params["speaker"] != "example-neutral" || params["text"] != "回答" || params["audio_params"].(map[string]any)["format"] != "pcm_s16le" {
t.Fatal("Agent did not send approved TTS voice/text/format")
if openingCall.Body["req_params"].(map[string]any)["text"] != "Example greeting" || params["speaker"] != "example-neutral" || params["text"] != "回答" || params["audio_params"].(map[string]any)["format"] != "pcm_s16le" {
t.Fatal("Agent did not send the approved opening then reply via TTS")
}
}