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 单独跑亦绿。
This commit is contained in:
AskaEth committed 2026-10-15 18:55:35 +08:00
1 parent 6432088586
commit fe53e029ea
4 files changed
+499 -24

No files matched your search

+70 -15
View File
@@ -19,6 +19,10 @@ import (
// 态度时的冷避。它按进程存于内存(重启即重置),
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
//
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
@@ -168,21 +172,14 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage,
defer st.mu.Unlock()
now := b.now()
// design/18 §5 冷避优先:群成员表达负面态度是
// 明确的「安静」信号 → COOLDOWN,即使正处在参与中。直接 @ / 高价值
// 关键词已在上方返回,因此只有真正「内容值得回复」
// 的消息(或主人消息,它根本不会走到这里)才能覆盖它。
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
b.log.Debug("engage cooldown (negative attitude)",
"group", key, "until", st.cooldownUntil)
return false, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return false, "engage: cooldown active"
// design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 →
// COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸
// (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此
// @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。
// 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义,
// 将来若多出别的调用点也不会漏掉它。
if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet {
return false, reason
}
// ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断
@@ -351,10 +348,68 @@ func (b *Bridge) negativeAttitude(text string) bool {
// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。
// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②),
// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。
//
// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而
// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤,
// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群
// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的
// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。
var negativeAttitudeKeywords = []string{
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
"打住", "住嘴", "shut up", "too loud", "stop spamming",
// 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式,
// 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 ——
// 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。
"减少文本输出",
// 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。
// 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出
// (音箱/视频/日志)调低的话题,故不取。
"文本输出调到最少",
}
// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸
// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环:
// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内。
//
// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住
// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化
// (2026-10-14 报障的病灶)。适用面严格限定如下:
//
// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按
// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟;
// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套
// 结构化旋钮(ownerlane.go),冷避绝不碰主人的必回语义;
// - **与参与度引擎是否装配无关**:未装配引擎(旧轻量判定车道)的部署顺序与
// 装配了的**完全一致** —— 闸本身不查 engageConfigured()。
func (b *Bridge) coldShoulderGate(msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now())
}
// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则
// **唯一**的实现:shouldRespond 的冷避闸与 engageDecision 都调它。
// 命中负面态度时进 COOLDOWN 并留一条带来源的 INFO —— 判据只读正文关键词,
// 日志只带群键、发送者与截止时刻,**绝不打印正文**(design/18 §9.7)。
func (b *Bridge) coldShoulderLocked(msg adapter.UnifiedMessage, actor adapter.Actor, key string, st *engageGroupState, cfg EngageConfig, now time.Time) (bool, string) {
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
// INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。
b.log.Info("engage cooldown: group asked her to stay quiet",
"group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor),
"until", st.cooldownUntil)
return true, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return true, "engage: cooldown active"
}
return false, ""
}
// contentSnippet 把消息截短为状态机所用的有界话题标记。
@@ -0,0 +1,394 @@
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
}
@@ -269,11 +269,15 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID)
}
// 直接 @ 仍会打断冷却(design/18 §5:除非 @ 她)。
// 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 != "sess-g" {
t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID)
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)。
+28 -6
View File
@@ -174,12 +174,19 @@ func isStrangerPrivate(sess session.Session) bool {
// shouldRespond 实现入站消息的最小打扰分级
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息
// 应驱动完整 agent 循环并回复,还是只登记并静默。
// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」):
// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说
// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回,
// 而主人群里不对她说的消息改为**进上下文不回**。sess 是
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 应驱动完整 agent 循环并回复,还是只登记并静默。判定顺序(M18 后的**权威顺序**):
//
// ① owner 车道(`actor.IsOwner()`,design/18 §11「乙 = C」):主人私聊恒回;
// 主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说(见 ownerlane.go)——
// 冷避不适用于主人,主人的 @/@全体仍必回。
// ② 平台/系统噪声 → 只登记。
// ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。
// ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、
// 冷却期内一律静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。
// ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。
//
// sess 是该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
@@ -219,6 +226,21 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
// 「参与度触发 / 概率」那一档(未触发则只登记)。**安静时段只作为信号**(inQuiet)
// 继续往下传,规则层不再因为它静默(裁决②)。
inQuiet := b.inQuietHours(b.now())
// design/18 §5 冷避**先于**「@她必回」(M18,主人 2026-10-14 裁决「给」:
// 群成员也能让她少说话):非主人**群**消息先过冷避闸 —— 命中负面态度就进
// COOLDOWN,冷却期内一律静默(两者都只登记、不进循环)。顺序上必须排在下一条
// 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true,
// 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。
//
// 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔)
// 且与是否装配参与度引擎无关 —— 旧轻量判定车道的部署同样得到这个顺序。
if sess.IsGroup {
if quiet, reason := b.coldShoulderGate(msg, actor); quiet {
return false, reason
}
}
if b.ruleLightJudge(msg) == verdictRespond {
return true, "rule signal: worth responding"
}