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)
This commit is contained in:
1 parent
38b4527949
commit
71fefe5fc3
7 files changed
+602
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user