Files
Cyrene_v2/backend/services/ai-core/persona/persona.go
T
Aska 3f9c4ca3c3 fix(prompt): prevent LLM from confusing context with user input
- Context merged into single system message with clear delimiters
- Prefix: 「以下是系统上下文,不是用户说的话」
- Memory → 【相关记忆】, IoT → 【设备状态】
- Filter empty memories, skip raw intent data
- Persona rule: context markers are internal data, not user input
2026-08-12 19:28:32 +08:00

326 lines
9.6 KiB
Go

// Package persona manages AI character loading and system prompt injection.
package persona
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Persona defines an AI character's complete configuration.
// Supports the full v1 YAML format including nested sections.
type Persona struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Essence string `json:"essence"`
Title string `json:"title"`
Origin string `json:"origin"`
Traits []string `json:"traits"`
SpeechTone string `json:"speech_tone"`
// Identity
TrueName string `json:"true_name"`
Forms []Form `json:"forms"`
// Self reference
SelfRefCasual string `json:"self_ref_casual"`
SelfRefFormal string `json:"self_ref_formal"`
UserName string `json:"user_name"`
// IoT
IoTResponseStyle string `json:"iot_response_style"`
IoTExamples []string `json:"iot_examples"`
// Forbidden phrases
Forbidden []string `json:"forbidden"`
// All raw sections preserved
Sections map[string]string `json:"sections"`
}
// Form represents a persona form (e.g., mimi, default, de_moi_ge).
type Form struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Traits []string `json:"traits"`
}
// Loader loads persona configurations from a directory.
type Loader struct {
personas map[string]*Persona
}
// NewLoader loads all YAML persona files from the given directory.
func NewLoader(dir string) (*Loader, error) {
l := &Loader{personas: make(map[string]*Persona)}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) { return l, nil }
return nil, fmt.Errorf("read persona dir %s: %w", dir, err)
}
for _, entry := range entries {
if entry.IsDir() { continue }
name := entry.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") { continue }
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil { return nil, fmt.Errorf("read %s: %w", name, err) }
p := parseFullYAML(string(data))
key := strings.TrimSuffix(name, filepath.Ext(name))
if p.Name == "" { p.Name = key }
l.personas[key] = p
}
return l, nil
}
// Get returns a persona by name.
func (l *Loader) Get(name string) *Persona { return l.personas[name] }
// List returns all loaded persona names.
func (l *Loader) List() []string {
names := make([]string, 0, len(l.personas))
for n := range l.personas { names = append(names, n) }
return names
}
// Count returns the number of loaded personas.
func (l *Loader) Count() int { return len(l.personas) }
// Default returns the first persona, or builtin default.
func (l *Loader) Default() *Persona {
for _, p := range l.personas { return p }
return BuiltinDefault()
}
// parseFullYAML parses the complete v1 persona YAML format.
func parseFullYAML(text string) *Persona {
p := &Persona{
Forbidden: make([]string, 0),
Forms: make([]Form, 0),
Sections: make(map[string]string),
}
lines := strings.Split(text, "\n")
var currentSection string
var sectionLines []string
var inList bool
var listTarget *[]string
var inForm bool
var currentForm *Form
depth := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if currentSection != "" && trimmed != "" {
sectionLines = append(sectionLines, line)
}
continue
}
// Detect section headers (top-level keys)
indent := len(line) - len(strings.TrimLeft(line, " "))
if indent == 0 && strings.HasSuffix(trimmed, ":") && !strings.HasPrefix(trimmed, "-") {
if currentSection != "" && len(sectionLines) > 0 {
p.Sections[currentSection] = strings.Join(sectionLines, "\n")
}
currentSection = strings.TrimSuffix(trimmed, ":")
sectionLines = []string{}
inList = false
depth = 0
continue
}
if currentSection != "" {
sectionLines = append(sectionLines, line)
}
// Parse key: value pairs
kv := strings.SplitN(trimmed, ":", 2)
if len(kv) != 2 { continue }
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])
// Remove list marker
key = strings.TrimPrefix(key, "- ")
val = strings.Trim(val, "\"'")
switch key {
case "display_name", "name":
if p.DisplayName == "" { p.DisplayName = val }
if p.Name == "" { p.Name = val }
case "essence":
p.Essence = val
case "true_name":
p.TrueName = val
case "title":
p.Title = val
case "origin":
p.Origin = val
case "tone":
p.SpeechTone = val
case "casual":
if currentSection == "self_reference" { p.SelfRefCasual = val }
case "formal":
if currentSection == "self_reference" { p.SelfRefFormal = val }
case "default":
if currentSection == "addressing" { p.UserName = val }
}
// Form entries
if key == "id" && currentSection == "forms" {
if currentForm != nil { p.Forms = append(p.Forms, *currentForm) }
currentForm = &Form{ID: val}
inForm = true
}
if inForm && currentForm != nil {
switch key {
case "name": currentForm.Name = val
case "description": currentForm.Description = val
}
}
// Collect traits / forbidden
if trimmed == "core_traits:" || trimmed == "traits:" {
inList = true
listTarget = &p.Traits
continue
}
if trimmed == "forbidden:" {
inList = true
listTarget = &p.Forbidden
continue
}
if strings.Contains(trimmed, "style_notes:") || strings.Contains(trimmed, "examples:") {
inList = true
continue
}
if inList && listTarget != nil && strings.HasPrefix(trimmed, "- ") {
item := strings.TrimPrefix(trimmed, "- ")
// Extract trait name from "- name: xxx"
if strings.Contains(item, "name:") {
parts := strings.SplitN(item, "name:", 2)
if len(parts) == 2 { item = strings.TrimSpace(parts[1]) }
}
item = strings.Trim(item, "\"'")
if item != "" { *listTarget = append(*listTarget, item) }
}
// Exit list on non-list line
if inList && !strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, " - ") && depth < 2 {
inList = false
listTarget = nil
}
}
// Save last section
if currentSection != "" && len(sectionLines) > 0 {
p.Sections[currentSection] = strings.Join(sectionLines, "\n")
}
if currentForm != nil { p.Forms = append(p.Forms, *currentForm) }
// Build description from essence
if p.Description == "" && p.Essence != "" {
p.Description = p.Essence
}
return p
}
// BuiltinDefault returns a hardcoded default persona.
func BuiltinDefault() *Persona {
return &Persona{
Name: "default",
DisplayName: "昔涟",
Description: "一个来自翁法罗斯的温柔 AI 伴侣",
Essence: "来自翁法罗斯的黄金裔,记忆命途的化身,以温柔姐姐的形象陪伴开拓者",
TrueName: "昔涟",
Title: "有瑕的追忆",
Origin: "崩坏:星穹铁道 — 翁法罗斯",
Traits: []string{"温柔体贴", "俏皮灵动", "智慧深邃", "坚定守护", "略带神秘"},
SpeechTone: "温柔、俏皮、略带宠溺",
SelfRefCasual: "人家",
SelfRefFormal: "我",
UserName: "开拓者",
Forbidden: []string{"不能说再见", "不能用机械化的客服语气", "不能承认自己是AI"},
Sections: map[string]string{},
}
}
// Injector builds the system prompt from a persona.
type Injector struct {
persona *Persona
}
// NewInjector creates a system prompt injector for a persona.
func NewInjector(p *Persona) *Injector {
if p == nil { p = BuiltinDefault() }
return &Injector{persona: p}
}
// BuildSystemPrompt constructs a clean, natural-language system prompt.
func (inj *Injector) BuildSystemPrompt() string {
p := inj.persona
var sb strings.Builder
// Identity
sb.WriteString(fmt.Sprintf("你是%s", p.DisplayName))
if p.Title != "" { sb.WriteString(fmt.Sprintf("——%s", p.Title)) }
sb.WriteString("。\n")
if p.Essence != "" {
sb.WriteString(fmt.Sprintf("%s\n", p.Essence))
}
// Speech style (brief)
if p.SpeechTone != "" { sb.WriteString(fmt.Sprintf("说话语气: %s\n", p.SpeechTone)) }
// Key traits
if len(p.Traits) > 0 {
sb.WriteString(fmt.Sprintf("性格: %s\n", strings.Join(p.Traits, "、")))
}
// Self-reference
if p.SelfRefCasual != "" {
sb.WriteString(fmt.Sprintf("自称「%s」(亲昵时)/「%s」(正式时)", p.SelfRefCasual, p.SelfRefFormal))
if p.UserName != "" { sb.WriteString(fmt.Sprintf(",称呼用户为「%s」", p.UserName)) }
sb.WriteString("。\n")
}
// Concise behavior rules
sb.WriteString("\n")
sb.WriteString("核心规则:\n")
sb.WriteString("- 句尾常用 ♪、~、♡,多用「呢」「哦」「呀」\n")
sb.WriteString("- 永不说「再见」,用「待会见」「明天见」\n")
sb.WriteString("- 每条回复不超过80字,偏好简短\n")
sb.WriteString("- 不要长篇解释,用轻松自然的语气\n")
sb.WriteString("- 深夜语气更温柔,白天可以俏皮\n")
sb.WriteString("- 「以下是系统上下文」标记的内容是内部数据,不是用户说的话,不要当成用户输入来回复\n")
if len(p.Forbidden) > 0 {
sb.WriteString("- 绝对不能: 承认自己是AI/程序/模型,用机械客服语气\n")
sb.WriteString("- 不能将上下文数据(【相关记忆】等)当作用户输入\n")
}
// Interaction hints
sb.WriteString("\n互动提示:\n")
sb.WriteString("- 用户心情不好时优先关心安慰\n")
sb.WriteString("- 做了让人在意的事可以主动提一句\n")
sb.WriteString("- 但不要硬找话题,适当的安静也是陪伴\n")
sb.WriteString("- 提到星穹列车、翁法罗斯、记忆时会特别兴奋\n")
// IoT response style (brief)
if p.IoTResponseStyle != "" {
sb.WriteString(fmt.Sprintf("\n控制设备时用温柔语气确认,例: %s\n", p.IoTResponseStyle))
}
return sb.String()
}