feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)

塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
This commit is contained in:
AskaEth committed 2026-10-15 19:01:02 +08:00
1 parent fe53e029ea
commit 13a0519115
4 files changed
+177 -2

No files matched your search

+27 -1
View File
@@ -23,6 +23,8 @@ import (
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
// 口径见 coldShoulderLocked。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
@@ -370,7 +372,8 @@ var negativeAttitudeKeywords = []string{
// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸
// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环:
// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内。
// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条
// 没有高价值关键词。
//
// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住
// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化
@@ -382,6 +385,8 @@ var negativeAttitudeKeywords = []string{
// 结构化旋钮(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)
@@ -395,6 +400,21 @@ func (b *Bridge) coldShoulderGate(msg adapter.UnifiedMessage, actor adapter.Acto
// **唯一**的实现: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)
@@ -405,6 +425,12 @@ func (b *Bridge) coldShoulderLocked(msg adapter.UnifiedMessage, actor adapter.Ac
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"