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 把消息截短为状态机所用的有界话题标记。