Files
Cyrene_v2/internal/services/channelbridge/engage_test.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

399 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 是参与度引擎测试用的可配置 LightJudge:
// 它按顺序回放一组裁决(以及可选的逐次调用错误),并记录
// 被咨询的次数以及最近一次看到的输入。
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 返回白天(非安静)的墙上时钟源,使引擎运行在
// 安静窗口之外。
func dayNow() func() time.Time {
return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
}
// engageTestBridge 构建一个已接好参与度引擎的 Bridge,使用给定的
// 判定器/配置/时钟/随机源。零值配置会被替换为默认值,
// 使调用方只需覆盖自己关心的参数。
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 } // 默认:永不命中概率触发
}
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 是冷却/活跃时长 1 小时、活跃窗口仅 2 条消息
// 的配置,便于确定性地驱动按条数计的窗口过期。
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)
// 触发②:没有 @ 但提到「昔涟」会把该消息送入受成本控制的 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)
}
// 不感兴趣 → 只登记、不回复。
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()
// 成本控制红线:未触发、也未命中概率的消息绝不调用
// 轻量判定器——不喂上下文、不走 LLM 轮次,只登记并静默。
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()
// 触发③:注入的随机值低于概率阈值(0.05 < 0.10),即使没有
// 角色名提及也会把消息送入 PROBING。
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: "感兴趣"}, // 探测 → ACTIVE
{WorthReply: true, Reason: "跟随"}, // 跟进 1 → 仍然回复
}}
b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil)
// msg1:角色名提及 → 探测 → 感兴趣 → ACTIVE(回复)。
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:仍在窗口内 → 跟进复审,仍回复。
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:2 条消息的窗口已满 → 退出 ACTIVE 回到 IDLE。无触发/无概率
// (随机值 0.5)→ 静默,且关键是判定器不会第三次被咨询。
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: "感兴趣"}, // 探测 → ACTIVE
{Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 1 → 无兴趣
{Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 2 → 达到阈值 → 退出
}}
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)
}
// 连续两次无兴趣跟进之后,随后一条未触发的消息
// 绝不再调用判定器(她已退出 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{} // 默认:感兴趣(若被咨询会回复)
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil)
// 冷避:「别刷屏」是负面态度 → COOLDOWN,她保持静默(只登记)。
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)
}
// 冷却期间,即使是角色名提及也不回复(不做主动回复)。
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)
}
// 直接 @ 仍会打断冷却(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)
}
// 冷却计时结束后,角色名提及会重新触发参与(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: "感兴趣"}, // 探测 → 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)
}
// ACTIVE 期间出现负面态度会立刻把她按进 COOLDOWN(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 是返回固定近期窗口的 captureHistory,便于
// 断言参与度上下文渲染器。
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)
}
})
}
}