Files
Cyrene_v2/internal/services/channelbridge/light.go
T
Aska 8cde0162e6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 公共平台最小化打扰(design/12 §6)——5条策略: 分级+轻思考+限流+审查剔噪
降噪A(策略一/二/五):
- channelbridge/light.go: 非owner分级门——小事件当系统消息登记(不进loop)、非owner轻响应(她判断回/不回,规则优先mention/关键词+轻LLM兜底fail-closed静默)
- backthink 触发降噪: SubjectSystemEvent 低价值不唤醒thinking(仅高价值/owner才think), iot/reminder不受影响
- quiet hours: 安静时段非owner无信号静默,被@才回(非硬性静默); config QUIET_START/END_HOUR 单源共用

限流B(策略三/四):
- adapter/flowgate.go: per-adapter 滑窗限流器+总上限+审查剔噪闸门(Admit两连:速率+上限/审查), owner私聊绕过
- channelbridge/flowgate.go: onMessage入站最前挂闸, 超限/被剔不占worker槽; 审查模型NewLLMReviewer复用model.Adapter(仅>80%阈值触发+跨分钟节流,失败回退FIFO,绝不判身份)
- config: ChannelAccount 加 GroupMsgPerMin(默认6)/MsgPerMin/MaxMessages(默认50), CHANNELS_JSON每账号, 向后兼容
- 被剔消息走OnRejected审计不静默丢

main wire接线: 加modelAdapter字段+Review注入(reviewFor nil-safe,无LLM回退FIFO)
全仓 go build/vet/test 绿(53包)+交叉编译, 零新依赖, 前端无涉
2026-09-07 12:18:21 +08:00

191 lines
8.0 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"
)
// This file holds the channel bridge's minimal-disturbance inbound classification
// for public platforms (DevDocs/design/12 §6 策略一/二/五). Non-owner group/stranger
// messages are judged cheaply before they reach the full agent loop, so a busy
// public group does not fling every message into an expensive LLM turn. The logic
// lives here to keep channelbridge.go under the module size bound; it never reads
// message content to decide identity — identity is already resolved structurally by
// the session/actor layer (a project red line).
// Quiet-hour defaults, mirroring the backthink service so the bridge and the
// background thinker share one do-not-disturb window (design/12 §6 策略五).
const (
defaultQuietStartHour = 23
defaultQuietEndHour = 7
)
// lightVerdict is the outcome of a rule-based light judgement before the optional
// injected LightJudge is consulted (design/12 §6 策略二/五).
type lightVerdict int
const (
// verdictRespond means the rule signals the message is worth a response.
verdictRespond lightVerdict = iota
// verdictSilent means the rule signals the message should be registered but not
// answered (e.g. during quiet hours with no strong signal).
verdictSilent
// verdictInconclusive means the rules cannot tell; defer to the LightJudge (or
// fail-closed to silence when none is wired).
verdictInconclusive
)
// LightInput is the set of signals a LightJudge receives to decide response
// worthiness. Identity is already resolved structurally by the session/actor
// layer, so the judge weighs only 值不值得回 — it never decides who the sender is.
type LightInput struct {
Msg adapter.UnifiedMessage
Actor adapter.Actor
SenderName string
InQuietHours bool
QuietStart int
QuietEnd int
}
// LightDecision is a LightJudge's verdict.
type LightDecision struct {
Respond bool
Reason string
}
// LightJudge is the optional cost-controlled light-response judge (design/12 §6
// 策略二/五). The bridge consults it only when the built-in rule judge is
// inconclusive, so a chat message is not repeatedly sent to an expensive LLM. A
// nil LightJudge (the default) keeps the bridge rule-only and fail-closed toward
// silence (不打扰为第一原则). The production wiring does not supply one yet, so the
// seam is ready but not token-consuming until it is deliberately wired.
type LightJudge interface {
Judge(ctx context.Context, in LightInput) (LightDecision, error)
}
// highValueKeywords is the small term list the rule judge matches to decide a
// message is worth a response even when the sender was not directly addressed
// (设计/12 §6 策略二: 关键词重要度). It is a cheap substring match — never an LLM call,
// and never used to infer identity.
var highValueKeywords = []string{
"紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃",
"求助", "帮帮我", "help", "urgent", "error", "bug",
}
// isLowValueEvent reports whether a non-owner message is platform/system noise
// that must be registered but never drives the full agent loop (设计/12 §6 策略一).
// It is only consulted for non-owner messages: a system/synthetic sender is the
// canonical low-value signal. Messages with no meaningful chat text are dropped at
// the bridge head anyway, so this primarily gates system-actor notifications.
func isLowValueEvent(actor adapter.Actor) bool {
return actor.IsSystem()
}
// hasHighValueKeyword reports whether text carries a term worth responding to even
// when the sender was not directly addressed. Kept tiny so the rule path stays O(1)
// per message.
func hasHighValueKeyword(text string) bool {
lower := strings.ToLower(text)
for _, k := range highValueKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// ruleLightJudge decides response-worthiness from cheap rule signals only
// (设计/12 §6 策略二/五). Priority: a direct mention, a mention of the owner's display
// name, a high-value keyword, then a quiet-hours bias toward silence; otherwise the
// verdict is inconclusive (defer to the injected LightJudge, else silence). It
// reads only already-resolved actor metadata and message content — never infers
// identity.
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 implements the minimal-disturbance classification for a non-owner
// inbound message (设计/12 §6 策略一/二/五). It returns whether the message should
// drive the full agent loop and reply, or be registered-and-silenced. Owner
// messages never reach here (processFrom gates on !actor.IsOwner), so owner-private
// chat and owner group @/high-value replies are untouched.
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
// 策略一: platform/system noise → register only, never the full loop.
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"
}
// 策略二/五: rule-based light judgement, quiet-hours aware.
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"
}
// Inconclusive → consult the optional injected light judge. Fail-closed to
// silence on error or absence (不打扰为第一原则).
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)"
}
// registerSilenced persists a silenced non-owner inbound turn into its isolated
// session's platform log so the adapter log page still renders the non-owner
// stream (design/16 §4.6 §4), then stops — no loop, no reply. It is the 登记 side
// of the minimal-disturbance path.
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) {
b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor))
b.log.Debug("non-owner message silenced, registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
}
// inQuietHours reports whether at falls inside the configured do-not-disturb
// window (shared semantics with backthink: equal non-zero hours disable it, and a
// window that wraps midnight is handled). It is a pure method on Bridge so tests
// drive it deterministically via the Now source.
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
}