主人 2026-10-13 裁决「乙 C」= 默认按甲(群里不对她说就不开口)+ 可调旋钮。 - inbound.go: owner 不再无条件短路参与闸(去掉 `!actor.IsOwner()` 门), 主人群消息改走 shouldRespond 的 owner 车道分支;主人私聊恒回。 - ownerlane.go(新): owner 车道结构化闸 + 判据 `msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)` (未接线渠道退回旧判据,保护 kook/webhook 的群 @);指向她本人必回(硬保留); 非指向消息按每群旋钮——默认只进上下文不回(registerSilenced 落隔离会话)。 「少说点」类关键词落成结构化降频配置,带 source 日志。 - channelbridge.go: Options.OwnerLaneFor/OwnerLaneSet 读写接缝 + Bridge 状态。 - engage.go: 冷避词表并入「少说点」(与既有「少说两句」同义)。 - runtimeconfig/channels_ownerlane.go(新): owner_lane.json 落盘面 (默认档 + 每群覆盖,可读可改,面板可写)。 - wire_channels.go: 注入 owner_lane.json 存储($DATA_DIR/config/)。 - 测试:不对她说就不回(进上下文零回复)/ @她必回(即使降频)/ 未接线退回 旧判据 / 降频生效且可恢复 + 频率闸 / 旋钮读写接缝 / 归属不变量闭合 / 陌生人私聊与主人私聊回归。变异自证:恢复 owner 短路 ⇒ 5 条用例红。
308 lines
15 KiB
Go
308 lines
15 KiB
Go
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 轮次;**陌生人私聊**则一律正常聊天(主人 2026-10-05 裁决①),
|
||
// 只受强制流控约束(裁决③)。这些逻辑放在这里是为了让 channelbridge.go
|
||
// 保持在模块体积上限内;它绝不读取消息内容来判定身份——身份早已由
|
||
// 会话/actor 层结构化解析完毕(项目红线)。
|
||
|
||
// 安静时段默认值,与 backthink 服务保持一致,使桥接器与
|
||
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。它在本车道里
|
||
// **只作为信号**随 inQuiet 传给参与度引擎/轻量判定器,不再让规则层静默
|
||
// (主人 2026-10-05 裁决②)。
|
||
const (
|
||
defaultQuietStartHour = 23
|
||
defaultQuietEndHour = 7
|
||
)
|
||
|
||
// lightVerdict 是规则轻量判定给出的结果,早于咨询可选的
|
||
// 注入式 LightJudge(design/12 §6 策略二/五)。
|
||
type lightVerdict int
|
||
|
||
const (
|
||
// verdictRespond 表示规则信号显示该消息值得回复。
|
||
verdictRespond lightVerdict = iota
|
||
// verdictInconclusive 表示规则无法判断;交给参与度引擎(design/18)
|
||
// 或注入式 LightJudge(未装配时则失败关闭为静默)。安静时段**不再**
|
||
// 单独构成静默(主人 2026-10-05 裁决②)——它只作为信号随 inQuiet
|
||
// 传给下游判定器,让轻思考/参与度自己权衡要不要回。
|
||
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,没有则失败关闭为静默)。**安静时段不是
|
||
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
|
||
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
|
||
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
|
||
// 推断身份(身份由 actor 元数据在会话层解析,红线)。
|
||
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) 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
|
||
}
|
||
return verdictInconclusive
|
||
}
|
||
|
||
// isStrangerPrivate 报告该会话是否为**陌生人私聊**(非群 + 非主人)。
|
||
// 它只读会话层结构化解析出的标志(项目红线:身份不靠正文/提示词)。
|
||
// 主人私聊在 onMessage/shouldRespond 之前就已被 actor 门拦下,因此
|
||
// 这里成真的只会是陌生人私聊。
|
||
func isStrangerPrivate(sess session.Session) bool {
|
||
return !sess.IsGroup && !sess.Owner
|
||
}
|
||
|
||
// shouldRespond 实现入站消息的最小打扰分级
|
||
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息
|
||
// 应驱动完整 agent 循环并回复,还是只登记并静默。
|
||
// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」):
|
||
// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说
|
||
// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回,
|
||
// 而主人群里不对她说的消息改为**进上下文不回**。sess 是
|
||
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
|
||
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
|
||
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
|
||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
|
||
// owner 车道:结构化参与闸(design/18 §11)。身份由 actor 元数据判定,
|
||
// 这里只决定「说不说 / 多频繁」,绝不动权限面。
|
||
if actor.IsOwner() {
|
||
return b.ownerGroupShouldRespond(msg, sess)
|
||
}
|
||
|
||
// 策略一:平台/系统噪声 → 只登记,绝不进完整循环。
|
||
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"
|
||
}
|
||
|
||
// 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、
|
||
// 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是流控:
|
||
// 同一发送者每分钟 ≤ 该账号的 directMsgPerMin(未配 = 保护性默认 6,裁决 13
|
||
// 让它可配;AllowDirect 记账)与同时最多 1 个在跑的回合
|
||
// (processFrom 用 AcquireTurn 施加)。
|
||
// 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带
|
||
// source 的可见告警(绝不静默),并报出**实际生效**的额度而不是硬编码默认值。
|
||
if isStrangerPrivate(sess) {
|
||
if g := b.gateFor(a); g != nil && !g.AllowDirect(msg) {
|
||
b.log.Warn("stranger private chat rate limited, registered only",
|
||
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID,
|
||
"limit_per_min", g.DirectPerMin())
|
||
return false, "stranger private: over the per-sender flow limit"
|
||
}
|
||
return true, "stranger private chat: normal reply"
|
||
}
|
||
|
||
// 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或
|
||
// 高价值关键词一律直接回;规则得不出结论的部分交给参与度
|
||
// 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传,
|
||
// 规则层不再因为它静默(裁决②)。
|
||
inQuiet := b.inQuietHours(b.now())
|
||
if b.ruleLightJudge(msg) == verdictRespond {
|
||
return true, "rule signal: 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 是
|
||
// 收到该消息的账号适配器,日志因此带上适配器配置标签。
|
||
// 它是最小打扰路径(以及 owner 车道「进上下文不回」)中「登记」的那一侧。
|
||
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) {
|
||
// 被静默的消息不摄取附件(摄取在闸门之后),因此这两列恒为空 ——
|
||
// 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。
|
||
// turn_id 同样传 "":这条消息**根本不会被回答**,不存在「它属于哪一路回合」,
|
||
// 给它编一个标识就是把不属于任何回合的行说成有回合(见 turnid.go 的口径)。
|
||
//
|
||
// 发送者标注与回帖路径**同源**(thirdPartySenderName):非主人恒有标注
|
||
// (昵称 → actor uid → 「未署名发送者」),主人恒空。静默行会被读回她的
|
||
// 上下文(transcript.go),而受限会话里空标注就是「主人本人」——用
|
||
// senderDisplayName 会在无昵称时写出空串,把一条陌生人消息渲染成
|
||
// 「来自主人(管理员账户)本人」(review p1:本次修掉的 bug 的镜像)。
|
||
b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, thirdPartySenderName(msg, actor), adapterName(a), "")
|
||
b.log.Debug("inbound message silenced, registered only",
|
||
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID,
|
||
"actor_role", actor.Role)
|
||
}
|
||
|
||
// registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的
|
||
// 非主人消息的落点:先走最小打扰的登记路径(registerSilenced)把它写进
|
||
// 其隔离会话,再留一条**带 source 的可见告警** —— 被限流绝不静默
|
||
// (主人 2026-10-05 裁决③)。限流路径与普通静默因此区分得开:前者
|
||
// 一定有可见日志,后者只有 debug。
|
||
func (b *Bridge) registerThrottled(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter, reason string) {
|
||
b.registerSilenced(ctx, msg, actor, sess, a)
|
||
b.log.Warn("inbound message throttled by flow gate, registered only",
|
||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||
"sender", msg.SenderID, "reason", reason)
|
||
}
|
||
|
||
// 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
|
||
}
|