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

+69 -8
View File
@@ -72,10 +72,20 @@ type turnEnvResolver struct {
log *slog.Logger
}
// defaultPersonaID 返回库中被标记为默认的人格 id(管理端记忆检索的回退用)。
// 它转调解析器的同一实现,避免"两处各扫一遍 List()"而口径分叉。
func (s *Server) defaultPersonaID() string {
return defaultPersonaIDFrom(s.personas)
}
// owner 返回 sessionID 一个面向 owner 回合的每回合环境:
// 全访问 owner env(身份不变)加上该会话绑定的角色。
// 空白角色意味着「继承全局默认」;loop 执行那最后一次
// 回退,因此网关永远不必知道默认角色的 id。
// 回退(`Options.PersonaID`),因此 **loop 的输入**是绑定本身而不是生效人格。
//
// 🔴 呈现层(TTS 音色)要的是**生效人格**,不是绑定 —— 主会话绑定恒为空,把它当成
// 「没有角色」就会让主会话的声音不跟全局人格走。取生效人格请用 `effectivePersona`
// / `ownerWithPersona`(同一份数据、同一次会话读取)。
//
// 构造走 loop.OwnerEnvFor —— 与 IM 的主人私聊车道**同一个入口**
// (channelbridge/transcript.go 的 turnEnv):两条车道的 runEnv 因此逐字段同源,
@@ -107,15 +117,66 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s
return personaID
}
// defaultPersonaID 返回库中默认角色的人格 id,未接线人格
// 存储或没有角色被标记为默认时返回 ""。它是
// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时
// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。
func (s *Server) defaultPersonaID() string {
if s.personas == nil {
// turnPersona 是"这一回合由哪个角色应答"的**单次解析**结果:
//
// - Binding 是会话的**显式**绑定(`chat_sessions.persona_id`;空 = 继承全局默认);
// - Effective 是**生效人格** —— 会话有绑定就取绑定,否则取全局默认人格。
//
// 两者刻意分开:`loop.SessionEnv.PersonaID` 要的是**绑定**(空由 loop 自己回退到
// `Options.PersonaID`,那是既有的唯一回退点,不许在这里替它决定);而**呈现层**
// (TTS 音色、前端头像/名字/心情)要的是**生效人格** —— 前端 `lib/persona.ts` 的
// `bound ?? defaultPersona(personas)` 就是这个口径。
//
// 🔴 为什么必须有 Effective:主会话(`web`)的 persona_id **恒为空且没有重绑路径**
// (`session_persona_test.go`),而"空"的真实语义是「继承全局默认人格」。若把空
// 当成"没有生效人格",主会话换全局人格后声音就不会跟 —— 这正是主人抱怨的那件事,
// 而且发生在唯一能给普通聊天出声的路径上(复审 r2 P2-1)。
type turnPersona struct {
// Binding 是会话显式绑定(可能为空)。
Binding string
// Effective 是生效人格(会话绑定 → 全局默认人格 → 空)。
Effective string
}
// resolvePersona 一次解析出 Binding 与 Effective(**一次**会话读取,零重复 I/O)。
func (t turnEnvResolver) resolvePersona(ctx context.Context, sessionID string) turnPersona {
binding := t.sessionPersona(ctx, sessionID)
if binding != "" {
return turnPersona{Binding: binding, Effective: binding}
}
return turnPersona{Effective: t.defaultPersonaID()}
}
// effectivePersona 返回该会话的**生效人格**:有绑定取绑定,否则取全局默认人格。
// 两者都没有时返回 ""(= 连默认人格都没配),由调用方按"没有角色"处置。
func (t turnEnvResolver) effectivePersona(ctx context.Context, sessionID string) string {
return t.resolvePersona(ctx, sessionID).Effective
}
// ownerWithPersona 与 owner 同义,但**顺带**返回这次解析出的 Binding/Effective ——
// 语音收尾需要生效人格选音色,而 owner 已经在读会话,因此这里复用同一次读取
// (WS 语音回合不为选音色多查一次库)。
func (t turnEnvResolver) ownerWithPersona(ctx context.Context, sessionID string) (loop.SessionEnv, turnPersona) {
tp := t.resolvePersona(ctx, sessionID)
return loop.OwnerEnvFor(tp.Binding), tp
}
// defaultPersonaID 返回库中被标记为默认的人格 id,未接线人格存储或没有角色被标记为
// 默认时返回 ""。它是 `owner.yaml:defaultPersona` 在网关侧的**唯一**视图 —— 与
// `(*Server).defaultPersonaID` 同源(后者直接转调这里,避免两处各写一份扫描)。
func (t turnEnvResolver) defaultPersonaID() string {
if t.personas == nil {
return ""
}
for _, c := range s.personas.List() {
return defaultPersonaIDFrom(t.personas)
}
// defaultPersonaIDFrom 是上面那个口径的共享实现(`*Server` 与解析器共用)。
func defaultPersonaIDFrom(personas PersonaStore) string {
if personas == nil {
return ""
}
for _, c := range personas.List() {
if c.Default && c.PersonaID != "" {
return c.PersonaID
}