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

395 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 (
"bytes"
"context"
"io"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖 M18(主人 2026-10-14 裁决「给」:群成员也能让她少说话)在
// channelbridge 里的落点 —— **冷避优先于「@她必回」**(design/18 §5):
//
// - 非主人**群**消息先过冷避闸(命中负面态度 → COOLDOWN;冷却期内一律静默),
// 顺序排在廉价规则判定(ruleLightJudge)**之前**,因此 @她 / @全体 /
// 高价值关键词都拦不住它;
// - 边界:私聊(含陌生人私聊)与 owner 车道**不受影响**;闸与是否装配
// 参与度引擎**无关**;
// - 被冷避静默的消息**照样进上下文**(registerSilenced / persistIsolated),
// 只是不进 loop —— M13 红线,端到端钉住。
// coldShoulderSelfID 是 2026-10-14 报障现场那个号(她自己的 self_id),用于把
// 「@她」判据按**结构化字段**接上线(与 ownerlane.go 的 ownerAddressedToMe 同一口径)。
const coldShoulderSelfID = "3443115524"
// coldShoulderMention 构造一条**指向她本人**的成员群消息:SelfID 已接线 ⇒
// AddressedToMe 由结构化字段算出(不是靠正文里的 "@<数字>" 猜)。修前的实现会把它
// 当成「必回」,这正是本片要改的顺序偏离。
func coldShoulderMention(content string) adapter.UnifiedMessage {
m := nonOwnerGroupMsg(content, adapter.Mention{UserID: coldShoulderSelfID})
m.SelfID = coldShoulderSelfID
m.AddressedToMe = m.AddressedToSelf()
return m
}
// TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**):
// 群成员 @她 并说「减少文本输出量」时 ——
//
// - 修前:@她 命中 ruleLightJudge 的 verdictRespond ⇒ shouldRespond 直接 return true,
// 冷避根本走不到(红:循环被驱动,她只会口头答应、行为零变化,即报障现场);
// - 修后:冷避闸排在「@她必回」之前 ⇒ 静默,但**照样登记**(静默 ≠ 丢弃)。
//
// 两种部署都断言:装配了参与度引擎的(原本走 engageDecision)与未装配的
// (原本走旧轻量判定车道)—— 顺序修复**与引擎装配无关**(任务①的边界要求)。
func TestColdShoulderPrecedesAddressedToMe(t *testing.T) {
t.Parallel()
cases := []struct {
name string
engage bool
}{
{name: "engage engine wired", engage: true},
{name: "no engage engine (legacy light lane)", engage: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
chat := &stubChatter{res: &loop.Result{Text: "好嘛,人家少说点♪"}}
hist := &captureHistory{}
opts := Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
}
if tc.engage {
opts.Light = judge
opts.TriggerWords = []string{"昔涟"}
opts.Engage = DefaultEngageConfig()
opts.EngageRand = func() float64 { return 0.5 }
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 || recs[0].Content != "减少文本输出量" {
t.Fatalf("records = %+v, want 恰好 1 条登记(静默 ≠ 丢弃)", recs)
}
// 只有装配了引擎的那一路才够得着判定器;旧车道根本没有它,
// 因此这一条只在装配时断言(不给旧车道记一笔假功劳)。
if tc.engage && judge.calls != 0 {
t.Fatalf("judge calls = %d, want 0(冷避命中不得咨询轻量 LLM:成本红线)", judge.calls)
}
})
}
}
// TestColdShoulderSecondReportedPhraseAndCooldownWindow 覆盖报障的**第二句原话**,并钉住
// 冷却的**时限性**:进冷却后连普通 @她 也不回(M18 口径 —— 旧口径「@ 打断冷却」正是本片
// 修掉的偏离);时钟推过冷却时长后她重新回到正常判定,**不是**永久闭嘴。
func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) {
t.Parallel()
// 真机时刻:2026-10-14 00:27(群 531082168,成员「企鹅神明」)。
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "好♪"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: &captureHistory{},
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少"))
if chat.sessionID != "" {
t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID)
}
// 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "" {
t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID)
}
// 推过默认冷却时长(30 分钟):她恢复到「@她必回」的常态。
now = now.Add(defaultCooldownDuration + time.Minute)
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.sessionID)
}
}
// TestColdShoulderNormalMentionNotCooled 是反例护栏(任务⑤②):不含负面态度的普通
// @她 消息**不得**被误冷却 —— 回得照常,且不把该群留在冷却态里(第二条普通 @她 仍回)。
// 诚实声明:这条在修前也是绿的,它是**护栏而非红前红**;红前红的证据是上面两条。
func TestColdShoulderNormalMentionNotCooled(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID)
}
// 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。
chat.sessionID = ""
b.process(context.Background(), coldShoulderMention("在不在"))
if chat.sessionID != "sess-g" {
t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID)
}
}
// TestColdShoulderGroupOnlyBoundaries 钉住冷避的**适用面**(任务①的三条边界):
//
// - 陌生人**私聊**说「闭嘴」照样正常聊天(冷避只存在于群聊车道,design/18 §11);
// - 主人**私聊**说「闭嘴」照样恒回(owner 车道在更早处分岔);
// - 主人**群聊**里 @她 说「安静点」仍必回(owner 车道的必回不受冷避影响,主人
// 有自己那套旋钮)。
//
// 三条在修前也是绿的(护栏),但它们钉住的是「不许把闸开大」的边界 —— 少了它们,
// 一次把闸挪到 shouldRespond 最前端就能悄悄改掉私聊与主人的行为而无人察觉。
func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
t.Parallel()
t.Run("stranger private still chats", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好呀"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-p", IsGroup: false, Owner: false}},
Owner: &user.OwnerProfile{}, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "9", ChannelType: adapter.ChannelDirect,
SenderID: "9", Sender: adapter.Actor{Role: adapter.RoleMember},
Content: "闭嘴", ContentType: adapter.ContentText,
})
if chat.sessionID != "sess-p" {
t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID)
}
})
t.Run("owner private still replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好的主人"}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{}, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
m := directMessage()
m.Content = "闭嘴"
b.process(context.Background(), m)
if chat.sessionID != history.MainSessionID {
t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID)
}
})
t.Run("owner group addressed still replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{},
dayNow(), slog.New(slog.NewTextHandler(io.Discard, nil)))
b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"}))
if chat.sessionID != "sess-og" {
t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID)
}
})
}
// TestColdShoulderSilencedMessageStillEntersContext 是 M13 红线的**端到端**守卫(任务②):
// 被冷避静默的消息照样进上下文,只是不进 loop。断言不走返回值,而走真实链路:
//
// ① 静默那条经 registerSilenced → persistIsolated 落进该群隔离会话(历史可读);
// ② 冷却结束后下一条 @她 真正驱动回合时,**该回合的模型可见消息里含那条被静默的话**
// —— 她看得见「刚才有人让她少说话」,不是被蒙在鼓里(transcript.go 的播种)。
func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
chat := &stubChatter{res: &loop.Result{Text: "知道啦♪"}}
hist := &recentHistory{}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID)
}
// ① 静默那条已被登记(不是被丢掉)。
registered := false
for _, r := range hist.records() {
if r.SessionID == "sess-g" && r.Role == history.RoleUser && r.Content == "减少文本输出量" {
registered = true
}
}
if !registered {
t.Fatalf("被冷避的消息没有登记:records = %+v", hist.records())
}
// ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。
now = now.Add(defaultCooldownDuration + time.Minute)
b.process(context.Background(), coldShoulderMention("在吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID)
}
if !loopMessagesContain(chat.messages, "减少文本输出量") {
t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages)
}
}
// TestNegativeAttitudeKeywordList 逐条钉住 M18 的词表增补(任务③):认识**真机原话**,
// 且**不**认识只在字面上擦边的日常说法(子串匹配的误伤护栏 —— 词条选取见
// engage.go 的 negativeAttitudeKeywords 注释)。
func TestNegativeAttitudeKeywordList(t *testing.T) {
t.Parallel()
b := &Bridge{}
cases := []struct {
name string
text string
want bool
}{
{"real report 1", "@3443115524 减少文本输出量", true},
{"real report 2", "@3443115524 在本群中文本输出调到最少", true},
{"same wording, no quantifier", "减少文本输出频率", true},
{"pre-existing owner wording", "少说点", true},
{"dev talk about logs", "我在写脚本,减少输出日志", false},
{"praising output quality", "这个模型的输出质量不错", false},
{"another kind of output", "把音箱输出调到最少", false},
{"plain chatter", "今天天气不错", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := b.negativeAttitude(tc.text); got != tc.want {
t.Fatalf("negativeAttitude(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
}
// TestColdShoulderLogsVisibleInfo 钉住可见性(任务④):进冷却时必须有**带来源的 INFO**
// (哪个群 / 被谁的话 / 冷却到什么时候),使主人能看出「她为什么突然不说话了」。
// 日志绝不打印正文(design/18 §9.7),因此这里只断言键值。
func TestColdShoulderLogsVisibleInfo(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, Log: log, Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
out := buf.String()
for _, want := range []string{
"level=INFO",
"source=services/channelbridge",
"group asked her to stay quiet",
"group=telegram",
"sender=9",
"sender_name=张三",
"until=2026-01-01T12:30:00.000Z",
} {
if !strings.Contains(out, want) {
t.Fatalf("INFO 日志缺少 %q;got:\n%s", want, out)
}
}
if strings.Contains(out, "减少文本输出量") {
t.Fatalf("日志打印了正文(design/18 §9.7 禁止):\n%s", out)
}
}
// loopMessagesContain 报告本回合的模型可见消息里是否出现了 want(用于断言
// 「被静默登记的消息确实进了上下文」,而不是只看落库记录)。
func loopMessagesContain(msgs []model.Message, want string) bool {
for _, m := range msgs {
for _, c := range m.Content {
if strings.Contains(c.Text, want) {
return true
}
}
}
return false
}
// recentHistory 是 captureHistory 的读取侧补齐:Recent 返回本会话已登记的消息,
// 使 seedTurnTranscript 真能把「被静默登记」的那条话播进下一个回合的上下文。
type recentHistory struct {
captureHistory
}
func (h *recentHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
var out []history.Message
for _, m := range h.records() {
if m.SessionID == sessionID {
out = append(out, m)
}
}
if n > 0 && len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}