From 13a0519115524a796b981c1dfc21cb5143309025 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 15 Oct 2026 19:01:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(channelbridge):=20=E5=86=B7=E9=81=BF?= =?UTF-8?q?=E6=9C=9F=E5=94=AF=E4=B8=80=E7=9A=84=E6=94=BE=E8=A1=8C=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=AB=98=E4=BB=B7=E5=80=BC=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=AF=8D=E7=A9=BF=E9=80=8F=EF=BC=88=E4=B8=8D=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E5=86=B7=E5=8D=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔 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 交叉编译通过。 --- internal/services/channelbridge/engage.go | 28 +++- .../engage_coldshoulder_exception_test.go | 126 ++++++++++++++++++ .../channelbridge/engage_coldshoulder_test.go | 15 +++ internal/services/channelbridge/light.go | 10 +- 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 internal/services/channelbridge/engage_coldshoulder_exception_test.go diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index cdba2ed..20481ee 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -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" diff --git a/internal/services/channelbridge/engage_coldshoulder_exception_test.go b/internal/services/channelbridge/engage_coldshoulder_exception_test.go new file mode 100644 index 0000000..1a47012 --- /dev/null +++ b/internal/services/channelbridge/engage_coldshoulder_exception_test.go @@ -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) + } +} diff --git a/internal/services/channelbridge/engage_coldshoulder_test.go b/internal/services/channelbridge/engage_coldshoulder_test.go index 2ab4b60..5fac222 100644 --- a/internal/services/channelbridge/engage_coldshoulder_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_test.go @@ -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 是本片的主断言(任务⑤①,**修前红 / 修后绿**): // 群成员 @她 并说「减少文本输出量」时 —— // diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index dacfad4..db1b6d7 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -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 {