P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是 「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的 bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格" ⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人, 换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。 修法: - turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""), 默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它); - ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的 effective —— WS 语音回合为选音色不新增任何 I/O; - REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。 因此 `default` 只剩"连默认人格都没有"这一种用途。 - service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底 角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。 回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)—— 未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音; 变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。 建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成 tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码; 用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。 P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。 go build ./... + go vet ./... + go test(含 -race)全绿。
143 lines
5.8 KiB
Go
143 lines
5.8 KiB
Go
package gateway
|
||
|
||
// 本文件是复审 r2 P2-1 的**回归锁**:主会话(`web`)的 `persona_id` 恒为空且没有
|
||
// 重绑路径,而"空"的真实语义是「**继承全局默认人格**」(`loop/persona.go` 回退到
|
||
// `Options.PersonaID`;前端 `lib/persona.ts` 的 `bound ?? defaultPersona(personas)`)。
|
||
//
|
||
// 若把空当成"没有生效人格",主会话聊天是 furina、声音却是 `voice-tts.json` 的
|
||
// `default` 那个人 —— 主人逐字抱怨的那件事,而且发生在唯一能给普通聊天出声的路径上。
|
||
//
|
||
// 两条用例分别钉住 REST 与 WS 两条既有出站口;变异(把 effectivePersona 退回
|
||
// 「空 = 无」)必须打红。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||
)
|
||
|
||
// defaultPersonaRegistry 是一个"全局默认人格 = furina"的人格库。
|
||
func defaultPersonaRegistry() PersonaStore {
|
||
return &stubPersonas{list: []personareg.Character{
|
||
{ID: "Cyrene", Name: "Cyrene", PersonaID: "cyrene"},
|
||
{ID: "芙宁娜", Name: "芙宁娜", PersonaID: "furina", Default: true},
|
||
}}
|
||
}
|
||
|
||
// unboundHistory 模拟**主会话**:SessionPersona 恒报空串(继承全局默认)。
|
||
type unboundHistory struct{ *fakeHistory }
|
||
|
||
func (h unboundHistory) SessionPersona(context.Context, string) (string, error) { return "", nil }
|
||
|
||
// TestSpeechEndpointUnboundSessionInheritsGlobalPersona 是复审那条用例的复现:
|
||
// 主会话(未绑定)+ 全局默认人格 furina + `default` 配成 cyrene 的角色 ⇒
|
||
// 必须用 **furina 的** 声音(不是 default)。
|
||
func TestSpeechEndpointUnboundSessionInheritsGlobalPersona(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
rec := &vocalRecorder{}
|
||
// default 故意指向 cyrene 的角色:修好之前这里会被选中(复审报的红)。
|
||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||
Personas: personaCharacters,
|
||
Default: personaCharacters["cyrene"],
|
||
}, &silentBase{}))
|
||
env.srv.envResolver = turnEnvResolver{
|
||
history: unboundHistory{fakeHistory: &fakeHistory{}},
|
||
personas: defaultPersonaRegistry(),
|
||
log: env.srv.log,
|
||
}
|
||
token := env.token(t, auth.RoleOwner)
|
||
|
||
// 主会话就是 "web"(与 history.MainSessionID 同值;这里只声明"未绑定"这一事实)。
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好,我是苏梦。", "sessionId": "web",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
got := rec.Voices()
|
||
want := personaCharacters["furina"]
|
||
if len(got) != 1 || got[0] != want {
|
||
t.Fatalf("主会话(继承全局人格 furina)用了 %v 的声音,want [%s] —— 这正是主人抱怨的那件事", got, want)
|
||
}
|
||
if string(body) != string(fakeWAV(want)) {
|
||
t.Fatalf("响应体不是 furina 的声音: %q", string(body))
|
||
}
|
||
}
|
||
|
||
// TestVoiceOutputUnboundSessionInheritsGlobalPersona 是同一条口径在 WS 口上的复现:
|
||
// 帧里的音频必须来自**全局默认人格**的角色。
|
||
func TestVoiceOutputUnboundSessionInheritsGlobalPersona(t *testing.T) {
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
|
||
hb.envResolver = turnEnvResolver{
|
||
history: unboundHistory{fakeHistory: &fakeHistory{}},
|
||
personas: defaultPersonaRegistry(),
|
||
log: sourcedLog(nil),
|
||
}
|
||
rec := &vocalRecorder{}
|
||
hb.voice = emotionTTSService(t, rec, voicetts.Config{
|
||
Personas: personaCharacters,
|
||
Default: personaCharacters["cyrene"],
|
||
}, &silentBase{})
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)})
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != "voice_output" {
|
||
t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev)
|
||
}
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data = %T", ev.Data)
|
||
}
|
||
raw, err := base64.StdEncoding.DecodeString(asString(data["audio"]))
|
||
if err != nil || len(raw) == 0 {
|
||
t.Fatalf("帧里没有可解码的音频(err=%v)", err)
|
||
}
|
||
want := personaCharacters["furina"]
|
||
if string(raw) != string(fakeWAV(want)) {
|
||
t.Fatalf("主会话的帧里不是 furina 的声音: %q", string(raw))
|
||
}
|
||
if got := rec.Voices(); len(got) != 1 || got[0] != want {
|
||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, want)
|
||
}
|
||
}
|
||
|
||
// TestSpeechEndpointPersonaUnmappedErrorCode 钉住复审 r2 建议 3:状态码仍是 500
|
||
// (服务端配置缺失,不是客户端的错),但**错误码可区分** —— 不许跟一般合成失败混成一个码。
|
||
func TestSpeechEndpointPersonaUnmappedErrorCode(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
env.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{
|
||
Personas: personaCharacters,
|
||
}, &silentBase{}))
|
||
env.srv.envResolver = turnEnvResolver{
|
||
history: defaultPersonaRegistryHistory(),
|
||
personas: defaultPersonaRegistry(),
|
||
log: env.srv.log,
|
||
}
|
||
token := env.token(t, auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好", "sessionId": "web:sub-7",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500(服务端配置缺失)", resp.StatusCode)
|
||
}
|
||
code := errorCodeOf(t, body)
|
||
if code != "tts_persona_unmapped" {
|
||
t.Fatalf("error.code = %q, want tts_persona_unmapped(body=%s)", code, body)
|
||
}
|
||
}
|
||
|
||
// defaultPersonaRegistryHistory 是一个"会话已绑定到没有 TTS 映射的人格"的存储。
|
||
func defaultPersonaRegistryHistory() HistoryStore {
|
||
return personaHistory{fakeHistory: &fakeHistory{}, persona: "stranger"}
|
||
}
|