Files
Cyrene_v2/internal/services/channelbridge/engage_test.go
T
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

399 lines
15 KiB
Go

package channelbridge
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"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/user"
)
// engageScriptJudge is a configurable LightJudge for the participation-engine tests:
// it replays an ordered list of decisions (and optional per-call errors) and records
// how often it was consulted plus the last input it saw.
type engageScriptJudge struct {
responses []LightDecision
errs []error
calls int
last LightInput
}
func (s *engageScriptJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++
s.last = in
i := s.calls - 1
if i < len(s.errs) && s.errs[i] != nil {
return LightDecision{}, s.errs[i]
}
if i < len(s.responses) {
return s.responses[i], nil
}
return LightDecision{Interesting: true, WorthReply: true, Reason: "默认参与"}, nil
}
// dayNow returns a daytime (non-quiet) wall-clock source so the engine runs outside
// the quiet window.
func dayNow() func() time.Time {
return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
}
// engageTestBridge builds a Bridge wired for the participation engine with the given
// judge/config/clock/rand source. A zero-valued config is replaced by the defaults so
// callers can override just the knob they care about.
func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock func() time.Time, rng func() float64) (*Bridge, *stubChatter, *captureHistory) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
if cfg.Probability == 0 && cfg.ActivityWindow == 0 && cfg.ActivityMessages == 0 && cfg.Cooldown == 0 {
cfg = DefaultEngageConfig()
}
if rng == nil {
rng = func() float64 { return 0.5 } // default: never hit the probability trigger
}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge, TriggerWords: []string{"昔涟", "Cyrene"},
PersonaPrompt: "你是昔涟。", Engage: cfg, EngageRand: rng, Now: clock,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b, chat, hist
}
// cfgShortWindow is a config with a 1h cooldown/activity and a small 2-message
// activity window, so count-based window expiry is easy to drive deterministically.
func cfgShortWindow() EngageConfig {
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 2
cfg.Cooldown = time.Hour
return cfg
}
func TestEngageRoleNameMentionProbesAndResponds(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
// 触发②: naming 昔涟 without an @ routes the message into a cost-controlled PROBING.
b.process(context.Background(), nonOwnerGroupMsg("大家在聊昔涟的事"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (role-name mention → PROBING)", judge.calls)
}
if !judge.last.Engage {
t.Fatal("judge input Engage = false, want true for a participation judgment")
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (interested → ACTIVE reply)", chat.sessionID)
}
}
func TestEngageRoleNameMentionNotInterestedSilences(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{{Interesting: false, WorthReply: false, Reason: "无感"}}}
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("提一句昔涟而已"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after no-interest, session = %q", chat.sessionID)
}
// Not interested → registered but not answered.
if len(hist.records()) != 1 || hist.records()[0].Content != "提一句昔涟而已" {
t.Fatalf("records = %+v, want 1 registered user message", hist.records())
}
}
func TestEngageAvoidTopicStaysIdle(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{{AvoidTopic: true, Reason: "敏感话题"}}}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟怎么办"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after avoidTopic, session = %q", chat.sessionID)
}
}
func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) {
t.Parallel()
// Cost-control red line: an untriggered, probability-missing message must NOT call
// the light judge at all — no context is fed, no LLM turn, just registered silent.
judge := &engageScriptJudge{}
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0 (untriggered must not call LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran without a trigger, session = %q", chat.sessionID)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered only)", len(hist.records()))
}
}
func TestEngageProbabilityTriggerResponds(t *testing.T) {
t.Parallel()
// 触发③: an injected rand below the probability (0.05 < 0.10) routes the message
// into PROBING even with no role-name mention.
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.05 })
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (probability hit → PROBING)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge interested → reply)", chat.sessionID)
}
}
func TestEngageProbabilityMissSilences(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.5 })
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0 (probability miss → no LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran on probability miss, session = %q", chat.sessionID)
}
}
func TestEngageStateMachineActivityWindowExpiry(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
{WorthReply: true, Reason: "跟随"}, // follow-up 1 → still reply
}}
b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil)
// msg1: role-name mention → probe → interested → ACTIVE (respond).
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if chat.sessionID != "sess-g" || judge.calls != 1 {
t.Fatalf("after probe: session=%q calls=%d, want sess-g/1", chat.sessionID, judge.calls)
}
// msg2: inside the window → follow-up review, still replies.
b.process(context.Background(), nonOwnerGroupMsg("周末去哪玩"))
if chat.sessionID != "sess-g" || judge.calls != 2 {
t.Fatalf("after follow-up: session=%q calls=%d, want sess-g/2", chat.sessionID, judge.calls)
}
// msg3: the 2-message window is now full → exit ACTIVE to IDLE. No trigger/probability
// (rand 0.5) → silent, and crucially the judge is NOT consulted a third time.
b.process(context.Background(), nonOwnerGroupMsg("接着聊"))
if judge.calls != 2 {
t.Fatalf("judge calls = %d, want 2 (window expired; no follow-up, no re-trigger)", judge.calls)
}
}
func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) {
t.Parallel()
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 100
cfg.Cooldown = time.Hour
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
{Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 1 → no interest
{Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 2 → threshold → exit
}}
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if judge.calls != 1 || chat.sessionID != "sess-g" {
t.Fatalf("probe: calls=%d session=%q, want 1/sess-g", judge.calls, chat.sessionID)
}
// Two consecutive no-interest follow-ups, then a subsequent untriggered message
// must no longer call the judge (she has dropped out of ACTIVE).
b.process(context.Background(), nonOwnerGroupMsg("说说别的"))
b.process(context.Background(), nonOwnerGroupMsg("再说一点"))
if judge.calls != 3 {
t.Fatalf("judge calls = %d, want 3 (two no-interest follow-ups)", judge.calls)
}
b.process(context.Background(), nonOwnerGroupMsg("最后随便"))
if judge.calls != 3 {
t.Fatalf("judge calls = %d, want 3 (exited ACTIVE, no re-trigger)", judge.calls)
}
}
func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
t.Parallel()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
judge := &engageScriptJudge{} // default: interested (would reply if consulted)
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil)
// 冷避: "别刷屏" is a negative attitude → COOLDOWN, and she is silent (registered only).
b.process(context.Background(), nonOwnerGroupMsg("昔涟别刷屏了"))
if judge.calls != 0 {
t.Fatalf("judge calls = %d, want 0 (negative attitude → cooldown, no LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after negative attitude, session = %q", chat.sessionID)
}
// During cooldown, even a role-name mention is NOT answered (no proactive reply).
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟"))
if judge.calls != 0 || chat.sessionID != "" {
t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID)
}
// A direct @ still breaks cooldown (design/18 §5: 除非 @ 她).
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID)
}
// After the cooldown timer elapses, a role-name mention re-engages (PROBING → ACTIVE).
now = now.Add(2 * time.Hour)
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("再提昔涟"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (cooldown expired → re-probe)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("session = %q, want sess-g (re-engaged after cooldown)", chat.sessionID)
}
}
func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) {
t.Parallel()
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 100
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
}}
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if chat.sessionID != "sess-g" {
t.Fatalf("probe: session=%q, want sess-g", chat.sessionID)
}
// A negative attitude during ACTIVE parks her in COOLDOWN immediately (design/18 §5).
b.process(context.Background(), nonOwnerGroupMsg("别说了太吵"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (negative attitude must not consult the judge)", judge.calls)
}
b.process(context.Background(), nonOwnerGroupMsg("再聊昔涟"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (cooldown suppresses re-engagement)", judge.calls)
}
}
func TestEngageJudgeReceivesContextAndPersonaPrompt(t *testing.T) {
t.Parallel()
hist := &contextHistory{
recent: []history.Message{
{SessionID: "sess-g", Role: history.RoleUser, Content: "有人知道昔涟吗", SenderName: "张三"},
{SessionID: "sess-g", Role: history.RoleAssistant, Content: "在呢", SenderName: ""},
},
}
judge := &engageScriptJudge{}
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge, TriggerWords: []string{"昔涟"},
PersonaPrompt: "你是昔涟。", Engage: DefaultEngageConfig(), EngageRand: func() float64 { return 0.5 },
Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟嘛"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1", judge.calls)
}
if judge.last.Engage != true || judge.last.PersonaPrompt != "你是昔涟。" {
t.Fatalf("judge input Engage=%v prompt=%q, want true/你是昔涟。", judge.last.Engage, judge.last.PersonaPrompt)
}
if judge.last.RecentContext == "" || judge.last.TriggerWords[0] != "昔涟" {
t.Fatalf("judge context=%q triggerWords=%v, want non-empty context and 昔涟", judge.last.RecentContext, judge.last.TriggerWords)
}
}
// contextHistory is a captureHistory that returns a fixed recent window, so the
// engagement context renderer can be asserted.
type contextHistory struct {
captureHistory
recent []history.Message
}
func (c *contextHistory) Recent(context.Context, string, int) ([]history.Message, error) {
return c.recent, nil
}
func TestParseEngageDecision(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want LightDecision
err bool
}{
{name: "pure", in: `{"interested":true,"worthReply":true,"avoidTopic":false,"reason":"想聊"}`, want: LightDecision{Interesting: true, WorthReply: true, Reason: "想聊"}},
{name: "fenced", in: "```json\n{\"interested\":false,\"worthReply\":false,\"avoidTopic\":false}\n```", want: LightDecision{}},
{name: "prose_wrapped", in: "分析一下:{\"interested\":true,\"worthReply\":false,\"avoidTopic\":true,\"reason\":\"敏感\"} 完毕", want: LightDecision{Interesting: true, WorthReply: false, AvoidTopic: true, Reason: "敏感"}},
{name: "malformed", in: "not json at all", err: true},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseEngageDecision(tt.in)
if tt.err {
if err == nil {
t.Fatalf("parseEngageDecision(%q) want error, got nil", tt.in)
}
return
}
if err != nil {
t.Fatalf("parseEngageDecision(%q) error: %v", tt.in, err)
}
if got.Interesting != tt.want.Interesting || got.WorthReply != tt.want.WorthReply ||
got.AvoidTopic != tt.want.AvoidTopic || got.Reason != tt.want.Reason {
t.Fatalf("parsed = %+v, want %+v", got, tt.want)
}
})
}
}