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:
1 parent
fe53e029ea
commit
13a0519115
4 files changed
+177
-2
No files matched your search
@@ -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"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件覆盖冷避**冷却期内的例外口径**(塔 2026-10-15 定稿;文档面 design/18 §5/§9
|
||||
// 由塔同步,这里只钉行为):
|
||||
//
|
||||
// ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默;
|
||||
// ② 高价值关键词(真故障)**穿透**冷却 —— 只放行这一条,且**不解除**冷却;
|
||||
// ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质);
|
||||
// ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。
|
||||
//
|
||||
// 冷避闸本体的顺序修正(M18 主体)与登记/词表/可见性等用例见
|
||||
// engage_coldshoulder_test.go(同包,共享 coldShoulderMention / coldShoulderBridge 夹具)。
|
||||
|
||||
// TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**:
|
||||
// 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。
|
||||
//
|
||||
// 每行对「本轮修前」= M18 提交 fe53e02 的性质用 bite 标出(红前红 / 护栏)——
|
||||
// 不得把护栏吹成证据。
|
||||
func TestColdShoulderCooldownMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg func() adapter.UnifiedMessage
|
||||
wantReply bool
|
||||
bite string
|
||||
}{
|
||||
{
|
||||
name: "fault keyword pierces the cooldown",
|
||||
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("服务报错了,谁知道怎么回事") },
|
||||
wantReply: true,
|
||||
bite: "本轮红前红(fe53e02 的冷避闸会把它静默掉,即「让她漏掉真故障」)",
|
||||
},
|
||||
{
|
||||
name: "fault keyword addressed to her pierces too",
|
||||
msg: func() adapter.UnifiedMessage { return coldShoulderMention("系统报错了,快看看") },
|
||||
wantReply: true,
|
||||
bite: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)",
|
||||
},
|
||||
{
|
||||
name: "cooldown is NOT lifted by the exception",
|
||||
msg: func() adapter.UnifiedMessage { return coldShoulderMention("晚上有空吗") },
|
||||
wantReply: false,
|
||||
bite: "护栏(fe53e02 亦绿):钉住穿透只放行那一条,不撤诉 ---- 本片的命根子",
|
||||
},
|
||||
{
|
||||
name: "plain chatter stays silenced",
|
||||
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("今晚吃点什么") },
|
||||
wantReply: false,
|
||||
bite: "护栏(fe53e02 亦绿)",
|
||||
},
|
||||
{
|
||||
name: "negative attitude wins inside the same message",
|
||||
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("别刷屏了,线上报错了") },
|
||||
wantReply: false,
|
||||
bite: "护栏(fe53e02 亦绿):钉住例外②不得被插到负面态度判定之前(对 base 是红)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 每行一张干净的桥接器、各自由真机原话①触发冷却,使各行的前状态完全一致。
|
||||
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
|
||||
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
|
||||
hist := &captureHistory{}
|
||||
b := coldShoulderBridge(t, chat, hist, func() time.Time { return now })
|
||||
|
||||
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("未能进入冷却:循环被驱动 = %q", chat.sessionID)
|
||||
}
|
||||
before := len(hist.records())
|
||||
|
||||
b.process(context.Background(), tc.msg())
|
||||
|
||||
if got := chat.sessionID != ""; got != tc.wantReply {
|
||||
t.Fatalf("冷却期内循环被驱动 = %v, want %v(%s)", got, tc.wantReply, tc.bite)
|
||||
}
|
||||
// 静默 ≠ 丢弃:本条必须已登记(被放行的那条在回合里另有一份落库)。
|
||||
if len(hist.records()) <= before {
|
||||
t.Fatalf("本条没有被登记:records 停在 %d", before)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColdShoulderHighValueKeywordBaselineOutsideCooldown 是反例护栏(塔要求 2 的第 4 条):
|
||||
// 冷却期**已过**后,高价值关键词与普通消息的行为与基线完全一致 —— 前者照旧必回
|
||||
// (ruleLightJudge 的高价值关键词路径),后者照旧只登记(未装配引擎时失败关闭)。
|
||||
//
|
||||
// 诚实标注:本条对 base 与本轮修前**都是绿的**,它是**护栏而非红前红** ——
|
||||
// 它守的是「例外只改冷却期内,不碰冷却之外的既有语义」。
|
||||
func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
|
||||
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
|
||||
b := coldShoulderBridge(t, chat, &captureHistory{}, func() time.Time { return now })
|
||||
|
||||
b.process(context.Background(), coldShoulderMention("减少文本输出量")) // 进冷却
|
||||
now = now.Add(defaultCooldownDuration + time.Minute) // 推过冷却
|
||||
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), nonOwnerGroupMsg("服务报错了"))
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("冷却过后的高价值关键词未回:session = %q, want sess-g", chat.sessionID)
|
||||
}
|
||||
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), nonOwnerGroupMsg("今晚吃点什么"))
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,21 @@ func coldShoulderMention(content string) adapter.UnifiedMessage {
|
||||
return m
|
||||
}
|
||||
|
||||
// coldShoulderBridge 构建一条成员群消息用的桥接器:**未装配**参与度引擎与 LightJudge
|
||||
// (规则得不出结论时失败关闭为静默),时钟可注入 —— 因此「必不必回」只由规则判据与
|
||||
// 冷避闸决定,用例完全确定。
|
||||
func coldShoulderBridge(t *testing.T, chat *stubChatter, hist *captureHistory, now func() time.Time) *Bridge {
|
||||
t.Helper()
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{}, History: hist, Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**):
|
||||
// 群成员 @她 并说「减少文本输出量」时 ——
|
||||
//
|
||||
|
||||
@@ -182,7 +182,9 @@ func isStrangerPrivate(sess session.Session) bool {
|
||||
// ② 平台/系统噪声 → 只登记。
|
||||
// ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。
|
||||
// ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、
|
||||
// 冷却期内一律静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
|
||||
// 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
|
||||
// 冷却期内唯一放行口是**高价值关键词穿透**(真故障不漏,且不解除冷却);
|
||||
// 同句负面态度优先于该例外(塔 2026-10-15 定稿)。
|
||||
// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。
|
||||
// ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。
|
||||
//
|
||||
@@ -233,6 +235,12 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
// 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true,
|
||||
// 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。
|
||||
//
|
||||
// 冷却期内的**唯一**放行口是「高价值关键词穿透」(塔 2026-10-15 定稿):
|
||||
// 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 按高价值关键词
|
||||
// 正常回复;@她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**。
|
||||
// 同句既有负面态度又有高价值关键词时,**负面态度优先**(仍静默)。
|
||||
// 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。
|
||||
//
|
||||
// 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔)
|
||||
// 且与是否装配参与度引擎无关 —— 旧轻量判定车道的部署同样得到这个顺序。
|
||||
if sess.IsGroup {
|
||||
|
||||
Reference in new issue
Block a user