diff --git a/cmd/cyrene/wire_capacity.go b/cmd/cyrene/wire_capacity.go index a50c92e..beb7614 100644 --- a/cmd/cyrene/wire_capacity.go +++ b/cmd/cyrene/wire_capacity.go @@ -8,6 +8,7 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" ) @@ -25,6 +26,10 @@ import ( // unless it was explicitly asked for, and its verdict cache TTL // (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader // only carries scalars. +// +// The persona-level awakening override (裁决 14.4.3) is filled here from the +// current main persona's YAML block (see defaultAwakening); a persona that +// declares no block contributes the zero value, i.e. no override. func (a *app) buildCapacity() *capacity.Decider { if !a.cfg.Capacity.Enabled { return nil @@ -34,10 +39,17 @@ func (a *app) buildCapacity() *capacity.Decider { a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err) sets = nil } + awakening := a.defaultAwakening() + if !awakening.IsZero() { + logger.WithSource(a.log, "cmd/cyrene").Info("capacity awakening override applied", + "default_state", awakening.DefaultState, "min_confidence", awakening.MinConfidence, + "force_state", awakening.ForceState, "persona", a.defaultPersonaID()) + } cfg := capacity.Config{ Enabled: true, MinConfidence: a.cfg.Capacity.MinConfidence, ToolSets: sets, + Awakening: awakening, } if a.cfg.Capacity.L3Enabled { cfg.L3Enabled = true @@ -83,6 +95,65 @@ func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { return out, nil } +// defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`, +// 未配置时回落 `cyrene`)档案里的 `awakening` 块,映射为 capacity 的人格档位覆盖 +// (design/14 §3,裁决 14.4.3)。下列情形一律返回零值 = **无覆盖**,由决策层走 +// 全局默认,绝不在此处凭空补档位: +// - 尚未装配人格注册表(降级启动 / 单测构造的 app); +// - 该角色档案里没写 `awakening` 块(缺席就是没有); +// - 档位名不是规范态名(loader 已在载入期拒绝非法值;这里按无覆盖处理并 +// 告警——未知态若被塞进决策层,会落进「不受限工具集」分支)。 +// +// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。 +func (a *app) defaultAwakening() capacity.Awakening { + if a.personas == nil { + return capacity.Awakening{} + } + p, ok := a.defaultPersona() + if !ok || p == nil || p.Awakening == nil { + return capacity.Awakening{} + } + aw := capacity.Awakening{ + DefaultState: a.awakeningStateOf(p.Awakening.DefaultState, p.ID, "default_state"), + } + if ov := p.Awakening.Overrides; ov != nil { + aw.MinConfidence = ov.MinConfidence + aw.ForceState = a.awakeningStateOf(ov.ForceState, p.ID, "overrides.force_state") + } + return aw +} + +// awakeningStateOf 映射单个档位字面量;映射不了就**告警**再按「无该档位」处理。 +// 经 loader.Load 进来的人格不可能走到告警分支(载入期已归一化并拒绝非法值), +// 因此它是给「代码构造的人格」留的响铃:宁可吵一声,也不让配置静默失效 +// (design/01 §2.1 禁止静默无效的配置)。 +func (a *app) awakeningStateOf(raw, personaID, field string) capacity.State { + if raw == "" { + return "" + } + state := awakeningState(raw) + if state == "" { + logger.WithSource(a.log, "cmd/cyrene").Warn("capacity awakening state unrecognized, treated as no override", + "value", raw, "field", field, "persona", personaID) + } + return state +} + +// awakeningState 把 loader 归一化后的档位名映射到决策层的态。空串(未声明、或未知 +// 值)返回零值态——映射层不做默认填充,缺失的档位就是缺失。 +func awakeningState(raw string) capacity.State { + switch raw { + case loader.AwakeningStateQuiet: + return capacity.StateQuiet + case loader.AwakeningStateAwake: + return capacity.StateAwake + case loader.AwakeningStateFull: + return capacity.StateFull + default: + return "" + } +} + // onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken // transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` // frame. The loop only emits on a real state transition (never every round), and diff --git a/cmd/cyrene/wire_capacity_awakening_test.go b/cmd/cyrene/wire_capacity_awakening_test.go new file mode 100644 index 0000000..506086d --- /dev/null +++ b/cmd/cyrene/wire_capacity_awakening_test.go @@ -0,0 +1,196 @@ +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) + } +} diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index d21eb3c..478ae98 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -98,6 +98,17 @@ affection: delta: 10 # 对应旧版五级:初识(0)/熟悉(50)/亲近(150)/信赖(350)/羁绊(700) +# 唤醒档位(design/14 §3,裁决 14.4.3):人格倾向 + 对意图感知的覆盖规则。 +# 本角色是「温柔陪伴」型:倾向落在静息档(缺省也是静息),并把人格级唤醒阈值抬到 +# 全局默认 0.6 之上 —— 只有明确的办事指令(L1 强信号,置信 0.9)才唤醒, +# 靠上下文趋势猜出来的任务(L2,置信 0.66)不再打扰。宁可少唤醒,也不误打扰。 +# 三态取值:静息|唤醒|全能(也可写规范态名 quiet|awake|full);本块整体缺席 = +# 无覆盖(不是配置错误),非法取值会在载入期显式报错,不会被静默忽略。 +awakening: + default_state: 静息 + overrides: + min_confidence: 0.7 + # 角色视觉(此角色的头像/背景;只用于本角色,其他角色各自配置,绝不串用) visual: avatar: /images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png diff --git a/internal/persona/loader/awakening.go b/internal/persona/loader/awakening.go new file mode 100644 index 0000000..ce7b377 --- /dev/null +++ b/internal/persona/loader/awakening.go @@ -0,0 +1,116 @@ +package loader + +import ( + "errors" + "fmt" + "math" + "strings" +) + +// 唤醒档位(design/14 §3,裁决 14.4.3)的规范态名,与能力决策层 +// capacity.State 的三个态同名。persona YAML 里既可按设计原文写中文档位名 +// (静息/唤醒/全能),也可写这里的规范态名;载入期一律归一化为规范态名, +// 组合根据此直接映射到 capacity.Awakening,不做二次猜测。 +const ( + // AwakeningStateQuiet 是静息档(静·陪伴):保守落点,窄工具面。 + AwakeningStateQuiet = "quiet" + // AwakeningStateAwake 是唤醒档(醒·办事):识别到真实任务时唤醒工具。 + AwakeningStateAwake = "awake" + // AwakeningStateFull 是全能档(全·复杂任务):完整能力窗口。 + AwakeningStateFull = "full" +) + +// awakeningStateLabels 是设计文档写法的中文档位名到规范态名的映射。 +func awakeningStateLabels() map[string]string { + return map[string]string{ + "静息": AwakeningStateQuiet, + "唤醒": AwakeningStateAwake, + "全能": AwakeningStateFull, + } +} + +// ErrInvalidAwakening 由 awakening 块的校验错误包裹,使调用方能识别 +// 「块写了、但档位名或阈值不合法」,而不是被静默忽略 +// (design/01 §2.1 红线:不允许静默无效的配置)。 +var ErrInvalidAwakening = errors.New("persona: invalid awakening config") + +// AwakeningParams 是人格可选的 `awakening` 块(design/14 §3):人格倾向 +// (default_state)加上对意图感知结果的覆盖规则(overrides)。它只描述 +// 「唤醒倾向」这一档位,**不构成能力边界**——能力始终全局全能。 +// +// 语义:**块缺席 = 无覆盖**,不是配置错误(裁决 14.4.3「缺席就是没有」)。 +// 因此整块与子块都用指针:nil 明确表示「未声明」,本层**绝不**补任何默认档位, +// 没写的角色就由决策层走全局默认(静息倾向 + 全局阈值)。 +type AwakeningParams struct { + // DefaultState 是人格倾向落点(default_state):意图各层无定论或低置信时 + // 落到该态。空串 = 未声明该档位。 + DefaultState string `yaml:"default_state,omitempty"` + // Overrides 是覆盖规则(overrides);整块缺席时为 nil。 + Overrides *AwakeningOverrides `yaml:"overrides,omitempty"` +} + +// AwakeningOverrides 是 `awakening.overrides` 块的字段(design/14 §3)。 +type AwakeningOverrides struct { + // MinConfidence 是人格级唤醒阈值(min_confidence):正值优先于全局 + // CAPACITY_MIN_CONFIDENCE;低于阈值强制回落 default_state(保守、不打扰)。 + // 零值 = 未声明(沿用全局阈值)。 + MinConfidence float64 `yaml:"min_confidence,omitempty"` + // ForceState 设定即无条件钉死状态(force_state),无视意图感知输出。 + // 空串 = 未声明。 + ForceState string `yaml:"force_state,omitempty"` +} + +// ValidateAwakening 校验并**就地归一化**该人格的 awakening 块。非法档位名或 +// 越界阈值返回 %w 包裹的 ErrInvalidAwakening(明确报错,绝不静默忽略); +// 块缺席(nil)返回 nil——缺席就是没有该档位,不是错误。归一化后 +// DefaultState / Overrides.ForceState 一律是 AwakeningStateQuiet/Awake/Full, +// 组合根可直接映射成决策层的态。 +func (p *Persona) ValidateAwakening() error { + if p == nil || p.Awakening == nil { + return nil + } + state, err := normalizeAwakeningState(p.Awakening.DefaultState, "default_state") + if err != nil { + return err + } + p.Awakening.DefaultState = state + if p.Awakening.Overrides == nil { + return nil + } + force, err := normalizeAwakeningState(p.Awakening.Overrides.ForceState, "overrides.force_state") + if err != nil { + return err + } + p.Awakening.Overrides.ForceState = force + if err := validateAwakeningConfidence(p.Awakening.Overrides.MinConfidence); err != nil { + return err + } + return nil +} + +// normalizeAwakeningState 把档位字面量归一化为规范态名。空串(未声明)原样返回; +// 非法值返回 %w 包裹的 ErrInvalidAwakening,错误信息点名出错字段与可选值。 +func normalizeAwakeningState(raw, field string) (string, error) { + trimmed := strings.TrimSpace(raw) + switch strings.ToLower(trimmed) { + case "": + return "", nil + case AwakeningStateQuiet, AwakeningStateAwake, AwakeningStateFull: + return strings.ToLower(trimmed), nil + } + if canonical, ok := awakeningStateLabels()[trimmed]; ok { + return canonical, nil + } + return "", fmt.Errorf("%w: %s %q (want %s|%s|%s 或 静息|唤醒|全能)", + ErrInvalidAwakening, field, raw, AwakeningStateQuiet, AwakeningStateAwake, AwakeningStateFull) +} + +// validateAwakeningConfidence 校验人格级阈值:它是一次判定的置信度,取值域为 +// [0,1];负数、超过 1 或 NaN 都判为非法——越界阈值会让「是否唤醒」变成无意义的 +// 恒定值,宁可显式报错也不静默生效。 +func validateAwakeningConfidence(v float64) error { + if math.IsNaN(v) || v < 0 || v > 1 { + return fmt.Errorf("%w: overrides.min_confidence %v (want 0..1)", ErrInvalidAwakening, v) + } + return nil +} diff --git a/internal/persona/loader/awakening_test.go b/internal/persona/loader/awakening_test.go new file mode 100644 index 0000000..d37e8a8 --- /dev/null +++ b/internal/persona/loader/awakening_test.go @@ -0,0 +1,201 @@ +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) + } +} diff --git a/internal/persona/loader/load.go b/internal/persona/loader/load.go index 116fc61..97adf0e 100644 --- a/internal/persona/loader/load.go +++ b/internal/persona/loader/load.go @@ -83,6 +83,9 @@ func (p *Persona) validate() error { if err := p.ValidateThinking(); err != nil { return err } + if err := p.ValidateAwakening(); err != nil { + return err + } return nil } diff --git a/internal/persona/loader/persona.go b/internal/persona/loader/persona.go index 0e7e6f7..a5b65b6 100644 --- a/internal/persona/loader/persona.go +++ b/internal/persona/loader/persona.go @@ -146,7 +146,10 @@ type Persona struct { // Thinking is the optional reasoning/presentation config (design/01 §2.1); // omitted when the persona configures none, so no existing YAML gains noise. Thinking ThinkingParams `yaml:"thinking,omitempty"` - Visual PersonaVisual `yaml:"visual,omitempty"` + // Awakening 是该人格可选的唤醒档位覆盖(design/14 §3,裁决 14.4.3)。 + // nil = 角色档案里没写该块 = 没有该档位:不补默认值,由决策层走全局默认。 + Awakening *AwakeningParams `yaml:"awakening,omitempty"` + Visual PersonaVisual `yaml:"visual,omitempty"` // TriggerWords are the role-name words that, when a public-platform group // message mentions one, route the message into the participation PROBING // stage even without a direct @ (design/18 §2 触发②). Each persona defines its