塔 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 交叉编译通过。
199 lines
9.0 KiB
Go
199 lines
9.0 KiB
Go
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 用),本片不许动")
|
||
}
|
||
}
|