package main import ( "context" "os" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" ) // 端到端覆盖裁决 14.4.3 的接线:人格 YAML 的 `awakening` 块 → loader 解析 → // 注册表 → 组合根 buildCapacity → capacity.Config.Awakening → 真实判定结果。 // 之所以断言「判定结果」而不是只断言配置字段,是因为这个档位曾经只「写了没人读」 // ——只看字段填没填,抓不住「读侧被摘掉」的回归。 const awakeningPersonaBody = `name: 测试角色 characterRef: test-role identity: role: r systemPrompt: s ` // appWithPersonaYAML 造一个最小 app:注册表里装一个人格文件,并把它设为默认角色 // (等价于 owner.yaml 的 defaultPersona 指向它)。 func appWithPersonaYAML(t *testing.T, body string) *app { t.Helper() path := filepath.Join(t.TempDir(), "test-role.yaml") if err := os.WriteFile(path, []byte("id: test-role\n"+body), 0o600); err != nil { t.Fatalf("write persona yaml: %v", err) } return appWithPersonaFile(t, path) } // appWithPersonaFile 同上,但从给定的档案路径载入(用于校验随仓库发布的主角色档案)。 func appWithPersonaFile(t *testing.T, path string) *app { t.Helper() p, err := loader.Load(path) if err != nil { t.Fatalf("loader.Load(%s): %v", path, err) } 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, Default: true, PersonaID: p.ID, }); err != nil { t.Fatalf("RegisterCharacter: %v", err) } a := &app{log: testLog(), personas: reg} a.cfg = &config.Config{} a.cfg.Capacity.Enabled = true return a } // decide 跑一次真实判定(无覆盖的基线判定器与接线后的判定器都用它)。 func decide(dec *capacity.Decider, messages ...string) capacity.State { return dec.Decide(context.Background(), capacity.Input{Messages: messages}).State } // baselineDecider 是「没有接线 awakening」的对照判定器:同一个全局阈值,零覆盖。 func baselineDecider() *capacity.Decider { return capacity.New(capacity.Config{Enabled: true}, capacity.WithLogger(testLog())) } // TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn 端到端正向:档案里声明 // `default_state: 唤醒` 后,一个意图各层都拿不准的回合真的落到唤醒态——而同一 // 回合在没有该档位时是静息。这就是「档位真的参与判定」的证据。 func TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn(t *testing.T) { t.Parallel() a := appWithPersonaYAML(t, awakeningPersonaBody+"awakening:\n default_state: 唤醒\n") dec := a.buildCapacity() if got := decide(dec, "这个"); got != capacity.StateAwake { t.Fatalf("declared default_state=唤醒 → state %s, want awake", got) } if got := decide(baselineDecider(), "这个"); got != capacity.StateQuiet { t.Fatalf("baseline (no awakening) → state %s, want quiet", got) } } // TestCapacityAwakening_AbsentIsNoOverride 端到端反向:档案里没写该块时,解析 // 结果为空、不报错,组合根传出零值覆盖,判定结果与「完全没接线」的基线逐字一致。 func TestCapacityAwakening_AbsentIsNoOverride(t *testing.T) { t.Parallel() a := appWithPersonaYAML(t, awakeningPersonaBody) if aw := a.defaultAwakening(); !aw.IsZero() { t.Fatalf("defaultAwakening() = %+v, want zero(缺席就是没有)", aw) } yamlPersona, ok := a.defaultPersona() if !ok { t.Fatal("default persona not resolvable") } if yamlPersona.Awakening != nil { t.Fatalf("Awakening = %+v, want nil", yamlPersona.Awakening) } dec := a.buildCapacity() for _, tc := range [][]string{{"这个"}, {"帮我查一下资料", "继续"}} { if got, want := decide(dec, tc...), decide(baselineDecider(), tc...); got != want { t.Fatalf("决定 %v = %s, baseline = %s(无档位必须与基线一致)", tc, got, want) } } } // TestCapacityAwakening_RealDefaultPersonaIsWired 锁定主人点名的那件事:随仓库 // 发布的**当前主角色**档案(configs/personas/cyrene.yaml)真的声明了该档位,且 // 它确实改变判定——「温柔陪伴」倾向把靠上下文趋势猜出的任务(L2,置信 0.66) // 压回静息,而明确的办事指令(L1,置信 0.9)照旧唤醒。 func TestCapacityAwakening_RealDefaultPersonaIsWired(t *testing.T) { t.Parallel() a := appWithPersonaFile(t, filepath.Join("..", "..", "configs", "personas", "cyrene.yaml")) p, ok := a.defaultPersona() if !ok || p.Awakening == nil { t.Fatalf("cyrene.yaml 缺少 awakening 块(裁决 14.4.3 要求主角色填上);persona=%+v", p) } if p.Awakening.DefaultState != loader.AwakeningStateQuiet { t.Fatalf("cyrene default_state = %q, want %q(温柔陪伴倾向)", p.Awakening.DefaultState, loader.AwakeningStateQuiet) } if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence <= capacity.DefaultMinConfidence { t.Fatalf("cyrene overrides = %+v, want a threshold above the global %.2f(更不易被打扰)", p.Awakening.Overrides, capacity.DefaultMinConfidence) } dec := a.buildCapacity() trend := []string{"帮我查一下资料", "继续"} if got := decide(dec, trend...); got != capacity.StateQuiet { t.Fatalf("紧跟任务的话题延续 → %s, want quiet(人格级阈值压回静息)", got) } if got := decide(baselineDecider(), trend...); got != capacity.StateAwake { t.Fatalf("同一回合在无档位基线 → %s, want awake(证明差异来自该档位)", got) } if got := decide(dec, "帮我查一下资料"); got != capacity.StateAwake { t.Fatalf("明确的办事指令 → %s, want awake(抬高阈值不得把真任务也压掉)", got) } } // TestCapacityAwakening_NilRegistryAndUnknownState 守住两个降级口:人格注册表还 // 没装配时不 panic(返回无覆盖),未知档位名不落进决策层(宁可无覆盖并告警, // 也不让决策层收到非法态——非法态会命中「不受限工具集」分支)。 func TestCapacityAwakening_NilRegistryAndUnknownState(t *testing.T) { t.Parallel() var noReg *app = &app{log: testLog()} if aw := noReg.defaultAwakening(); !aw.IsZero() { t.Fatalf("no registry → %+v, want zero", aw) } // loader 会把中文字面量归一化成规范态名,所以组合根这一层只认规范名; // 未声明(空串)与任何未知值都映射为「无该档位」。 cases := map[string]capacity.State{ "": "", "quiet": capacity.StateQuiet, "awake": capacity.StateAwake, "full": capacity.StateFull, "静息": "", "AWAKE": "", "nonsense": "", } for raw, want := range cases { if got := awakeningState(raw); got != want { t.Errorf("awakeningState(%q) = %q, want %q", raw, got, want) } } // 一个「代码构造」的人格(绕过 loader):非法档位名不得静默生效。 reg := personareg.New() bad := &loader.Persona{ ID: "bad", Name: "bad", CharacterRef: "bad", Identity: loader.Identity{Role: "r", SystemPrompt: "s"}, Awakening: &loader.AwakeningParams{DefaultState: "睡觉"}, } if err := reg.RegisterPersona(bad); err != nil { t.Fatalf("RegisterPersona: %v", err) } if err := reg.RegisterCharacter(&personareg.Character{ID: "bad", Name: "bad", Default: true, PersonaID: "bad"}); err != nil { t.Fatalf("RegisterCharacter: %v", err) } a := &app{log: testLog(), personas: reg} if aw := a.defaultAwakening(); !aw.IsZero() { t.Fatalf("unrecognized state → %+v, want zero(按无档位处理)", aw) } }