Files
Aska 8eaf81bd0d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(engage): 群聊自主参与度引擎核心(design/18 P2a+P2b)
- persona 加 triggerWords(昔涟/Cyrene/有瑕的追忆/追忆);Loader 解析
- light.go: shouldRespond 拆分 legacy 与 engage 路径;LightInput/LightDecision 扩展
- engage.go(新): EngageConfig + IDLE/PROBING/ACTIVE/COOLDOWN 状态机(按 platform:groupID 内存) +
  概率触发(可注入rand) + 角色名提词触发 + 活动期跟随(话题转移/连续无感/窗口) + 负面态度冷避
- engagejudge.go(新): NewLLMLightJudge,一次成本受控轻LLM判 值不值得回(不判身份),
  解析失败/超时 fail-closed 静默
- channelbridge.go: Options(TriggerWords/PersonaPrompt/Engage/EngageFor/EngageRand) + 字段初始化
- wire.go: 接线 Light+TriggerWords+PersonaPrompt+Engage(默认概率0.10)
- 红线: 未触发/未命中概率绝不调LLM; 身份靠actor元数据; 敏感不落日志; 零新依赖
- 测试: engage_test.go(触发/状态机/冷避/成本受控/JUDGE输入) + persona triggerWords 用例
- 验收: go build/vet/test ./... 全绿
2026-09-07 18:16:55 +08:00

244 lines
7.0 KiB
Go

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_TriggerWords(t *testing.T) {
t.Parallel()
// A persona without triggerWords yields an empty slice; with them, the list is
// preserved (design/18 §2: per-persona role-name reminder words).
yaml := "id: xilian\nname: 昔涟\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" +
"triggerWords:\n - 昔涟\n - Cyrene\n - 有瑕的追忆\n"
path := writeFile(t, t.TempDir(), "xilian.yaml", yaml)
p, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if len(p.TriggerWords) != 3 || p.TriggerWords[0] != "昔涟" ||
p.TriggerWords[1] != "Cyrene" || p.TriggerWords[2] != "有瑕的追忆" {
t.Fatalf("TriggerWords = %v, want [昔涟 Cyrene 有瑕的追忆]", p.TriggerWords)
}
}
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")
}
}