feat(persona): 人格系统(YAML loader / 角色人格注册+记忆隔离 / 情绪状态机+好感度)

This commit is contained in:
Aska committed 2026-09-06 04:36:31 +08:00
1 parent 653c2489bf
commit 70fd915242
11 files changed
+1568

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
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, ", "))
}
return nil
}
// isYAMLExt reports whether ext is one of the accepted YAML extensions.
func isYAMLExt(ext string) bool {
return ext == ".yaml" || ext == ".yml"
}
+108
View File
@@ -0,0 +1,108 @@
// Package loader loads persona definitions from YAML files using gopkg.in/yaml.v3.
// A persona is the self-awareness/identity bound to a character and, per
// DevDocs/design/01, it also carries the emotion state-machine parameters, the
// affection curve, and a per-persona memory namespace (memory isolation).
package loader
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
)
// Default affection bounds and step, applied when a persona defines no
// affection curve.
const (
defaultAffectionMin = 0
defaultAffectionMax = 100
defaultAffectionInit = 50
defaultAffectionDelta = 5
)
// defaultMemoryNS is the namespace prefix used when a persona omits a concrete
// memory namespace; the full namespace becomes prefix + "." + persona id.
const defaultMemoryNS = "persona"
// Identity is the persona's self-awareness: the 人设 (who she is, e.g. a
// character role) plus the system prompt that carries that identity at runtime.
type Identity struct {
// Role is the 人设 — the characterization the persona assumes.
Role string `yaml:"role"`
// SystemPrompt is the identity block injected as the system prompt.
SystemPrompt string `yaml:"systemPrompt"`
}
// EmotionParams configures the persona's emotion state machine and its per-
// persona memory isolation namespace. Transitions may be empty, in which case
// the emotion package installs its default transition table.
type EmotionParams struct {
InitialState emotion.EmotionState `yaml:"initialState"`
DecayRate float64 `yaml:"decayRate"`
Transitions map[emotion.EmotionState][]emotion.EmotionState `yaml:"transitions"`
// MemoryNamespace is the memory isolation namespace for this persona.
MemoryNamespace string `yaml:"memoryNamespace"`
}
// AffectionParams defines this persona's affection curve bounds and the default
// step applied to an Adjust call that carries no delta.
type AffectionParams struct {
Initial int `yaml:"initial"`
Min int `yaml:"min"`
Max int `yaml:"max"`
Delta int `yaml:"delta"`
}
// Persona is a loaded persona definition. It describes who a character is (the
// identity, personality, speech style, catchphrase, background, values) and
// never restricts what that character can do.
type Persona struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
CharacterRef string `yaml:"characterRef"`
Identity Identity `yaml:"identity"`
Personality string `yaml:"personality"`
SpeechStyle string `yaml:"speechStyle"`
Catchphrase string `yaml:"catchphrase"`
Background string `yaml:"background"`
Values []string `yaml:"values"`
Emotion EmotionParams `yaml:"emotion"`
Affection AffectionParams `yaml:"affection"`
}
// EmotionConfig converts this persona's own fields into the params consumed by
// the emotion machine constructor.
func (p *Persona) EmotionConfig() emotion.Params {
return emotion.Params{
InitialState: p.Emotion.InitialState,
DecayRate: p.Emotion.DecayRate,
Transitions: p.Emotion.Transitions,
}
}
// MemoryNamespace returns the effective memory isolation namespace for this
// persona — the configured one, or a derived default of "persona.<id>" when none
// is set. The derived default guarantees per-persona isolation.
func (p *Persona) MemoryNamespace() string {
if p.Emotion.MemoryNamespace != "" {
return p.Emotion.MemoryNamespace
}
return defaultMemoryNS + "." + p.ID
}
// NewMachine builds the persona's emotion state machine from its config.
func (p *Persona) NewMachine() (*emotion.Machine, error) {
return emotion.NewMachine(p.EmotionConfig())
}
// NewAffection builds the persona's affection tracker from its curve config,
// applying default bounds when no curve was defined.
func (p *Persona) NewAffection() (*emotion.Affection, error) {
params := p.Affection
if params.Min == 0 && params.Max == 0 && params.Initial == 0 && params.Delta == 0 {
params = AffectionParams{
Initial: defaultAffectionInit,
Min: defaultAffectionMin,
Max: defaultAffectionMax,
Delta: defaultAffectionDelta,
}
}
return emotion.NewAffection(params.Initial, params.Min, params.Max)
}
+225
View File
@@ -0,0 +1,225 @@
package loader
import (
"errors"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
)
const validPersonaYAML = `id: xilian
name: 昔涟
characterRef: xilian-game
identity:
role: 全能小女友
systemPrompt: 你是昔涟,温柔体贴的虚拟伴侣。
personality: 温柔体贴
speechStyle: 轻声细语
catchphrase: 嘿嘿
background: 来自一款日常陪伴游戏。
values:
- 陪伴
- 真诚
emotion:
initialState: calm
decayRate: 0.4
transitions:
calm: [happy, sad]
happy: [excited, calm]
memoryNamespace: memory.xilian
affection:
initial: 60
min: 0
max: 100
delta: 5
`
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func TestLoad_ValidYAML(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if p.ID != "xilian" || p.Name != "昔涟" || p.CharacterRef != "xilian-game" {
t.Fatalf("identity fields = %q/%q/%q", p.ID, p.Name, p.CharacterRef)
}
if p.Identity.Role != "全能小女友" || p.Identity.SystemPrompt == "" {
t.Fatalf("Identity = %+v", p.Identity)
}
if p.Personality != "温柔体贴" || p.Catchphrase != "嘿嘿" {
t.Fatalf("personality/catchphrase = %q/%q", p.Personality, p.Catchphrase)
}
if len(p.Values) != 2 {
t.Fatalf("Values = %v, want 2 entries", p.Values)
}
if p.Emotion.InitialState != emotion.Calm {
t.Errorf("Emotion.InitialState = %q, want %q", p.Emotion.InitialState, emotion.Calm)
}
if p.Emotion.DecayRate != 0.4 {
t.Errorf("Emotion.DecayRate = %v, want 0.4", p.Emotion.DecayRate)
}
if p.Emotion.MemoryNamespace != "memory.xilian" {
t.Errorf("MemoryNamespace = %q, want memory.xilian", p.Emotion.MemoryNamespace)
}
if p.Affection.Initial != 60 || p.Affection.Min != 0 || p.Affection.Max != 100 {
t.Errorf("Affection = %+v", p.Affection)
}
}
func TestLoad_MissingRequiredFields(t *testing.T) {
t.Parallel()
cases := []struct {
name string
yaml string
want string
}{
{name: "no_id", yaml: "name: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "id"},
{name: "no_name", yaml: "id: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "name"},
{name: "no_characterRef", yaml: "id: a\nname: b\nidentity:\n role: r\n systemPrompt: s\n", want: "characterRef"},
{name: "no_systemPrompt", yaml: "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n", want: "systemPrompt"},
{name: "no_identity", yaml: "id: a\nname: b\ncharacterRef: c\n", want: "identity"},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "p.yaml", tt.yaml)
_, err := Load(path)
if err == nil {
t.Fatal("Load() want error, got nil")
}
if !errors.Is(err, ErrRequiredField) {
t.Fatalf("error = %v, want ErrRequiredField", err)
}
})
}
}
func TestLoad_FileMissing(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "nope.yaml")
if _, err := Load(path); err == nil {
t.Fatal("Load(missing) want error, got nil")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "bad.yaml", "id: [unclosed\n")
if _, err := Load(path); err == nil {
t.Fatal("Load(invalid yaml) want error, got nil")
}
}
func TestLoad_MemoryNamespaceDefault(t *testing.T) {
t.Parallel()
// No explicit namespace: derived as "persona.<id>".
yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n"
path := writeFile(t, t.TempDir(), "a.yaml", yaml)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if got := p.MemoryNamespace(); got != "persona.a" {
t.Fatalf("MemoryNamespace() = %q, want %q", got, "persona.a")
}
}
func TestLoad_NewAffectionDefaults(t *testing.T) {
t.Parallel()
yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n"
path := writeFile(t, t.TempDir(), "a.yaml", yaml)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
aff, err := p.NewAffection()
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
if aff.Value() != 50 || aff.Min() != 0 || aff.Max() != 100 {
t.Fatalf("default affection = %d in [%d,%d], want 50 in [0,100]", aff.Value(), aff.Min(), aff.Max())
}
}
func TestLoad_NewMachineUsesConfig(t *testing.T) {
t.Parallel()
path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
m, err := p.NewMachine()
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
if m.Current() != emotion.Calm {
t.Fatalf("machine Current() = %q, want %q", m.Current(), emotion.Calm)
}
// The configured table allows calm->happy.
if !m.CanTransition(emotion.Happy) {
t.Fatal("calm should reach happy per configured transitions")
}
}
func TestLoadDir_Batch(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeFile(t, dir, "b.yaml", validPersonaYAML)
writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n")
writeFile(t, dir, "notes.txt", "not yaml")
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(dir, "sub"), "nested.yaml", validPersonaYAML)
got, err := LoadDir(dir)
if err != nil {
t.Fatalf("LoadDir() error: %v", err)
}
if len(got) != 2 {
t.Fatalf("LoadDir() len = %d, want 2 (non-yaml + subdir ignored)", len(got))
}
if got[0].ID != "a" || got[1].ID != "xilian" {
t.Fatalf("LoadDir() order = %q/%q, want a/xilian (lexical)", got[0].ID, got[1].ID)
}
}
func TestLoadDir_MissingDir(t *testing.T) {
t.Parallel()
if _, err := LoadDir(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("LoadDir(missing) want error, got nil")
}
}
func TestLoadDir_AbortsOnBadFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n")
writeFile(t, dir, "b.yaml", "id: [unclosed\n")
if _, err := LoadDir(dir); err == nil {
t.Fatal("LoadDir(bad file) want error, got nil")
}
}