Files
Cyrene_v2/internal/services/channelbridge/engage_coldshoulder_exception_test.go
T
AskaEth 13a0519115 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 交叉编译通过。
2026-10-15 19:01:02 +08:00

127 lines
5.4 KiB
Go
Raw 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
// 由塔同步,这里只钉行为):
//
// ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默;
// ② 高价值关键词(真故障)**穿透**冷却 —— 只放行这一条,且**不解除**冷却;
// ③ @她 / @全体 **不穿透**(那正是主人 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)
}
}