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, "" }