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 红线)。 // 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,即使正处在参与中。直接 @ / 高价值 // 关键词已在上方返回,因此只有真正「内容值得回复」 // 的消息(或主人消息,它根本不会走到这里)才能覆盖它。 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" } // 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 车道据此把诉求落成结构化降频。 var negativeAttitudeKeywords = []string{ "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", "安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦", "打住", "住嘴", "shut up", "too loud", "stop spamming", } // 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 = "" }