diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index a1ed09d..cdba2ed 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -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 把消息截短为状态机所用的有界话题标记。 diff --git a/internal/services/channelbridge/engage_coldshoulder_test.go b/internal/services/channelbridge/engage_coldshoulder_test.go new file mode 100644 index 0000000..2ab4b60 --- /dev/null +++ b/internal/services/channelbridge/engage_coldshoulder_test.go @@ -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 +} diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go index 16c7986..4aa317f 100644 --- a/internal/services/channelbridge/engage_test.go +++ b/internal/services/channelbridge/engage_test.go @@ -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)。 diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 8ebe8cd..dacfad4 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -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" }