From 28967d343578f49ea18d1e9b981f5c7d074d27e2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 15 Oct 2026 19:04:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(channelbridge):=20=E5=86=B7=E9=81=BF?= =?UTF-8?q?=E4=BE=8B=E5=A4=96=E2=91=A1=E6=94=B6=E7=AA=84=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E7=9C=9F=E6=95=85=E9=9A=9C/=E6=B1=82=E6=95=91=E3=80=8D?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=AD=90=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔 2026-10-15 改裁定:例外给了命中词「穿透 30 分钟冷避」的权力,噪声不再只是多回 一句话,而是直接削弱冷避本身 —— 而实测全表 14 条在 598 条真实 user 消息里只命中 1 次,且那次是假阳性(QQ 机器人卡片文案里的 `bug`,而该群本来就在放这种卡片)。 - 新增 coldShoulderPierceKeywords(具名常量,注释写明为什么比全表严、依据即那条 卡片假阳性)与 hasColdShoulderPierceKeyword:保留 报错/出错/故障/紧急/救命/求助/ 帮帮我/urgent,剔出 ASCII `bug`/`error`/`help`(会命中结构化载荷/URL/卡片)与 情绪歧义的 `崩溃`/`坏了`/`坏掉`。 - 例外② 改用该子集;hasHighValueKeyword(基线,ruleLightJudge 用)**一字未改**, 两张表的分工写进注释,并有断言钉住「基线不许被这次收窄波及」。 - 例外矩阵新增三行:机器人卡片含 `bug` ⇒ 不穿透;英文 error/help ⇒ 不穿透; 情绪「我崩溃了」⇒ 不穿透(三行对上一提交均为红)。第 1/2 行继续用保留词 报错, 第 3/4/5/6 行原样保留;另加子集单元护栏(正例六条 / 反例六条)。 - 按项目「文件 ≤500 行」把冷避规则簇拆到 engage_coldshoulder.go(engage.go 528→376)。 红前绿证据:把例外判据临时还原成上一提交的 hasHighValueKeyword 全表(与该提交逐字 等价)⇒ 恰好上述三行变红、其余行与护栏保持绿;已还原(无残留)。 已知代价如实登记在案:用英文写的真求救不再穿透 —— 塔裁定以「宁可少穿透」为准; 全表(highValueKeywords)本身的收窄由塔另排切片,不在本片。 验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包); channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。 --- internal/services/channelbridge/engage.go | 108 ----------- .../channelbridge/engage_coldshoulder.go | 168 ++++++++++++++++++ .../engage_coldshoulder_exception_test.go | 76 +++++++- internal/services/channelbridge/light.go | 13 +- 4 files changed, 250 insertions(+), 115 deletions(-) create mode 100644 internal/services/channelbridge/engage_coldshoulder.go diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index 20481ee..ad0bb1c 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -330,114 +330,6 @@ func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool { return b.engageRand() < cfg.Probability } -// negativeAttitude 报告群是否在让她安静(design/18 §5 -// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号 -// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。 -func (b *Bridge) negativeAttitude(text string) bool { - if text == "" { - return false - } - lower := strings.ToLower(text) - for _, k := range negativeAttitudeKeywords { - if strings.Contains(lower, k) { - return true - } - } - return false -} - -// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 -// 兜底;同一信号也会被判定器中的跟进复审通过 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()。 -// -// 例外口径(塔 2026-10-15 定稿,design/18 §5/§9 由塔同步)见 coldShoulderLocked。 -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)。 -// -// 判定顺序(塔 2026-10-15 裁定;该口径的文档面 design/18 §5/§9 由塔同步,本片只写代码注释): -// -// ① 同句**负面态度优先**:一句里既有「安静点」又有「报错」也按负面态度处理 -// —— 加个高价值词绝不能把「安静点」作废,仍进冷却、仍静默。 -// ② 冷却期内 + **高价值关键词**(真故障)⇒ **放行这一条**,且**不解除冷却** -// (不动 st.state / st.cooldownUntil):「让她少**闲聊**」≠「让她漏掉**真故障**」, -// 技术群里的 `报错`/`error`/`bug` 是刚需;但这不等于撤诉 —— 紧接着的 -// @她 / 闲聊消息仍在冷却里静默。 -// ③ 冷却期内其余一切(含 @她 / @全体)⇒ 静默。 -// -// **主人为什么不需要单独写一条例外**:owner 车道在 shouldRespond 的**第一步**就按 -// `actor.IsOwner()` 分岔(见 ownerlane.go 的 ownerGroupShouldRespond),主人的消息 -// 根本走不到这个闸;在这里再写一条「主人例外」是死代码,还会让人误以为主人 -// 也可能被冷避压住。 -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) { - // 例外②:高价值关键词穿透冷避 —— 只放行这一条,冷却本身照旧。 - if hasHighValueKeyword(msg.Content) { - b.log.Debug("engage cooldown: high-value keyword allowed through, cooldown kept", - "group", key, "until", st.cooldownUntil) - return false, "" - } - b.log.Debug("engage cooldown active, not responding", - "group", key, "until", st.cooldownUntil) - return true, "engage: cooldown active" - } - return false, "" -} - // contentSnippet 把消息截短为状态机所用的有界话题标记。 func contentSnippet(text string) string { runes := []rune(strings.TrimSpace(text)) diff --git a/internal/services/channelbridge/engage_coldshoulder.go b/internal/services/channelbridge/engage_coldshoulder.go new file mode 100644 index 0000000..363cc5b --- /dev/null +++ b/internal/services/channelbridge/engage_coldshoulder.go @@ -0,0 +1,168 @@ +package channelbridge + +import ( + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// 本文件承载**冷避规则**(design/18 §5)——与它的状态机骨架(engage.go)分开, +// 使两边都留在项目「文件 ≤500 行」的上限内:负面态度词表 + 例外②的真故障/求救 +// 严格子集 + 冷避闸本体(coldShoulderGate / coldShoulderLocked)。 +// +// 顺序口径(塔 2026-10-15 定稿,文档面 design/18 §5/§9 由塔同步): +// 同句负面态度优先 → 冷却期内真故障/求救信号穿透(不解除冷却)→ 其余一律静默; +// @她 / @全体 不穿透;主人不需要例外(owner 车道在 shouldRespond 第一步已分岔)。 + +// negativeAttitude 报告群是否在让她安静(design/18 §5 +// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号 +// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。 +func (b *Bridge) negativeAttitude(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + for _, k := range negativeAttitudeKeywords { + if strings.Contains(lower, k) { + return true + } + } + return false +} + +// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 +// 兜底;同一信号也会被判定器中的跟进复审通过 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", + // 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式, + // 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 —— + // 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。 + "减少文本输出", + // 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。 + // 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出 + // (音箱/视频/日志)调低的话题,故不取。 + "文本输出调到最少", +} + +// coldShoulderPierceKeywords 是冷避**例外②专用**的严格子集(塔 2026-10-15 裁定): +// 冷却期内只有命中它的消息能穿透冷避。它**只服务这一个用途** —— 与 light.go 的 +// highValueKeywords(基线规则,决定「平时值不值得回」)是**两张表**:那张表一个字 +// 不许改,也**不要**把它并进来(见下面的理由)。 +// +// **为什么它比全表严**:例外②给命中词「穿透 30 分钟冷避」的权力,于是噪声不再只是 +// 多回一句话,而是**直接削弱冷避本身**。实测(598 条真实 user 消息)全表 14 条只 +// 命中 1 次,且那次是**假阳性** —— QQ 机器人卡片文案「唯一的 bug 是你至今没搞懂 +// 为什么猪喜欢拱泥」(群 531082168,2026-10-14),而该群本来就在放这类卡片。 +// 因此按塔裁定剔掉三类噪声源: +// +// - ASCII `bug` / `error` / `help`:子串会命中结构化载荷(`%7B%22version%22`)、 +// URL、markdown 卡片与英文闲聊里的同形片段(上面那例即实证); +// - 情绪 / 口语歧义的中文 `崩溃`(「我崩溃了」是情绪,不是故障)与 +// `坏了` / `坏掉`(「坏了,我忘了」是「糟糕」义)。 +// +// `urgent` 同样是 ASCII 却在保留之列:它的语义只有「紧急」一种,不会像 `error`/`bug` +// 那样以名词形态出现在卡片/日志载荷里(实测 0 命中)—— 按塔的裁定保留。 +// +// 代价明写在案:用英文写的真求救("…error…help…")**不再穿透**。塔裁定以「宁可少 +// 穿透、不可多穿透」为准;全表本身的收窄是**独立切片**,不在本片做。 +var coldShoulderPierceKeywords = []string{ + "报错", "出错", "故障", "紧急", "救命", "求助", "帮帮我", "urgent", +} + +// hasColdShoulderPierceKeyword 报告文本是否命中例外②的严格子集。与全表 +// (hasHighValueKeyword)同一手法:廉价子串匹配,只读正文,绝不推断身份。 +func hasColdShoulderPierceKeyword(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + for _, k := range coldShoulderPierceKeywords { + if strings.Contains(lower, k) { + return true + } + } + return false +} + +// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸 +// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环: +// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条 +// 没有命中**真故障/求救信号**严格子集(coldShoulderPierceKeywords)。 +// +// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住 +// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化 +// (2026-10-14 报障的病灶)。适用面严格限定如下: +// +// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按 +// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟; +// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套 +// 结构化旋钮(ownerlane.go),冷避绝不碰主人的必回语义; +// - **与参与度引擎是否装配无关**:未装配引擎(旧轻量判定车道)的部署顺序与 +// 装配了的**完全一致** —— 闸本身不查 engageConfigured()。 +// +// 例外口径(塔 2026-10-15 定稿,design/18 §5/§9 由塔同步)见 coldShoulderLocked。 +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)。 +// +// 判定顺序(塔 2026-10-15 裁定;该口径的文档面 design/18 §5/§9 由塔同步,本片只写代码注释): +// +// ① 同句**负面态度优先**:一句里既有「安静点」又有「报错」也按负面态度处理 +// —— 加个高价值词绝不能把「安静点」作废,仍进冷却、仍静默。 +// ② 冷却期内 + **真故障/求救信号** ⇒ **放行这一条**,且**不解除冷却** +// (不动 st.state / st.cooldownUntil):「让她少**闲聊**」≠「让她漏掉**真故障**」。 +// 判据是**严格子集** coldShoulderPierceKeywords,**不是** hasHighValueKeyword 全表 +// —— 例外给的是「穿透冷却」的权力,噪声会直接削弱冷避(依据见该表的注释); +// 但这不等于撤诉:紧接着的 @她 / 闲聊消息仍在冷却里静默。 +// ③ 冷却期内其余一切(含 @她 / @全体)⇒ 静默。 +// +// **主人为什么不需要单独写一条例外**:owner 车道在 shouldRespond 的**第一步**就按 +// `actor.IsOwner()` 分岔(见 ownerlane.go 的 ownerGroupShouldRespond),主人的消息 +// 根本走不到这个闸;在这里再写一条「主人例外」是死代码,还会让人误以为主人 +// 也可能被冷避压住。 +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) { + // 例外②:真故障/求救信号穿透冷避 —— 只放行这一条,冷却本身照旧。 + // 判据必须是**严格子集**(coldShoulderPierceKeywords),不是 + // hasHighValueKeyword 全表:穿透权一旦挂在全表上,卡片/载荷里的 + // `bug` 这类噪声就能把冷避按掉(2026-10-15 实测假阳性)。 + if hasColdShoulderPierceKeyword(msg.Content) { + b.log.Debug("engage cooldown: fault signal allowed through, cooldown kept", + "group", key, "until", st.cooldownUntil) + return false, "" + } + b.log.Debug("engage cooldown active, not responding", + "group", key, "until", st.cooldownUntil) + return true, "engage: cooldown active" + } + return false, "" +} diff --git a/internal/services/channelbridge/engage_coldshoulder_exception_test.go b/internal/services/channelbridge/engage_coldshoulder_exception_test.go index 1a47012..63cdb06 100644 --- a/internal/services/channelbridge/engage_coldshoulder_exception_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_exception_test.go @@ -13,7 +13,9 @@ import ( // 由塔同步,这里只钉行为): // // ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默; -// ② 高价值关键词(真故障)**穿透**冷却 —— 只放行这一条,且**不解除**冷却; +// ② **真故障/求救信号**(严格子集 coldShoulderPierceKeywords)穿透冷却 —— +// 只放行这一条,且**不解除**冷却;**不是**高价值关键词全表(`bug`/`error`/`help` +// 与情绪歧义的 `崩溃`/`坏了`/`坏掉` 被剔出去,矩阵里有对应行); // ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质); // ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。 // @@ -23,7 +25,7 @@ import ( // TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**: // 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。 // -// 每行对「本轮修前」= M18 提交 fe53e02 的性质用 bite 标出(红前红 / 护栏)—— +// 每行的 bite 标出它对**上一提交**(例外放宽为全表的那一版)的性质:红前红 / 护栏 —— // 不得把护栏吹成证据。 func TestColdShoulderCooldownMatrix(t *testing.T) { t.Parallel() @@ -46,6 +48,32 @@ func TestColdShoulderCooldownMatrix(t *testing.T) { wantReply: true, bite: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)", }, + { + // 真机实证(群 531082168,2026-10-14):机器人卡片文案里出现 `bug`。 + // 例外②收窄前(用 hasHighValueKeyword 全表)这条会**穿透冷却**; + // 收窄成严格子集后不穿透 —— 这正是塔 2026-10-15 改裁定的依据。 + name: "bot card containing bug does NOT pierce", + msg: func() adapter.UnifiedMessage { + return nonOwnerGroupMsg("【猪工智能】Lv.2 正在学习 —— 唯一的bug是你至今没搞懂为什么猪喜欢拱泥") + }, + wantReply: false, + bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行这张卡片)", + }, + { + // ASCII `error` / `help` 同属被剔的三类。如实记录代价:用英文写的 + // 真求救不再穿透(塔裁定以「宁可少穿透」为准)。 + name: "english error/help does NOT pierce", + msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("there is an error in my code, help me") }, + wantReply: false, + bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行);同时如实记录严格子集的已知代价", + }, + { + // 情绪/口语歧义的 `崩溃` 同属被剔的三类(「我崩溃了」= 情绪,不是故障)。 + name: "emotional 崩溃 does NOT pierce", + msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("这周稿子又没写完,我崩溃了") }, + wantReply: false, + bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行)", + }, { name: "cooldown is NOT lifted by the exception", msg: func() adapter.UnifiedMessage { return coldShoulderMention("晚上有空吗") }, @@ -124,3 +152,47 @@ func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) { t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID) } } + +// TestColdShoulderPierceKeywordSubset 直接钉住例外②的**严格子集**本身:保留的八条命中, +// 被剔的三类(ASCII `bug`/`error`/`help`、情绪歧义的 `崩溃`、口语「糟糕」义的 `坏了`/`坏掉`) +// 不命中。这是防「后人顺手把全表并回来」的第一道闸(第二道是矩阵里的卡片/英文/情绪三行)。 +// +// 同时钉住**分工边界**:`hasHighValueKeyword` 全表一个字未改 —— `bug` 在 ruleLightJudge +// 眼里仍是高价值词(基线行为),只是不再拥有「穿透冷避」的权力。 +func TestColdShoulderPierceKeywordSubset(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + text string + want bool + }{ + {"report error", "服务报错了,谁知道怎么回事", true}, + {"error occurred", "这里出错了", true}, + {"fault", "群文件传输故障", true}, + {"urgent", "this is urgent", true}, + {"rescued", "救命啊", true}, + {"asking for help", "帮帮我看看这个", true}, + {"help me", "谁能帮我一下", false}, // 「帮我」不在表里,只有「帮帮我」在 + {"bug in a bot card", "唯一的bug是你至今没搞懂为什么猪喜欢拱泥", false}, + {"english error/help", "there is an error in my code, help me", false}, + {"emotional collapse", "这周稿子又没写完,我崩溃了", false}, + {"colloquial oops", "坏了,我忘了", false}, + {"plain chatter", "今晚吃点什么", false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := hasColdShoulderPierceKeyword(tc.text); got != tc.want { + t.Fatalf("hasColdShoulderPierceKeyword(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } + + // 分工边界:全表(基线)不许被这次收窄波及。 + if !hasHighValueKeyword("唯一的bug是你至今没搞懂为什么猪喜欢拱泥") { + t.Fatal("hasHighValueKeyword 被改动了:它是基线行为(ruleLightJudge 用),本片不许动") + } +} diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index db1b6d7..af4b867 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -183,7 +183,8 @@ func isStrangerPrivate(sess session.Session) bool { // ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。 // ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、 // 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。 -// 冷却期内唯一放行口是**高价值关键词穿透**(真故障不漏,且不解除冷却); +// 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表: +// 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**; // 同句负面态度优先于该例外(塔 2026-10-15 定稿)。 // ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。 // ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。 @@ -235,10 +236,12 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, // 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true, // 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。 // - // 冷却期内的**唯一**放行口是「高价值关键词穿透」(塔 2026-10-15 定稿): - // 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 按高价值关键词 - // 正常回复;@她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**。 - // 同句既有负面态度又有高价值关键词时,**负面态度优先**(仍静默)。 + // 冷却期内的**唯一**放行口是「真故障/求救信号穿透」(塔 2026-10-15 定稿): + // 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 正常回复; + // 判据是严格子集 coldShoulderPierceKeywords(**不是** ruleLightJudge 用的 + // hasHighValueKeyword 全表 —— 穿透权落在全表上会让卡片里的 `bug` 把冷避按掉)。 + // @她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**; + // 同句既有负面态度又有真故障信号时,**负面态度优先**(仍静默)。 // 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。 // // 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔)