refactor(ai): name approved bindings by domain role
This commit is contained in:
@@ -140,6 +140,8 @@
|
||||
|
||||
- 当前文档单入口:`AGENTS.md`、根 `README.md` 和 `docs/README.md` 不再把旧 MQ-only、F 工作包或 v0.4 发现写作当前执行依据,改指唯一项目内通信说明、机器合同与新的 `docs/plan-saas-dispatcher.md` 状态入口;物理部署说明标明当前制品只支持隔离 Mock、不批准真实安装。原 AGENTS 原字节归档到 `docs/archive/AGENTS-before-current-contract.md`(SHA-256 `8996ce2db909508172f59f39e780bdab2f48b2eba1acc3590dcffc50f8b2f6f0`)。版本化计划 `docs/plan-saas-dispatcher-v05-v0.1.md` 已被当前 `contracts/local/manifest.json` 固定原路径及 SHA-256 `666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87`,因此保留原字节作为合同**历史来源例外**,另建不带代次的当前计划索引,而不伪造来源或另立运行合同。四份当前根文档的相对链接、两份原字节 SHA-256、`go test ./contracts ./internal/config`、`make check`(含 race/vet/build 与三项真实隔离 MQ PASS)及 `make release-check-local` 均已核对通过;当前待办仍有 AI 自有名称及历史夹具残留分类,本批不宣布 P07 完成。真实 SaaS、Asterisk/ECS、OSS、AI 与非生产主机诊断均未运行。
|
||||
|
||||
- AI 自有代次命名清理:新增 `TestAIBindingsUseDomainNames`,先在旧 `CurrentBound`/`BindCurrent` 和 `current*.go` 上得到预期失败;将获批配置绑定、ASR/LLM/TTS/对话参数和单通话控制统一改为业务名 `Binding`、`ASRConfig`/`LLMConfig`/`TTSConfig`/`ConversationConfig`、`Bind`、`Call`/`NewCall`,对应 `internal/ai/` 的源码和测试文件不再带 `current` 前缀,调用方与测试同步改名,业务行为未增减。命名测试、四个受影响模块的 `go test -race ... -count=1`、全仓 `make check`(含三项真实隔离 MQ PASS)及 `make release-check-local` 通过,旧 AI 标识和文件名在活动 Go 源中已无匹配。旧 `contracts/local/v0.1–v0.4` 文件与未使用的 `internal/testfixture` 仍需按真实来源及调用情况分类,不能凭命名删除合同来源或现存数据;此批不宣称 P07 完成。
|
||||
|
||||
## 验收台账
|
||||
|
||||
P01–P06 的项目内隔离证据见上;P07 全仓命名与唯一入口尚在清理,A01–A12 和 K01–K16 的最终对照仍待 P08。不得用本地 Mock 冒充外部签收。
|
||||
|
||||
@@ -23,7 +23,7 @@ type ApprovedMockTurn struct {
|
||||
}
|
||||
|
||||
// ApprovedMockPipeline is private to one approved Mock call. The signed AI
|
||||
// parameters remain owned by CurrentBound; this adapter neither contacts a
|
||||
// parameters remain owned by Binding; this adapter neither contacts a
|
||||
// provider nor silently manufactures absent script turns or audio.
|
||||
type ApprovedMockPipeline struct {
|
||||
mu sync.Mutex
|
||||
@@ -36,7 +36,7 @@ type ApprovedMockPipeline struct {
|
||||
next int
|
||||
}
|
||||
|
||||
func NewApprovedMockPipeline(bound CurrentBound, script ApprovedMockScript, hangup func(context.Context) error) (*ApprovedMockPipeline, error) {
|
||||
func NewApprovedMockPipeline(bound Binding, script ApprovedMockScript, hangup func(context.Context) error) (*ApprovedMockPipeline, error) {
|
||||
keyword, err := NewKeywordHangup(bound.HangupKeywords, hangup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestApprovedMockASROnlyDoesNotInventRefusalOrAssistantAudio(t *testing.T) {
|
||||
var hangups int
|
||||
pipeline, err := NewApprovedMockPipeline(CurrentBound{Mode: "asr_only", Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{
|
||||
pipeline, err := NewApprovedMockPipeline(Binding{Mode: "asr_only", Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{
|
||||
Turns: []ApprovedMockTurn{{Transcript: "我拒绝但是没有配置关键词"}},
|
||||
}, func(context.Context) error { hangups++; return nil })
|
||||
if err != nil {
|
||||
@@ -27,7 +27,7 @@ func TestApprovedMockASROnlyDoesNotInventRefusalOrAssistantAudio(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApprovedMockAbsentOptionalTurnLimitUsesSharedOneTurnBoundary(t *testing.T) {
|
||||
pipeline, err := NewApprovedMockPipeline(CurrentBound{Mode: "asr_only"}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "隔离识别"}}}, func(context.Context) error { return nil })
|
||||
pipeline, err := NewApprovedMockPipeline(Binding{Mode: "asr_only"}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "隔离识别"}}}, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestApprovedMockFullAIFreezesScriptAndStopsBeforeKeywordReply(t *testing.T)
|
||||
{Transcript: "请不要联系", Reply: "不得播报", ReplyPCM16: reply},
|
||||
}}
|
||||
var hangups int
|
||||
bound := CurrentBound{Mode: "full_ai", Opening: "批准的开场", HangupKeywords: []string{"不要联系"}, LLM: &CurrentLLM{}, TTS: &CurrentTTS{}, Conversation: CurrentConversation{MaxTurns: 2}}
|
||||
bound := Binding{Mode: "full_ai", Opening: "批准的开场", HangupKeywords: []string{"不要联系"}, LLM: &LLMConfig{}, TTS: &TTSConfig{}, Conversation: ConversationConfig{MaxTurns: 2}}
|
||||
pipeline, err := NewApprovedMockPipeline(bound, script, func(context.Context) error { hangups++; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -70,16 +70,16 @@ func TestApprovedMockFullAIFreezesScriptAndStopsBeforeKeywordReply(t *testing.T)
|
||||
func TestApprovedMockRejectsMissingAIOrUnscriptedMedia(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
bound CurrentBound
|
||||
bound Binding
|
||||
script ApprovedMockScript
|
||||
}{
|
||||
{"missing_script", CurrentBound{Mode: "asr_only", Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{}},
|
||||
{"asr_with_llm", CurrentBound{Mode: "asr_only", LLM: &CurrentLLM{}, Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
{"full_without_llm", CurrentBound{Mode: "full_ai", TTS: &CurrentTTS{}, Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
{"unavailable_opening_audio", CurrentBound{Mode: "full_ai", LLM: &CurrentLLM{}, TTS: &CurrentTTS{}, Opening: "批准开场", Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试", Reply: "模拟答复", ReplyPCM16: []byte{1, 0}}}}},
|
||||
{"unsolicited_odd_opening_audio", CurrentBound{Mode: "full_ai", LLM: &CurrentLLM{}, TTS: &CurrentTTS{}, Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{OpeningPCM16: []byte{1}, Turns: []ApprovedMockTurn{{Transcript: "测试", Reply: "模拟答复", ReplyPCM16: []byte{1, 0}}}}},
|
||||
{"extra_unapproved_turn", CurrentBound{Mode: "asr_only", Conversation: CurrentConversation{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "第一轮"}, {Transcript: "第二轮"}}}},
|
||||
{"negative_turn_limit", CurrentBound{Mode: "asr_only", Conversation: CurrentConversation{MaxTurns: -1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
{"missing_script", Binding{Mode: "asr_only", Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{}},
|
||||
{"asr_with_llm", Binding{Mode: "asr_only", LLM: &LLMConfig{}, Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
{"full_without_llm", Binding{Mode: "full_ai", TTS: &TTSConfig{}, Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
{"unavailable_opening_audio", Binding{Mode: "full_ai", LLM: &LLMConfig{}, TTS: &TTSConfig{}, Opening: "批准开场", Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试", Reply: "模拟答复", ReplyPCM16: []byte{1, 0}}}}},
|
||||
{"unsolicited_odd_opening_audio", Binding{Mode: "full_ai", LLM: &LLMConfig{}, TTS: &TTSConfig{}, Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{OpeningPCM16: []byte{1}, Turns: []ApprovedMockTurn{{Transcript: "测试", Reply: "模拟答复", ReplyPCM16: []byte{1, 0}}}}},
|
||||
{"extra_unapproved_turn", Binding{Mode: "asr_only", Conversation: ConversationConfig{MaxTurns: 1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "第一轮"}, {Transcript: "第二轮"}}}},
|
||||
{"negative_turn_limit", Binding{Mode: "asr_only", Conversation: ConversationConfig{MaxTurns: -1}}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := NewApprovedMockPipeline(tc.bound, tc.script, func(context.Context) error { return nil }); err == nil {
|
||||
@@ -87,7 +87,7 @@ func TestApprovedMockRejectsMissingAIOrUnscriptedMedia(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := NewApprovedMockPipeline(CurrentBound{Mode: "asr_only"}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}, nil); err == nil {
|
||||
if _, err := NewApprovedMockPipeline(Binding{Mode: "asr_only"}, ApprovedMockScript{Turns: []ApprovedMockTurn{{Transcript: "测试"}}}, nil); err == nil {
|
||||
t.Fatal("mock without a real hangup callback was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type currentASRWireCapture struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func TestCurrentASRParametersReachDoubaoSDKWebSocket(t *testing.T) {
|
||||
func TestASRParametersReachDoubaoSDKWebSocket(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode, resultType string
|
||||
nonstream bool
|
||||
@@ -43,7 +43,7 @@ func TestCurrentASRParametersReachDoubaoSDKWebSocket(t *testing.T) {
|
||||
asrProvider := providers["asr-example"]
|
||||
asrProvider.Endpoint = server.URL
|
||||
providers[asrProvider.ProviderRef] = asrProvider
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -13,29 +13,29 @@ import (
|
||||
doubaospeech "github.com/GizClaw/doubao-speech-go"
|
||||
)
|
||||
|
||||
// CurrentBound is a per-call, immutable binding of the approved task and
|
||||
// Binding is a per-call, immutable binding of the approved task and
|
||||
// provider settings. It must be built before dispatching any side effects.
|
||||
// Credential values are never included in errors or logs.
|
||||
type CurrentBound struct {
|
||||
type Binding struct {
|
||||
Mode string
|
||||
ASR CurrentASR
|
||||
LLM *CurrentLLM
|
||||
TTS *CurrentTTS
|
||||
ASR ASRConfig
|
||||
LLM *LLMConfig
|
||||
TTS *TTSConfig
|
||||
Prompt string
|
||||
PromptMaxBytes int
|
||||
AllowedVariables []string
|
||||
Opening string
|
||||
HangupKeywords []string
|
||||
Conversation CurrentConversation
|
||||
Conversation ConversationConfig
|
||||
}
|
||||
|
||||
type CurrentASR struct {
|
||||
type ASRConfig struct {
|
||||
Provider configread.Provider
|
||||
Request doubaospeech.ASRV2Config
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type CurrentLLM struct {
|
||||
type LLMConfig struct {
|
||||
Provider configread.Provider
|
||||
Model string
|
||||
Temperature *float64
|
||||
@@ -43,15 +43,15 @@ type CurrentLLM struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type CurrentTTS struct {
|
||||
type TTSConfig struct {
|
||||
Provider configread.Provider
|
||||
Request doubaospeech.TTSV2Request
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// CurrentConversation preserves the task's explicit dialogue limits. An
|
||||
// ConversationConfig preserves the task's explicit dialogue limits. An
|
||||
// absent positive limit remains zero; an explicit false remains non-nil.
|
||||
type CurrentConversation struct {
|
||||
type ConversationConfig struct {
|
||||
AllowInterrupt *bool
|
||||
SilenceTimeout time.Duration
|
||||
MaxDuration time.Duration
|
||||
@@ -111,51 +111,51 @@ type currentAgentSettings struct {
|
||||
} `json:"conversation"`
|
||||
}
|
||||
|
||||
// BindCurrent rejects schema-valid settings which the selected SDK cannot
|
||||
// Bind rejects schema-valid settings which the selected SDK cannot
|
||||
// express. In particular, the published TTS schema is intentionally not
|
||||
// silently narrowed to the SDK's speed/format capabilities.
|
||||
func BindCurrent(task configread.Task, providers map[string]configread.Provider) (CurrentBound, error) {
|
||||
func Bind(task configread.Task, providers map[string]configread.Provider) (Binding, error) {
|
||||
if len(task.Raw) == 0 {
|
||||
return CurrentBound{}, errors.New("approved task snapshot is missing")
|
||||
return Binding{}, errors.New("approved task snapshot is missing")
|
||||
}
|
||||
if err := contract.ValidateCurrent("config-read", task.Raw); err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("task snapshot: %w", err)
|
||||
return Binding{}, fmt.Errorf("task snapshot: %w", err)
|
||||
}
|
||||
var frozen configread.Task
|
||||
if err := json.Unmarshal(task.Raw, &frozen); err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("decode task snapshot: %w", err)
|
||||
return Binding{}, fmt.Errorf("decode task snapshot: %w", err)
|
||||
}
|
||||
if frozen.Resource != "task_config" || frozen.TaskID != task.TaskID || frozen.TenantID != task.TenantID || frozen.DispatcherID != task.DispatcherID {
|
||||
return CurrentBound{}, errors.New("approved task snapshot identity mismatch")
|
||||
return Binding{}, errors.New("approved task snapshot identity mismatch")
|
||||
}
|
||||
var settings currentAgentSettings
|
||||
if err := json.Unmarshal(frozen.Agent.Raw, &settings); err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("decode immutable AI settings: %w", err)
|
||||
return Binding{}, fmt.Errorf("decode immutable AI settings: %w", err)
|
||||
}
|
||||
bound := CurrentBound{Mode: settings.Mode}
|
||||
bound := Binding{Mode: settings.Mode}
|
||||
asrProvider, err := currentProvider(providers, settings.ASR.ProviderRef, "asr", "volcengine_asr")
|
||||
if err != nil {
|
||||
return CurrentBound{}, err
|
||||
return Binding{}, err
|
||||
}
|
||||
if settings.ASR.Input.Encoding != "pcm_s16le" || settings.ASR.Input.Channels != 1 || settings.ASR.Input.SampleWidthBytes != 2 {
|
||||
return CurrentBound{}, errors.New("ASR input format is unsupported by selected SDK")
|
||||
return Binding{}, errors.New("ASR input format is unsupported by selected SDK")
|
||||
}
|
||||
sampleRate, err := currentSampleRate(settings.ASR.Input.SampleRateHz)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("ASR input: %w", err)
|
||||
return Binding{}, fmt.Errorf("ASR input: %w", err)
|
||||
}
|
||||
if sampleRate != doubaospeech.SampleRate(16000) {
|
||||
return CurrentBound{}, errors.New("ASR media requires 16000 Hz PCM16")
|
||||
return Binding{}, errors.New("ASR media requires 16000 Hz PCM16")
|
||||
}
|
||||
lang := doubaospeech.Language(settings.ASR.Language)
|
||||
switch lang {
|
||||
case doubaospeech.LanguageZhCN, doubaospeech.LanguageEnUS, doubaospeech.LanguageJaJP, doubaospeech.LanguageKoKR:
|
||||
default:
|
||||
return CurrentBound{}, errors.New("ASR language is unsupported by selected SDK")
|
||||
return Binding{}, errors.New("ASR language is unsupported by selected SDK")
|
||||
}
|
||||
asrTimeout, err := currentTimeout(settings.ASR.TimeoutMS)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("ASR timeout: %w", err)
|
||||
return Binding{}, fmt.Errorf("ASR timeout: %w", err)
|
||||
}
|
||||
asrRequest := doubaospeech.ASRV2Config{
|
||||
Format: doubaospeech.FormatPCMS16LE, SampleRate: sampleRate,
|
||||
@@ -172,54 +172,54 @@ func BindCurrent(task configread.Task, providers map[string]configread.Provider)
|
||||
}
|
||||
asrRequest.Request.ResultType = asrRequest.ResultType
|
||||
}
|
||||
bound.ASR = CurrentASR{Provider: asrProvider, Request: asrRequest, Timeout: asrTimeout}
|
||||
bound.ASR = ASRConfig{Provider: asrProvider, Request: asrRequest, Timeout: asrTimeout}
|
||||
|
||||
switch settings.Mode {
|
||||
case "asr_only":
|
||||
return bound, nil
|
||||
case "full_ai":
|
||||
if settings.LLM == nil || settings.TTS == nil || settings.Prompt == nil || settings.Conversation == nil {
|
||||
return CurrentBound{}, errors.New("full AI settings are incomplete")
|
||||
return Binding{}, errors.New("full AI settings are incomplete")
|
||||
}
|
||||
default:
|
||||
return CurrentBound{}, errors.New("AI mode is unsupported")
|
||||
return Binding{}, errors.New("AI mode is unsupported")
|
||||
}
|
||||
llmProvider, err := currentProvider(providers, settings.LLM.ProviderRef, "llm", "openai_compatible")
|
||||
if err != nil {
|
||||
return CurrentBound{}, err
|
||||
return Binding{}, err
|
||||
}
|
||||
llmTimeout, err := currentTimeout(settings.LLM.TimeoutMS)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("LLM timeout: %w", err)
|
||||
return Binding{}, fmt.Errorf("LLM timeout: %w", err)
|
||||
}
|
||||
bound.LLM = &CurrentLLM{Provider: llmProvider, Model: settings.LLM.Model, Temperature: settings.LLM.Temperature, MaxTokens: settings.LLM.MaxTokens, Timeout: llmTimeout}
|
||||
bound.LLM = &LLMConfig{Provider: llmProvider, Model: settings.LLM.Model, Temperature: settings.LLM.Temperature, MaxTokens: settings.LLM.MaxTokens, Timeout: llmTimeout}
|
||||
ttsProvider, err := currentProvider(providers, settings.TTS.ProviderRef, "tts", "volcengine_tts")
|
||||
if err != nil {
|
||||
return CurrentBound{}, err
|
||||
return Binding{}, err
|
||||
}
|
||||
if settings.TTS.Format.Encoding != "pcm_s16le" || settings.TTS.Format.Channels != 1 {
|
||||
return CurrentBound{}, errors.New("TTS output encoding/channel is unsupported by selected SDK")
|
||||
return Binding{}, errors.New("TTS output encoding/channel is unsupported by selected SDK")
|
||||
}
|
||||
ttsSampleRate, err := currentSampleRate(settings.TTS.Format.SampleRateHz)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("TTS output: %w", err)
|
||||
return Binding{}, fmt.Errorf("TTS output: %w", err)
|
||||
}
|
||||
if ttsSampleRate != doubaospeech.SampleRate(16000) {
|
||||
return CurrentBound{}, errors.New("TTS media requires 16000 Hz PCM16")
|
||||
return Binding{}, errors.New("TTS media requires 16000 Hz PCM16")
|
||||
}
|
||||
ttsRate := 0
|
||||
if settings.TTS.Speed != nil {
|
||||
floatRate := (*settings.TTS.Speed - 1) * 100
|
||||
if floatRate < -50 || floatRate > 100 || math.Abs(floatRate-math.Round(floatRate)) > 1e-9 {
|
||||
return CurrentBound{}, errors.New("TTS speed is unsupported by selected SDK")
|
||||
return Binding{}, errors.New("TTS speed is unsupported by selected SDK")
|
||||
}
|
||||
ttsRate = int(math.Round(floatRate))
|
||||
}
|
||||
ttsTimeout, err := currentTimeout(settings.TTS.TimeoutMS)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("TTS timeout: %w", err)
|
||||
return Binding{}, fmt.Errorf("TTS timeout: %w", err)
|
||||
}
|
||||
bound.TTS = &CurrentTTS{Provider: ttsProvider, Request: doubaospeech.TTSV2Request{
|
||||
bound.TTS = &TTSConfig{Provider: ttsProvider, Request: doubaospeech.TTSV2Request{
|
||||
Speaker: settings.TTS.Voice, ResourceID: settings.TTS.Model,
|
||||
Format: doubaospeech.FormatPCMS16LE, SampleRate: ttsSampleRate, SpeechRate: ttsRate,
|
||||
}, Timeout: ttsTimeout}
|
||||
@@ -228,21 +228,21 @@ func BindCurrent(task configread.Task, providers map[string]configread.Provider)
|
||||
if settings.Prompt.MaxBytes != nil {
|
||||
bound.PromptMaxBytes = *settings.Prompt.MaxBytes
|
||||
if len(bound.Prompt) > bound.PromptMaxBytes {
|
||||
return CurrentBound{}, errors.New("prompt exceeds its approved byte limit")
|
||||
return Binding{}, errors.New("prompt exceeds its approved byte limit")
|
||||
}
|
||||
}
|
||||
if settings.Conversation.AllowInterrupt != nil && *settings.Conversation.AllowInterrupt {
|
||||
return CurrentBound{}, errors.New("conversation interrupt is unsupported by current media controller")
|
||||
return Binding{}, errors.New("conversation interrupt is unsupported by current media controller")
|
||||
}
|
||||
silence, err := currentTimeout(settings.Conversation.SilenceTimeoutMS)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("conversation silence timeout: %w", err)
|
||||
return Binding{}, fmt.Errorf("conversation silence timeout: %w", err)
|
||||
}
|
||||
maxDuration, err := currentTimeout(settings.Conversation.MaxDurationMS)
|
||||
if err != nil {
|
||||
return CurrentBound{}, fmt.Errorf("conversation duration: %w", err)
|
||||
return Binding{}, fmt.Errorf("conversation duration: %w", err)
|
||||
}
|
||||
bound.Conversation = CurrentConversation{
|
||||
bound.Conversation = ConversationConfig{
|
||||
AllowInterrupt: settings.Conversation.AllowInterrupt,
|
||||
SilenceTimeout: silence, MaxDuration: maxDuration,
|
||||
MaxTurns: settings.Conversation.MaxTurns,
|
||||
@@ -63,7 +63,7 @@ func changeCurrentAgent(t *testing.T, task configread.Task, change func(map[stri
|
||||
|
||||
func TestBindCurrentFullAIUsesApprovedSDKFields(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestBindCurrentASROnlyDoesNotBindOtherProviders(t *testing.T) {
|
||||
task, providers := currentFixture(t, "asr_only")
|
||||
delete(providers, "llm-example")
|
||||
delete(providers, "tts-example")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestBindCurrentRejectsSDKUnsupportedTTSWithoutChangingSchema(t *testing.T)
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) { tc.edit(agent["tts"].(map[string]any)) })
|
||||
_, err := BindCurrent(task, providers)
|
||||
_, err := Bind(task, providers)
|
||||
if err == nil || !strings.Contains(err.Error(), "TTS") || strings.Contains(err.Error(), providers["tts-example"].Credential) {
|
||||
t.Fatalf("expected explicit non-secret TTS capability error, got %v", err)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestBindCurrentRejectsUnauthorizedProviderBeforeCall(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
tc.mutate(providers)
|
||||
if _, err := BindCurrent(task, providers); err == nil {
|
||||
if _, err := Bind(task, providers); err == nil {
|
||||
t.Fatal("unavailable AI provider cannot authorize execution")
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestBindCurrentPreservesPromptAndConversationControls(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestBindCurrentRejectsPromptOverConfiguredByteLimit(t *testing.T) {
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) {
|
||||
agent["prompt"].(map[string]any)["max_bytes"] = 3
|
||||
})
|
||||
_, err := BindCurrent(task, providers)
|
||||
_, err := Bind(task, providers)
|
||||
if err == nil || !strings.Contains(err.Error(), "prompt") {
|
||||
t.Fatalf("prompt limit was ignored: %v", err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestBindCurrentRejectsUnsupportedBargeInBeforeCall(t *testing.T) {
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) {
|
||||
agent["conversation"].(map[string]any)["allow_interrupt"] = true
|
||||
})
|
||||
_, err := BindCurrent(task, providers)
|
||||
_, err := Bind(task, providers)
|
||||
if err == nil || !strings.Contains(err.Error(), "interrupt") {
|
||||
t.Fatalf("unsupported interruption cannot silently become false: %v", err)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentTTSRejectsExcessPendingAudioWithoutRetry(t *testing.T) {
|
||||
func TestTTSRejectsExcessPendingAudioWithoutRetry(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) {
|
||||
agent["conversation"].(map[string]any)["max_pending_audio_chunks"] = 1
|
||||
@@ -29,7 +29,7 @@ func TestCurrentTTSRejectsExcessPendingAudioWithoutRetry(t *testing.T) {
|
||||
p := providers["tts-example"]
|
||||
p.Endpoint = server.URL
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestCurrentTTSRejectsExcessPendingAudioWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentReplyChunksUnicodeByApprovedSentenceLimit(t *testing.T) {
|
||||
func TestReplyChunksUnicodeByApprovedSentenceLimit(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) {
|
||||
conversation := agent["conversation"].(map[string]any)
|
||||
@@ -67,7 +67,7 @@ func TestCurrentReplyChunksUnicodeByApprovedSentenceLimit(t *testing.T) {
|
||||
p := providers["tts-example"]
|
||||
p.Endpoint = server.URL
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentBindRejectsSampleRatesUnsupportedByAgentMedia(t *testing.T) {
|
||||
func TestBindRejectsSampleRatesUnsupportedByAgentMedia(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, mode, endpoint string
|
||||
}{
|
||||
@@ -22,7 +22,7 @@ func TestCurrentBindRejectsSampleRatesUnsupportedByAgentMedia(t *testing.T) {
|
||||
agent["tts"].(map[string]any)["format"].(map[string]any)["sample_rate_hz"] = 24000
|
||||
}
|
||||
})
|
||||
if _, err := BindCurrent(task, providers); err == nil || !strings.Contains(err.Error(), "media") {
|
||||
if _, err := Bind(task, providers); err == nil || !strings.Contains(err.Error(), "media") {
|
||||
t.Fatalf("SDK-supported sample rate cannot silently mismatch 16 kHz Agent media: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAIBindingsUseDomainNames(t *testing.T) {
|
||||
files, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
name := file.Name()
|
||||
if file.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "current") {
|
||||
t.Errorf("%s retains an implementation-generation filename", name)
|
||||
}
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
var name string
|
||||
switch declaration := node.(type) {
|
||||
case *ast.TypeSpec:
|
||||
name = declaration.Name.Name
|
||||
case *ast.FuncDecl:
|
||||
name = declaration.Name.Name
|
||||
}
|
||||
if strings.Contains(name, "Current") {
|
||||
t.Errorf("%s retains an implementation-generation declaration %s", file.Name(), name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentCallOpeningUsesApprovedTTSOnce(t *testing.T) {
|
||||
func TestCallOpeningUsesApprovedTTSOnce(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
var calls atomic.Int32
|
||||
text := make(chan string, 1)
|
||||
@@ -35,11 +35,11 @@ func TestCurrentCallOpeningUsesApprovedTTSOnce(t *testing.T) {
|
||||
p := providers["tts-example"]
|
||||
p.Endpoint = server.URL
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestCurrentCallOpeningUsesApprovedTTSOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentCallOpeningFailureIsVisibleAndNeverRetried(t *testing.T) {
|
||||
func TestCallOpeningFailureIsVisibleAndNeverRetried(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) {
|
||||
@@ -63,11 +63,11 @@ func TestCurrentCallOpeningFailureIsVisibleAndNeverRetried(t *testing.T) {
|
||||
p := providers["tts-example"]
|
||||
p.Endpoint = server.URL
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -84,13 +84,13 @@ func TestCurrentCallOpeningFailureIsVisibleAndNeverRetried(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentCallASROnlyNeverRequestsOpeningTTS(t *testing.T) {
|
||||
func TestCallASROnlyNeverRequestsOpeningTTS(t *testing.T) {
|
||||
task, providers := currentFixture(t, "asr_only")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -99,16 +99,16 @@ func TestCurrentCallASROnlyNeverRequestsOpeningTTS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentCallOptionalEmptyOpeningMakesNoTTSRequest(t *testing.T) {
|
||||
func TestCallOptionalEmptyOpeningMakesNoTTSRequest(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)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -120,13 +120,13 @@ func TestCurrentCallOptionalEmptyOpeningMakesNoTTSRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentCallRequiresApprovedOpeningBeforeFullAITurn(t *testing.T) {
|
||||
func TestCallRequiresApprovedOpeningBeforeFullAITurn(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -16,27 +16,27 @@ import (
|
||||
"github.com/openai/openai-go/v3/shared"
|
||||
)
|
||||
|
||||
// CurrentCall owns the keyword action for exactly one call. A failed or
|
||||
// Call 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
|
||||
type Call struct {
|
||||
bound Binding
|
||||
keyword *KeywordHangup
|
||||
mu sync.Mutex
|
||||
openingRequested bool
|
||||
openingReady bool
|
||||
}
|
||||
|
||||
func NewCurrentCall(bound CurrentBound, hangup func(context.Context) error) (*CurrentCall, error) {
|
||||
func NewCall(bound Binding, hangup func(context.Context) error) (*Call, error) {
|
||||
keyword, err := NewKeywordHangup(bound.HangupKeywords, hangup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CurrentCall{bound: bound, keyword: keyword}, nil
|
||||
return &Call{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) {
|
||||
func (c *Call) 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")
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (c *CurrentCall) Open(ctx context.Context) ([]byte, error) {
|
||||
return audio, nil
|
||||
}
|
||||
|
||||
func (c *CurrentCall) HandleFinalASR(ctx context.Context, segment ASRSegment) (bool, error) {
|
||||
func (c *Call) HandleFinalASR(ctx context.Context, segment ASRSegment) (bool, error) {
|
||||
if c == nil {
|
||||
return false, errors.New("AI call is unavailable")
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (c *CurrentCall) HandleFinalASR(ctx context.Context, segment ASRSegment) (b
|
||||
|
||||
// RunTurn performs one utterance. No LLM or TTS request is sent after a
|
||||
// keyword hangup, and only a final user recognition may enter the LLM.
|
||||
func (c *CurrentCall) RunTurn(ctx context.Context, pcm16 []byte) (TurnResult, error) {
|
||||
func (c *Call) RunTurn(ctx context.Context, pcm16 []byte) (TurnResult, error) {
|
||||
if c == nil {
|
||||
return TurnResult{}, errors.New("AI call is unavailable")
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (c *CurrentCall) RunTurn(ctx context.Context, pcm16 []byte) (TurnResult, er
|
||||
return TurnResult{Transcript: text, Reply: reply, AudioPCM16: audio}, nil
|
||||
}
|
||||
|
||||
func (b CurrentBound) Recognize(ctx context.Context, pcm16 []byte) (string, error) {
|
||||
func (b Binding) Recognize(ctx context.Context, pcm16 []byte) (string, error) {
|
||||
if len(pcm16) == 0 || len(pcm16)%2 != 0 {
|
||||
return "", errors.New("ASR requires nonempty signed 16-bit PCM")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (b CurrentBound) Recognize(ctx context.Context, pcm16 []byte) (string, erro
|
||||
return final.Result()
|
||||
}
|
||||
|
||||
func (b CurrentBound) Complete(ctx context.Context, finalUserText string) (string, error) {
|
||||
func (b Binding) Complete(ctx context.Context, finalUserText string) (string, error) {
|
||||
if b.Mode != "full_ai" || b.LLM == nil {
|
||||
return "", errors.New("ASR-only mode does not call LLM")
|
||||
}
|
||||
@@ -185,14 +185,14 @@ func (b CurrentBound) Complete(ctx context.Context, finalUserText string) (strin
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func (b CurrentBound) Synthesize(ctx context.Context, text string) ([]byte, error) {
|
||||
func (b Binding) Synthesize(ctx context.Context, text string) ([]byte, error) {
|
||||
audio, _, err := b.synthesize(ctx, text, 0)
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// SynthesizeReply splits a reply at the approved Unicode character limit,
|
||||
// retaining every character and the same pending-audio bound across requests.
|
||||
func (b CurrentBound) SynthesizeReply(ctx context.Context, text string) ([]byte, error) {
|
||||
func (b Binding) SynthesizeReply(ctx context.Context, text string) ([]byte, error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, errors.New("TTS requires nonempty reply")
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func (b CurrentBound) SynthesizeReply(ctx context.Context, text string) ([]byte,
|
||||
return audio, nil
|
||||
}
|
||||
|
||||
func (b CurrentBound) synthesize(ctx context.Context, text string, alreadyPending int) ([]byte, int, error) {
|
||||
func (b Binding) synthesize(ctx context.Context, text string, alreadyPending int) ([]byte, int, error) {
|
||||
if b.Mode != "full_ai" || b.TTS == nil {
|
||||
return nil, 0, errors.New("ASR-only mode does not call TTS")
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentTTSPassesApprovedParametersToSDK(t *testing.T) {
|
||||
func TestTTSPassesApprovedParametersToSDK(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
task = changeCurrentAgent(t, task, func(agent map[string]any) { agent["tts"].(map[string]any)["speed"] = 1.3 })
|
||||
var captured map[string]any
|
||||
@@ -33,7 +33,7 @@ func TestCurrentTTSPassesApprovedParametersToSDK(t *testing.T) {
|
||||
p := providers["tts-example"]
|
||||
p.Endpoint = server.URL
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestCurrentTTSPassesApprovedParametersToSDK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentLLMPassesExplicitZeroAndDoesNotRetry(t *testing.T) {
|
||||
func TestLLMPassesExplicitZeroAndDoesNotRetry(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
calls := 0
|
||||
var captured map[string]any
|
||||
@@ -74,7 +74,7 @@ func TestCurrentLLMPassesExplicitZeroAndDoesNotRetry(t *testing.T) {
|
||||
p := providers["llm-example"]
|
||||
p.Endpoint = server.URL + "/v1"
|
||||
providers[p.ProviderRef] = p
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestCurrentLLMPassesExplicitZeroAndDoesNotRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentLLMFailureAndEmptyChoicesAreNotRetried(t *testing.T) {
|
||||
func TestLLMFailureAndEmptyChoicesAreNotRetried(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
code int
|
||||
@@ -113,7 +113,7 @@ func TestCurrentLLMFailureAndEmptyChoicesAreNotRetried(t *testing.T) {
|
||||
provider := providers["llm-example"]
|
||||
provider.Endpoint = server.URL + "/v1"
|
||||
providers[provider.ProviderRef] = provider
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,14 +124,14 @@ func TestCurrentLLMFailureAndEmptyChoicesAreNotRetried(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentKeywordHangupUsesFinalUserTextOnly(t *testing.T) {
|
||||
func TestKeywordHangupUsesFinalUserTextOnly(t *testing.T) {
|
||||
task, providers := currentFixture(t, "full_ai")
|
||||
bound, err := BindCurrent(task, providers)
|
||||
bound, err := Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
call, err := NewCurrentCall(bound, func(context.Context) error { calls++; return nil })
|
||||
call, err := NewCall(bound, func(context.Context) error { calls++; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestCurrentKeywordHangupUsesFinalUserTextOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentASRRequiresFinalResult(t *testing.T) {
|
||||
func TestASRRequiresFinalResult(t *testing.T) {
|
||||
var text finalASRAccumulator
|
||||
text.Add("不用了", false)
|
||||
if _, err := text.Result(); err == nil {
|
||||
@@ -28,7 +28,7 @@ func ExecuteApproved(ctx context.Context, session MediaSession, mode ai.Mode, pi
|
||||
|
||||
// ApprovedCapture hands explicit task conversation limits to the shared media
|
||||
// controller. A zero field denotes an absent optional business value.
|
||||
func ApprovedCapture(bound ai.CurrentBound) CaptureConfig {
|
||||
func ApprovedCapture(bound ai.Binding) CaptureConfig {
|
||||
return CaptureConfig{
|
||||
FirstSpeechTimeout: bound.Conversation.SilenceTimeout,
|
||||
CallDuration: bound.Conversation.MaxDuration,
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestApprovedASROnlySkipsOpeningAndReplyEvenIfPipelineHasAudio(t *testing.T)
|
||||
}
|
||||
|
||||
func TestApprovedCapturePreservesConversationControls(t *testing.T) {
|
||||
bound := ai.CurrentBound{Conversation: ai.CurrentConversation{SilenceTimeout: 3 * time.Second, MaxDuration: 2 * time.Minute, MaxTurns: 20, SentenceMaxChars: 80, MaxPendingAudioChunks: 32}}
|
||||
bound := ai.Binding{Conversation: ai.ConversationConfig{SilenceTimeout: 3 * time.Second, MaxDuration: 2 * time.Minute, MaxTurns: 20, SentenceMaxChars: 80, MaxPendingAudioChunks: 32}}
|
||||
capture := ApprovedCapture(bound)
|
||||
if capture.FirstSpeechTimeout != 3*time.Second || capture.EndSilence != 3*time.Second || capture.CallDuration != 2*time.Minute || capture.MaxDuration != 0 || capture.MaxTurns != 20 || capture.VoiceThreshold <= 0 || capture.MaxPendingAudioChunks != 32 {
|
||||
t.Fatalf("approved conversation limits were not handed to media controller: %+v", capture)
|
||||
@@ -179,7 +179,7 @@ func (p *approvedDeadlineProbe) RunTurn(ctx context.Context, _ []byte) (ai.TurnR
|
||||
func TestApprovedConversationDurationBoundsWholeCall(t *testing.T) {
|
||||
pipeline := &approvedDeadlineProbe{}
|
||||
session := &scriptedTurnSession{turns: [][]byte{bytes.Repeat([]byte{1, 0}, 3200)}}
|
||||
capture := ApprovedCapture(ai.CurrentBound{Conversation: ai.CurrentConversation{
|
||||
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
|
||||
SilenceTimeout: 2 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1,
|
||||
}})
|
||||
if _, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture); err != nil || !pipeline.deadlinePresent {
|
||||
@@ -190,7 +190,7 @@ func TestApprovedConversationDurationBoundsWholeCall(t *testing.T) {
|
||||
func TestApprovedSilenceTimeoutRejectsMissingSpeech(t *testing.T) {
|
||||
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不应执行"}}
|
||||
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
||||
capture := ApprovedCapture(ai.CurrentBound{Conversation: ai.CurrentConversation{
|
||||
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
|
||||
SilenceTimeout: 10 * time.Millisecond, MaxDuration: 100 * time.Millisecond, MaxTurns: 1,
|
||||
}})
|
||||
_, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// approved task configuration. SDK-inexpressible settings must fail before a
|
||||
// call reserves capacity or reaches the Agent; no defaults replace them.
|
||||
func validateAISnapshot(snapshot configread.Snapshot) error {
|
||||
if _, err := ai.BindCurrent(snapshot.Task, snapshot.Providers); err != nil {
|
||||
if _, err := ai.Bind(snapshot.Task, snapshot.Providers); err != nil {
|
||||
return fmt.Errorf("task %q approved AI snapshot: %w", snapshot.Task.TaskID, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -38,7 +38,7 @@ type ApprovedExecution struct {
|
||||
RingTimeout time.Duration
|
||||
MaxCallDuration time.Duration
|
||||
DialBefore time.Time
|
||||
AI ai.CurrentBound
|
||||
AI ai.Binding
|
||||
}
|
||||
|
||||
// GetLoadedSIP reports the adapter's observed state, not the desired static
|
||||
@@ -112,7 +112,7 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
|
||||
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "approved provider JSON is invalid")
|
||||
}
|
||||
bound, err := ai.BindCurrent(task, providers)
|
||||
bound, err := ai.Bind(task, providers)
|
||||
if err != nil {
|
||||
// Errors from externally supplied snapshots must not echo a prompt,
|
||||
// conversation, provider credential, or vendor endpoint into logs.
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
|
||||
if approved.AI.Mode != "full_ai" || approved.AI.LLM == nil || approved.AI.TTS == nil || approved.AI.Conversation.MaxTurns != 20 || approved.AI.LLM.Temperature == nil || *approved.AI.LLM.Temperature != 0 {
|
||||
t.Fatal("approved full-AI settings were not delivered to the Agent")
|
||||
}
|
||||
call, err := ai.NewCurrentCall(approved.AI, func(context.Context) error { hangups++; return nil })
|
||||
call, err := ai.NewCall(approved.AI, func(context.Context) error { hangups++; return nil })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func recordedMockFixture(t *testing.T, pcm []byte, maxDuration time.Duration) (*
|
||||
DispatcherID: "c046b893-8628-4589-ae50-619d049248a6", TenantID: 42,
|
||||
TaskID: "task-asr", CallerProfileID: "caller-mock", SourceEventID: "event-fixture", CallID: "event-fixture",
|
||||
Callee: "15003164745", SelectedTrunkID: "trunk-mock", MaxCallDuration: maxDuration,
|
||||
DialBefore: time.Now().Add(time.Minute), AI: ai.CurrentBound{
|
||||
Mode: "asr_only", Conversation: ai.CurrentConversation{SilenceTimeout: 100 * time.Millisecond, MaxDuration: maxDuration},
|
||||
DialBefore: time.Now().Add(time.Minute), AI: ai.Binding{
|
||||
Mode: "asr_only", Conversation: ai.ConversationConfig{SilenceTimeout: 100 * time.Millisecond, MaxDuration: maxDuration},
|
||||
},
|
||||
}
|
||||
delivery := &agent.RecordingDelivery{
|
||||
|
||||
@@ -49,13 +49,13 @@ func TestApprovedCallRunnerASROnlyHonorsSignedTimeout(t *testing.T) {
|
||||
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound, err := ai.BindCurrent(task, providers)
|
||||
bound, err := ai.Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaSession := &blockedApprovedMedia{}
|
||||
hangup := func(context.Context) error { return nil }
|
||||
call, err := ai.NewCurrentCall(bound, hangup)
|
||||
call, err := ai.NewCall(bound, hangup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,13 +98,13 @@ func TestApprovedCallRunnerSynthesizesOpeningBeforeMediaCapture(t *testing.T) {
|
||||
tts := providers["tts-example"]
|
||||
tts.Endpoint = server.URL
|
||||
providers[tts.ProviderRef] = tts
|
||||
bound, err := ai.BindCurrent(task, providers)
|
||||
bound, err := ai.Bind(task, providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaSession := &blockedApprovedMedia{}
|
||||
hangup := func(context.Context) error { return nil }
|
||||
call, err := ai.NewCurrentCall(bound, hangup)
|
||||
call, err := ai.NewCall(bound, hangup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func TestApprovedCallRunnerUsesExplicitIsolatedPipelineAndObservedMedia(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound := ai.CurrentBound{Mode: "asr_only", Conversation: ai.CurrentConversation{SilenceTimeout: 100 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1}}
|
||||
bound := ai.Binding{Mode: "asr_only", Conversation: ai.ConversationConfig{SilenceTimeout: 100 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1}}
|
||||
hangup := func(context.Context) error { return nil }
|
||||
mock, err := ai.NewApprovedMockPipeline(bound, ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "隔离 Mock 最终识别文本"}}}, hangup)
|
||||
if err != nil {
|
||||
@@ -136,14 +136,14 @@ func TestApprovedCallRunnerUsesExplicitIsolatedPipelineAndObservedMedia(t *testi
|
||||
}
|
||||
|
||||
func TestApprovedCallRunnerRejectsTypedNilPipelineBeforeMedia(t *testing.T) {
|
||||
var missing *ai.CurrentCall
|
||||
var missing *ai.Call
|
||||
session := callflow.NewMemorySession(bytes.Repeat([]byte{1, 0}, 1600))
|
||||
_, err := RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.CurrentBound{Mode: "asr_only"}, MaxCallDuration: time.Second}, session, func(context.Context) error { return nil }, missing)
|
||||
_, err := RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.Binding{Mode: "asr_only"}, MaxCallDuration: time.Second}, session, func(context.Context) error { return nil }, missing)
|
||||
if err == nil || session.Stats().ReceivedPackets != 0 {
|
||||
t.Fatalf("missing pipeline was allowed to consume call media: err=%v stats=%+v", err, session.Stats())
|
||||
}
|
||||
var missingMedia *callflow.MemorySession
|
||||
_, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.CurrentBound{Mode: "asr_only"}, MaxCallDuration: time.Second}, missingMedia, func(context.Context) error { return nil }, &ai.CurrentCall{})
|
||||
_, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.Binding{Mode: "asr_only"}, MaxCallDuration: time.Second}, missingMedia, func(context.Context) error { return nil }, &ai.Call{})
|
||||
if err == nil {
|
||||
t.Fatal("typed-nil media session was admitted")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestRecordingDeliveryRealMutualTLSAndLocalOSSCommitsOneSQLiteResult(t *test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound := ai.CurrentBound{Mode: "asr_only", Conversation: ai.CurrentConversation{SilenceTimeout: 100 * time.Millisecond, MaxDuration: time.Second}}
|
||||
bound := ai.Binding{Mode: "asr_only", Conversation: ai.ConversationConfig{SilenceTimeout: 100 * time.Millisecond, MaxDuration: time.Second}}
|
||||
hangup := func(context.Context) error { return nil }
|
||||
mock, err := ai.NewApprovedMockPipeline(bound, ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "隔离 Mock 最终识别文本"}}}, hangup)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user