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包)+交叉编译, 零新依赖, 前端无涉
This commit is contained in:
1 parent
764d592859
commit
8cde0162e6
16 files changed
+1750
-28
No files matched your search
@@ -90,7 +90,30 @@ type Options struct {
|
||||
// allow/deny black-or-white lists (design/16 §4.6 §6), so the composition root
|
||||
// can route it into an audit trail. It is invoked with the rejected message and
|
||||
// a short reason token ("group_denied", "user_not_allowed", ...). Nil = log only.
|
||||
//
|
||||
// It is also wired as the flow gate's OnPruned audit hook (design/12 §6 策略三/四),
|
||||
// so a message dropped by the rate limiter or the review prune reaches the same
|
||||
// audit trail with reason "rate_limited", "review_pruned" or "capped".
|
||||
OnRejected func(adapter.UnifiedMessage, string)
|
||||
// Review is the optional review model (design/12 §6 策略四) that prunes low-value
|
||||
// queued messages. Nil falls back to FIFO eviction at the total cap. Use
|
||||
// NewLLMReviewer to wrap a model.Adapter into this signature.
|
||||
Review adapter.Reviewer
|
||||
// Light is the optional light-response judge for non-owner group/stranger
|
||||
// messages (design/12 §6 策略二/五). When nil the bridge uses its built-in
|
||||
// rule-based judge and fails-closed to silence, so no chat message is sent to an
|
||||
// LLM just to decide whether to bother the owner. It is only consulted when the
|
||||
// rule judge is inconclusive.
|
||||
Light LightJudge
|
||||
// QuietStartHour and QuietEndHour bound the do-not-disturb window (local clock
|
||||
// hours), shared with the backthink service so 策略五 reuses the same definition.
|
||||
// Equal non-zero hours disable the window; both zero pick the backthink defaults
|
||||
// (23-07). This is the price of keeping one source of truth across services.
|
||||
QuietStartHour int
|
||||
QuietEndHour int
|
||||
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
|
||||
// which makes time-dependent behaviour injectable in tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||
@@ -103,12 +126,24 @@ type Bridge struct {
|
||||
history history.HistoryStore
|
||||
onPersisted func(history.Message)
|
||||
onRejected func(adapter.UnifiedMessage, string)
|
||||
review adapter.Reviewer
|
||||
|
||||
light LightJudge
|
||||
quietStartHour int
|
||||
quietEndHour int
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
connected map[string]bool
|
||||
closed bool
|
||||
|
||||
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
|
||||
// built lazily on the first inbound message per account and are independent of
|
||||
// the adapter/connected lifecycle, so the same gate survives reconnects.
|
||||
gateMu sync.Mutex
|
||||
gates map[string]*adapter.FlowGate
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
@@ -129,18 +164,34 @@ func New(opts Options) (*Bridge, error) {
|
||||
if log == nil {
|
||||
log = logger.New(logger.Options{Out: nil})
|
||||
}
|
||||
// Quiet hours default to the same window the backthink service uses, so the two
|
||||
// services never disagree about the do-not-disturb window (design/12 §6 策略五).
|
||||
qStart, qEnd := opts.QuietStartHour, opts.QuietEndHour
|
||||
if qStart == 0 && qEnd == 0 {
|
||||
qStart, qEnd = defaultQuietStartHour, defaultQuietEndHour
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Bridge{
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
onRejected: opts.OnRejected,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
onRejected: opts.OnRejected,
|
||||
review: opts.Review,
|
||||
light: opts.Light,
|
||||
quietStartHour: qStart,
|
||||
quietEndHour: qEnd,
|
||||
now: now,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -341,6 +392,23 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad
|
||||
return
|
||||
}
|
||||
|
||||
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
|
||||
// messages bypass it entirely (the owner is never throttled or pruned); every
|
||||
// other message passes a sliding-window rate limit (策略三) and the bounded
|
||||
// pending set whose saturation triggers the review model to prune noise (策略四).
|
||||
// Dropped messages are audited through the gate's OnPruned hook (wired to
|
||||
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if !actor.IsOwner() {
|
||||
verdict := b.gateFor(a).Admit(ctx, msg)
|
||||
if !verdict.Proceed {
|
||||
b.log.Warn("inbound message dropped by flow gate",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
@@ -415,6 +483,21 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
|
||||
// non-owner group/stranger message that the cheap light judgement deems not
|
||||
// worth a response is registered to its isolated session log and silenced — it
|
||||
// never drives the full agent loop. Owner messages are never gated here, so
|
||||
// owner-private chat and owner group @/high-value replies are unchanged.
|
||||
if !actor.IsOwner() {
|
||||
if ok, reason := b.shouldRespond(ctx, msg, actor); !ok {
|
||||
b.registerSilenced(ctx, msg, actor, sess)
|
||||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "reason", reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
|
||||
// main web session (shared across platforms) and persisted to chat_messages with
|
||||
// the channel platform, so the Web client mirrors it; group chats and strangers
|
||||
|
||||
Reference in new issue
Block a user