Merge branch 'feat/memberquiet-cold-shoulder-applies-before' into dev
This commit is contained in:
commit
4ed5925296
6 files changed
+835
-50
No files matched your search
@@ -19,6 +19,12 @@ import (
|
||||
// 态度时的冷避。它按进程存于内存(重启即重置),
|
||||
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
|
||||
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
|
||||
//
|
||||
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
|
||||
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
|
||||
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
|
||||
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
|
||||
// 口径见 coldShoulderLocked。
|
||||
|
||||
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
|
||||
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
|
||||
@@ -168,21 +174,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 跟进:活跃窗口未过期间,她持续轻量判断
|
||||
@@ -331,32 +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 车道据此把诉求落成结构化降频。
|
||||
var negativeAttitudeKeywords = []string{
|
||||
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
|
||||
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
|
||||
"打住", "住嘴", "shut up", "too loud", "stop spamming",
|
||||
}
|
||||
|
||||
// contentSnippet 把消息截短为状态机所用的有界话题标记。
|
||||
func contentSnippet(text string) string {
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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 用),本片不许动")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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 是本片的主断言(任务⑤①,**修前红 / 修后绿**):
|
||||
// 群成员 @她 并说「减少文本输出量」时 ——
|
||||
//
|
||||
// - 修前:@她 命中 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
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -174,12 +174,22 @@ 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、
|
||||
// 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。
|
||||
// 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表:
|
||||
// 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**;
|
||||
// 同句负面态度优先于该例外(塔 2026-10-15 定稿)。
|
||||
// ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。
|
||||
// ⑥ 规则无结论 → 参与度引擎(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 +229,29 @@ 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 报障病灶)。
|
||||
//
|
||||
// 冷却期内的**唯一**放行口是「真故障/求救信号穿透」(塔 2026-10-15 定稿):
|
||||
// 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 正常回复;
|
||||
// 判据是严格子集 coldShoulderPierceKeywords(**不是** ruleLightJudge 用的
|
||||
// hasHighValueKeyword 全表 —— 穿透权落在全表上会让卡片里的 `bug` 把冷避按掉)。
|
||||
// @她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**;
|
||||
// 同句既有负面态度又有真故障信号时,**负面态度优先**(仍静默)。
|
||||
// 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。
|
||||
//
|
||||
// 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user