feat(ai-core): mood state machine and affection level
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled

- new emotion package: rule-based sentiment (positive/negative lexicon)
  drives mood transitions (开心/平静/难过) with 30min inactivity decay;
  affection 0-100 grows with positive interaction, with 初识→熟悉→信赖→
  羁绊 tiers
- pipeline records each user message and injects a mood line into the
  system prompt so the persona's tone follows her mood
- mood changes broadcast over WS; frontend shows a mood badge with
  affection tooltip; also handles the 'reminder' message type from the
  reminder scheduler
- tests: sentiment scoring, mood shift, affection bounds, prompt line,
  onChange callback
This commit is contained in:
Aska committed 2026-08-21 18:13:23 +08:00
1 parent 26d65dd6bb
commit b8fb7feb86
8 files changed
+309 -3

No files matched your search

+14
View File
@@ -33,6 +33,7 @@ import (
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/background"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
@@ -262,6 +263,11 @@ func main() {
thinker.Start(context.Background())
orch.SetPostChatHook(thinker.TriggerPostChat)
// === 12.6 Emotion tracker ===
emotionTracker := emotion.NewTracker()
orch.SetEmotionTracker(emotionTracker)
logger.Infof("[emotion] tracker ready (mood=%s)", emotionTracker.Snapshot().Mood)
// === 12.6 Memory decay scheduler ===
if memSvc != nil {
go func() {
@@ -307,6 +313,14 @@ func main() {
}
}()
// Broadcast mood changes to web clients
emotionTracker.OnChange(func(s emotion.Snapshot) {
wsHub.Broadcast(map[string]any{
"type": "mood", "mood": string(s.Mood),
"intensity": s.Intensity, "affection": s.Affection,
})
})
// === 12.7 Automation engine ===
autoEngine := engine.New(
automationSourceAdapter{gwDeps.Automation},
+164
View File
@@ -0,0 +1,164 @@
// Package emotion implements Cyrene's mood state machine and affection level.
// Mood drifts with user sentiment and decays back to calm over time;
// affection grows with positive interactions.
package emotion
import (
"strings"
"sync"
"time"
)
// Mood is the current emotional state.
type Mood string
const (
MoodCalm Mood = "平静"
MoodHappy Mood = "开心"
MoodSad Mood = "难过"
MoodShy Mood = "害羞"
MoodTouched Mood = "感动"
)
// Snapshot is a point-in-time view of the emotional state.
type Snapshot struct {
Mood Mood `json:"mood"`
Intensity float64 `json:"intensity"` // 0.0 - 1.0
Affection int `json:"affection"` // 0 - 100
UpdatedAt time.Time `json:"updated_at"`
}
// Tracker tracks mood and affection over time.
type Tracker struct {
mu sync.Mutex
mood Mood
intensity float64
affection int
updatedAt time.Time
decayAfter time.Duration
onChange func(Snapshot)
}
// NewTracker creates a tracker starting calm with zero affection.
func NewTracker() *Tracker {
return &Tracker{
mood: MoodCalm,
intensity: 0.3,
updatedAt: time.Now(),
decayAfter: 30 * time.Minute,
}
}
// OnChange registers a callback fired whenever the snapshot changes.
func (t *Tracker) OnChange(fn func(Snapshot)) { t.onChange = fn }
// Snapshot returns the current state, applying time decay first.
func (t *Tracker) Snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
t.decayLocked()
return Snapshot{Mood: t.mood, Intensity: t.intensity, Affection: t.affection, UpdatedAt: t.updatedAt}
}
// PromptLine renders the state for injection into the system prompt.
func (t *Tracker) PromptLine() string {
s := t.Snapshot()
return moodPromptLine(s)
}
// RecordMessage updates mood/affection based on a user message.
func (t *Tracker) RecordMessage(text string) {
t.mu.Lock()
defer t.mu.Unlock()
t.decayLocked()
sent := AnalyzeSentiment(text)
switch {
case sent > 0:
t.affection += 2
t.shiftMood(MoodHappy, 0.6+0.2*float64(sent))
case sent < 0:
t.affection -= 1
t.shiftMood(MoodSad, 0.5)
default:
t.affection += 1 // every interaction counts a little
t.shiftMood(MoodCalm, 0.3)
}
if t.affection > 100 {
t.affection = 100
}
if t.affection < 0 {
t.affection = 0
}
t.updatedAt = time.Now()
if t.onChange != nil {
s := Snapshot{Mood: t.mood, Intensity: t.intensity, Affection: t.affection, UpdatedAt: t.updatedAt}
go t.onChange(s)
}
}
// shiftMood moves toward a mood, raising intensity when already there.
func (t *Tracker) shiftMood(m Mood, intensity float64) {
if t.mood == m {
t.intensity = min64(1.0, t.intensity+0.1)
return
}
t.mood = m
t.intensity = intensity
}
// decayLocked relaxes mood toward calm after inactivity. Caller holds mu.
func (t *Tracker) decayLocked() {
if time.Since(t.updatedAt) < t.decayAfter {
return
}
if t.mood != MoodCalm {
t.mood = MoodCalm
t.intensity = 0.3
}
}
// AnalyzeSentiment scores text: positive count minus negative count.
func AnalyzeSentiment(text string) int {
score := 0
for _, w := range positiveWords {
if strings.Contains(text, w) {
score++
}
}
for _, w := range negativeWords {
if strings.Contains(text, w) {
score--
}
}
return score
}
var positiveWords = []string{
"谢谢", "感谢", "开心", "喜欢", "爱你", "棒", "好耶", "哈哈", "可爱", "太好了", "厉害", "乖",
}
var negativeWords = []string{
"讨厌", "生气", "难过", "伤心", "笨", "滚", "烦", "垃圾", "无聊", "失望",
}
func moodPromptLine(s Snapshot) string {
level := "初识"
switch {
case s.Affection >= 80:
level = "羁绊"
case s.Affection >= 50:
level = "信赖"
case s.Affection >= 20:
level = "熟悉"
}
return "当前情绪状态:" + string(s.Mood) + "(好感度阶段:" + level + ")。让回复的语气与情绪一致,但不要刻意提及这些数值。"
}
func min64(a, b float64) float64 {
if a < b {
return a
}
return b
}
@@ -0,0 +1,73 @@
package emotion
import (
"strings"
"testing"
"time"
)
func TestSentiment(t *testing.T) {
if AnalyzeSentiment("今天好开心,谢谢你") <= 0 {
t.Error("expected positive sentiment")
}
if AnalyzeSentiment("太讨厌了,真烦") >= 0 {
t.Error("expected negative sentiment")
}
if AnalyzeSentiment("今天天气如何") != 0 {
t.Error("expected neutral sentiment")
}
}
func TestMoodShiftsOnPositive(t *testing.T) {
tr := NewTracker()
tr.RecordMessage("我好喜欢你呀")
s := tr.Snapshot()
if s.Mood != MoodHappy {
t.Errorf("mood = %s, want %s", s.Mood, MoodHappy)
}
if s.Affection <= 0 {
t.Errorf("affection should grow, got %d", s.Affection)
}
}
func TestAffectionBounds(t *testing.T) {
tr := NewTracker()
for i := 0; i < 80; i++ {
tr.RecordMessage("谢谢,太棒了")
}
if got := tr.Snapshot().Affection; got != 100 {
t.Errorf("affection should cap at 100, got %d", got)
}
tr2 := NewTracker()
for i := 0; i < 30; i++ {
tr2.RecordMessage("讨厌,烦死了")
}
if got := tr2.Snapshot().Affection; got != 0 {
t.Errorf("affection should floor at 0, got %d", got)
}
}
func TestPromptLine(t *testing.T) {
tr := NewTracker()
tr.RecordMessage("哈哈太好了")
line := tr.PromptLine()
if !strings.Contains(line, "开心") || !strings.Contains(line, "好感度") {
t.Errorf("unexpected prompt line: %q", line)
}
}
func TestOnChangeCallback(t *testing.T) {
tr := NewTracker()
fired := make(chan Snapshot, 1)
tr.OnChange(func(s Snapshot) { fired <- s })
tr.RecordMessage("爱你哦")
select {
case s := <-fired:
if s.Affection == 0 {
t.Error("expected affection > 0 in callback")
}
case <-time.After(time.Second):
t.Error("onChange not fired")
}
}
@@ -35,6 +35,13 @@ type Tracer interface {
FinishTrace(id, status, errMsg string)
}
// EmotionTracker tracks mood/affection from user messages.
type EmotionTracker interface {
RecordMessage(text string)
// PromptLine renders the current emotional state for the system prompt.
PromptLine() string
}
// maxToolRounds caps consecutive tool-call rounds to avoid runaway loops.
const maxToolRounds = 3
@@ -55,6 +62,7 @@ type Pipeline struct {
memoryExtractor MemoryExtractor
toolExecutor ToolExecutor
tracer Tracer
emotionTracker EmotionTracker
postChatHook func(sessionID string)
}
@@ -97,6 +105,11 @@ func (p *Pipeline) SetPostChatHook(fn func(sessionID string)) {
p.postChatHook = fn
}
// SetEmotionTracker injects the mood/affection tracker.
func (p *Pipeline) SetEmotionTracker(t EmotionTracker) {
p.emotionTracker = t
}
// span records a trace span when tracing is enabled.
func (p *Pipeline) span(traceID, name, label, status string, start time.Time, detail string) {
if p.tracer == nil || traceID == "" {
@@ -159,6 +172,10 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMess
// 3. Build system prompt
t1 := time.Now()
systemPrompt := p.personaInjector.BuildSystemPrompt()
if p.emotionTracker != nil {
p.emotionTracker.RecordMessage(userMessage)
systemPrompt += "\n\n" + p.emotionTracker.PromptLine()
}
p.span(traceID, "persona", "人设注入", "success", t1, "")
// 4. Sub-session dispatch (parallel)