- 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)
96 lines
2.5 KiB
Go
96 lines
2.5 KiB
Go
package loader
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ErrRequiredField is wrapped by validation errors so callers can detect a
|
|
// persona that is missing required fields.
|
|
var ErrRequiredField = errors.New("persona: required field missing")
|
|
|
|
// Load reads and unmarshals a single persona YAML file at path. Missing required
|
|
// fields produce a %w-wrapped validation error; IO and parse failures are also
|
|
// wrapped with their source path.
|
|
func Load(path string) (*Persona, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: read %s: %w", path, err)
|
|
}
|
|
p := &Persona{}
|
|
if err := yaml.Unmarshal(data, p); err != nil {
|
|
return nil, fmt.Errorf("persona: parse %s: %w", path, err)
|
|
}
|
|
if err := p.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// LoadDir loads every *.yaml / *.yml file directly inside dir in lexical order.
|
|
// It fails atomically: the first file that errors aborts the whole load. The
|
|
// directory itself must exist.
|
|
func LoadDir(dir string) ([]*Persona, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: read dir %s: %w", dir, err)
|
|
}
|
|
files := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !isYAMLExt(filepath.Ext(e.Name())) {
|
|
continue
|
|
}
|
|
files = append(files, e.Name())
|
|
}
|
|
personas := make([]*Persona, 0, len(files))
|
|
for _, name := range files {
|
|
p, err := Load(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: dir %s: %w", dir, err)
|
|
}
|
|
personas = append(personas, p)
|
|
}
|
|
return personas, nil
|
|
}
|
|
|
|
// validate checks the persona for its required identity fields and reports all
|
|
// the missing ones at once.
|
|
func (p *Persona) validate() error {
|
|
missing := make([]string, 0, 5)
|
|
if p.ID == "" {
|
|
missing = append(missing, "id")
|
|
}
|
|
if p.Name == "" {
|
|
missing = append(missing, "name")
|
|
}
|
|
if p.CharacterRef == "" {
|
|
missing = append(missing, "characterRef")
|
|
}
|
|
if p.Identity.Role == "" {
|
|
missing = append(missing, "identity.role")
|
|
}
|
|
if p.Identity.SystemPrompt == "" {
|
|
missing = append(missing, "identity.systemPrompt")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("%w %q: %s", ErrRequiredField, p.ID, strings.Join(missing, ", "))
|
|
}
|
|
if err := p.ValidateThinking(); err != nil {
|
|
return err
|
|
}
|
|
if err := p.ValidateAwakening(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isYAMLExt reports whether ext is one of the accepted YAML extensions.
|
|
func isYAMLExt(ext string) bool {
|
|
return ext == ".yaml" || ext == ".yml"
|
|
}
|