Files
Cyrene_v2/internal/persona/loader/awakening_test.go
T
AskaEth 71fefe5fc3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,capacity): persona awakening 档位真生效 + 当前主角色已填(裁决 14.4.3)
- loader 新增可选 awakening 块(default_state/overrides{min_confidence,force_state}),载入期校验 + 归一化,
  非法值显式报错(拒绝「写了却静默无效」);缺失 = nil = 无该档位,不补默认
- 组合根把当前主角色档案的档位填进 capacity.Config.Awakening(未知档位名告警后按无档位处理),
  真的参与 Decide → applyAwakening(MinConfidence 优先于全局 0.6;force_state 钉死态)
- 当前主角色 cyrene.yaml 填 default_state=静息 + min_confidence=0.7(设计 §3 陪伴型取静息;
  0.7 压掉 L2 趋势(0.66)误唤醒、仍低于 L1 强信号(0.9),温柔陪伴不误打扰)
- 复核:build/vet 绿 + persona/capacity/cmd 包测试绿;端到端 4 例锁定「同回合有无档位判定相反」;
  变异两轮(yaml tag 改错 → 8 处红;解析但不接线 → 端到端红)均已还原
- 已知盲区(另派):per-role 注入未接线(loop/request.go 的 Input.Awakening);
  面板整块替换 persona 会抹掉 awakening 块(需 applyAwakeningEdit)
2026-10-04 12:49:40 +08:00

202 lines
6.6 KiB
Go
Raw 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 loader
import (
"errors"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// 一份满足必填字段的最小人格,用作 awakening 用例的载体。
const awakeningBaseYAML = `id: a
name: b
characterRef: c
identity:
role: r
systemPrompt: s
`
// TestLoad_AwakeningAbsentIsNil 锁定裁决 14.4.3 的「缺席就是没有」:角色档案里
// 没写 `awakening` 块 → 解析结果为空(nil),**不报错、也不补默认档位**;
// 序列化回去时也不会凭空长出该键。
func TestLoad_AwakeningAbsentIsNil(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if p.Awakening != nil {
t.Fatalf("Awakening = %+v, want nil(缺席就是没有)", p.Awakening)
}
out, err := yaml.Marshal(p)
if err != nil {
t.Fatalf("Marshal() error: %v", err)
}
if strings.Contains(string(out), "awakening") {
t.Fatalf("marshal output grew an awakening key: %s", out)
}
}
// TestLoad_AwakeningParsesAndNormalizes 覆盖两种写法:设计原文的中文档位名
// (静息/唤醒/全能)与决策层的规范态名(quiet/awake/full)都必须被接受,
// 并在载入期归一化成规范态名,供组合根直接映射。
func TestLoad_AwakeningParsesAndNormalizes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
yaml string
wantDefault string
wantForce string
wantMinConf float64
}{
{
name: "chinese_labels",
yaml: "awakening:\n default_state: 唤醒\n overrides:\n min_confidence: 0.7\n force_state: 全能\n",
wantDefault: AwakeningStateAwake,
wantForce: AwakeningStateFull,
wantMinConf: 0.7,
},
{
name: "canonical_names",
yaml: "awakening:\n default_state: full\n overrides:\n force_state: quiet\n",
wantDefault: AwakeningStateFull,
wantForce: AwakeningStateQuiet,
},
{
name: "default_state_only",
yaml: "awakening:\n default_state: 静息\n",
wantDefault: AwakeningStateQuiet,
},
{
name: "empty_overrides_block",
yaml: "awakening:\n default_state: awake\n overrides: {}\n",
wantDefault: AwakeningStateAwake,
},
{
name: "whitespace_and_case",
yaml: "awakening:\n default_state: ' AWAKE '\n",
wantDefault: AwakeningStateAwake,
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML+tt.yaml)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if p.Awakening == nil {
t.Fatal("Awakening = nil, want the parsed block")
}
if p.Awakening.DefaultState != tt.wantDefault {
t.Errorf("DefaultState = %q, want %q", p.Awakening.DefaultState, tt.wantDefault)
}
gotForce, gotMinConf := "", 0.0
if ov := p.Awakening.Overrides; ov != nil {
gotForce, gotMinConf = ov.ForceState, ov.MinConfidence
}
if gotForce != tt.wantForce {
t.Errorf("ForceState = %q, want %q", gotForce, tt.wantForce)
}
if gotMinConf != tt.wantMinConf {
t.Errorf("MinConfidence = %v, want %v", gotMinConf, tt.wantMinConf)
}
})
}
}
// TestLoad_AwakeningInvalidIsExplicitError 锁定 design/01 §2.1 的红线:写了却不
// 合法的档位/阈值必须**显式报错**(%w 包裹 ErrInvalidAwakening),绝不静默忽略。
func TestLoad_AwakeningInvalidIsExplicitError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
yaml string
want string
}{
{name: "unknown_default_state", yaml: "awakening:\n default_state: 睡觉\n", want: "default_state"},
{name: "english_typo", yaml: "awakening:\n default_state: awke\n", want: "default_state"},
{name: "unknown_force_state", yaml: "awakening:\n overrides:\n force_state: 暴力\n", want: "force_state"},
{name: "negative_confidence", yaml: "awakening:\n overrides:\n min_confidence: -0.1\n", want: "min_confidence"},
{name: "confidence_above_one", yaml: "awakening:\n overrides:\n min_confidence: 1.5\n", want: "min_confidence"},
{name: "confidence_nan", yaml: "awakening:\n overrides:\n min_confidence: .nan\n", want: "min_confidence"},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML+tt.yaml)
_, err := Load(path)
if err == nil {
t.Fatal("Load() want error, got nil")
}
if !errors.Is(err, ErrInvalidAwakening) {
t.Fatalf("error = %v, want ErrInvalidAwakening", err)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error %v does not name the offending field %q", err, tt.want)
}
})
}
}
// TestAwakening_MarshalRoundTrip 保证写盘路径不丢块:带块的人格序列化后含
// default_state / min_confidence,且能被同一套载入规则重新读回(管理面写人格
// 走同一个 loader,写出去的 YAML 必须必然可载入)。
func TestAwakening_MarshalRoundTrip(t *testing.T) {
t.Parallel()
p := &Persona{
ID: "a",
Name: "b",
CharacterRef: "c",
Identity: Identity{Role: "r", SystemPrompt: "s"},
Awakening: &AwakeningParams{
DefaultState: AwakeningStateAwake,
Overrides: &AwakeningOverrides{MinConfidence: 0.5, ForceState: AwakeningStateFull},
},
}
out, err := yaml.Marshal(p)
if err != nil {
t.Fatalf("Marshal() error: %v", err)
}
for _, key := range []string{"awakening:", "default_state: awake", "min_confidence: 0.5", "force_state: full"} {
if !strings.Contains(string(out), key) {
t.Fatalf("marshaled persona missing %q:\n%s", key, out)
}
}
path := writeFile(t, t.TempDir(), "a.yaml", string(out))
back, err := Load(path)
if err != nil {
t.Fatalf("reload error: %v", err)
}
if back.Awakening == nil || back.Awakening.DefaultState != AwakeningStateAwake {
t.Fatalf("reloaded Awakening = %+v, want default_state awake", back.Awakening)
}
if back.Awakening.Overrides == nil || back.Awakening.Overrides.ForceState != AwakeningStateFull {
t.Fatalf("reloaded Overrides = %+v, want force_state full", back.Awakening.Overrides)
}
}
// TestAwakening_ZeroValueIsNoOverride 是反证靶子:没有块时决策层看到的覆盖为零值
// (据此走全局默认),因此「把解析接上 / 断开」会在端到端用例里产生可观测差异
// —— 见 cmd/cyrene 的 awakening 端到端用例。
func TestAwakening_ZeroValueIsNoOverride(t *testing.T) {
t.Parallel()
var p Persona
if err := p.ValidateAwakening(); err != nil {
t.Fatalf("ValidateAwakening(no block) = %v, want nil", err)
}
if p.Awakening != nil {
t.Fatalf("Awakening = %+v, want nil", p.Awakening)
}
}