Files
AskaEth e684061e56 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)全绿。
2026-10-26 13:21:44 +08:00

216 lines
9.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"errors"
"log/slog"
"net/http"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// errPersonaBindingUnsupported 由 newSubSession 在调用方要求一个
// 绑定角色的会话、但注入的 history 存储无法绑定角色时返回。
// handler 把它映射为 501,而不是假装绑定已生效。
var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding")
// 本文件拥有网关的回合环境解析(DevDocs/design/22
// §2/§4.5):由哪个角色应答,表达为该回合运行的
// loop.SessionEnv 环境。
//
// 优先级(design/22 §2,自上而下):
// 1. 请求级覆盖 —— 本批次不实现(批次 1 暴露的是
// 会话级绑定;按请求覆盖是加性且不持久化的);
// 2. 会话级绑定 —— chat_sessions.persona_id,在此解析;
// 3. 渠道级映射 —— 批次 2(channels.json / channel_sessions);
// 4. 全局默认 —— 表达为 PersonaID == "",由 loop 解析到其
// 已配置的默认角色(Options.PersonaID)。这正是让每个
// 既有会话的行为与以往完全一致的原因。
//
// env 的其他字段不受影响:对这些面向 owner 的 Web/语音路径,
// ActorEnv 与 MemWritable 仍来自 loop.OwnerEnv()。这就是身份红线
// —— 谁可以做什么由 channel 的 actor 元数据决定(此处是一个 JWT
// owner),绝不是由所扮演的角色决定。人格选择只改变
// 提示词、记忆命名空间、情绪机与思考预算。
// sessionPersonaReader 是 HistoryStore 可选的更窄视图,用于读取
// 会话的绑定角色。*history.Store 实现了它;未实现的存储(测试
// 替身,或早于 design/22 的存储)只是得到 "" —— 全局默认 —— 因此
// 网关继续工作。
type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// sessionPersonaWriter 是 HistoryStore 可选的更窄视图,用于写入
// 会话的角色绑定。会话角色在创建时决定(design/22 §8-1),
// 因此不再有任何请求路径重绑 —— 创建走 subSessionCreator ——
// 而网关也不再对该接口做类型断言。它留在读取器
// 与创建器旁边,使存储的角色绑定表面集中记录在一处:
// *history.Store 仍实现 SetSessionPersona(design/22 §4.1),而会用它的
// 管理/owner 写路径是独立端点,不是 PATCH。
type sessionPersonaWriter interface {
SetSessionPersona(ctx context.Context, sessionID, personaID string) error
}
// subSessionCreator 是 HistoryStore 可选的更窄视图,用于创建一个
// 已绑定角色的会话(POST /sessions {personaId},design/22 §4.5)。
// *history.Store 实现了它;没有它的存储只能创建未绑定的
// 会话,因此显式要求角色的请求以 501 拒绝。
type subSessionCreator interface {
NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error)
}
// turnEnvResolver 为一个回合构建 loop 会话环境。它持有
// 解析所需的两个只读依赖,因此 HTTP/WS handler
// 与 hub 都经同一条代码路径解析角色(design/22 §4.5:一个
// 解析器,而不是每个入口各硬编码一份 loop.OwnerEnv())。
type turnEnvResolver struct {
history HistoryStore
personas PersonaStore
log *slog.Logger
}
// defaultPersonaID 返回库中被标记为默认的人格 id(管理端记忆检索的回退用)。
// 它转调解析器的同一实现,避免"两处各扫一遍 List()"而口径分叉。
func (s *Server) defaultPersonaID() string {
return defaultPersonaIDFrom(s.personas)
}
// owner 返回 sessionID 一个面向 owner 回合的每回合环境:
// 全访问 owner env(身份不变)加上该会话绑定的角色。
// 空白角色意味着「继承全局默认」;loop 执行那最后一次
// 回退(`Options.PersonaID`),因此 **loop 的输入**是绑定本身而不是生效人格。
//
// 🔴 呈现层(TTS 音色)要的是**生效人格**,不是绑定 —— 主会话绑定恒为空,把它当成
// 「没有角色」就会让主会话的声音不跟全局人格走。取生效人格请用 `effectivePersona`
// / `ownerWithPersona`(同一份数据、同一次会话读取)。
//
// 构造走 loop.OwnerEnvFor —— 与 IM 的主人私聊车道**同一个入口**
// (channelbridge/transcript.go 的 turnEnv):两条车道的 runEnv 因此逐字段同源,
// SessionEnv 将来新增字段时会一起拿到,而不是各自漂移。
func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv {
return loop.OwnerEnvFor(t.sessionPersona(ctx, sessionID))
}
// sessionPersona 返回绑定到 sessionID 的角色,会话继承全局
// 默认时返回 ""。未知会话、没有读取器的存储、或
// 读错误都退化为 ""(默认),而不是让该回合失败:
// 聊天绝不能被一次元数据查找阻塞,而该回退正是
// 多角色之前的行为。
func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string {
if t.history == nil || sessionID == "" {
return ""
}
reader, ok := t.history.(sessionPersonaReader)
if !ok {
return ""
}
personaID, err := reader.SessionPersona(ctx, sessionID)
if err != nil {
if t.log != nil && !errors.Is(err, history.ErrSessionNotFound) {
t.log.Debug("session persona read", "err", err, "session", sessionID)
}
return ""
}
return personaID
}
// 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 ""
}
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
}
}
return ""
}
// requireKnownPersona 拒绝注册表不认识的 personaId,返回
// 400 并返回 false。空 id 一律允许(它意味着「继承
// 全局默认」),而没有 persona 存储的网关无法校验,因此
// 那里接受该绑定,之后只是解析为未知角色。
func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool {
if personaID == "" || s.personas == nil {
return true
}
if _, ok := s.personas.Persona(personaID); !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown personaId %q", personaID))
return false
}
return true
}
// newSubSession 创建一个绑定到 personaID 的子会话,存储提供
// 角色感知创建器时使用它。没有它的存储只能创建
// 未绑定会话,因此显式的角色请求被拒绝(501),而不是
// 静默丢弃该绑定。
func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) {
creator, ok := s.history.(subSessionCreator)
if !ok {
if personaID != "" {
return "", errPersonaBindingUnsupported
}
return s.history.NewSubSession(ctx, ownerID)
}
return creator.NewSubSessionFor(ctx, ownerID, personaID)
}