Files
AskaEth fe53e029ea fix(channelbridge): 冷避优先于「@她必回」——群成员也能让她少说话
M18(主人 2026-10-14 裁决「给」)。病灶是**顺序**:`shouldRespond` 把
`ruleLightJudge`(指向她本人 / 高价值关键词)排在参与度引擎之前,@她的消息
在规则层就 return true,永远走不到 `engageDecision` 的冷避判定——群成员说
「少说点」只换来一句口头答应,行为零变化(2026-10-14 00:27 群 531082168 报障)。

- light.go:新增冷避闸位次 —— 非主人**群**消息在廉价规则之前先过
  `coldShoulderGate`,命中负面态度即进 COOLDOWN、冷却期内一律静默(仍登记)。
  边界:私聊不受影响(冷避是群机制)、owner 车道更早分岔不受影响、
  与是否装配参与度引擎无关。shouldRespond 的判定顺序写进函数注释。
- engage.go:把冷避判据抽成唯一实现 `coldShoulderLocked`(engageDecision 仍调用
  它,作纵深防御),命中时补一条带 source 的 INFO(群 / 发送者 / 冷却到何时)。
- engage.go 词表:依真机原话补「减少文本输出」(覆盖「减少文本输出量」)与
  「文本输出调到最少」;刻意不取「减少 / 输出 / 最少」等短泛词(子串匹配会误伤)。
- 测试:新增 engage_coldshoulder_test.go(主断言修前红/修后绿、登记进上下文的
  端到端守卫、私聊与 owner 边界护栏、词表误伤护栏、INFO 可见性);
  engage_test.go 更新旧口径「@ 打断冷却」为「@ 不得作废冷避」。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(windows 本机
与 darwin/arm64 交叉编译均通过);channelbridge / channel / cmd/cyrene 单独跑亦绿。
2026-10-15 18:55:35 +08:00

403 lines
15 KiB
Go
Raw Permalink 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)
}
// M18(主人 2026-10-14 裁决「给」):冷却期内**连 @ 也不回** ——
// 冷避优先于「@她必回」,群成员的一句「少说点」不被随后的 @ 作废。
// (旧口径「@ 打断冷却」正是本片修掉的偏离;见 design/18 §5 与
// engage_coldshoulder_test.go 的端到端用例。)
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "" || judge.calls != 0 {
t.Fatalf("mention during cooldown: session=%q calls=%d, want \"\"/0 (@ no longer breaks the cold shoulder)",
chat.sessionID, judge.calls)
}
// 冷却计时结束后,角色名提及会重新触发参与(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)
}
})
}
}