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

+104
View File
@@ -0,0 +1,104 @@
package emotion
import (
"fmt"
"sync"
)
// Affection tracks a persona's affection value (好感度) bounded to [min, max].
// All methods are safe for concurrent use and the value is clamped on every
// mutation so it never escapes its bounds.
type Affection struct {
mu sync.RWMutex
value int
min int
max int
}
// NewAffection builds an Affection bounded to [min, max]. It fails when the
// bounds are inverted or the initial value lies outside them.
func NewAffection(initial, min, max int) (*Affection, error) {
if min > max {
return nil, fmt.Errorf("emotion: affection min %d exceeds max %d", min, max)
}
if initial < min || initial > max {
return nil, fmt.Errorf("emotion: affection initial %d outside [%d, %d]", initial, min, max)
}
return &Affection{value: initial, min: min, max: max}, nil
}
// Value returns the current affection.
func (a *Affection) Value() int {
a.mu.RLock()
defer a.mu.RUnlock()
return a.value
}
// Min returns the lower bound.
func (a *Affection) Min() int {
a.mu.RLock()
defer a.mu.RUnlock()
return a.min
}
// Max returns the upper bound.
func (a *Affection) Max() int {
a.mu.RLock()
defer a.mu.RUnlock()
return a.max
}
// Increase adds delta to the value, clamping at the upper bound. Deltas that
// are not positive leave the value unchanged. The resulting value is returned.
func (a *Affection) Increase(delta int) int {
a.mu.Lock()
defer a.mu.Unlock()
if delta > 0 {
a.value = clampInt(a.value+delta, a.min, a.max)
}
return a.value
}
// Decrease subtracts delta from the value, clamping at the lower bound. Deltas
// that are not positive leave the value unchanged. The resulting value is
// returned.
func (a *Affection) Decrease(delta int) int {
a.mu.Lock()
defer a.mu.Unlock()
if delta > 0 {
a.value = clampInt(a.value-delta, a.min, a.max)
}
return a.value
}
// Adjust applies a signed delta to the value — a positive delta increases it,
// a negative delta decreases it — clamping to the bounds. It is the event-
// driven entry point (随事件变化): before and after values are returned so the
// caller can observe the full change.
func (a *Affection) Adjust(delta int) (before, after int) {
a.mu.Lock()
defer a.mu.Unlock()
before = a.value
a.value = clampInt(a.value+delta, a.min, a.max)
return before, a.value
}
// Set forces the value to v, clamped to the bounds. The resulting value is
// returned.
func (a *Affection) Set(v int) int {
a.mu.Lock()
defer a.mu.Unlock()
a.value = clampInt(v, a.min, a.max)
return a.value
}
// clampInt restricts v into [lo, hi].
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,84 @@
package emotion
import "testing"
func TestNewAffection_RejectsBadBounds(t *testing.T) {
t.Parallel()
if _, err := NewAffection(50, 100, 0); err == nil {
t.Fatal("NewAffection() should error when min > max")
}
if _, err := NewAffection(200, 0, 100); err == nil {
t.Fatal("NewAffection() should error when initial > max")
}
if _, err := NewAffection(-1, 0, 100); err == nil {
t.Fatal("NewAffection() should error when initial < min")
}
}
func TestAffection_IncreaseAndDecreaseClamp(t *testing.T) {
t.Parallel()
a, err := NewAffection(50, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
if got := a.Increase(30); got != 80 {
t.Fatalf("Increase(30) = %d, want 80", got)
}
if got := a.Increase(100); got != 100 {
t.Fatalf("Increase(100) clamped = %d, want 100", got)
}
if got := a.Decrease(40); got != 60 {
t.Fatalf("Decrease(40) = %d, want 60", got)
}
if got := a.Decrease(1000); got != 0 {
t.Fatalf("Decrease(1000) clamped = %d, want 0", got)
}
}
func TestAffection_IncreaseIgnoresNonPositiveDelta(t *testing.T) {
t.Parallel()
a, err := NewAffection(50, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
if got := a.Increase(0); got != 50 {
t.Fatalf("Increase(0) = %d, want 50", got)
}
if got := a.Decrease(-5); got != 50 {
t.Fatalf("Decrease(-5) = %d, want 50", got)
}
}
func TestAffection_AdjustSigned(t *testing.T) {
t.Parallel()
a, err := NewAffection(40, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
before, after := a.Adjust(30)
if before != 40 || after != 70 {
t.Fatalf("Adjust(+30) before/after = %d/%d, want 40/70", before, after)
}
before, after = a.Adjust(-100)
if before != 70 || after != 0 {
t.Fatalf("Adjust(-100) before/after = %d/%d, want 70/0", before, after)
}
}
func TestAffection_BoundsReported(t *testing.T) {
t.Parallel()
a, err := NewAffection(10, 0, 200)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
if a.Min() != 0 || a.Max() != 200 {
t.Fatalf("Min/Max = %d/%d, want 0/200", a.Min(), a.Max())
}
}
+90
View File
@@ -0,0 +1,90 @@
package emotion
import (
"errors"
"fmt"
"sync"
)
// ErrUnknownPersona is returned by Manager when an operation targets a persona
// id that was never registered.
var ErrUnknownPersona = errors.New("emotion: unknown persona")
// entry binds a persona's machine and affection together so whatever happens to
// one persona never leaks into another.
type entry struct {
machine *Machine
affection *Affection
}
// Manager owns per-persona emotion and affection. Each persona id maps to its
// own Machine and Affection, which is what makes emotion follow the persona
// rather than a behavior mode. All methods are safe for concurrent use.
type Manager struct {
mu sync.RWMutex
items map[string]*entry
}
// NewManager returns an empty Manager.
func NewManager() *Manager {
return &Manager{items: make(map[string]*entry)}
}
// Set registers (or replaces) the machine and affection for id.
func (m *Manager) Set(id string, machine *Machine, affection *Affection) {
m.mu.Lock()
defer m.mu.Unlock()
m.items[id] = &entry{machine: machine, affection: affection}
}
// Get returns the machine and affection for id, and whether it is registered.
func (m *Manager) Get(id string) (*Machine, *Affection, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.items[id]
if !ok {
return nil, nil, false
}
return e.machine, e.affection, true
}
// Remove deletes the entry for id, reporting whether an entry was present.
func (m *Manager) Remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.items[id]; !ok {
return false
}
delete(m.items, id)
return true
}
// Transition moves the persona identified by id into the given emotion state.
func (m *Manager) Transition(id string, to EmotionState) error {
machine, _, ok := m.Get(id)
if !ok {
return fmt.Errorf("emotion: transition for %q: %w", id, ErrUnknownPersona)
}
if err := machine.Transition(to); err != nil {
return fmt.Errorf("emotion: transition persona %q to %q: %w", id, to, err)
}
return nil
}
// AdjustAffection applies a signed delta to the persona's affection. It returns
// the before and after values and an error for unknown personas.
func (m *Manager) AdjustAffection(id string, delta int) (before, after int, err error) {
_, affection, ok := m.Get(id)
if !ok {
return 0, 0, fmt.Errorf("emotion: affection for %q: %w", id, ErrUnknownPersona)
}
before, after = affection.Adjust(delta)
return before, after, nil
}
// Count reports how many personas are currently tracked.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.items)
}
+147
View File
@@ -0,0 +1,147 @@
package emotion
import (
"errors"
"fmt"
"sync"
"testing"
)
func mustMachine(t *testing.T, s EmotionState) *Machine {
t.Helper()
m, err := NewMachine(Params{InitialState: s})
if err != nil {
t.Fatalf("NewMachine(%q) error: %v", s, err)
}
return m
}
func TestManager_EmotionFollowsPersona(t *testing.T) {
t.Parallel()
mg := NewManager()
a, err := NewAffection(50, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
mg.Set("personaA", mustMachine(t, Excited), a)
mg.Set("personaB", mustMachine(t, Calm), nil)
// Transition personaA; personaB must be untouched.
if err := mg.Transition("personaA", Happy); err != nil {
t.Fatalf("Transition(personaA, happy) error: %v", err)
}
mA, _, ok := mg.Get("personaA")
if !ok {
t.Fatal("personaA not found")
}
if mA.Current() != Happy {
t.Fatalf("personaA Current() = %q, want %q", mA.Current(), Happy)
}
mB, _, _ := mg.Get("personaB")
if mB.Current() != Calm {
t.Fatalf("personaB Current() = %q, want %q (isolation)", mB.Current(), Calm)
}
}
func TestManager_AdjustAffectionIsolated(t *testing.T) {
t.Parallel()
mg := NewManager()
affA, err := NewAffection(40, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
affB, err := NewAffection(10, 0, 100)
if err != nil {
t.Fatalf("NewAffection() error: %v", err)
}
mg.Set("personaA", mustMachine(t, Calm), affA)
mg.Set("personaB", mustMachine(t, Calm), affB)
before, after, err := mg.AdjustAffection("personaA", 30)
if err != nil {
t.Fatalf("AdjustAffection(personaA) error: %v", err)
}
if before != 40 || after != 70 {
t.Fatalf("AdjustAffection(personaA) before/after = %d/%d, want 40/70", before, after)
}
if got := affB.Value(); got != 10 {
t.Fatalf("personaB value = %d, want 10 (isolation)", got)
}
}
func TestManager_TransitionUnknownPersona(t *testing.T) {
t.Parallel()
mg := NewManager()
mg.Set("personaA", mustMachine(t, Calm), nil)
err := mg.Transition("ghost", Happy)
if !errors.Is(err, ErrUnknownPersona) {
t.Fatalf("Transition(ghost) error = %v, want ErrUnknownPersona", err)
}
if _, _, err := mg.AdjustAffection("ghost", 1); !errors.Is(err, ErrUnknownPersona) {
t.Fatalf("AdjustAffection(ghost) error = %v, want ErrUnknownPersona", err)
}
}
func TestManager_SetGetRemoveCount(t *testing.T) {
t.Parallel()
mg := NewManager()
mg.Set("a", mustMachine(t, Calm), nil)
mg.Set("b", mustMachine(t, Happy), nil)
if got := mg.Count(); got != 2 {
t.Fatalf("Count() = %d, want 2", got)
}
if _, _, ok := mg.Get("a"); !ok {
t.Fatal("Get(a) not found")
}
if !mg.Remove("a") {
t.Fatal("Remove(a) should report true")
}
if mg.Remove("a") {
t.Fatal("Remove(a) again should report false")
}
if got := mg.Count(); got != 1 {
t.Fatalf("Count() after remove = %d, want 1", got)
}
}
func TestManager_ConcurrentAccess(t *testing.T) {
t.Parallel()
mg := NewManager()
const n = 8
for i := 0; i < n; i++ {
id := idOf(i)
aff, _ := NewAffection(50, 0, 100)
mg.Set(id, mustMachine(t, Excited), aff)
}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
id := idOf(i)
wg.Add(1)
go func() {
defer wg.Done()
_, _, _ = mg.Get(id)
_, _, _ = mg.AdjustAffection(id, 5)
_ = mg.Transition(id, Happy)
}()
}
wg.Wait()
for i := 0; i < n; i++ {
id := idOf(i)
m, _, ok := mg.Get(id)
if !ok {
t.Fatalf("persona %s missing after concurrent access", id)
}
if m.Current() != Happy {
t.Fatalf("persona %s Current() = %q, want %q", id, m.Current(), Happy)
}
}
}
func idOf(i int) string { return fmt.Sprintf("persona%d", i) }
+182
View File
@@ -0,0 +1,182 @@
// Package emotion implements the persona emotion state machine and affection
// tracking. Emotion and affection are always keyed by persona id so that each
// persona's internal state stays isolated — they follow the persona/character,
// never a behavior mode (see DevDocs/design/01).
package emotion
import (
"fmt"
"sync"
)
// EmotionState is one state in the emotion state machine.
type EmotionState string
// Standard emotion states. More states can be added at any time by registering
// transitions via AddTransition, so the machine is extensible rather than a
// closed enum.
const (
Calm EmotionState = "calm"
Happy EmotionState = "happy"
Excited EmotionState = "excited"
Sad EmotionState = "sad"
Angry EmotionState = "angry"
)
// Params configures the state machine for one persona (情绪状态机参数). It is the
// static, loadable description of how that persona's emotion may move.
type Params struct {
// InitialState is the emotion a freshly created machine starts in. When it
// is empty the machine keeps it empty until Set is called explicitly.
InitialState EmotionState `yaml:"initialState"`
// DecayRate is the per-Decay step magnitude. A value greater than zero
// enables the drift toward calm; zero or negative disables it. The actual
// step is one position on the toward-calm ladder (see closerToCalm).
DecayRate float64 `yaml:"decayRate"`
// Transitions maps a source state to the set of states it may transition
// to. When empty, DefaultTransitions is used.
Transitions map[EmotionState][]EmotionState `yaml:"transitions"`
}
// Machine is a persona's live emotion state machine. All methods are safe for
// concurrent use.
type Machine struct {
mu sync.RWMutex
params Params
current EmotionState
transitions map[EmotionState]map[EmotionState]struct{}
}
// DefaultTransitions returns the built-in transition table used when a Params
// instance carries no explicit transitions.
func DefaultTransitions() map[EmotionState][]EmotionState {
return map[EmotionState][]EmotionState{
Calm: {Happy, Sad},
Happy: {Excited, Calm, Sad},
Excited: {Happy, Calm, Angry},
Sad: {Calm, Happy, Angry},
Angry: {Calm, Excited},
}
}
// NewMachine builds a Machine from params. When params.Transitions is empty the
// default transition table is installed. An empty InitialState is allowed, but
// callers should normally Set it before use.
func NewMachine(params Params) (*Machine, error) {
if params.DecayRate < 0 {
return nil, fmt.Errorf("emotion: negative decay rate %v", params.DecayRate)
}
base := params.Transitions
if len(base) == 0 {
base = DefaultTransitions()
}
return &Machine{
params: params,
current: params.InitialState,
transitions: normalizeTransitions(base),
}, nil
}
// normalizeTransitions flattens the slice-based table into a membership set so
// lookups are O(1) and symmetric.
func normalizeTransitions(src map[EmotionState][]EmotionState) map[EmotionState]map[EmotionState]struct{} {
out := make(map[EmotionState]map[EmotionState]struct{}, len(src))
for from, tos := range src {
set := make(map[EmotionState]struct{}, len(tos))
for _, to := range tos {
set[to] = struct{}{}
}
out[from] = set
}
return out
}
// Current returns the machine's present emotion state.
func (m *Machine) Current() EmotionState {
m.mu.RLock()
defer m.mu.RUnlock()
return m.current
}
// Params returns a copy of the machine's configuration.
func (m *Machine) Params() Params {
m.mu.RLock()
defer m.mu.RUnlock()
return m.params
}
// CanTransition reports whether the machine may move to to from its current
// state.
func (m *Machine) CanTransition(to EmotionState) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.allowed(m.current, to)
}
// allowed reports whether from may transition to to under the installed table.
func (m *Machine) allowed(from, to EmotionState) bool {
_, ok := m.transitions[from][to]
return ok
}
// Transition moves the machine to to when the installed table permits it. It
// returns an error when the move is not allowed.
func (m *Machine) Transition(to EmotionState) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.allowed(m.current, to) {
return fmt.Errorf("emotion: cannot transition from %q to %q", m.current, to)
}
m.current = to
return nil
}
// Set forces the machine to state regardless of the transition table. It is
// used for initialization, restore, and resetting a persona's emotion.
func (m *Machine) Set(state EmotionState) {
m.mu.Lock()
defer m.mu.Unlock()
m.current = state
}
// AddTransition registers a new allowed edge, extending the table.
func (m *Machine) AddTransition(from, to EmotionState) {
m.mu.Lock()
defer m.mu.Unlock()
m.ensure(from)
m.transitions[from][to] = struct{}{}
}
// ensure returns the membership set for from, creating it when absent.
func (m *Machine) ensure(from EmotionState) map[EmotionState]struct{} {
if m.transitions[from] == nil {
m.transitions[from] = make(map[EmotionState]struct{})
}
return m.transitions[from]
}
// Decay drifts the emotion one position toward calm whenever DecayRate is
// positive; otherwise it leaves the current state unchanged. The new state is
// returned.
func (m *Machine) Decay() EmotionState {
m.mu.Lock()
defer m.mu.Unlock()
if m.params.DecayRate <= 0 {
return m.current
}
m.current = closerToCalm(m.current)
return m.current
}
// closerToCalm returns the single relaxed step toward calm for a state. States
// at or below calm are returned unchanged.
func closerToCalm(s EmotionState) EmotionState {
switch s {
case Excited:
return Happy
case Happy, Sad, Angry:
return Calm
default:
return s
}
}
+144
View File
@@ -0,0 +1,144 @@
package emotion
import "testing"
func TestDefaultTransitions_StandardEdges(t *testing.T) {
t.Parallel()
d := DefaultTransitions()
if _, ok := d[Calm]; !ok {
t.Errorf("DefaultTransitions missing %q", Calm)
}
if _, ok := d[Angry]; !ok {
t.Errorf("DefaultTransitions missing %q", Angry)
}
}
func TestNewMachine_DefaultTransitionsWhenEmpty(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: Excited})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
if got := m.Current(); got != Excited {
t.Fatalf("Current() = %q, want %q", got, Excited)
}
if !m.CanTransition(Happy) {
t.Errorf("excited should be able to reach happy by default")
}
if m.CanTransition(Sad) {
t.Errorf("excited must not reach sad by default")
}
}
func TestNewMachine_RejectsNegativeDecay(t *testing.T) {
t.Parallel()
if _, err := NewMachine(Params{InitialState: Calm, DecayRate: -1}); err == nil {
t.Fatal("NewMachine() should error on negative decay rate")
}
}
func TestMachine_TransitionFollowsTable(t *testing.T) {
t.Parallel()
tr := map[EmotionState][]EmotionState{
Calm: {Happy, Sad, Excited},
Happy: {Excited, Calm},
Sad: {Calm},
Angry: {Calm},
}
tests := []struct {
name string
initial EmotionState
to EmotionState
wantErr bool
}{
{name: "ok_calm_to_happy", initial: Calm, to: Happy},
{name: "ok_calm_to_excited", initial: Calm, to: Excited},
{name: "ok_happy_to_excited", initial: Happy, to: Excited},
{name: "reject_calm_to_angry", initial: Calm, to: Angry, wantErr: true},
{name: "reject_happy_to_sad", initial: Happy, to: Sad, wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: tt.initial, Transitions: tr})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
err = m.Transition(tt.to)
if tt.wantErr && err == nil {
t.Fatalf("Transition(%q) want error, got nil", tt.to)
}
if !tt.wantErr && err != nil {
t.Fatalf("Transition(%q) want nil, got %v", tt.to, err)
}
if !tt.wantErr && m.Current() != tt.to {
t.Fatalf("Current() = %q, want %q", m.Current(), tt.to)
}
})
}
}
func TestMachine_SetForce(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: Calm})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
m.Set(Angry)
if got := m.Current(); got != Angry {
t.Fatalf("Current() = %q, want %q", got, Angry)
}
}
func TestMachine_AddTransitionExtends(t *testing.T) {
t.Parallel()
tr := map[EmotionState][]EmotionState{Calm: {Happy}}
m, err := NewMachine(Params{InitialState: Calm, Transitions: tr})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
if m.CanTransition(Angry) {
t.Fatalf("calm must not reach angry before AddTransition")
}
m.AddTransition(Calm, Angry)
if !m.CanTransition(Angry) {
t.Errorf("calm should reach angry after AddTransition")
}
}
func TestMachine_DecayMovesTowardCalm(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: Excited, DecayRate: 1})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
if got := m.Decay(); got != Happy {
t.Fatalf("Decay() from excited = %q, want %q", got, Happy)
}
if got := m.Decay(); got != Calm {
t.Fatalf("Decay() from happy = %q, want %q", got, Calm)
}
if got := m.Decay(); got != Calm {
t.Fatalf("Decay() from calm = %q, want %q", got, Calm)
}
}
func TestMachine_DecayDisabledAtZeroRate(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: Angry, DecayRate: 0})
if err != nil {
t.Fatalf("NewMachine() error: %v", err)
}
if got := m.Decay(); got != Angry {
t.Fatalf("Decay() at zero rate = %q, want %q", got, Angry)
}
}
+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")
}
}
+186
View File
@@ -0,0 +1,186 @@
// Package registry implements the character library and persona registration
// for the persona system. A character comes from the character library, binds
// exactly one persona, and declares the behavior modes it is good at; the
// registered persona additionally owns a per-persona memory isolation namespace
// and the emotion/affection state that follows the persona.
package registry
import (
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
)
// Character is an entity from the character library. It binds one persona and
// declares the behavior modes it excels at (as a suggestion, never an ability
// bound — see DevDocs/design/01).
type Character struct {
// ID is the unique character identifier.
ID string
// Name is the display name of the character.
Name string
// Default marks this character as the library's default role.
Default bool
// PersonaID is the persona bound to this character.
PersonaID string
// Modes lists the behavior modes the character is good at.
Modes []string
}
// Registry is the in-memory, concurrency-safe character library and persona
// registry. It also owns the emotion Manager so that emotion/affection always
// follow the registered persona.
type Registry struct {
mu sync.RWMutex
personas map[string]*loader.Persona
characters map[string]*Character
defaultID string
emotions *emotion.Manager
}
// New returns an empty Registry.
func New() *Registry {
return &Registry{
personas: make(map[string]*loader.Persona),
characters: make(map[string]*Character),
emotions: emotion.NewManager(),
}
}
// RegisterPersona registers p and builds its emotion machine and affection
// tracker. It fails when p is nil, has no id, is already registered, or its
// emotion/affection config is invalid.
func (r *Registry) RegisterPersona(p *loader.Persona) error {
if p == nil {
return fmt.Errorf("registry: nil persona")
}
if p.ID == "" {
return fmt.Errorf("registry: persona id required")
}
machine, err := p.NewMachine()
if err != nil {
return fmt.Errorf("registry: persona %q machine: %w", p.ID, err)
}
affection, err := p.NewAffection()
if err != nil {
return fmt.Errorf("registry: persona %q affection: %w", p.ID, err)
}
r.mu.Lock()
if _, ok := r.personas[p.ID]; ok {
r.mu.Unlock()
return fmt.Errorf("registry: persona %q already registered", p.ID)
}
r.personas[p.ID] = p
r.mu.Unlock()
r.emotions.Set(p.ID, machine, affection)
return nil
}
// Persona returns the persona registered under id.
func (r *Registry) Persona(id string) (*loader.Persona, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.personas[id]
return p, ok
}
// RegisterCharacter registers c. It requires c's persona to exist, so persona
// registration must happen first. A character marked Default becomes the
// library's default role.
func (r *Registry) RegisterCharacter(c *Character) error {
if c == nil {
return fmt.Errorf("registry: nil character")
}
if c.ID == "" {
return fmt.Errorf("registry: character id required")
}
if c.Name == "" {
return fmt.Errorf("registry: character %q name required", c.ID)
}
if c.PersonaID == "" {
return fmt.Errorf("registry: character %q personaID required", c.ID)
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.characters[c.ID]; ok {
return fmt.Errorf("registry: character %q already registered", c.ID)
}
if _, ok := r.personas[c.PersonaID]; !ok {
return fmt.Errorf("registry: character %q references unknown persona %q", c.ID, c.PersonaID)
}
r.characters[c.ID] = c
if c.Default {
r.defaultID = c.ID
}
return nil
}
// Character returns the character registered under id.
func (r *Registry) Character(id string) (*Character, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.characters[id]
return c, ok
}
// SetDefaultCharacter makes the character with id the library default. It fails
// when id is not a registered character.
func (r *Registry) SetDefaultCharacter(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.characters[id]
if !ok {
return fmt.Errorf("registry: set default to unknown character %q", id)
}
c.Default = true
r.defaultID = id
return nil
}
// DefaultCharacter returns the library's default character, if one is set.
func (r *Registry) DefaultCharacter() (*Character, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if r.defaultID == "" {
return nil, false
}
c, ok := r.characters[r.defaultID]
return c, ok
}
// MemoryNamespace returns the per-persona memory isolation namespace for the
// persona under personaID, guarding memory isolation between personas.
func (r *Registry) MemoryNamespace(personaID string) (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.personas[personaID]
if !ok {
return "", false
}
return p.MemoryNamespace(), true
}
// Emotions returns the emotion Manager that owns every registered persona's
// emotion and affection state.
func (r *Registry) Emotions() *emotion.Manager {
return r.emotions
}
// CountPersonas returns the number of registered personas.
func (r *Registry) CountPersonas() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.personas)
}
// CountCharacters returns the number of registered characters.
func (r *Registry) CountCharacters() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.characters)
}
+209
View File
@@ -0,0 +1,209 @@
package registry
import (
"fmt"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
)
func mkPersona(id, ref, ns string) *loader.Persona {
return &loader.Persona{
ID: id,
Name: id,
CharacterRef: ref,
Identity: loader.Identity{Role: "role", SystemPrompt: "system prompt"},
Emotion: loader.EmotionParams{InitialState: emotion.Calm, MemoryNamespace: ns},
}
}
func mkChar(id, personaID string, def bool) *Character {
return &Character{ID: id, Name: id, PersonaID: personaID, Default: def, Modes: []string{"chat", "home"}}
}
func TestRegistry_RegisterAndGet(t *testing.T) {
t.Parallel()
r := New()
if err := r.RegisterPersona(mkPersona("xilian", "c", "")); err != nil {
t.Fatalf("RegisterPersona() error: %v", err)
}
if err := r.RegisterCharacter(mkChar("xilian-char", "xilian", false)); err != nil {
t.Fatalf("RegisterCharacter() error: %v", err)
}
p, ok := r.Persona("xilian")
if !ok || p.Name != "xilian" {
t.Fatalf("Persona(xilian) = %+v, ok=%v", p, ok)
}
c, ok := r.Character("xilian-char")
if !ok || c.PersonaID != "xilian" {
t.Fatalf("Character(xilian-char) = %+v, ok=%v", c, ok)
}
if r.CountPersonas() != 1 || r.CountCharacters() != 1 {
t.Fatalf("counts = %d/%d, want 1/1", r.CountPersonas(), r.CountCharacters())
}
}
func TestRegistry_DefaultCharacter(t *testing.T) {
t.Parallel()
r := New()
_ = r.RegisterPersona(mkPersona("pa", "c", ""))
_ = r.RegisterPersona(mkPersona("pb", "c", ""))
if err := r.RegisterCharacter(mkChar("first", "pa", true)); err != nil {
t.Fatalf("RegisterCharacter(first) error: %v", err)
}
_ = r.RegisterCharacter(&Character{ID: "second", Name: "second", PersonaID: "pb"})
def, ok := r.DefaultCharacter()
if !ok || def.ID != "first" {
t.Fatalf("DefaultCharacter() = %+v, ok=%v; want first", def, ok)
}
if err := r.SetDefaultCharacter("second"); err != nil {
t.Fatalf("SetDefaultCharacter(second) error: %v", err)
}
def, ok = r.DefaultCharacter()
if !ok || def.ID != "second" {
t.Fatalf("DefaultCharacter() after set = %+v, ok=%v; want second", def, ok)
}
}
func TestRegistry_SetDefaultUnknown(t *testing.T) {
t.Parallel()
r := New()
if err := r.SetDefaultCharacter("ghost"); err == nil {
t.Fatal("SetDefaultCharacter(ghost) want error, got nil")
}
}
func TestRegistry_RegisterCharacterUnknownPersona(t *testing.T) {
t.Parallel()
r := New()
if err := r.RegisterCharacter(mkChar("c", "missing-persona", false)); err == nil {
t.Fatal("RegisterCharacter with unknown persona want error, got nil")
}
}
func TestRegistry_DuplicateRegistration(t *testing.T) {
t.Parallel()
r := New()
if err := r.RegisterPersona(mkPersona("a", "c", "")); err != nil {
t.Fatalf("first RegisterPersona error: %v", err)
}
if err := r.RegisterPersona(mkPersona("a", "c", "")); err == nil {
t.Fatal("duplicate persona want error, got nil")
}
_ = r.RegisterCharacter(mkChar("c1", "a", false))
if err := r.RegisterCharacter(mkChar("c1", "a", false)); err == nil {
t.Fatal("duplicate character want error, got nil")
}
}
func TestRegistry_RegisterCharacterValidation(t *testing.T) {
t.Parallel()
r := New()
if err := r.RegisterCharacter(&Character{ID: "c", Name: "", PersonaID: "a"}); err == nil {
t.Fatal("character with empty name want error, got nil")
}
if err := r.RegisterCharacter(&Character{ID: "c", Name: "c", PersonaID: ""}); err == nil {
t.Fatal("character with empty personaID want error, got nil")
}
}
func TestRegistry_MemoryNamespaceIsolation(t *testing.T) {
t.Parallel()
r := New()
_ = r.RegisterPersona(mkPersona("explicit", "c", "memory.special"))
_ = r.RegisterPersona(mkPersona("derived", "c", ""))
if ns, ok := r.MemoryNamespace("explicit"); !ok || ns != "memory.special" {
t.Fatalf("MemoryNamespace(explicit) = %q, ok=%v; want memory.special", ns, ok)
}
if ns, ok := r.MemoryNamespace("derived"); !ok || ns != "persona.derived" {
t.Fatalf("MemoryNamespace(derived) = %q, ok=%v; want persona.derived", ns, ok)
}
nsExplicit, _ := r.MemoryNamespace("explicit")
nsDerived, _ := r.MemoryNamespace("derived")
if nsExplicit == nsDerived {
t.Fatalf("namespaces must be isolated: %q == %q", nsExplicit, nsDerived)
}
if _, ok := r.MemoryNamespace("ghost"); ok {
t.Fatal("MemoryNamespace(ghost) ok=true, want false")
}
}
func TestRegistry_EmotionsFollowPersona(t *testing.T) {
t.Parallel()
r := New()
_ = r.RegisterPersona(mkPersona("xilian", "c", ""))
m, aff, ok := r.Emotions().Get("xilian")
if !ok {
t.Fatal("registered persona missing from emotion manager")
}
if m.Current() != emotion.Calm {
t.Fatalf("emotion Current() = %q, want calm", m.Current())
}
if aff.Value() != 50 {
t.Fatalf("affection value = %d, want 50 (default)", aff.Value())
}
if err := r.Emotions().Transition("xilian", emotion.Happy); err != nil {
t.Fatalf("Transition(xilian, happy) error: %v", err)
}
if m.Current() != emotion.Happy {
t.Fatalf("emotion Current() = %q, want happy", m.Current())
}
}
func TestRegistry_ConcurrentLifecycle(t *testing.T) {
t.Parallel()
r := New()
const n = 16
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
id := fmt.Sprintf("p%d", i)
if err := r.RegisterPersona(mkPersona(id, "c", "")); err != nil {
t.Errorf("RegisterPersona(%s): %v", id, err)
return
}
if _, ok := r.Persona(id); !ok {
t.Errorf("Persona(%s) missing after register", id)
}
ch := &Character{ID: "c" + id, Name: id, PersonaID: id, Modes: []string{"chat"}}
if err := r.RegisterCharacter(ch); err != nil {
t.Errorf("RegisterCharacter(%s): %v", id, err)
return
}
if _, ok := r.Character(ch.ID); !ok {
t.Errorf("Character(%s) missing after register", ch.ID)
}
if _, ok := r.MemoryNamespace(id); !ok {
t.Errorf("MemoryNamespace(%s) missing", id)
}
}(i)
}
wg.Wait()
if got := r.CountPersonas(); got != n {
t.Fatalf("CountPersonas() = %d, want %d", got, n)
}
if got := r.CountCharacters(); got != n {
t.Fatalf("CountCharacters() = %d, want %d", got, n)
}
}