Files
Cyrene_v2/internal/services/gateway/admin_write_persona_blocks_test.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00

179 lines
8.2 KiB
Go

package gateway
import (
"net/http"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// 本文件锁定人格写路径的**非破坏性更新**语义:面板 PUT 是整块替换,而请求体只
// 带它认识的字段,因此「请求体里不出现的角色级块」必须保留已存值。曾经只有
// `thinking` 做了这件事,`awakening`(裁决 14.4.3 刚接入的档位)、`visual`、
// `triggerWords`、`emotion`(初始态/衰减/转移表)与 `affection`(好感度曲线)都会被
// 一次面板保存静默抹掉 —— 配置从档案里消失(design/01 §2.1 禁止静默无效的配置),
// 随仓库发布的 configs/personas/cyrene.yaml 正是受害者(它五个块都写了)。
// personaWithReadOnlyBlocks 造一个「手工写过 YAML」的角色:这一整类只读块都配齐。
func personaWithReadOnlyBlocks() *loader.Persona {
return &loader.Persona{
ID: "xilian", Name: "昔涟", CharacterRef: "xilian",
Identity: loader.Identity{Role: "r", SystemPrompt: "你是昔涟。"},
Awakening: &loader.AwakeningParams{
DefaultState: loader.AwakeningStateQuiet,
Overrides: &loader.AwakeningOverrides{MinConfidence: 0.7, ForceState: loader.AwakeningStateAwake},
},
Visual: loader.PersonaVisual{
Avatar: "/images/Cyrene_Avatar/x.png",
ChatBackground: loader.ChatBackground{Landscape: "l.png", Portrait: "p.png"},
},
TriggerWords: []string{"昔涟", "Cyrene"},
Emotion: loader.EmotionParams{
InitialState: emotion.Happy,
DecayRate: 0.05,
Transitions: map[emotion.EmotionState][]emotion.EmotionState{emotion.Happy: {emotion.Calm}},
},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
}
}
// registerStoredPersona 把该角色连同一个同名角色登记进注册表(写路径要求两者都在)。
func registerStoredPersona(t *testing.T, p *loader.Persona) *personareg.Registry {
t.Helper()
reg := personareg.New()
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: p.ID, Name: p.Name, PersonaID: p.ID}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
return reg
}
// TestPersonaUpdate_PreservesReadOnlyBlocks 是那次数据丢失的回归锁:面板 PUT 一次
// (请求体只带 name/role/systemPrompt),五个只读块必须**仍在** —— 在内存注册表里、
// 也在落盘 YAML 里(能重新载入出同一份配置)。
func TestPersonaUpdate_PreservesReadOnlyBlocks(t *testing.T) {
t.Chdir(t.TempDir())
reg := registerStoredPersona(t, personaWithReadOnlyBlocks())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{
"name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。",
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// 内存注册表:一个块都不能少(更新会据此重建情绪机与好感度曲线)。
p, ok := reg.Persona("xilian")
if !ok {
t.Fatal("persona disappeared from the registry")
}
assertReadOnlyBlocks(t, "registry", p)
// 落盘 YAML:面板保存的是真档案,重新载入必须得到同样的块。
reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml"))
if err != nil {
t.Fatalf("reload the saved persona: %v", err)
}
assertReadOnlyBlocks(t, "saved yaml", reloaded)
}
// TestPersonaUpdate_KeepsMemoryNamespaceFromRequest 守住那次修复的边界:
// `emotion.memoryNamespace` **不是**只读块(面板确实编辑它),因此保留其它情绪参数
// 不得把请求体里的新命名空间一并丢掉。
func TestPersonaUpdate_KeepsMemoryNamespaceFromRequest(t *testing.T) {
t.Chdir(t.TempDir())
stored := personaWithReadOnlyBlocks()
stored.Emotion.MemoryNamespace = "persona.old"
reg := registerStoredPersona(t, stored)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{
"name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。", "memoryNamespace": "persona.new",
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
p, _ := reg.Persona("xilian")
if p.Emotion.MemoryNamespace != "persona.new" {
t.Fatalf("memoryNamespace = %q, want the request's value (面板确实编辑它)", p.Emotion.MemoryNamespace)
}
if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 {
t.Fatalf("emotion params = %+v, want the stored initialState/decayRate", p.Emotion)
}
}
// assertReadOnlyBlocks 断言这一整类只读块逐项还在(含嵌套值)。
func assertReadOnlyBlocks(t *testing.T, where string, p *loader.Persona) {
t.Helper()
if p.Awakening == nil {
t.Fatalf("%s: awakening block was wiped by the update (档位数据丢失)", where)
}
if p.Awakening.DefaultState != loader.AwakeningStateQuiet {
t.Errorf("%s: awakening.default_state = %q, want quiet", where, p.Awakening.DefaultState)
}
if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence != 0.7 || ov.ForceState != loader.AwakeningStateAwake {
t.Errorf("%s: awakening.overrides = %+v, want 0.7/awake", where, p.Awakening.Overrides)
}
if p.Visual.Avatar != "/images/Cyrene_Avatar/x.png" ||
p.Visual.ChatBackground.Landscape != "l.png" || p.Visual.ChatBackground.Portrait != "p.png" {
t.Errorf("%s: visual = %+v, want the stored assets (头像/背景数据丢失)", where, p.Visual)
}
if len(p.TriggerWords) != 2 || p.TriggerWords[0] != "昔涟" || p.TriggerWords[1] != "Cyrene" {
t.Errorf("%s: triggerWords = %v, want [昔涟 Cyrene] (角色提醒词数据丢失)", where, p.TriggerWords)
}
if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 || len(p.Emotion.Transitions) != 1 {
t.Errorf("%s: emotion = %+v, want the stored initialState/decayRate/transitions (情绪机参数被重置)", where, p.Emotion)
}
if p.Affection.Delta != 10 || p.Affection.Max != 700 || p.Affection.Initial != 50 {
t.Errorf("%s: affection = %+v, want 50/0/700/10 (好感度曲线被重置)", where, p.Affection)
}
}
// TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate 直接锁两个编辑助手:非 nil 的
// 已存角色一律保留;没有已存角色(创建路径不会走到这里,但助手必须不 panic)得到
// 零值。保留的切片还要**复制**,否则改一处会把注册表里那份也改掉。
func TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate(t *testing.T) {
t.Parallel()
stored := personaWithReadOnlyBlocks()
if got := applyAwakeningEdit(stored); got != stored.Awakening {
t.Fatalf("applyAwakeningEdit(stored) = %+v, want the stored block", got)
}
// 请求体里的命名空间在保留其它情绪参数时不被覆盖。
p := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.new"}}
applyPersonaReadOnlyBlocks(p, stored)
if p.Emotion.MemoryNamespace != "persona.new" {
t.Fatalf("memoryNamespace = %q, want the request's value", p.Emotion.MemoryNamespace)
}
if p.Visual.Avatar != stored.Visual.Avatar || p.Emotion.InitialState != emotion.Happy ||
p.Affection.Delta != 10 || len(p.TriggerWords) != 2 {
t.Fatalf("read-only blocks were not preserved: %+v", p)
}
p.TriggerWords[0] = "改了"
if stored.TriggerWords[0] != "昔涟" {
t.Fatal("applyPersonaReadOnlyBlocks aliased the registry's slice")
}
// 创建路径(没有已存人格):助手必须什么都不做,而不是 panic。
created := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.cyrene"}}
applyPersonaReadOnlyBlocks(created, nil)
if created.Awakening != nil || created.Visual != (loader.PersonaVisual{}) || created.Affection != (loader.AffectionParams{}) {
t.Fatalf("a nil stored persona must leave the request's persona alone: %+v", created)
}
if applyAwakeningEdit(nil) != nil {
t.Fatal("applyAwakeningEdit(nil) must yield nil")
}
}