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

+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)
}
}