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)全绿。
216 lines
9.9 KiB
Go
216 lines
9.9 KiB
Go
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)
|
||
}
|