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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user