Files
Cyrene_v2/internal/services/channelbridge/light.go
T
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00

354 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 策略二)。优先级:**指向她本人**(design/18 §2 触发①)、
// 提及主人的显示名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
// 推断身份(身份由 actor 元数据在会话层解析,红线)。
//
// 触发①的判据与 owner 车道**同一份**(ownerAddressedToMe,见 ownerlane.go,
// 口径写死在 design/18 §2 触发① 的 2026-10-13 修订里):`@ 她本人 / @全体`
// 才必回,**不是「有任意提及」** —— 群里 @ 别人与 @ 她长得一模一样,
// 拿「Mentions 非空」当判据会让群员之间的互 @ 把她拽进每一段对话
// (2026-10-14 真机病灶:`@3889001246`、`@867587435` 两条都被必回)。
//
// 判据的后半句(SelfID 为空时退回「有提及」)**不可省**:kook / webhook 等尚未
// 填 SelfID 的平台 AddressedToMe 恒 false,只留前半句会把它们的群 @ 一并打死
// (「没有信号」≠「没在对我说话」)。反过来,判据**全部来自结构化字段**,
// 绝不匹配正文里的 "@<数字>" —— @ 她与 @ 别人在正文里长得一模一样,
// 那正是本病灶的成因。
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict {
if ownerAddressedToMe(msg) {
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 循环并回复,还是只登记并静默。判定顺序(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) {
// 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())
// 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"
}
// 廉价规则得不出结论。参与度引擎(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: " + judgeReason(d.Reason)
}
return false, "light judge: " + judgeReason(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
}