塔 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 交叉编译通过。
485 lines
21 KiB
Go
485 lines
21 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
)
|
||
|
||
// 本文件承载公开平台群聊/陌生人会话的参与度引擎
|
||
// (DevDocs/design/18):按会话的状态机
|
||
// (IDLE→PROBING→ACTIVE→COOLDOWN)、受成本控制的触发闸门(角色名提及
|
||
// / 概率)、带跟进复审的活跃窗口,以及群内出现负面
|
||
// 态度时的冷避。它按进程存于内存(重启即重置),
|
||
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
|
||
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
|
||
//
|
||
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
|
||
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
|
||
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
|
||
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
|
||
// 口径见 coldShoulderLocked。
|
||
|
||
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
|
||
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
|
||
// 常量,组合根中的 EngageFor 接缝保持 nil。
|
||
type EngageConfig struct {
|
||
// Enabled 是按会话开关(design/18 §6 engageEnabled)。为 false 时
|
||
// 只有角色名提醒词触发仍会探测;概率路径关闭。
|
||
Enabled bool
|
||
Probability float64 // 触发③ 概率,默认 0.10
|
||
ActivityWindow time.Duration // ACTIVE 窗口,默认 15m
|
||
ActivityMessages int // ACTIVE 窗口按消息条数计,默认 20
|
||
Cooldown time.Duration // COOLDOWN 时长,默认 30m
|
||
ContextMessages int // 探测时喂给判定器的近期消息条数
|
||
}
|
||
|
||
// 参数默认值(design/18 §6)。编译期常量,运行路径上就不会出现
|
||
// 裸魔法数字(项目规范 §10)。
|
||
const (
|
||
defaultEngageProbability = 0.10
|
||
defaultActivityWindow = 15 * time.Minute
|
||
defaultActivityMessages = 20
|
||
defaultCooldownDuration = 30 * time.Minute
|
||
defaultEngageContext = 12
|
||
// consecutiveNoInterestThreshold 是连续多少次跟进探测都返回「无
|
||
// 兴趣」/回避后,她从 ACTIVE 退回 IDLE(design/18 §4)。负面
|
||
// 态度则由下面的规则词表把她按进 COOLDOWN。
|
||
consecutiveNoInterestThreshold = 2
|
||
)
|
||
|
||
// errNoEngageJudge 表示参与度引擎已配置但未装配 LightJudge,
|
||
// 因此它失败关闭为静默。
|
||
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
|
||
|
||
// DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们
|
||
// 可按群配置/在面板编辑)。
|
||
func DefaultEngageConfig() EngageConfig {
|
||
return EngageConfig{
|
||
Enabled: true,
|
||
Probability: defaultEngageProbability,
|
||
ActivityWindow: defaultActivityWindow,
|
||
ActivityMessages: defaultActivityMessages,
|
||
Cooldown: defaultCooldownDuration,
|
||
ContextMessages: defaultEngageContext,
|
||
}
|
||
}
|
||
|
||
// engageState 是按会话状态机的一个阶段(design/18 §1)。
|
||
type engageState int
|
||
|
||
const (
|
||
engageIDLE engageState = iota
|
||
engagePROBING // 瞬态:某次触发刚把该消息送去判定
|
||
engageACTIVE // 她正在参与;跟进复审已开启
|
||
engageCOOLDOWN // 她被要求安静;计时结束后自动恢复
|
||
)
|
||
|
||
// engageGroupState 是一个参与度引擎实例,以群聊/陌生人
|
||
// 会话为键(design/18 §1)。它仅存于内存(进程重启即重置)。
|
||
type engageGroupState struct {
|
||
mu sync.Mutex
|
||
state engageState
|
||
lastTopic string
|
||
lastInteract time.Time
|
||
activeUntil time.Time
|
||
msgsInWindow int
|
||
cooldownUntil time.Time
|
||
consecutiveNoInterest int
|
||
}
|
||
|
||
// engageConfigured 报告该桥接器是否装配了 design/18 参与度引擎。
|
||
// 角色带触发词,或参与度参数启用引擎时为 true;
|
||
// 否则桥接器沿用引入参与度引擎之前的旧轻量判定
|
||
// (design/12 §6),不做改动。
|
||
func (b *Bridge) engageConfigured() bool {
|
||
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
|
||
}
|
||
|
||
// engageKey 返回该会话在参与度引擎里的状态键(design/18 §1/§6,裁决 06.4.3)。键口径与
|
||
// 入站节流**完全一致**:adapter.ConversationKey(platform+channelID+groupID/peerID),
|
||
// 所以同一会话在「滑窗计数」和「参与度状态机」里落在同一个桶——两个群并发互不挤占。
|
||
// 群聊缺 group id 时用 actor 上报的群 id 补齐(适配器可能只填了 Sender.GroupID)。
|
||
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
|
||
if msg.GroupID == "" && (msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "") {
|
||
msg.GroupID = actor.GroupID
|
||
}
|
||
return adapter.ConversationKey(msg)
|
||
}
|
||
|
||
// engageState 返回会话键对应的状态,首次使用时创建 IDLE
|
||
// 实例(内存态,可安全并发使用)。
|
||
func (b *Bridge) engageState(key string) *engageGroupState {
|
||
b.engageMu.Lock()
|
||
defer b.engageMu.Unlock()
|
||
st := b.engageGroups[key]
|
||
if st == nil {
|
||
st = &engageGroupState{state: engageIDLE}
|
||
b.engageGroups[key] = st
|
||
}
|
||
return st
|
||
}
|
||
|
||
// engageConfigFor 解析一条消息实际生效的参与度配置:装配了按群
|
||
// 覆盖(EngageFor,P2a 中为 nil)时用它,否则用基准配置,
|
||
// 并把所有零值字段归一化为默认值。
|
||
func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig {
|
||
cfg := b.engage
|
||
if b.engageFor != nil {
|
||
g := msg.GroupID
|
||
if g == "" {
|
||
g = actor.GroupID
|
||
}
|
||
if ov := b.engageFor(msg.Platform, g); ov != nil {
|
||
cfg = *ov
|
||
}
|
||
}
|
||
// 把未设置的字段归一化为保守默认值,使只配了触发的配置仍能
|
||
// 限定活跃窗口与冷却,而不是以零值运行。
|
||
if cfg.ActivityWindow <= 0 {
|
||
cfg.ActivityWindow = defaultActivityWindow
|
||
}
|
||
if cfg.ActivityMessages <= 0 {
|
||
cfg.ActivityMessages = defaultActivityMessages
|
||
}
|
||
if cfg.Cooldown <= 0 {
|
||
cfg.Cooldown = defaultCooldownDuration
|
||
}
|
||
if cfg.ContextMessages <= 0 {
|
||
cfg.ContextMessages = defaultEngageContext
|
||
}
|
||
if cfg.Probability <= 0 {
|
||
cfg.Probability = defaultEngageProbability
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// engageDecision 是对(廉价规则无法判定)非主人消息的 design/18
|
||
// 参与度决策。仅在 engageConfigured() 为 true 时才会走到。
|
||
// 它返回该消息是否应驱动完整 agent 循环并回复,以及
|
||
// 供日志用的简短原因。调用方(shouldRespond)不持锁;
|
||
// 这里获取按键的状态互斥锁,使同一会话的并发消息串行化,
|
||
// 不同会话之间保持独立。
|
||
func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, inQuiet bool) (bool, string) {
|
||
cfg := b.engageConfigFor(msg, actor)
|
||
key := engageKey(msg, actor)
|
||
st := b.engageState(key)
|
||
st.mu.Lock()
|
||
defer st.mu.Unlock()
|
||
now := b.now()
|
||
|
||
// 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 跟进:活跃窗口未过期间,她持续轻量判断
|
||
// 群里延续的讨论,并倾向于保持参与(design/18 §4)。
|
||
if st.state == engageACTIVE {
|
||
if st.activityExpired(now, cfg) {
|
||
b.toIdle(st)
|
||
b.log.Debug("engage activity window expired, back to idle", "group", key)
|
||
} else {
|
||
return b.engageFollowUp(ctx, msg, actor, sess, key, st, cfg, inQuiet)
|
||
}
|
||
}
|
||
|
||
// IDLE:只有角色名提及或概率命中才会把该消息送入受成本控制的
|
||
// PROBING 判定(design/18 §2 触发②/③);否则它只被
|
||
// 登记并静默,不调用 LLM(一条红线)。
|
||
if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) {
|
||
b.log.Debug("engage no trigger, registered only", "group", key)
|
||
return false, "engage: no trigger"
|
||
}
|
||
return b.engageProbe(ctx, msg, actor, sess, key, st, cfg, inQuiet)
|
||
}
|
||
|
||
// engageProbe 是一次受成本控制的 PROBING 判定(design/18 §3):把
|
||
// 群上下文 + 角色提示词喂给轻量判定器,并决定 ACTIVE(回复)还是
|
||
// IDLE(仅登记、静默)。解析/LLM 失败时失败关闭为静默。
|
||
func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
|
||
now := b.now()
|
||
st.state = engagePROBING
|
||
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
|
||
if err != nil {
|
||
b.toIdle(st)
|
||
b.log.Warn("engage probe failed; silenced", "group", key, "error", err)
|
||
return false, "engage: probe failed; silenced"
|
||
}
|
||
if d.AvoidTopic {
|
||
b.toIdle(st)
|
||
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason)
|
||
return false, "engage: avoid topic"
|
||
}
|
||
if d.WorthReply || d.Interesting {
|
||
b.enterActive(st, cfg, now, msg)
|
||
b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason)
|
||
return true, "engage: " + d.Reason
|
||
}
|
||
b.toIdle(st)
|
||
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
|
||
return false, "engage: no interest"
|
||
}
|
||
|
||
// engageFollowUp 是 ACTIVE 窗口的跟进复审(design/18 §4):她仍
|
||
// 轻量判断讨论,但倾向于继续参与。连续无兴趣或
|
||
// 窗口结束会使她退出 ACTIVE;负面态度早已
|
||
// 被前面的冷避规则捕获。
|
||
func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
|
||
st.msgsInWindow++
|
||
now := b.now()
|
||
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
|
||
if err != nil {
|
||
b.countNoInterest(st)
|
||
b.log.Warn("engage follow-up judge failed; counted as no interest", "group", key, "error", err)
|
||
return b.engageAfterFollowUp(st, cfg, now, key)
|
||
}
|
||
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
|
||
b.countNoInterest(st)
|
||
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason)
|
||
return b.engageAfterFollowUp(st, cfg, now, key)
|
||
}
|
||
st.lastInteract = now
|
||
st.lastTopic = contentSnippet(msg.Content)
|
||
st.consecutiveNoInterest = 0
|
||
b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason)
|
||
return true, "engage follow-up: " + d.Reason
|
||
}
|
||
|
||
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
|
||
func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now time.Time, key string) (bool, string) {
|
||
if st.consecutiveNoInterest >= consecutiveNoInterestThreshold || st.activityExpired(now, cfg) {
|
||
b.toIdle(st)
|
||
b.log.Debug("engage exited active (no interest / window ended)", "group", key)
|
||
return false, "engage: left active"
|
||
}
|
||
return false, "engage: not responding"
|
||
}
|
||
|
||
// countNoInterest 为一次跟进探测递增连续无兴趣计数。
|
||
func (b *Bridge) countNoInterest(st *engageGroupState) {
|
||
st.consecutiveNoInterest++
|
||
}
|
||
|
||
// judgeEngage 为一次受成本控制的参与度判定渲染 LightInput,并
|
||
// 咨询注入的 LightJudge。只有在这里才喂入群的近期上下文与
|
||
// 角色提示词——绝不在未触发的消息上喂。
|
||
func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, cfg EngageConfig, inQuiet bool) (LightDecision, error) {
|
||
if b.light == nil {
|
||
return LightDecision{}, errNoEngageJudge
|
||
}
|
||
return b.light.Judge(ctx, LightInput{
|
||
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
|
||
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
|
||
Engage: true,
|
||
PersonaPrompt: b.personaPrompt,
|
||
TriggerWords: b.triggerWords,
|
||
RecentContext: b.buildEngageContext(ctx, sess, cfg),
|
||
})
|
||
}
|
||
|
||
// buildEngageContext 为参与度判定器渲染该会话最近的 N 条消息(带发送者
|
||
// 名,最旧在前)。未装配历史存储或存储尚无数据时为空操作
|
||
// (返回空字符串),判定器因此退化为只读
|
||
// 那一条触发消息。
|
||
func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string {
|
||
if b.history == nil || cfg.ContextMessages <= 0 {
|
||
return ""
|
||
}
|
||
msgs, err := b.history.Recent(ctx, sess.SessionID, cfg.ContextMessages)
|
||
if err != nil || len(msgs) == 0 {
|
||
return ""
|
||
}
|
||
var sb strings.Builder
|
||
for _, m := range msgs {
|
||
name := m.SenderName
|
||
if name == "" {
|
||
name = "(未标注)"
|
||
}
|
||
role := m.Role
|
||
if role == "" {
|
||
role = "msg"
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%s %s: %s\n", role, name, truncateText(m.Content, 80)))
|
||
}
|
||
if strings.TrimSpace(sb.String()) == "" {
|
||
return ""
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// engageProbabilityHit 报告该消息是否独立命中与角色名提醒词无关的
|
||
// 概率触发(design/18 §2 触发③)。它读取可注入的
|
||
// EngagRand 源,使测试具备确定性;随机源为 nil 时概率路径
|
||
// 关闭(失败关闭)。
|
||
func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
|
||
if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil {
|
||
return false
|
||
}
|
||
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))
|
||
if len(runes) <= 40 {
|
||
return string(runes)
|
||
}
|
||
return string(runes[:40]) + "…"
|
||
}
|
||
|
||
func (st *engageGroupState) inCooldown(now time.Time) bool {
|
||
return st.state == engageCOOLDOWN && now.Before(st.cooldownUntil)
|
||
}
|
||
|
||
func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) bool {
|
||
return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages
|
||
}
|
||
|
||
// toIdle 把会话重置回 IDLE(design/18:退出 ACTIVE → IDLE)。
|
||
func (b *Bridge) toIdle(st *engageGroupState) {
|
||
st.state = engageIDLE
|
||
st.msgsInWindow = 0
|
||
st.consecutiveNoInterest = 0
|
||
st.lastTopic = ""
|
||
}
|
||
|
||
// enterActive 打开活跃窗口(design/18 §4)。调用方须持有 st.mu。
|
||
func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Time, msg adapter.UnifiedMessage) {
|
||
st.state = engageACTIVE
|
||
st.activeUntil = now.Add(cfg.ActivityWindow)
|
||
st.msgsInWindow = 1
|
||
st.consecutiveNoInterest = 0
|
||
st.lastInteract = now
|
||
st.lastTopic = contentSnippet(msg.Content)
|
||
}
|
||
|
||
// enterCooldown 把会话按进安静期(design/18 §5)。调用方
|
||
// 须持有 st.mu。
|
||
func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) {
|
||
st.state = engageCOOLDOWN
|
||
st.cooldownUntil = now.Add(cfg.Cooldown)
|
||
st.msgsInWindow = 0
|
||
st.consecutiveNoInterest = 0
|
||
st.lastTopic = ""
|
||
}
|