feat(persona): 人格系统(YAML loader / 角色人格注册+记忆隔离 / 情绪状态机+好感度)
This commit is contained in:
1 parent
653c2489bf
commit
70fd915242
11 files changed
+1568
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user