P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】 (publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现), 不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、 不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系 即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。 P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则 (不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。 P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令 不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。 P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/ 模型请求均不含人格正文;并纠正 memory_filter 的错误注释 (作用域 ≠ 敏感度,审计 §6 R1)。
356 lines
19 KiB
Go
356 lines
19 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 是当前角色的身份/系统提示词块。它由装配方传入,
|
||
// 但 LLM 判定器**不把它透传进模型输入**:判定器只吃由它折算出的
|
||
// 「角色名 + 偏好摘要」(engagejudge.go 的 judgePersonaBrief)。
|
||
// 理由见 engageJudgePreferenceDigest 的注释 —— 整份人格指令里的称呼与
|
||
// 关系设定会被判定器搬进 reason 去指代第三方(审计 2026-10-30 §2.4)。
|
||
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
|
||
}
|