package tts // 本文件是**发音人的选角**:把「本次请求属于哪个角色(personaId)」映射成 // 「上游要用的 characterId」。它是主人 2026-10-26 那条硬要求的落点: // // 「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了别的角色(人格配置), // 还是用老角色的 TTS 角色」 // // 三条设计约束(违反其一就是那条病害复发): // // 1. **单一真源**:映射只来自 `Config.Personas`(面板文件 voice-tts.json)。 // `configs/personas/*.yaml` **不参与** —— 那是启动期静态配置,面板是运行期 // 可热调,两处必然漂移。 // 2. **不许定死**:代码里没有任何兜底 character(`DefaultCharacter` 只是文档示例)。 // 3. 🔴 **映射缺失时绝不借用别人的声音**:`Default` 只服务「personaId 解析不到」 // (空串),**不服务**「配了 Personas 但没有这个 persona 的键」——后者一律 // 拒绝合成,让文本照发。用 default 去顶就是"换了人格还用老角色的声音"。 // // 选角是**每次调用现算**的纯函数(读 ctx 里的 personaId + 配置),没有任何跨请求 // 缓存 —— 因此同一进程内先后服务两个 persona,第二次绝不会沿用第一次的 voice //(`selection_test.go` 的 `TestResolveIsNotCachedAcrossPersonas` 钉这一条)。 import ( "context" "errors" "strings" ) // Selection 是本次合成选中的发音人与它的**来源**(来源要进日志:出问题时能一眼 // 看出"这次是按哪条规则选的")。 type Selection struct { // PersonaID 是本次请求所属角色(空 = 解析不到)。 PersonaID string // Character 是映射到的上游角色 id(可能带 `char_` 前缀,按原样发出)。 Character string // Source 是选角来源:mapped(persona 映射命中)| default(解析不到 persona, // 用了配置里的 default)。 Source SelectionSource } // SelectionSource 是选角来源的闭集。 type SelectionSource string const ( // SourceMapped 表示命中 `Config.Personas` 里该 persona 的映射。 SourceMapped SelectionSource = "mapped" // SourceDefault 表示 personaId 解析不到,用了 `Config.Default`。 SourceDefault SelectionSource = "default" ) // 选角失败的原因(可分类 —— 面板/日志据此说清"为什么这次没声音")。 var ( // ErrNoCharacter 报告「解析不到 personaId,且没有配 default」⇒ 不合成。 ErrNoCharacter = errors.New("voice/tts: no character for this request") // ErrPersonaUnmapped 报告「这个 persona 没有映射」。它**绝不**回落到 default: // 那会让换了人格的她继续用别的角色的声音(主人点名的病害)。 ErrPersonaUnmapped = errors.New("voice/tts: persona has no character mapping") ) // Resolve 按 personaID 选出本次要用的 character。 // // 四条分支(与主人的验收清单逐条对应): // // personaID 有映射 ⇒ (Selection{mapped}, nil) // personaID 为空(连全局默认人格都没有)+ 配了 Default ⇒ (Selection{default}, nil) // personaID 为空 + 没配 Default ⇒ ("", ErrNoCharacter) // personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped) // // 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。 func (c Config) Resolve(personaID string) (Selection, error) { trimmed := strings.TrimSpace(personaID) if trimmed == "" { if c.Default == "" { return Selection{}, ErrNoCharacter } return Selection{Character: c.Default, Source: SourceDefault}, nil } character := strings.TrimSpace(c.Personas[trimmed]) if character == "" { return Selection{PersonaID: trimmed}, ErrPersonaUnmapped } return Selection{PersonaID: trimmed, Character: character, Source: SourceMapped}, nil } // personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。 type personaKey struct{} // WithPersonaID 返回携带本次请求**生效人格** id 的 ctx。空串是合法的,但它的唯一 // 合法成因是"连全局默认人格都没有"——**不是**"会话未绑定"(未绑定但存在全局默认人格 // 时,调用方应传全局默认人格)。传空串只在 Resolve 层意味着"走 default 分支"。 // // 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出 // `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有 // 第二个"会话 → 角色"的解析器(design/22 §4.5:一个解析器)。 func WithPersonaID(ctx context.Context, personaID string) context.Context { return context.WithValue(ctx, personaKey{}, strings.TrimSpace(personaID)) } // PersonaIDFromContext 返回 ctx 里携带的角色 id(未设置时为 "")。 func PersonaIDFromContext(ctx context.Context) string { if ctx == nil { return "" } if v, ok := ctx.Value(personaKey{}).(string); ok { return v } return "" }