From 91d23a15d33f346be7e81c2547bda6f8ae8a8c99 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 17:02:22 +0800 Subject: [PATCH] refactor(ai): name approved bindings by domain role --- .../saas-dispatcher-implementation.md | 2 + internal/ai/approved_mock.go | 4 +- internal/ai/approved_mock_test.go | 24 +++--- ...urrent_asr_sdk_test.go => asr_sdk_test.go} | 4 +- internal/ai/{current.go => binding.go} | 86 +++++++++---------- .../ai/{current_test.go => binding_test.go} | 8 +- ...rent_controls_test.go => controls_test.go} | 6 +- ...{current_limits_test.go => limits_test.go} | 8 +- ..._profile_test.go => media_profile_test.go} | 4 +- internal/ai/naming_test.go | 43 ++++++++++ ...urrent_opening_test.go => opening_test.go} | 30 +++---- .../ai/{current_pipeline.go => pipeline.go} | 26 +++--- ...rent_pipeline_test.go => pipeline_test.go} | 20 ++--- internal/callflow/approved.go | 2 +- internal/callflow/approved_test.go | 6 +- internal/dispatcher/ai.go | 2 +- internal/rpc/approved_execution.go | 4 +- .../rpc/approved_full_ai_integration_test.go | 2 +- internal/rpc/approved_recorded_mock_test.go | 4 +- internal/rpc/approved_runner_test.go | 16 ++-- .../recording_delivery_integration_test.go | 2 +- 21 files changed, 174 insertions(+), 129 deletions(-) rename internal/ai/{current_asr_sdk_test.go => asr_sdk_test.go} (95%) rename internal/ai/{current.go => binding.go} (75%) rename internal/ai/{current_test.go => binding_test.go} (96%) rename internal/ai/{current_controls_test.go => controls_test.go} (91%) rename internal/ai/{current_limits_test.go => limits_test.go} (91%) rename internal/ai/{current_media_profile_test.go => media_profile_test.go} (81%) create mode 100644 internal/ai/naming_test.go rename internal/ai/{current_opening_test.go => opening_test.go} (78%) rename internal/ai/{current_pipeline.go => pipeline.go} (88%) rename internal/ai/{current_pipeline_test.go => pipeline_test.go} (90%) diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 2fe19ae..6e0b752 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -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 冒充外部签收。 diff --git a/internal/ai/approved_mock.go b/internal/ai/approved_mock.go index d6f2cb4..74ef184 100644 --- a/internal/ai/approved_mock.go +++ b/internal/ai/approved_mock.go @@ -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 diff --git a/internal/ai/approved_mock_test.go b/internal/ai/approved_mock_test.go index c91289e..73934d0 100644 --- a/internal/ai/approved_mock_test.go +++ b/internal/ai/approved_mock_test.go @@ -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") } } diff --git a/internal/ai/current_asr_sdk_test.go b/internal/ai/asr_sdk_test.go similarity index 95% rename from internal/ai/current_asr_sdk_test.go rename to internal/ai/asr_sdk_test.go index d5d476c..dceae34 100644 --- a/internal/ai/current_asr_sdk_test.go +++ b/internal/ai/asr_sdk_test.go @@ -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) } diff --git a/internal/ai/current.go b/internal/ai/binding.go similarity index 75% rename from internal/ai/current.go rename to internal/ai/binding.go index 0d3e302..1db6bfb 100644 --- a/internal/ai/current.go +++ b/internal/ai/binding.go @@ -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, diff --git a/internal/ai/current_test.go b/internal/ai/binding_test.go similarity index 96% rename from internal/ai/current_test.go rename to internal/ai/binding_test.go index cb3bfb6..f2f323c 100644 --- a/internal/ai/current_test.go +++ b/internal/ai/binding_test.go @@ -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") } }) diff --git a/internal/ai/current_controls_test.go b/internal/ai/controls_test.go similarity index 91% rename from internal/ai/current_controls_test.go rename to internal/ai/controls_test.go index 11cc261..07530a4 100644 --- a/internal/ai/current_controls_test.go +++ b/internal/ai/controls_test.go @@ -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) } diff --git a/internal/ai/current_limits_test.go b/internal/ai/limits_test.go similarity index 91% rename from internal/ai/current_limits_test.go rename to internal/ai/limits_test.go index e3ca19e..90e6426 100644 --- a/internal/ai/current_limits_test.go +++ b/internal/ai/limits_test.go @@ -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) } diff --git a/internal/ai/current_media_profile_test.go b/internal/ai/media_profile_test.go similarity index 81% rename from internal/ai/current_media_profile_test.go rename to internal/ai/media_profile_test.go index ec218f7..0fbd295 100644 --- a/internal/ai/current_media_profile_test.go +++ b/internal/ai/media_profile_test.go @@ -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) } }) diff --git a/internal/ai/naming_test.go b/internal/ai/naming_test.go new file mode 100644 index 0000000..3338765 --- /dev/null +++ b/internal/ai/naming_test.go @@ -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 + }) + } +} diff --git a/internal/ai/current_opening_test.go b/internal/ai/opening_test.go similarity index 78% rename from internal/ai/current_opening_test.go rename to internal/ai/opening_test.go index bf81a91..6248273 100644 --- a/internal/ai/current_opening_test.go +++ b/internal/ai/opening_test.go @@ -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) } diff --git a/internal/ai/current_pipeline.go b/internal/ai/pipeline.go similarity index 88% rename from internal/ai/current_pipeline.go rename to internal/ai/pipeline.go index 43eb90f..a1487ab 100644 --- a/internal/ai/current_pipeline.go +++ b/internal/ai/pipeline.go @@ -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") } diff --git a/internal/ai/current_pipeline_test.go b/internal/ai/pipeline_test.go similarity index 90% rename from internal/ai/current_pipeline_test.go rename to internal/ai/pipeline_test.go index 332aebe..72e0151 100644 --- a/internal/ai/current_pipeline_test.go +++ b/internal/ai/pipeline_test.go @@ -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 { diff --git a/internal/callflow/approved.go b/internal/callflow/approved.go index bd9f8c6..dee5127 100644 --- a/internal/callflow/approved.go +++ b/internal/callflow/approved.go @@ -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, diff --git a/internal/callflow/approved_test.go b/internal/callflow/approved_test.go index 784bd20..8a6486f 100644 --- a/internal/callflow/approved_test.go +++ b/internal/callflow/approved_test.go @@ -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) diff --git a/internal/dispatcher/ai.go b/internal/dispatcher/ai.go index 98d863a..74e246b 100644 --- a/internal/dispatcher/ai.go +++ b/internal/dispatcher/ai.go @@ -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 diff --git a/internal/rpc/approved_execution.go b/internal/rpc/approved_execution.go index e1323f6..1257320 100644 --- a/internal/rpc/approved_execution.go +++ b/internal/rpc/approved_execution.go @@ -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. diff --git a/internal/rpc/approved_full_ai_integration_test.go b/internal/rpc/approved_full_ai_integration_test.go index 365d597..f077b35 100644 --- a/internal/rpc/approved_full_ai_integration_test.go +++ b/internal/rpc/approved_full_ai_integration_test.go @@ -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 } diff --git a/internal/rpc/approved_recorded_mock_test.go b/internal/rpc/approved_recorded_mock_test.go index b09f38e..5c23139 100644 --- a/internal/rpc/approved_recorded_mock_test.go +++ b/internal/rpc/approved_recorded_mock_test.go @@ -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{ diff --git a/internal/rpc/approved_runner_test.go b/internal/rpc/approved_runner_test.go index d77361f..f689a85 100644 --- a/internal/rpc/approved_runner_test.go +++ b/internal/rpc/approved_runner_test.go @@ -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") } diff --git a/internal/rpc/recording_delivery_integration_test.go b/internal/rpc/recording_delivery_integration_test.go index c4ec1e7..d589586 100644 --- a/internal/rpc/recording_delivery_integration_test.go +++ b/internal/rpc/recording_delivery_integration_test.go @@ -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 {