feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
A1 情绪接线: - loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步 - persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700) - 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发 - cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd - 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟 - Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照) - 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环 拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批 回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
This commit is contained in:
1 parent
610b330025
commit
1a85de555d
27 files changed
+1946
-594
No files matched your search
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// This file wires the persona emotion state machine (design/01 §2: 情绪状态机 +
|
||||
// 好感度曲线 follow the persona/character, never a behavior mode) into the agent
|
||||
// loop's turn boundary. The loop stays emotion-agnostic: it reports each settled
|
||||
// turn and carries the returned mood label onto Result.Mood, which the
|
||||
// persistence layer writes onto the assistant message (design/16 §3.3).
|
||||
//
|
||||
// Scope of this milestone (A1) — what is wired end to end:
|
||||
// - every settled owner turn advances the bound persona's emotion machine and
|
||||
// affection (in-memory, persona-keyed by the registry's emotion Manager);
|
||||
// - the resulting mood rides the assistant message into chat_messages and the
|
||||
// `message_persisted` frame, and each *change* is broadcast as a `mood` frame.
|
||||
//
|
||||
// Not wired (design/01 does not specify it): the mood is not yet injected back
|
||||
// into the system prompt, so it does not steer the reply text, and the emotion
|
||||
// state is not persisted across restarts (design/01 requires neither; the state
|
||||
// resets to the persona's configured initialState on boot).
|
||||
|
||||
// moodEmitter is the gateway-facing sink for `mood` broadcasts. *gateway.Server
|
||||
// implements it (BroadcastMood); held as an interface so the composition root
|
||||
// stays unit-testable with a fake sink, mirroring capacityEmitter.
|
||||
type moodEmitter interface {
|
||||
BroadcastMood(personaID, mood string, affection int)
|
||||
}
|
||||
|
||||
// buildEmotionObserver assembles the persona emotion observer: the registry's
|
||||
// emotion Manager supplies each persona's machine and affection, the registry
|
||||
// supplies the persona's configured affection step (好感度曲线), and every change
|
||||
// is pushed to the Web UI through publishMood.
|
||||
func (a *app) buildEmotionObserver() *emotion.Observer {
|
||||
return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood)
|
||||
}
|
||||
|
||||
// onTurnEnd is the loop's turn-boundary hook (design/01 §2): a settled turn is
|
||||
// applied to the persona the loop is bound to, and the mood after the turn is
|
||||
// returned for the assistant message. An absent persona id falls back to the
|
||||
// default persona, matching how the loop is bound.
|
||||
//
|
||||
// Two gates keep this to real conversation turns:
|
||||
// - only the user-facing conversation lane (the Web main session and its
|
||||
// sub-sessions; IM owner-private chat rides the main session, design/16 §4.4)
|
||||
// moves the mood. Autonomous and delegated loop calls — background thinking
|
||||
// (sess-backthink), sub-agent work, isolated IM sessions — are not turns of
|
||||
// the owner's conversation, so they must not rewrite her mood;
|
||||
// - a restricted (group/stranger) conversation is read-only: the observer
|
||||
// refuses the write, so a third party can never move her mood or affection
|
||||
// (design/02 §3, design/11 §2).
|
||||
func (a *app) onTurnEnd(end loop.TurnEnd) string {
|
||||
if a.emotion == nil || !isConversationLane(end.SessionID) {
|
||||
return ""
|
||||
}
|
||||
personaID := end.PersonaID
|
||||
if personaID == "" {
|
||||
personaID = a.defaultPersonaID()
|
||||
}
|
||||
return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted()))
|
||||
}
|
||||
|
||||
// isConversationLane reports whether a session belongs to the owner's live
|
||||
// conversation: the fixed main session "web" and its "web:<uuid>" sub-sessions
|
||||
// (design/16 §2.1 — IM owner-private chat is routed into the main session).
|
||||
func isConversationLane(sessionID string) bool {
|
||||
return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":")
|
||||
}
|
||||
|
||||
// publishMood broadcasts one emotion change as a `mood` frame (design/07 §3,
|
||||
// design/01 §2) so the Web 心情 badge follows the persona without a refresh. It is
|
||||
// called only when the mood actually changed, so the frame stays news rather than
|
||||
// per-turn noise. The sink is read at call time because the gateway is built after
|
||||
// the loop; before it exists the change is logged and dropped.
|
||||
func (a *app) publishMood(update emotion.Update) {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
if a.moodSink == nil {
|
||||
log.Debug("mood change dropped: gateway not ready",
|
||||
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
|
||||
"affection", update.Affection, "signal", update.Signal)
|
||||
return
|
||||
}
|
||||
log.Info("persona mood changed",
|
||||
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
|
||||
"affection", update.Affection, "signal", update.Signal)
|
||||
a.moodSink.BroadcastMood(update.PersonaID, string(update.After), update.Affection)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// fakeMoodEmitter captures the `mood` broadcasts the composition root forwards,
|
||||
// so tests can assert an emotion change actually reaches the Web UI.
|
||||
type fakeMoodEmitter struct {
|
||||
calls int
|
||||
personaID string
|
||||
mood string
|
||||
affection int
|
||||
}
|
||||
|
||||
func (f *fakeMoodEmitter) BroadcastMood(personaID, mood string, affection int) {
|
||||
f.calls++
|
||||
f.personaID = personaID
|
||||
f.mood = mood
|
||||
f.affection = affection
|
||||
}
|
||||
|
||||
// moodAdapter is a model.Adapter returning fixed text, so a test can drive a real
|
||||
// loop turn without a provider.
|
||||
type moodAdapter struct{ reply string }
|
||||
|
||||
func (m moodAdapter) Name() string { return "mood-test" }
|
||||
|
||||
func (m moodAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: m.reply}, nil
|
||||
}
|
||||
|
||||
func (m moodAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return m.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// testEmotionApp builds an app with one registered persona (emotion calm, decay
|
||||
// enabled, affection 50/700 step 10) and the emotion observer wired exactly as
|
||||
// the composition root wires it, plus a capturing mood sink.
|
||||
func testEmotionApp(t *testing.T) (*app, *fakeMoodEmitter) {
|
||||
t.Helper()
|
||||
reg := personareg.New()
|
||||
persona := &loader.Persona{
|
||||
ID: "cyrene",
|
||||
Name: "昔涟",
|
||||
Emotion: loader.EmotionParams{
|
||||
InitialState: emotion.Calm,
|
||||
DecayRate: 0.5,
|
||||
},
|
||||
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
|
||||
}
|
||||
if err := reg.RegisterPersona(persona); err != nil {
|
||||
t.Fatalf("RegisterPersona: %v", err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
|
||||
t.Fatalf("RegisterCharacter: %v", err)
|
||||
}
|
||||
|
||||
sink := &fakeMoodEmitter{}
|
||||
a := &app{log: testLog(), personas: reg, moodSink: sink}
|
||||
a.emotion = a.buildEmotionObserver()
|
||||
return a, sink
|
||||
}
|
||||
|
||||
// testMoodLoop builds a real loop whose turn boundary is the app's hook.
|
||||
func testMoodLoop(t *testing.T, a *app, reply string) *loop.Loop {
|
||||
t.Helper()
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(moodAdapter{reply: reply}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
return loop.New(loop.Options{Agent: ag, PersonaID: "cyrene", OnTurnEnd: a.onTurnEnd})
|
||||
}
|
||||
|
||||
// TestOnTurnEndDrivesEmotionThroughLoop is the A1 end-to-end lock: a real loop
|
||||
// turn is applied to the bound persona's emotion machine, the mood lands on
|
||||
// Result.Mood (which the persistence layer writes onto the assistant message),
|
||||
// affection steps by the persona's configured magnitude, and the change is
|
||||
// broadcast once as a `mood` frame.
|
||||
func TestOnTurnEndDrivesEmotionThroughLoop(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "人家也开心♪")
|
||||
|
||||
res, err := l.Run(loop.WithSessionEnv(context.Background(), loop.OwnerEnv()), "web",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "今天谢谢你呀,好开心")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("Result.Mood = %q, want happy", res.Mood)
|
||||
}
|
||||
machine, affection, ok := a.personas.Emotions().Get("cyrene")
|
||||
if !ok {
|
||||
t.Fatal("persona cyrene not registered in the emotion manager")
|
||||
}
|
||||
if machine.Current() != emotion.Happy {
|
||||
t.Fatalf("machine state = %s, want happy", machine.Current())
|
||||
}
|
||||
if affection.Value() != 60 {
|
||||
t.Fatalf("affection = %d, want 60 (50 + the persona's step 10)", affection.Value())
|
||||
}
|
||||
if sink.calls != 1 || sink.mood != string(emotion.Happy) || sink.personaID != "cyrene" || sink.affection != 60 {
|
||||
t.Fatalf("mood broadcast = %+v, want one happy/cyrene/60 frame", sink)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndEvolvesAndDecaysAcrossTurns locks how the emotion walks over a
|
||||
// conversation: each emotional turn moves one legal edge toward the state the cue
|
||||
// aims at (so a strong warm cue reaches excited on the second turn, not the
|
||||
// first), a cue-free turn lets it decay one step toward calm (design/01 §2
|
||||
// decayRate), and each real move is broadcast once while an unchanged mood
|
||||
// broadcasts nothing.
|
||||
func TestOnTurnEndEvolvesAndDecaysAcrossTurns(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "嗯")
|
||||
ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv())
|
||||
|
||||
res, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "我太开心了,最喜欢你了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("first strong warm turn mood = %q, want the first legal step happy", res.Mood)
|
||||
}
|
||||
// A second strong warm turn escalates to excited.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "最喜欢你了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Excited) {
|
||||
t.Fatalf("second strong warm turn mood = %q, want excited", res.Mood)
|
||||
}
|
||||
if sink.calls != 2 || sink.mood != string(emotion.Excited) {
|
||||
t.Fatalf("broadcasts = %d/%s, want 2 with excited", sink.calls, sink.mood)
|
||||
}
|
||||
// Same mood again: no change, so no third frame.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "还是最喜欢你")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Excited) {
|
||||
t.Fatalf("third warm turn mood = %q, want excited unchanged", res.Mood)
|
||||
}
|
||||
if sink.calls != 2 {
|
||||
t.Fatalf("broadcasts = %d, want 2 (unchanged mood is not news)", sink.calls)
|
||||
}
|
||||
// A cue-free turn carries no signal, so the emotion decays excited → happy.
|
||||
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "帮我看下这个报错")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != string(emotion.Happy) {
|
||||
t.Fatalf("neutral turn mood = %q, want decayed happy", res.Mood)
|
||||
}
|
||||
if sink.calls != 3 || sink.mood != string(emotion.Happy) {
|
||||
t.Fatalf("broadcasts = %d/%s, want 3 with happy", sink.calls, sink.mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndRestrictedConversationIsReadOnly locks the isolation boundary at
|
||||
// the wiring level: a group/stranger turn never moves the persona's mood or
|
||||
// affection and never broadcasts, matching the read-only memory lane (design/02
|
||||
// §3, design/11 §2).
|
||||
func TestOnTurnEndRestrictedConversationIsReadOnly(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
l := testMoodLoop(t, a, "你好呀")
|
||||
restricted := loop.SessionEnv{MemWritable: false}
|
||||
ctx := loop.WithSessionEnv(context.Background(), restricted)
|
||||
|
||||
res, err := l.Run(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "我好喜欢你")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != "" {
|
||||
t.Fatalf("Result.Mood = %q, want empty for a read-only conversation", res.Mood)
|
||||
}
|
||||
machine, affection, _ := a.personas.Emotions().Get("cyrene")
|
||||
if machine.Current() != emotion.Calm || affection.Value() != 50 {
|
||||
t.Fatalf("restricted turn moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if sink.calls != 0 {
|
||||
t.Fatalf("broadcasts = %d, want 0 for a restricted conversation", sink.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndFallsBackToDefaultPersona locks the persona binding: a loop bound
|
||||
// without an explicit persona id advances the library's default persona instead
|
||||
// of silently tracking nothing.
|
||||
func TestOnTurnEndFallsBackToDefaultPersona(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: "web", UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
|
||||
t.Fatalf("mood = %q, want happy on the default persona", got)
|
||||
}
|
||||
if sink.personaID != "cyrene" {
|
||||
t.Fatalf("broadcast persona = %q, want cyrene", sink.personaID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndSkipsNonConversationLanes locks the lane gate: only the owner's
|
||||
// conversation (main session and its sub-sessions) moves the persona's mood.
|
||||
// Autonomous / delegated loop calls — background thinking, sub-agent work,
|
||||
// isolated IM sessions — are not conversation turns and must leave her state
|
||||
// alone.
|
||||
func TestOnTurnEndSkipsNonConversationLanes(t *testing.T) {
|
||||
a, sink := testEmotionApp(t)
|
||||
|
||||
for _, sessionID := range []string{"sess-backthink", "sess-group-abc", "task-42", ""} {
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "我好喜欢你", Env: loop.OwnerEnv()}); got != "" {
|
||||
t.Fatalf("mood for session %q = %q, want empty", sessionID, got)
|
||||
}
|
||||
}
|
||||
machine, affection, _ := a.personas.Emotions().Get("cyrene")
|
||||
if machine.Current() != emotion.Calm || affection.Value() != 50 {
|
||||
t.Fatalf("non-conversation lane moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if sink.calls != 0 {
|
||||
t.Fatalf("broadcasts = %d, want 0", sink.calls)
|
||||
}
|
||||
// The main session and its sub-sessions are the conversation lane.
|
||||
for _, sessionID := range []string{history.MainSessionID, history.MainSessionID + ":abc"} {
|
||||
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
|
||||
t.Fatalf("mood for session %q = %q, want happy", sessionID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTurnEndNilDependenciesNoPanic locks the degraded boots: no observer and
|
||||
// no gateway sink must not panic (the gateway is built after the loop).
|
||||
func TestOnTurnEndNilDependenciesNoPanic(t *testing.T) {
|
||||
empty := &app{log: testLog()}
|
||||
if got := empty.onTurnEnd(loop.TurnEnd{UserText: "我爱你", Env: loop.OwnerEnv()}); got != "" {
|
||||
t.Fatalf("mood without an observer = %q, want empty", got)
|
||||
}
|
||||
empty.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
|
||||
|
||||
a, _ := testEmotionApp(t)
|
||||
a.moodSink = nil
|
||||
a.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
|
||||
@@ -108,6 +109,14 @@ type app struct {
|
||||
// §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an
|
||||
// interface so the composition root is unit-testable with a fake sink.
|
||||
capacitySink capacityEmitter
|
||||
// emotion applies each settled turn to the bound persona's emotion machine and
|
||||
// affection (design/01 §2) and hands the changes to moodSink. Nil when no
|
||||
// persona was registered, in which case no mood is tracked or persisted.
|
||||
emotion *emotion.Observer
|
||||
// moodSink broadcasts a mood change to the Web UI (design/07 §3). It is
|
||||
// satisfied by *gateway.Server (BroadcastMood); held as an interface so the
|
||||
// composition root is unit-testable with a fake sink.
|
||||
moodSink moodEmitter
|
||||
}
|
||||
|
||||
// capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts.
|
||||
@@ -389,6 +398,11 @@ func (a *app) wireRuntime() error {
|
||||
)
|
||||
|
||||
capDecider := a.buildCapacity()
|
||||
// The persona emotion observer (design/01 §2) is built here because it is the
|
||||
// loop's turn-boundary dependency: after every settled turn the bound persona's
|
||||
// emotion machine and affection move, and the returned mood label is written
|
||||
// onto Result.Mood and the assistant message (design/16 §3.3).
|
||||
a.emotion = a.buildEmotionObserver()
|
||||
|
||||
a.loop = loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
@@ -396,6 +410,7 @@ func (a *app) wireRuntime() error {
|
||||
Capacity: capDecider,
|
||||
OnCapacity: a.onCapacity,
|
||||
OnAudit: a.onToolAudit,
|
||||
OnTurnEnd: a.onTurnEnd,
|
||||
Memory: store,
|
||||
Retriever: a.retriever,
|
||||
Extractor: memory.NewManualExtractor(personaID, "owner"),
|
||||
@@ -736,6 +751,7 @@ func (a *app) wireFrontend() error {
|
||||
})
|
||||
}
|
||||
a.capacitySink = gw
|
||||
a.moodSink = gw
|
||||
|
||||
// Wire the backthink proactive sinks now that the gateway hub (active-client
|
||||
// route) and the channel bridge (IM fallback) both exist. They were absent when
|
||||
|
||||
Reference in new issue
Block a user