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 用),本片不许动") } }