fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分

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)全绿。
This commit is contained in:
AskaEth committed 2026-10-26 13:21:44 +08:00
1 parent 8820b0b316
commit e684061e56
6 files changed
+259 -20

No files matched your search

+10 -2
View File
@@ -16,9 +16,17 @@ package tts
// 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算
// 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。
//
// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次
// 🔴 **发音人按当次请求的生效人格现算**(主人 2026-10-26 的硬要求):每次
// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character,
// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。
// **没有任何跨请求缓存**。
//
// 补充限定(复审 r2 P2-1 要求收回原来的绝对说法):本包看到的 personaId 是**调用方
// 解析出来的生效人格**(`chat_sessions.persona_id` → 全局默认人格)。"换人格后仍用
// 老角色声音"在**本包内**不可能发生(无缓存 + 无兜底角色 + 未映射即拒绝);但若
// 调用方把"会话未绑定"当成"没有角色"直接传空串,主会话就会落到配置里的 `default`
// —— 那不是本包能判断的,因此**调用方必须传生效人格**(网关两处已按此修,
// `turnEnvResolver.effectivePersona`)。`default` 的唯一合法用途只剩"连默认人格
// 都没有"这一种情形。
import (
"context"