Files
AskaEth 13a0519115 feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)
塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:01:02 +08:00

410 lines
16 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 (
"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
}
// coldShoulderBridge 构建一条成员群消息用的桥接器:**未装配**参与度引擎与 LightJudge
// (规则得不出结论时失败关闭为静默),时钟可注入 —— 因此「必不必回」只由规则判据与
// 冷避闸决定,用例完全确定。
func coldShoulderBridge(t *testing.T, chat *stubChatter, hist *captureHistory, now func() time.Time) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist, Now: now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b
}
// 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
}