根因:摄取结果只挂到「内存里的模型可见消息」(inbound.go 的 attachInboundAttachments), 落库时只传了 text、history.Message 没有 Document 字段 ⇒ 该列恒空 ⇒ 读侧 omitempty 导致 documents 键整个缺席 ⇒ 任何客户端只看到一条空消息。Web 侧不这样,是因为 gateway/session_persist.go 有 messageTextImageDocument 把 ContentTypeDocument 部件编码进该列 —— IM 路径缺的就是同一步。 修法:inboundAttachments.documentColumn() 复用 history.EncodeDocuments 同一口径写出该列; persistToMain/persistIsolated 增 document 参数并返回刚落库的消息;新增两段式第二段 persistTurnDocumentStatus 按 id 回写抽取结论(只补结论、按存储名匹配、匹配不到原样留), 与 gateway/session_document_status.go 逐字对齐。 契约零扩张:无新端点/帧/字段;0 附件 ⇒ 列空 ⇒ 键省略;形状即 web 的同一结构体。 测试 6 条(含元素 JSON 逐字断言、unreadable 带 reason、被拒不编造芯片、群聊隔离分支、 真循环端到端);变异两条(还原挂载 / 关闭结论回写)⇒ 各 4 条红。
258 lines
11 KiB
Go
258 lines
11 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 轮次。这些逻辑
|
||
// 放在这里是为了让 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
|
||
}
|