package channelbridge import ( "context" "strings" "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" ) // 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级 // (DevDocs/design/12 §6 策略一/二/五)。非主人的群聊/陌生人 // 消息在进入完整 agent 循环前先经廉价判断,使繁忙的 // 公开群不会把每条消息都扔进昂贵的 LLM 轮次。这些逻辑 // 放在这里是为了让 channelbridge.go 保持在模块体积上限内;它绝不 // 读取消息内容来判定身份——身份早已由 // 会话/actor 层结构化解析完毕(项目红线)。 // 安静时段默认值,与 backthink 服务保持一致,使桥接器与 // 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。 const ( defaultQuietStartHour = 23 defaultQuietEndHour = 7 ) // lightVerdict 是规则轻量判定给出的结果,早于咨询可选的 // 注入式 LightJudge(design/12 §6 策略二/五)。 type lightVerdict int const ( // verdictRespond 表示规则信号显示该消息值得回复。 verdictRespond lightVerdict = iota // verdictSilent 表示规则信号显示该消息应登记但不应 // 回复(例如安静时段内没有强信号)。 verdictSilent // verdictInconclusive 表示规则无法判断;交给 LightJudge(未装配 // 时则失败关闭为静默)。 verdictInconclusive ) // LightInput 是 LightJudge 判断是否值得回复时收到的信号集合。 // 身份早已由会话/actor 层结构化解析完毕, // 因此判定器只权衡值不值得回 —— 绝不判断发送者是谁。 type LightInput struct { Msg adapter.UnifiedMessage Actor adapter.Actor SenderName string InQuietHours bool QuietStart int QuietEnd int // Engage 报告这是否是一次受成本控制的参与度判定 // (design/18):只有触发(角色名提及 / 概率) // 或活跃窗口把消息送到这里时才为真,因此调用方只投喂一次 // 群上下文与角色提示词,绝不会按每条聊天消息投喂。旧的 // (design/12 §6)无结论判定会使它为 false。 Engage bool // PersonaPrompt 是当前角色的身份/系统提示词块,使判定器 // 能权衡角色的语气而不涉身份判定。它不含 // 主人敏感信息(项目红线)。 PersonaPrompt string // RecentContext 是群会话最近 N 条消息(带各发送者 // 显示名)加上触发消息的渲染文本,使判定器能判断 // 该话题是否值得参与。 RecentContext string // TriggerWords 是当前角色的角色名提醒词(design/18 §2), // 作为上下文传入,使判定器知道被提及的是哪个词。 TriggerWords []string } // LightDecision 是 LightJudge 的裁决。 type LightDecision struct { // Respond 是旧的 design/12 §6 简单裁决(回不回)。保留它 // 是为了向后兼容;下面的 design/18 字段承载结构化的 // 参与度裁决,供参与度引擎读取。 Respond bool Reason string // Interesting 报告她是否觉得该话题有意思(design/18)—— // 进入/保持 ACTIVE 的信号。 Interesting bool // WorthReply 报告该消息是否值得回复。 WorthReply bool // AvoidTopic 报告该话题是否应回避(敏感 / 私密 / // 红线),使她在其他方面感兴趣时也不参与。 AvoidTopic bool } // LightJudge 是可选的受成本控制的轻量应答判定器(design/12 §6 // 策略二/五)。桥接器只在内置规则判定器得不出结论时咨询它, // 因此聊天消息不会被反复送往昂贵的 LLM。LightJudge 为 // nil(默认)时桥接器只用规则,并失败关闭为 // 静默(不打扰为第一原则)。生产装配尚未提供它,因此这个 // 接缝已就绪,但在被刻意接入前不消耗 token。 type LightJudge interface { Judge(ctx context.Context, in LightInput) (LightDecision, error) } // highValueKeywords 是规则判定器用于匹配的小词表:即使发送者 // 未被直接点名,命中即认为该消息值得回复 // (设计/12 §6 策略二:关键词重要度)。它是廉价子串匹配——不调用 LLM, // 也绝不用于推断身份。 var highValueKeywords = []string{ "紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃", "求助", "帮帮我", "help", "urgent", "error", "bug", } // isLowValueEvent 报告非主人消息是否为平台/系统噪声: // 必须登记,但绝不驱动完整 agent 循环(设计/12 §6 策略一)。 // 它只对非主人消息咨询:系统/合成发送者是最典型的 // 低价值信号。没有实质聊天文本的消息本就会在 // 桥接器入口被丢弃,因此它主要用于拦系统 actor 的通知。 func isLowValueEvent(actor adapter.Actor) bool { return actor.IsSystem() } // hasHighValueKeyword 报告文本是否含有「即使未被直接点名 // 也值得回复」的词。词表保持极小,使规则路径对每条消息 // 都是 O(1)。 func hasHighValueKeyword(text string) bool { lower := strings.ToLower(text) for _, k := range highValueKeywords { if strings.Contains(lower, k) { return true } } return false } // ruleLightJudge 只依据廉价规则信号判断是否值得回复 // (设计/12 §6 策略二/五)。优先级:直接点名、提及主人的显示 // 名、高价值关键词,其次是安静时段偏向静默;否则 // 结论为无结论(交给注入的 LightJudge,没有则静默)。它 // 只读取已解析的 actor 元数据与消息内容——绝不推断 // 身份。 func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict { if len(msg.Mentions) > 0 { return verdictRespond } if b.owner != nil && b.owner.DisplayName != "" && strings.Contains(msg.Content, b.owner.DisplayName) { return verdictRespond } if hasHighValueKeyword(msg.Content) { return verdictRespond } if inQuiet { return verdictSilent } return verdictInconclusive } // shouldRespond 实现非主人入站消息的最小打扰分级 // (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5)。它返回该消息 // 应驱动完整 agent 循环并回复,还是只登记并静默。 // 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门), // 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是 // 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // 引擎探测时能读到群的近期上下文。 func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) { // 策略一:平台/系统噪声 → 只登记,绝不进完整循环。 if isLowValueEvent(actor) { b.log.Debug("non-owner low-value system event, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role) return false, "low-value system event; registered only" } // 策略二/五:基于规则的轻量判定,感知安静时段。直接 @、 // 提及主人显示名或高价值关键词一律回复;安静时段 // 偏向静默。只有无结论的剩余部分才咨询 // 参与度引擎。 inQuiet := b.inQuietHours(b.now()) switch b.ruleLightJudge(msg, actor, inQuiet) { case verdictRespond: return true, "rule signal: worth responding" case verdictSilent: b.log.Debug("non-owner message silenced (quiet/no signal), registered only", "platform", msg.Platform, "channel", msg.ChannelID, "quiet", inQuiet, "actor_role", actor.Role) return false, "rule signal: not worth responding" } // 廉价规则得不出结论。参与度引擎(design/18)已配置时 // 由它接管,且只在真正触发(角色名提及 / 概率)或 // 活跃窗口内才咨询轻量判定器——绝不按每条 // 聊天消息咨询,成本因此可控(红线)。未配置参与度 // 引擎时,桥接器保持引入引擎之前的旧行为:对每条无结论 // 消息咨询可选的注入式轻量判定器,否则失败关闭。 if !b.engageConfigured() { return b.legacyLightJudge(ctx, msg, actor, inQuiet) } return b.engageDecision(ctx, msg, actor, sess, inQuiet) } // legacyLightJudge 是 design/18 之前的无结论处理:咨询可选的 // 注入式轻量判定器,出错或缺失时失败关闭为静默 // (不打扰为第一原则)。仅在未装配参与度引擎时才走到。 func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) (bool, string) { if b.light != nil { d, err := b.light.Judge(ctx, LightInput{ Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor), InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour, }) if err == nil { if d.Respond { return true, "light judge: " + d.Reason } return false, "light judge: " + d.Reason } b.log.Warn("light judge failed; silencing", "error", err) } b.log.Debug("non-owner message has no rule signal, silenced", "platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role) return false, "no rule signal; silenced (fail-closed)" } // mentionsTriggerWord 报告消息内容是否通过当前角色的某个触发词 // 提到该角色(design/18 §2 触发②)。它是廉价子串匹配—— // 不调用 LLM,也绝不用于推断发送者是谁。 func (b *Bridge) mentionsTriggerWord(text string) bool { if text == "" { return false } for _, w := range b.triggerWords { if w != "" && strings.Contains(text, w) { return true } } return false } // registerSilenced 把一次被静默的非主人入站对话持久化进其隔离 // 会话的平台日志,使适配器日志页仍能渲染非主人 // 消息流(design/16 §4.6 §4),随后就此结束——不进循环、不回复。a 是 // 收到该消息的账号适配器,日志因此带上适配器配置标签。 // 它是最小打扰路径中「登记」的那一侧。 func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) { // 被静默的消息不摄取附件(摄取在闸门之后),因此这一列恒为空 —— // 绝不写一条指向不存在文件的引用。 b.persistIsolated(ctx, history.RoleUser, msg.Content, "", deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a)) b.log.Debug("non-owner message silenced, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID) } // inQuietHours 报告 at 是否落在配置的免打扰窗口内 // (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口, // 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试 // 可通过 Now 源确定性地驱动它。 func (b *Bridge) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.quietStartHour, b.quietEndHour if start <= end { return h >= start && h < end } return h >= start || h < end }