- 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)
202 lines
6.6 KiB
Go
202 lines
6.6 KiB
Go
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)
|
||
}
|
||
}
|