塔 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 交叉编译通过。
410 lines
16 KiB
Go
410 lines
16 KiB
Go
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
|
||
}
|