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 单独跑亦绿。
403 lines
15 KiB
Go
403 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 是参与度引擎测试用的可配置 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)
|
||
}
|
||
})
|
||
}
|
||
}
|