From fe53e029ea734ec68bb635466fc8c241795f9530 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 15 Oct 2026 18:55:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(channelbridge):=20=E5=86=B7=E9=81=BF?= =?UTF-8?q?=E4=BC=98=E5=85=88=E4=BA=8E=E3=80=8C@=E5=A5=B9=E5=BF=85?= =?UTF-8?q?=E5=9B=9E=E3=80=8D=E2=80=94=E2=80=94=E7=BE=A4=E6=88=90=E5=91=98?= =?UTF-8?q?=E4=B9=9F=E8=83=BD=E8=AE=A9=E5=A5=B9=E5=B0=91=E8=AF=B4=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M18(主人 2026-10-14 裁决「给」)。病灶是**顺序**:`shouldRespond` 把 `ruleLightJudge`(指向她本人 / 高价值关键词)排在参与度引擎之前,@她的消息 在规则层就 return true,永远走不到 `engageDecision` 的冷避判定——群成员说 「少说点」只换来一句口头答应,行为零变化(2026-10-14 00:27 群 531082168 报障)。 - light.go:新增冷避闸位次 —— 非主人**群**消息在廉价规则之前先过 `coldShoulderGate`,命中负面态度即进 COOLDOWN、冷却期内一律静默(仍登记)。 边界:私聊不受影响(冷避是群机制)、owner 车道更早分岔不受影响、 与是否装配参与度引擎无关。shouldRespond 的判定顺序写进函数注释。 - engage.go:把冷避判据抽成唯一实现 `coldShoulderLocked`(engageDecision 仍调用 它,作纵深防御),命中时补一条带 source 的 INFO(群 / 发送者 / 冷却到何时)。 - engage.go 词表:依真机原话补「减少文本输出」(覆盖「减少文本输出量」)与 「文本输出调到最少」;刻意不取「减少 / 输出 / 最少」等短泛词(子串匹配会误伤)。 - 测试:新增 engage_coldshoulder_test.go(主断言修前红/修后绿、登记进上下文的 端到端守卫、私聊与 owner 边界护栏、词表误伤护栏、INFO 可见性); engage_test.go 更新旧口径「@ 打断冷却」为「@ 不得作废冷避」。 验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(windows 本机 与 darwin/arm64 交叉编译均通过);channelbridge / channel / cmd/cyrene 单独跑亦绿。 --- internal/services/channelbridge/engage.go | 85 +++- .../channelbridge/engage_coldshoulder_test.go | 394 ++++++++++++++++++ .../services/channelbridge/engage_test.go | 10 +- internal/services/channelbridge/light.go | 34 +- 4 files changed, 499 insertions(+), 24 deletions(-) create mode 100644 internal/services/channelbridge/engage_coldshoulder_test.go diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index a1ed09d..cdba2ed 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -19,6 +19,10 @@ import ( // 态度时的冷避。它按进程存于内存(重启即重置), // 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要 // 打扰某人」就把聊天消息发给 LLM(design/18 红线)。 +// +// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」—— +// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用, +// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。 // EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它 // 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供 @@ -168,21 +172,14 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, defer st.mu.Unlock() now := b.now() - // design/18 §5 冷避优先:群成员表达负面态度是 - // 明确的「安静」信号 → COOLDOWN,即使正处在参与中。直接 @ / 高价值 - // 关键词已在上方返回,因此只有真正「内容值得回复」 - // 的消息(或主人消息,它根本不会走到这里)才能覆盖它。 - if b.negativeAttitude(msg.Content) { - b.enterCooldown(st, cfg, now) - b.log.Debug("engage cooldown (negative attitude)", - "group", key, "until", st.cooldownUntil) - return false, "engage: cooldown (negative attitude)" - } - - if st.inCooldown(now) { - b.log.Debug("engage cooldown active, not responding", - "group", key, "until", st.cooldownUntil) - return false, "engage: cooldown active" + // design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 → + // COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸 + // (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此 + // @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。 + // 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义, + // 将来若多出别的调用点也不会漏掉它。 + if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet { + return false, reason } // ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断 @@ -351,10 +348,68 @@ func (b *Bridge) negativeAttitude(text string) bool { // 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。 // 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②), // 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。 +// +// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而 +// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤, +// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群 +// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的 +// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。 var negativeAttitudeKeywords = []string{ "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", "安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦", "打住", "住嘴", "shut up", "too loud", "stop spamming", + // 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式, + // 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 —— + // 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。 + "减少文本输出", + // 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。 + // 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出 + // (音箱/视频/日志)调低的话题,故不取。 + "文本输出调到最少", +} + +// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸 +// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环: +// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内。 +// +// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住 +// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化 +// (2026-10-14 报障的病灶)。适用面严格限定如下: +// +// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按 +// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟; +// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套 +// 结构化旋钮(ownerlane.go),冷避绝不碰主人的必回语义; +// - **与参与度引擎是否装配无关**:未装配引擎(旧轻量判定车道)的部署顺序与 +// 装配了的**完全一致** —— 闸本身不查 engageConfigured()。 +func (b *Bridge) coldShoulderGate(msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) { + cfg := b.engageConfigFor(msg, actor) + key := engageKey(msg, actor) + st := b.engageState(key) + st.mu.Lock() + defer st.mu.Unlock() + return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now()) +} + +// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则 +// **唯一**的实现:shouldRespond 的冷避闸与 engageDecision 都调它。 +// 命中负面态度时进 COOLDOWN 并留一条带来源的 INFO —— 判据只读正文关键词, +// 日志只带群键、发送者与截止时刻,**绝不打印正文**(design/18 §9.7)。 +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) + // INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。 + b.log.Info("engage cooldown: group asked her to stay quiet", + "group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor), + "until", st.cooldownUntil) + return true, "engage: cooldown (negative attitude)" + } + if st.inCooldown(now) { + b.log.Debug("engage cooldown active, not responding", + "group", key, "until", st.cooldownUntil) + return true, "engage: cooldown active" + } + return false, "" } // contentSnippet 把消息截短为状态机所用的有界话题标记。 diff --git a/internal/services/channelbridge/engage_coldshoulder_test.go b/internal/services/channelbridge/engage_coldshoulder_test.go new file mode 100644 index 0000000..2ab4b60 --- /dev/null +++ b/internal/services/channelbridge/engage_coldshoulder_test.go @@ -0,0 +1,394 @@ +package channelbridge + +import ( + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/user" +) + +// 本文件覆盖 M18(主人 2026-10-14 裁决「给」:群成员也能让她少说话)在 +// channelbridge 里的落点 —— **冷避优先于「@她必回」**(design/18 §5): +// +// - 非主人**群**消息先过冷避闸(命中负面态度 → COOLDOWN;冷却期内一律静默), +// 顺序排在廉价规则判定(ruleLightJudge)**之前**,因此 @她 / @全体 / +// 高价值关键词都拦不住它; +// - 边界:私聊(含陌生人私聊)与 owner 车道**不受影响**;闸与是否装配 +// 参与度引擎**无关**; +// - 被冷避静默的消息**照样进上下文**(registerSilenced / persistIsolated), +// 只是不进 loop —— M13 红线,端到端钉住。 + +// coldShoulderSelfID 是 2026-10-14 报障现场那个号(她自己的 self_id),用于把 +// 「@她」判据按**结构化字段**接上线(与 ownerlane.go 的 ownerAddressedToMe 同一口径)。 +const coldShoulderSelfID = "3443115524" + +// coldShoulderMention 构造一条**指向她本人**的成员群消息:SelfID 已接线 ⇒ +// AddressedToMe 由结构化字段算出(不是靠正文里的 "@<数字>" 猜)。修前的实现会把它 +// 当成「必回」,这正是本片要改的顺序偏离。 +func coldShoulderMention(content string) adapter.UnifiedMessage { + m := nonOwnerGroupMsg(content, adapter.Mention{UserID: coldShoulderSelfID}) + m.SelfID = coldShoulderSelfID + m.AddressedToMe = m.AddressedToSelf() + return m +} + +// TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**): +// 群成员 @她 并说「减少文本输出量」时 —— +// +// - 修前:@她 命中 ruleLightJudge 的 verdictRespond ⇒ shouldRespond 直接 return true, +// 冷避根本走不到(红:循环被驱动,她只会口头答应、行为零变化,即报障现场); +// - 修后:冷避闸排在「@她必回」之前 ⇒ 静默,但**照样登记**(静默 ≠ 丢弃)。 +// +// 两种部署都断言:装配了参与度引擎的(原本走 engageDecision)与未装配的 +// (原本走旧轻量判定车道)—— 顺序修复**与引擎装配无关**(任务①的边界要求)。 +func TestColdShoulderPrecedesAddressedToMe(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + engage bool + }{ + {name: "engage engine wired", engage: true}, + {name: "no engage engine (legacy light lane)", engage: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + judge := &engageScriptJudge{} + chat := &stubChatter{res: &loop.Result{Text: "好嘛,人家少说点♪"}} + hist := &captureHistory{} + opts := Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(), + } + if tc.engage { + opts.Light = judge + opts.TriggerWords = []string{"昔涟"} + opts.Engage = DefaultEngageConfig() + opts.EngageRand = func() float64 { return 0.5 } + } + b, err := New(opts) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), coldShoulderMention("减少文本输出量")) + + if chat.sessionID != "" { + t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID) + } + recs := hist.records() + if len(recs) != 1 || recs[0].Content != "减少文本输出量" { + t.Fatalf("records = %+v, want 恰好 1 条登记(静默 ≠ 丢弃)", recs) + } + // 只有装配了引擎的那一路才够得着判定器;旧车道根本没有它, + // 因此这一条只在装配时断言(不给旧车道记一笔假功劳)。 + if tc.engage && judge.calls != 0 { + t.Fatalf("judge calls = %d, want 0(冷避命中不得咨询轻量 LLM:成本红线)", judge.calls) + } + }) + } +} + +// TestColdShoulderSecondReportedPhraseAndCooldownWindow 覆盖报障的**第二句原话**,并钉住 +// 冷却的**时限性**:进冷却后连普通 @她 也不回(M18 口径 —— 旧口径「@ 打断冷却」正是本片 +// 修掉的偏离);时钟推过冷却时长后她重新回到正常判定,**不是**永久闭嘴。 +func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) { + t.Parallel() + + // 真机时刻:2026-10-14 00:27(群 531082168,成员「企鹅神明」)。 + now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) + chat := &stubChatter{res: &loop.Result{Text: "好♪"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: &captureHistory{}, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少")) + if chat.sessionID != "" { + t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID) + } + + // 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。 + b.process(context.Background(), coldShoulderMention("晚上有空吗")) + if chat.sessionID != "" { + t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID) + } + + // 推过默认冷却时长(30 分钟):她恢复到「@她必回」的常态。 + now = now.Add(defaultCooldownDuration + time.Minute) + b.process(context.Background(), coldShoulderMention("晚上有空吗")) + if chat.sessionID != "sess-g" { + t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.sessionID) + } +} + +// TestColdShoulderNormalMentionNotCooled 是反例护栏(任务⑤②):不含负面态度的普通 +// @她 消息**不得**被误冷却 —— 回得照常,且不把该群留在冷却态里(第二条普通 @她 仍回)。 +// 诚实声明:这条在修前也是绿的,它是**护栏而非红前红**;红前红的证据是上面两条。 +func TestColdShoulderNormalMentionNotCooled(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "在的"}} + hist := &captureHistory{} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), coldShoulderMention("晚上有空吗")) + if chat.sessionID != "sess-g" { + t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID) + } + + // 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。 + chat.sessionID = "" + b.process(context.Background(), coldShoulderMention("在不在")) + if chat.sessionID != "sess-g" { + t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID) + } +} + +// TestColdShoulderGroupOnlyBoundaries 钉住冷避的**适用面**(任务①的三条边界): +// +// - 陌生人**私聊**说「闭嘴」照样正常聊天(冷避只存在于群聊车道,design/18 §11); +// - 主人**私聊**说「闭嘴」照样恒回(owner 车道在更早处分岔); +// - 主人**群聊**里 @她 说「安静点」仍必回(owner 车道的必回不受冷避影响,主人 +// 有自己那套旋钮)。 +// +// 三条在修前也是绿的(护栏),但它们钉住的是「不许把闸开大」的边界 —— 少了它们, +// 一次把闸挪到 shouldRespond 最前端就能悄悄改掉私聊与主人的行为而无人察觉。 +func TestColdShoulderGroupOnlyBoundaries(t *testing.T) { + t.Parallel() + + t.Run("stranger private still chats", func(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "好呀"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-p", IsGroup: false, Owner: false}}, + Owner: &user.OwnerProfile{}, Now: dayNow(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), adapter.UnifiedMessage{ + Platform: "telegram", ChannelID: "9", ChannelType: adapter.ChannelDirect, + SenderID: "9", Sender: adapter.Actor{Role: adapter.RoleMember}, + Content: "闭嘴", ContentType: adapter.ContentText, + }) + + if chat.sessionID != "sess-p" { + t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID) + } + }) + + t.Run("owner private still replies", func(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "好的主人"}} + ad := &fakeAdapter{name: "telegram"} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}, + Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{}, Now: dayNow(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + b.Add(ad) + + m := directMessage() + m.Content = "闭嘴" + b.process(context.Background(), m) + + if chat.sessionID != history.MainSessionID { + t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID) + } + }) + + t.Run("owner group addressed still replies", func(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "好嘛"}} + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, + dayNow(), slog.New(slog.NewTextHandler(io.Discard, nil))) + + b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"})) + + if chat.sessionID != "sess-og" { + t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID) + } + }) +} + +// TestColdShoulderSilencedMessageStillEntersContext 是 M13 红线的**端到端**守卫(任务②): +// 被冷避静默的消息照样进上下文,只是不进 loop。断言不走返回值,而走真实链路: +// +// ① 静默那条经 registerSilenced → persistIsolated 落进该群隔离会话(历史可读); +// ② 冷却结束后下一条 @她 真正驱动回合时,**该回合的模型可见消息里含那条被静默的话** +// —— 她看得见「刚才有人让她少说话」,不是被蒙在鼓里(transcript.go 的播种)。 +func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) + chat := &stubChatter{res: &loop.Result{Text: "知道啦♪"}} + hist := &recentHistory{} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: hist, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), coldShoulderMention("减少文本输出量")) + if chat.sessionID != "" { + t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID) + } + + // ① 静默那条已被登记(不是被丢掉)。 + registered := false + for _, r := range hist.records() { + if r.SessionID == "sess-g" && r.Role == history.RoleUser && r.Content == "减少文本输出量" { + registered = true + } + } + if !registered { + t.Fatalf("被冷避的消息没有登记:records = %+v", hist.records()) + } + + // ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。 + now = now.Add(defaultCooldownDuration + time.Minute) + b.process(context.Background(), coldShoulderMention("在吗")) + if chat.sessionID != "sess-g" { + t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID) + } + if !loopMessagesContain(chat.messages, "减少文本输出量") { + t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages) + } +} + +// TestNegativeAttitudeKeywordList 逐条钉住 M18 的词表增补(任务③):认识**真机原话**, +// 且**不**认识只在字面上擦边的日常说法(子串匹配的误伤护栏 —— 词条选取见 +// engage.go 的 negativeAttitudeKeywords 注释)。 +func TestNegativeAttitudeKeywordList(t *testing.T) { + t.Parallel() + + b := &Bridge{} + cases := []struct { + name string + text string + want bool + }{ + {"real report 1", "@3443115524 减少文本输出量", true}, + {"real report 2", "@3443115524 在本群中文本输出调到最少", true}, + {"same wording, no quantifier", "减少文本输出频率", true}, + {"pre-existing owner wording", "少说点", true}, + {"dev talk about logs", "我在写脚本,减少输出日志", false}, + {"praising output quality", "这个模型的输出质量不错", false}, + {"another kind of output", "把音箱输出调到最少", false}, + {"plain chatter", "今天天气不错", false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := b.negativeAttitude(tc.text); got != tc.want { + t.Fatalf("negativeAttitude(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } +} + +// TestColdShoulderLogsVisibleInfo 钉住可见性(任务④):进冷却时必须有**带来源的 INFO** +// (哪个群 / 被谁的话 / 冷却到什么时候),使主人能看出「她为什么突然不说话了」。 +// 日志绝不打印正文(design/18 §9.7),因此这里只断言键值。 +func TestColdShoulderLogsVisibleInfo(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + chat := &stubChatter{res: &loop.Result{Text: "好嘛"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, Log: log, Now: dayNow(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), coldShoulderMention("减少文本输出量")) + + out := buf.String() + for _, want := range []string{ + "level=INFO", + "source=services/channelbridge", + "group asked her to stay quiet", + "group=telegram", + "sender=9", + "sender_name=张三", + "until=2026-01-01T12:30:00.000Z", + } { + if !strings.Contains(out, want) { + t.Fatalf("INFO 日志缺少 %q;got:\n%s", want, out) + } + } + if strings.Contains(out, "减少文本输出量") { + t.Fatalf("日志打印了正文(design/18 §9.7 禁止):\n%s", out) + } +} + +// loopMessagesContain 报告本回合的模型可见消息里是否出现了 want(用于断言 +// 「被静默登记的消息确实进了上下文」,而不是只看落库记录)。 +func loopMessagesContain(msgs []model.Message, want string) bool { + for _, m := range msgs { + for _, c := range m.Content { + if strings.Contains(c.Text, want) { + return true + } + } + } + return false +} + +// recentHistory 是 captureHistory 的读取侧补齐:Recent 返回本会话已登记的消息, +// 使 seedTurnTranscript 真能把「被静默登记」的那条话播进下一个回合的上下文。 +type recentHistory struct { + captureHistory +} + +func (h *recentHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) { + var out []history.Message + for _, m := range h.records() { + if m.SessionID == sessionID { + out = append(out, m) + } + } + if n > 0 && len(out) > n { + out = out[len(out)-n:] + } + return out, nil +} diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go index 16c7986..4aa317f 100644 --- a/internal/services/channelbridge/engage_test.go +++ b/internal/services/channelbridge/engage_test.go @@ -269,11 +269,15 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) { t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID) } - // 直接 @ 仍会打断冷却(design/18 §5:除非 @ 她)。 + // M18(主人 2026-10-14 裁决「给」):冷却期内**连 @ 也不回** —— + // 冷避优先于「@她必回」,群成员的一句「少说点」不被随后的 @ 作废。 + // (旧口径「@ 打断冷却」正是本片修掉的偏离;见 design/18 §5 与 + // engage_coldshoulder_test.go 的端到端用例。) chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) - if chat.sessionID != "sess-g" { - t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID) + if chat.sessionID != "" || judge.calls != 0 { + t.Fatalf("mention during cooldown: session=%q calls=%d, want \"\"/0 (@ no longer breaks the cold shoulder)", + chat.sessionID, judge.calls) } // 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。 diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 8ebe8cd..dacfad4 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -174,12 +174,19 @@ func isStrangerPrivate(sess session.Session) bool { // shouldRespond 实现入站消息的最小打扰分级 // (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息 -// 应驱动完整 agent 循环并回复,还是只登记并静默。 -// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」): -// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说 -// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回, -// 而主人群里不对她说的消息改为**进上下文不回**。sess 是 -// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 +// 应驱动完整 agent 循环并回复,还是只登记并静默。判定顺序(M18 后的**权威顺序**): +// +// ① owner 车道(`actor.IsOwner()`,design/18 §11「乙 = C」):主人私聊恒回; +// 主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说(见 ownerlane.go)—— +// 冷避不适用于主人,主人的 @/@全体仍必回。 +// ② 平台/系统噪声 → 只登记。 +// ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。 +// ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、 +// 冷却期内一律静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。 +// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。 +// ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。 +// +// sess 是该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器, // 用于取该账号的流控闸门(陌生人私聊的强制限流)。 func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) { @@ -219,6 +226,21 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, // 「参与度触发 / 概率」那一档(未触发则只登记)。**安静时段只作为信号**(inQuiet) // 继续往下传,规则层不再因为它静默(裁决②)。 inQuiet := b.inQuietHours(b.now()) + + // design/18 §5 冷避**先于**「@她必回」(M18,主人 2026-10-14 裁决「给」: + // 群成员也能让她少说话):非主人**群**消息先过冷避闸 —— 命中负面态度就进 + // COOLDOWN,冷却期内一律静默(两者都只登记、不进循环)。顺序上必须排在下一条 + // 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true, + // 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。 + // + // 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔) + // 且与是否装配参与度引擎无关 —— 旧轻量判定车道的部署同样得到这个顺序。 + if sess.IsGroup { + if quiet, reason := b.coldShoulderGate(msg, actor); quiet { + return false, reason + } + } + if b.ruleLightJudge(msg) == verdictRespond { return true, "rule signal: worth responding" } From 13a0519115524a796b981c1dfc21cb5143309025 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 15 Oct 2026 19:01:02 +0800 Subject: [PATCH 2/3] =?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 { From 28967d343578f49ea18d1e9b981f5c7d074d27e2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 15 Oct 2026 19:04:48 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(channelbridge):=20=E5=86=B7?= =?UTF-8?q?=E9=81=BF=E4=BE=8B=E5=A4=96=E2=91=A1=E6=94=B6=E7=AA=84=E4=B8=BA?= =?UTF-8?q?=E3=80=8C=E7=9C=9F=E6=95=85=E9=9A=9C/=E6=B1=82=E6=95=91?= =?UTF-8?q?=E3=80=8D=E4=B8=A5=E6=A0=BC=E5=AD=90=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔 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 交叉编译通过。 --- internal/services/channelbridge/engage.go | 108 ----------- .../channelbridge/engage_coldshoulder.go | 168 ++++++++++++++++++ .../engage_coldshoulder_exception_test.go | 76 +++++++- internal/services/channelbridge/light.go | 13 +- 4 files changed, 250 insertions(+), 115 deletions(-) create mode 100644 internal/services/channelbridge/engage_coldshoulder.go diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index 20481ee..ad0bb1c 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -330,114 +330,6 @@ func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool { return b.engageRand() < cfg.Probability } -// negativeAttitude 报告群是否在让她安静(design/18 §5 -// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号 -// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。 -func (b *Bridge) negativeAttitude(text string) bool { - if text == "" { - return false - } - lower := strings.ToLower(text) - for _, k := range negativeAttitudeKeywords { - if strings.Contains(lower, k) { - return true - } - } - return false -} - -// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 -// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。 -// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②), -// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。 -// -// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而 -// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤, -// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群 -// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的 -// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。 -var negativeAttitudeKeywords = []string{ - "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", - "安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦", - "打住", "住嘴", "shut up", "too loud", "stop spamming", - // 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式, - // 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 —— - // 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。 - "减少文本输出", - // 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。 - // 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出 - // (音箱/视频/日志)调低的话题,故不取。 - "文本输出调到最少", -} - -// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸 -// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环: -// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条 -// 没有高价值关键词。 -// -// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住 -// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化 -// (2026-10-14 报障的病灶)。适用面严格限定如下: -// -// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按 -// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟; -// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套 -// 结构化旋钮(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) - st := b.engageState(key) - st.mu.Lock() - defer st.mu.Unlock() - return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now()) -} - -// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则 -// **唯一**的实现: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) - // INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。 - b.log.Info("engage cooldown: group asked her to stay quiet", - "group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor), - "until", st.cooldownUntil) - 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" - } - return false, "" -} - // contentSnippet 把消息截短为状态机所用的有界话题标记。 func contentSnippet(text string) string { runes := []rune(strings.TrimSpace(text)) diff --git a/internal/services/channelbridge/engage_coldshoulder.go b/internal/services/channelbridge/engage_coldshoulder.go new file mode 100644 index 0000000..363cc5b --- /dev/null +++ b/internal/services/channelbridge/engage_coldshoulder.go @@ -0,0 +1,168 @@ +package channelbridge + +import ( + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// 本文件承载**冷避规则**(design/18 §5)——与它的状态机骨架(engage.go)分开, +// 使两边都留在项目「文件 ≤500 行」的上限内:负面态度词表 + 例外②的真故障/求救 +// 严格子集 + 冷避闸本体(coldShoulderGate / coldShoulderLocked)。 +// +// 顺序口径(塔 2026-10-15 定稿,文档面 design/18 §5/§9 由塔同步): +// 同句负面态度优先 → 冷却期内真故障/求救信号穿透(不解除冷却)→ 其余一律静默; +// @她 / @全体 不穿透;主人不需要例外(owner 车道在 shouldRespond 第一步已分岔)。 + +// negativeAttitude 报告群是否在让她安静(design/18 §5 +// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号 +// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。 +func (b *Bridge) negativeAttitude(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + for _, k := range negativeAttitudeKeywords { + if strings.Contains(lower, k) { + return true + } + } + return false +} + +// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 +// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。 +// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②), +// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。 +// +// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而 +// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤, +// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群 +// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的 +// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。 +var negativeAttitudeKeywords = []string{ + "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", + "安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦", + "打住", "住嘴", "shut up", "too loud", "stop spamming", + // 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式, + // 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 —— + // 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。 + "减少文本输出", + // 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。 + // 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出 + // (音箱/视频/日志)调低的话题,故不取。 + "文本输出调到最少", +} + +// coldShoulderPierceKeywords 是冷避**例外②专用**的严格子集(塔 2026-10-15 裁定): +// 冷却期内只有命中它的消息能穿透冷避。它**只服务这一个用途** —— 与 light.go 的 +// highValueKeywords(基线规则,决定「平时值不值得回」)是**两张表**:那张表一个字 +// 不许改,也**不要**把它并进来(见下面的理由)。 +// +// **为什么它比全表严**:例外②给命中词「穿透 30 分钟冷避」的权力,于是噪声不再只是 +// 多回一句话,而是**直接削弱冷避本身**。实测(598 条真实 user 消息)全表 14 条只 +// 命中 1 次,且那次是**假阳性** —— QQ 机器人卡片文案「唯一的 bug 是你至今没搞懂 +// 为什么猪喜欢拱泥」(群 531082168,2026-10-14),而该群本来就在放这类卡片。 +// 因此按塔裁定剔掉三类噪声源: +// +// - ASCII `bug` / `error` / `help`:子串会命中结构化载荷(`%7B%22version%22`)、 +// URL、markdown 卡片与英文闲聊里的同形片段(上面那例即实证); +// - 情绪 / 口语歧义的中文 `崩溃`(「我崩溃了」是情绪,不是故障)与 +// `坏了` / `坏掉`(「坏了,我忘了」是「糟糕」义)。 +// +// `urgent` 同样是 ASCII 却在保留之列:它的语义只有「紧急」一种,不会像 `error`/`bug` +// 那样以名词形态出现在卡片/日志载荷里(实测 0 命中)—— 按塔的裁定保留。 +// +// 代价明写在案:用英文写的真求救("…error…help…")**不再穿透**。塔裁定以「宁可少 +// 穿透、不可多穿透」为准;全表本身的收窄是**独立切片**,不在本片做。 +var coldShoulderPierceKeywords = []string{ + "报错", "出错", "故障", "紧急", "救命", "求助", "帮帮我", "urgent", +} + +// hasColdShoulderPierceKeyword 报告文本是否命中例外②的严格子集。与全表 +// (hasHighValueKeyword)同一手法:廉价子串匹配,只读正文,绝不推断身份。 +func hasColdShoulderPierceKeyword(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + for _, k := range coldShoulderPierceKeywords { + if strings.Contains(lower, k) { + return true + } + } + return false +} + +// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸 +// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环: +// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条 +// 没有命中**真故障/求救信号**严格子集(coldShoulderPierceKeywords)。 +// +// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住 +// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化 +// (2026-10-14 报障的病灶)。适用面严格限定如下: +// +// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按 +// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟; +// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套 +// 结构化旋钮(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) + st := b.engageState(key) + st.mu.Lock() + defer st.mu.Unlock() + return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now()) +} + +// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则 +// **唯一**的实现:shouldRespond 的冷避闸与 engageDecision 都调它。 +// 命中负面态度时进 COOLDOWN 并留一条带来源的 INFO —— 判据只读正文关键词, +// 日志只带群键、发送者与截止时刻,**绝不打印正文**(design/18 §9.7)。 +// +// 判定顺序(塔 2026-10-15 裁定;该口径的文档面 design/18 §5/§9 由塔同步,本片只写代码注释): +// +// ① 同句**负面态度优先**:一句里既有「安静点」又有「报错」也按负面态度处理 +// —— 加个高价值词绝不能把「安静点」作废,仍进冷却、仍静默。 +// ② 冷却期内 + **真故障/求救信号** ⇒ **放行这一条**,且**不解除冷却** +// (不动 st.state / st.cooldownUntil):「让她少**闲聊**」≠「让她漏掉**真故障**」。 +// 判据是**严格子集** coldShoulderPierceKeywords,**不是** hasHighValueKeyword 全表 +// —— 例外给的是「穿透冷却」的权力,噪声会直接削弱冷避(依据见该表的注释); +// 但这不等于撤诉:紧接着的 @她 / 闲聊消息仍在冷却里静默。 +// ③ 冷却期内其余一切(含 @她 / @全体)⇒ 静默。 +// +// **主人为什么不需要单独写一条例外**: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) + // INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。 + b.log.Info("engage cooldown: group asked her to stay quiet", + "group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor), + "until", st.cooldownUntil) + return true, "engage: cooldown (negative attitude)" + } + if st.inCooldown(now) { + // 例外②:真故障/求救信号穿透冷避 —— 只放行这一条,冷却本身照旧。 + // 判据必须是**严格子集**(coldShoulderPierceKeywords),不是 + // hasHighValueKeyword 全表:穿透权一旦挂在全表上,卡片/载荷里的 + // `bug` 这类噪声就能把冷避按掉(2026-10-15 实测假阳性)。 + if hasColdShoulderPierceKeyword(msg.Content) { + b.log.Debug("engage cooldown: fault signal 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" + } + return false, "" +} diff --git a/internal/services/channelbridge/engage_coldshoulder_exception_test.go b/internal/services/channelbridge/engage_coldshoulder_exception_test.go index 1a47012..63cdb06 100644 --- a/internal/services/channelbridge/engage_coldshoulder_exception_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_exception_test.go @@ -13,7 +13,9 @@ import ( // 由塔同步,这里只钉行为): // // ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默; -// ② 高价值关键词(真故障)**穿透**冷却 —— 只放行这一条,且**不解除**冷却; +// ② **真故障/求救信号**(严格子集 coldShoulderPierceKeywords)穿透冷却 —— +// 只放行这一条,且**不解除**冷却;**不是**高价值关键词全表(`bug`/`error`/`help` +// 与情绪歧义的 `崩溃`/`坏了`/`坏掉` 被剔出去,矩阵里有对应行); // ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质); // ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。 // @@ -23,7 +25,7 @@ import ( // TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**: // 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。 // -// 每行对「本轮修前」= M18 提交 fe53e02 的性质用 bite 标出(红前红 / 护栏)—— +// 每行的 bite 标出它对**上一提交**(例外放宽为全表的那一版)的性质:红前红 / 护栏 —— // 不得把护栏吹成证据。 func TestColdShoulderCooldownMatrix(t *testing.T) { t.Parallel() @@ -46,6 +48,32 @@ func TestColdShoulderCooldownMatrix(t *testing.T) { 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("晚上有空吗") }, @@ -124,3 +152,47 @@ func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) { 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 用),本片不许动") + } +} diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index db1b6d7..af4b867 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -183,7 +183,8 @@ func isStrangerPrivate(sess session.Session) bool { // ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。 // ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、 // 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。 -// 冷却期内唯一放行口是**高价值关键词穿透**(真故障不漏,且不解除冷却); +// 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表: +// 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**; // 同句负面态度优先于该例外(塔 2026-10-15 定稿)。 // ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。 // ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。 @@ -235,10 +236,12 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, // 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true, // 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。 // - // 冷却期内的**唯一**放行口是「高价值关键词穿透」(塔 2026-10-15 定稿): - // 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 按高价值关键词 - // 正常回复;@她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**。 - // 同句既有负面态度又有高价值关键词时,**负面态度优先**(仍静默)。 + // 冷却期内的**唯一**放行口是「真故障/求救信号穿透」(塔 2026-10-15 定稿): + // 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 正常回复; + // 判据是严格子集 coldShoulderPierceKeywords(**不是** ruleLightJudge 用的 + // hasHighValueKeyword 全表 —— 穿透权落在全表上会让卡片里的 `bug` 把冷避按掉)。 + // @她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**; + // 同句既有负面态度又有真故障信号时,**负面态度优先**(仍静默)。 // 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。 // // 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔)