Files
Cyrene_v2/internal/services/channelbridge/engage_coldshoulder_exception_test.go
AskaEth 28967d3435 refactor(channelbridge): 冷避例外②收窄为「真故障/求救」严格子集
塔 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 交叉编译通过。
2026-10-15 19:04:48 +08:00

199 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 由塔同步,这里只钉行为):
//
// ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默;
// ② **真故障/求救信号**(严格子集 coldShoulderPierceKeywords)穿透冷却 ——
// 只放行这一条,且**不解除**冷却;**不是**高价值关键词全表(`bug`/`error`/`help`
// 与情绪歧义的 `崩溃`/`坏了`/`坏掉` 被剔出去,矩阵里有对应行);
// ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质);
// ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。
//
// 冷避闸本体的顺序修正(M18 主体)与登记/词表/可见性等用例见
// engage_coldshoulder_test.go(同包,共享 coldShoulderMention / coldShoulderBridge 夹具)。
// TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**:
// 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。
//
// 每行的 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: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)",
},
{
// 真机实证(群 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("晚上有空吗") },
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)
}
}
// 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 用),本片不许动")
}
}