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,103 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
|
||||
// test can assert the mood the loop reported is what gets persisted.
|
||||
type moodRunner struct {
|
||||
mood string
|
||||
}
|
||||
|
||||
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
||||
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
|
||||
// path: the mood the loop's turn-boundary hook reported lands on the persisted
|
||||
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
|
||||
// `message_persisted` and history replay carry real data instead of an always
|
||||
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
|
||||
// mood to the assistant reply).
|
||||
func TestOrchestratorPersistsTurnMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: moodRunner{mood: "happy"}.Run,
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
History: hist,
|
||||
})
|
||||
|
||||
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || res == nil {
|
||||
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||||
}
|
||||
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
|
||||
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
|
||||
}
|
||||
wire := toHistoryMessage(recs[1])
|
||||
if wire.Mood != "happy" {
|
||||
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
|
||||
}
|
||||
if recs[0].Mood != "" {
|
||||
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
|
||||
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
|
||||
// empty rather than being fabricated.
|
||||
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: moodRunner{mood: ""}.Run,
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
History: hist,
|
||||
})
|
||||
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
|
||||
t.Fatal("sync result is nil")
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("appends = %d, want 2", len(recs))
|
||||
}
|
||||
if recs[1].Mood != "" {
|
||||
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
|
||||
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
|
||||
// affection ride along as extra detail.
|
||||
func TestServerBroadcastMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
c := newFakeConn(env.srv.hub, 32)
|
||||
env.srv.hub.register(c)
|
||||
|
||||
env.srv.BroadcastMood("cyrene", "happy", 60)
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "mood" {
|
||||
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
|
||||
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
|
||||
}
|
||||
if got, ok := data["affection"].(float64); !ok || got != 60 {
|
||||
t.Fatalf("frame affection = %v, want 60", data["affection"])
|
||||
}
|
||||
}
|
||||
@@ -681,6 +681,30 @@ func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool str
|
||||
})
|
||||
}
|
||||
|
||||
// wsMoodEvent is the WS event type carrying the persona's mood after a turn
|
||||
// (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing
|
||||
// `mood` handler (ws.ts) reads data.mood and shows the 心情 badge.
|
||||
const wsMoodEvent = "mood"
|
||||
|
||||
// BroadcastMood fans one emotion change to every Web client as a `mood` frame
|
||||
// (design/07 §3). It is called only when the mood actually changed — the
|
||||
// machinery in the composition root is the gate — so the badge does not flicker
|
||||
// with an unchanged value on every turn. data matches what the frontend `mood`
|
||||
// handler reads ({mood}); affection/personaId ride along as the honest extra
|
||||
// detail (affection is the persona's current 好感度, personaId the persona whose
|
||||
// mood moved) and are ignored by clients that do not render them. The mood is a
|
||||
// persona-level value, not a per-session one, so no sessionId is attached.
|
||||
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsMoodEvent,
|
||||
Data: map[string]any{
|
||||
"mood": mood,
|
||||
"personaId": personaID,
|
||||
"affection": affection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
|
||||
@@ -78,14 +78,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
|
||||
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
|
||||
// the reply with the client that sent the turn (design/16 §4.5) and mode is the
|
||||
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when
|
||||
// empty. It is a no-op when no history store is wired or the turn produced no text
|
||||
// (interrupted/errored turns leave res nil). The write runs under a fresh
|
||||
// empty. Mood is the persona's mood for that turn (design/01 §2 情绪状态机), taken
|
||||
// off the loop result, so the stored/DTO/`message_persisted` mood is real data
|
||||
// rather than a permanently empty column; a turn with no mood tracking stores an
|
||||
// empty mood. It is a no-op when no history store is wired or the turn produced no
|
||||
// text (interrupted/errored turns leave res nil). The write runs under a fresh
|
||||
// background context so a canceled turn context never aborts it.
|
||||
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) {
|
||||
if o.history == nil || res == nil || res.Text == "" {
|
||||
return
|
||||
}
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, DeviceID: deviceID, Mode: modeOrDefault(mode)})
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode)})
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user