mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
fix(tts): unify voice resolution — dashboard settings affect tts tool (#1175)
The dashboard /tts page writes to system_configs[tts.<provider>.voice], but the LLM-invoked tts tool was only checking args > agent OtherConfig > builtin_tool_tenant_configs[tts].default_voice_id. Two different storage locations → the user's chosen voice was ignored, Edge defaulted to en-US-AriaNeural even when "HoaiMy" (vi-VN-HoaiMyNeural) was set correctly in the dashboard. Add system_configs as a 4th-level fallback so the dashboard becomes the single source of truth. - TtsTool gains SetSystemConfigStore(s) setter - resolveVoiceAndModel takes providerName + looks up tts.<provider>.voice/model when no higher-precedence source set - effectiveProvider resolved BEFORE voice/model so the right key is hit - Wired at gateway boot in cmd/gateway.go (after pgStores ready) - 4 unit tests covering: fallback, arg precedence, no-store, empty provider Verified against production trace 019e6036-44bb-703b-85fa-dee34f7ab2c0 where the tts tool was called with provider=edge, no voice arg, and defaulted to English instead of the configured Vietnamese voice.
This commit is contained in:
1 parent
cc9956c1f9
commit
e3dad3c60c
4 files changed
+119
-13
No files matched your search
@@ -278,6 +278,10 @@ func runGateway() {
|
||||
browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies))
|
||||
}
|
||||
|
||||
if ttsTool != nil && pgStores.SystemConfigs != nil {
|
||||
ttsTool.SetSystemConfigStore(pgStores.SystemConfigs)
|
||||
}
|
||||
|
||||
// Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'.
|
||||
// Summon goroutines don't survive process restart; stale DB rows would trap the UI.
|
||||
if pgStores.Agents != nil {
|
||||
|
||||
+34
-8
@@ -27,13 +27,20 @@ import (
|
||||
// Implements Tool + ContextualTool interfaces.
|
||||
// Per-call channel is read from ctx for thread-safety.
|
||||
type TtsTool struct {
|
||||
mu sync.RWMutex
|
||||
manager *tts.Manager
|
||||
vaultIntc *VaultInterceptor
|
||||
mu sync.RWMutex
|
||||
manager *tts.Manager
|
||||
vaultIntc *VaultInterceptor
|
||||
systemConfigs store.SystemConfigStore
|
||||
}
|
||||
|
||||
func (t *TtsTool) SetVaultInterceptor(v *VaultInterceptor) { t.vaultIntc = v }
|
||||
|
||||
func (t *TtsTool) SetSystemConfigStore(s store.SystemConfigStore) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.systemConfigs = s
|
||||
}
|
||||
|
||||
// NewTtsTool creates a TTS tool backed by the given manager.
|
||||
func NewTtsTool(mgr *tts.Manager) *TtsTool {
|
||||
return &TtsTool{manager: mgr}
|
||||
@@ -98,7 +105,7 @@ type agentAudioConfig struct {
|
||||
// resolveVoiceAndModel computes the effective voice + model IDs for the
|
||||
// request using the documented precedence order:
|
||||
//
|
||||
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > empty.
|
||||
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > system_configs > empty.
|
||||
//
|
||||
// Empty return values signal "use provider default" downstream — they are not
|
||||
// errors. Missing agent snapshot emits slog.Warn so operators can spot
|
||||
@@ -108,7 +115,7 @@ type agentAudioConfig struct {
|
||||
// tts_voice_id override (not from tool args or tenant defaults). Callers use
|
||||
// this flag to emit a warning when the voice is later found incompatible with
|
||||
// the selected provider.
|
||||
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
|
||||
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, providerName, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
|
||||
voice, model = argVoice, argModel
|
||||
|
||||
// Pull agent-level config from the dispatcher-injected snapshot.
|
||||
@@ -153,6 +160,22 @@ func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel s
|
||||
model = tenantCfg.DefaultModel
|
||||
}
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
systemConfigs := t.systemConfigs
|
||||
t.mu.RUnlock()
|
||||
if (voice == "" || model == "") && systemConfigs != nil && providerName != "" {
|
||||
if voice == "" {
|
||||
if v, err := systemConfigs.Get(ctx, "tts."+providerName+".voice"); err == nil && v != "" {
|
||||
voice = v
|
||||
}
|
||||
}
|
||||
if model == "" {
|
||||
if m, err := systemConfigs.Get(ctx, "tts."+providerName+".model"); err == nil && m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
}
|
||||
return voice, model, voiceFromAgent
|
||||
}
|
||||
|
||||
@@ -252,9 +275,6 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
argModel, _ := args["model"].(string)
|
||||
providerName, _ := args["provider"].(string)
|
||||
|
||||
// Resolve voice/model via args > agent (ctx snapshot) > tenant > default.
|
||||
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, argVoice, argModel)
|
||||
|
||||
// Read generic agent TTS params once; adapt PER-ATTEMPT below (Finding #1 CRITICAL).
|
||||
// Storing generic keys here so each fallback provider gets its own adapted copy.
|
||||
genericAgentParams := t.resolveAgentGenericTTSParams(ctx)
|
||||
@@ -264,6 +284,12 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
mgr := t.manager
|
||||
t.mu.RUnlock()
|
||||
|
||||
effectiveProvider := providerName
|
||||
if effectiveProvider == "" {
|
||||
effectiveProvider = t.resolvePrimary(ctx, mgr)
|
||||
}
|
||||
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, effectiveProvider, argVoice, argModel)
|
||||
|
||||
// Determine format based on channel (read from ctx — thread-safe)
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
opts := tts.Options{Voice: voice, Model: model}
|
||||
|
||||
@@ -35,7 +35,7 @@ func ctxWithAgentAudio(t *testing.T, voiceID, modelID string) context.Context {
|
||||
func TestResolveVoiceAndModel_ArgsWinOverAgent(t *testing.T) {
|
||||
tool := NewTtsTool(makeTTSManager("elevenlabs"))
|
||||
ctx := ctxWithAgentAudio(t, "AGENT_V", "AGENT_M")
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "ARG_V", "ARG_M")
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "ARG_V", "ARG_M")
|
||||
if v != "ARG_V" {
|
||||
t.Errorf("voice: got %q, want ARG_V (args must win)", v)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestResolveVoiceAndModel_AgentWinsOverTenantWhenArgsEmpty(t *testing.T) {
|
||||
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
|
||||
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
|
||||
})
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
|
||||
if v != "AGENT_V" {
|
||||
t.Errorf("voice: got %q, want AGENT_V (agent > tenant)", v)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
|
||||
ctx := WithBuiltinToolSettings(context.Background(), BuiltinToolSettings{
|
||||
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
|
||||
})
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
|
||||
if v != "TENANT_V" {
|
||||
t.Errorf("voice: got %q, want TENANT_V", v)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
|
||||
|
||||
func TestResolveVoiceAndModel_EmptyAllMeansDefault(t *testing.T) {
|
||||
tool := NewTtsTool(makeTTSManager("elevenlabs"))
|
||||
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "")
|
||||
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
|
||||
if v != "" {
|
||||
t.Errorf("voice: got %q, want empty (no sources → provider default)", v)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestResolveVoiceAndModel_PartialAgentConfig(t *testing.T) {
|
||||
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
|
||||
"tts": rawJSON(t, map[string]string{"default_model": "TENANT_M"}),
|
||||
})
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
|
||||
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
|
||||
if v != "AGENT_V" {
|
||||
t.Errorf("voice: got %q, want AGENT_V", v)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeSystemConfigStore struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeSystemConfigStore) Get(_ context.Context, key string) (string, error) {
|
||||
return f.data[key], nil
|
||||
}
|
||||
func (f *fakeSystemConfigStore) Set(_ context.Context, key, value string) error {
|
||||
if f.data == nil {
|
||||
f.data = map[string]string{}
|
||||
}
|
||||
f.data[key] = value
|
||||
return nil
|
||||
}
|
||||
func (f *fakeSystemConfigStore) Delete(_ context.Context, key string) error {
|
||||
delete(f.data, key)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeSystemConfigStore) List(_ context.Context) (map[string]string, error) {
|
||||
return f.data, nil
|
||||
}
|
||||
|
||||
func TestResolveVoiceAndModel_SystemConfigsFallback(t *testing.T) {
|
||||
tool := NewTtsTool(nil)
|
||||
sc := &fakeSystemConfigStore{data: map[string]string{
|
||||
"tts.edge.voice": "vi-VN-HoaiMyNeural",
|
||||
"tts.edge.model": "edge-tts-1",
|
||||
}}
|
||||
tool.SetSystemConfigStore(sc)
|
||||
|
||||
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
|
||||
if v != "vi-VN-HoaiMyNeural" {
|
||||
t.Errorf("voice fallback failed: got %q, want vi-VN-HoaiMyNeural", v)
|
||||
}
|
||||
if m != "edge-tts-1" {
|
||||
t.Errorf("model fallback failed: got %q, want edge-tts-1", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVoiceAndModel_ArgWinsOverSystemConfigs(t *testing.T) {
|
||||
tool := NewTtsTool(nil)
|
||||
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
|
||||
"tts.edge.voice": "vi-VN-HoaiMyNeural",
|
||||
}})
|
||||
|
||||
v, _, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "en-US-AriaNeural", "")
|
||||
if v != "en-US-AriaNeural" {
|
||||
t.Errorf("arg voice must win over system_configs: got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVoiceAndModel_NoStoreNoFallback(t *testing.T) {
|
||||
tool := NewTtsTool(nil)
|
||||
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
|
||||
if v != "" || m != "" {
|
||||
t.Errorf("expected empty fallback when no system_configs wired, got voice=%q model=%q", v, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVoiceAndModel_EmptyProviderSkipsFallback(t *testing.T) {
|
||||
tool := NewTtsTool(nil)
|
||||
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
|
||||
"tts..voice": "should-not-match",
|
||||
}})
|
||||
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "", "")
|
||||
if v != "" || m != "" {
|
||||
t.Errorf("empty provider must skip lookup, got voice=%q model=%q", v, m)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user