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
@@ -69,6 +69,10 @@ STT_PROVIDER=dashscope
|
||||
ENABLE_BACKGROUND_THINKING=true
|
||||
THINK_INTERVAL_SEC=300
|
||||
THINK_SILENCE_TIMEOUT_SEC=120
|
||||
# 安静时段(本地时钟小时,默认 23:00-07:00;两端相等且非 0 则关闭)。
|
||||
# 同时用于 backthink 后台思考与 channelbridge 公共平台安静时段轻思考(design/12 §6 策略五)。
|
||||
QUIET_START_HOUR=23
|
||||
QUIET_END_HOUR=7
|
||||
|
||||
# ---- WebSocket ----
|
||||
WS_MAX_CONNECTIONS=1000
|
||||
@@ -105,4 +109,9 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
# 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、
|
||||
# allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、
|
||||
# allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。
|
||||
# 群/总限流与总上限(可选,design/12 §6 策略三/四,公共平台防刷屏/最小化打扰):
|
||||
# groupMsgPerMin 群聊每分钟上限(0=用默认6,-1=不限,正整数=该值)
|
||||
# msgPerMin 非主人消息每分钟总上限(>0 生效,0/-1=不限)
|
||||
# maxMessages 待处理上限,接近上限时由审查模型剔除低价值消息(0=用默认50,-1=不限,正整数=该值)
|
||||
# 旧账号不配这些字段默认走「保护性默认/不限」,保持向后兼容。
|
||||
# CHANNELS_JSON=
|
||||
+13
-1
@@ -110,13 +110,19 @@ type namedAdapter struct {
|
||||
denyGroups []string
|
||||
allowUsers []string
|
||||
denyUsers []string
|
||||
// flow limits (design/12 §6 策略三/四). They are always surfaced through
|
||||
// Config() so the bridge can build a per-account ingress gate; the bridge maps
|
||||
// the zero default to the protective values and a negative value to "off".
|
||||
groupMsgPerMin int
|
||||
msgPerMin int
|
||||
maxMessages int
|
||||
}
|
||||
|
||||
func (n *namedAdapter) Name() string { return n.name }
|
||||
|
||||
func (n *namedAdapter) Config() map[string]any {
|
||||
cfg := n.PlatformAdapter.Config()
|
||||
out := make(map[string]any, len(cfg)+4)
|
||||
out := make(map[string]any, len(cfg)+7)
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
@@ -124,6 +130,9 @@ func (n *namedAdapter) Config() map[string]any {
|
||||
out["denyGroups"] = n.denyGroups
|
||||
out["allowUsers"] = n.allowUsers
|
||||
out["denyUsers"] = n.denyUsers
|
||||
out["groupMsgPerMin"] = n.groupMsgPerMin
|
||||
out["msgPerMin"] = n.msgPerMin
|
||||
out["maxMessages"] = n.maxMessages
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -182,6 +191,9 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
|
||||
denyGroups: acct.DenyGroups,
|
||||
allowUsers: acct.AllowUsers,
|
||||
denyUsers: acct.DenyUsers,
|
||||
groupMsgPerMin: acct.GroupMsgPerMin,
|
||||
msgPerMin: acct.MsgPerMin,
|
||||
maxMessages: acct.MaxMessages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+24
-5
@@ -76,6 +76,7 @@ type app struct {
|
||||
log *slog.Logger
|
||||
db *sql.DB
|
||||
bus bus.Bus
|
||||
modelAdapter model.Adapter
|
||||
personas *personareg.Registry
|
||||
caps *registry.Registry
|
||||
dispatcher *tool.Dispatcher
|
||||
@@ -263,6 +264,9 @@ func (a *app) wireRuntime() error {
|
||||
a.log.Warn("llm adapter unavailable", "error", aerr)
|
||||
adapter = nil
|
||||
}
|
||||
// Keep the model adapter for cross-function use (e.g. the channel-bridge
|
||||
// review model that prunes low-value messages, design/12 §6 策略四).
|
||||
a.modelAdapter = adapter
|
||||
router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM),
|
||||
model.WithVisionModel(a.cfg.LLM.VisionModel))
|
||||
|
||||
@@ -484,11 +488,16 @@ func (a *app) wireChannels() error {
|
||||
}
|
||||
|
||||
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
Sessions: a.sessions,
|
||||
Owner: a.owner,
|
||||
History: a.history,
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
Sessions: a.sessions,
|
||||
Owner: a.owner,
|
||||
History: a.history,
|
||||
QuietStartHour: a.cfg.Background.QuietStartHour,
|
||||
QuietEndHour: a.cfg.Background.QuietEndHour,
|
||||
// Optional review model prunes low-value queued messages near the per-adapter
|
||||
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
|
||||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
@@ -652,3 +661,13 @@ func resolveSQLitePath(cfg *config.Config, dataDir string) string {
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil
|
||||
// when no model adapter is available so the gate falls back to FIFO eviction — the
|
||||
// reviewer is never invoked with a nil adapter (which would panic on .Chat).
|
||||
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
return channelbridge.NewLLMReviewer(llm, log)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reviewer decides which buffered messages are worth keeping. It receives the
|
||||
// current pending set (in arrival order) and returns the indices it wants to keep.
|
||||
// Messages at a returned index survive; all others are pruned and audited.
|
||||
//
|
||||
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
|
||||
// never decide identity: who the sender is was already resolved structurally by the
|
||||
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
|
||||
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
|
||||
|
||||
// GateVerdict reports how the gate classified one inbound message.
|
||||
type GateVerdict struct {
|
||||
// Proceed reports whether the message should be delivered downstream.
|
||||
Proceed bool
|
||||
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
|
||||
// or "capped". It is empty when Proceed is true.
|
||||
Reason string
|
||||
// DroppedMsg is the message that failed admission (nil when Proceed is true).
|
||||
DroppedMsg *UnifiedMessage
|
||||
// Pruned lists every message the gate dropped during this admission. On the
|
||||
// review path it is the noise the reviewer rejected; on the cap path it is the
|
||||
// oldest messages evicted to honor the total cap. Each is recorded to the
|
||||
// OnPruned audit hook before being dropped, so nothing is lost silently.
|
||||
Pruned []UnifiedMessage
|
||||
}
|
||||
|
||||
// FlowGateOptions configures a per-adapter ingress gate. Every limit is
|
||||
// independent and a zero value disables that limit, so an account that does not
|
||||
// declare the new fields keeps its previous unlocked behavior (backward
|
||||
// compatible, design/12 §6 实现要点).
|
||||
type FlowGateOptions struct {
|
||||
// GroupMsgPerMin caps group-chat inbound messages per minute (design/12 §6
|
||||
// 策略三). Zero disables the group limit.
|
||||
GroupMsgPerMin int
|
||||
// MsgPerMin caps total inbound non-owner messages per minute. Zero disables it.
|
||||
MsgPerMin int
|
||||
// MaxMessages is the total pending-message cap. When the pending set nears it
|
||||
// the Reviewer is invoked to prune noise; with no Reviewer the oldest messages
|
||||
// are evicted FIFO. Zero disables the cap (design/12 §6 策略四).
|
||||
MaxMessages int
|
||||
// Window is the sliding window over which the per-minute limits are counted.
|
||||
// Zero uses defaultRateWindow.
|
||||
Window time.Duration
|
||||
// Review is the optional review model. When nil the gate enforces the cap with
|
||||
// FIFO eviction instead of model judgment.
|
||||
Review Reviewer
|
||||
// OnPruned is called once per pruned/dropped message with a short reason token
|
||||
// ("review_pruned" or "capped") so the caller can record an audit trail.
|
||||
// It may be nil (then the caller only sees Pruned on the verdict).
|
||||
OnPruned func(UnifiedMessage, string)
|
||||
// Now overrides the clock for tests. Nil uses time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// defaultRateWindow is the sliding window over which per-minute limits count.
|
||||
defaultRateWindow = time.Minute
|
||||
// defaultReviewPct is how full the pending set must be before the review model
|
||||
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
|
||||
// that keeps review from firing on every message.
|
||||
defaultReviewPct = 80
|
||||
// FlowLimitDefault is the protective default of 6 group messages per minute
|
||||
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
|
||||
FlowLimitDefault = 6
|
||||
// FlowCapDefault is the protective default total-cap of 50 pending messages
|
||||
// (design/12 §6 策略四).
|
||||
FlowCapDefault = 50
|
||||
)
|
||||
|
||||
// FlowGate is a concurrency-safe, per-adapter ingress gate implementing the two
|
||||
// measures from design/12 §6 策略三/四:
|
||||
//
|
||||
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all
|
||||
// non-owner messages). Over-limit messages are dropped (downgraded) rather
|
||||
// than blocking a flood of messages from saturating the loop.
|
||||
// - 策略四: a bounded pending-message set. When it nears its cap the Reviewer is
|
||||
// invoked to prune low-value messages; with no Reviewer the oldest messages are
|
||||
// evicted FIFO. Either way every dropped message is audited.
|
||||
//
|
||||
// Owner-private messages are not routed through the gate (the caller bypasses it),
|
||||
// so the owner is never throttled or pruned. Identity is never examined here.
|
||||
type FlowGate struct {
|
||||
groupPerMin int
|
||||
msgPerMin int
|
||||
maxMessages int
|
||||
reviewAt int
|
||||
window time.Duration
|
||||
review Reviewer
|
||||
onPruned func(UnifiedMessage, string)
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
groupHits []time.Time
|
||||
totalHits []time.Time
|
||||
buffer []queuedMsg
|
||||
lastReview time.Time
|
||||
seq uint64
|
||||
}
|
||||
|
||||
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
|
||||
// be audited and the fate of the just-admitted message be tracked across index
|
||||
// shifts (value equality on UnifiedMessage is unsafe because it holds slices).
|
||||
type queuedMsg struct {
|
||||
seq uint64
|
||||
msg UnifiedMessage
|
||||
}
|
||||
|
||||
// NewFlowGate builds a gate from opts. Options that are zero disable their limit,
|
||||
// except Window which defaults to one minute when left zero.
|
||||
func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
g := &FlowGate{
|
||||
groupPerMin: opts.GroupMsgPerMin,
|
||||
msgPerMin: opts.MsgPerMin,
|
||||
maxMessages: opts.MaxMessages,
|
||||
window: opts.Window,
|
||||
review: opts.Review,
|
||||
onPruned: opts.OnPruned,
|
||||
now: opts.Now,
|
||||
}
|
||||
if g.window <= 0 {
|
||||
g.window = defaultRateWindow
|
||||
}
|
||||
if g.maxMessages > 0 {
|
||||
g.reviewAt = g.maxMessages * defaultReviewPct / 100
|
||||
if g.reviewAt < 1 {
|
||||
g.reviewAt = 1
|
||||
}
|
||||
}
|
||||
if g.now == nil {
|
||||
g.now = time.Now
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Admit applies the rate limit then the total-cap/review gate to one inbound
|
||||
// message. It returns a verdict telling the caller whether to proceed; messages
|
||||
// that the gate drops are returned (and audited) so nothing is lost silently.
|
||||
func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
now := g.now()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// 策略三: sliding-window rate limits. Group chat has its own cap; MsgPerMin is
|
||||
// an optional total cap across all non-owner messages. Over-limit messages are
|
||||
// dropped at the door (downgraded) instead of blocking.
|
||||
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
|
||||
g.groupHits = g.trimHits(g.groupHits, now)
|
||||
if len(g.groupHits) >= g.groupPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
}
|
||||
g.groupHits = append(g.groupHits, now)
|
||||
}
|
||||
if g.msgPerMin > 0 {
|
||||
g.totalHits = g.trimHits(g.totalHits, now)
|
||||
if len(g.totalHits) >= g.msgPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
}
|
||||
g.totalHits = append(g.totalHits, now)
|
||||
}
|
||||
|
||||
if g.maxMessages <= 0 {
|
||||
return GateVerdict{Proceed: true}
|
||||
}
|
||||
|
||||
// 策略四: add to the pending set, then prune when it nears the cap.
|
||||
g.seq++
|
||||
seq := g.seq
|
||||
g.buffer = append(g.buffer, queuedMsg{seq: seq, msg: msg})
|
||||
|
||||
var dropped []queuedMsg
|
||||
if g.review != nil && len(g.buffer) >= g.reviewAt && !g.reviewRecently(now) {
|
||||
keep, err := g.review(ctx, queueMessages(g.buffer))
|
||||
if err == nil {
|
||||
g.buffer, dropped = g.applyReview(g.buffer, keep)
|
||||
g.lastReview = now
|
||||
g.auditDropped(dropped, "review_pruned")
|
||||
}
|
||||
}
|
||||
if len(g.buffer) > g.maxMessages {
|
||||
n := len(g.buffer) - g.maxMessages
|
||||
var evicted []queuedMsg
|
||||
g.buffer, evicted = g.dropFront(g.buffer, n)
|
||||
dropped = append(dropped, evicted...)
|
||||
g.auditDropped(evicted, "capped")
|
||||
}
|
||||
|
||||
if !g.containsSeq(seq) {
|
||||
reason := "capped"
|
||||
if containsSeq(dropped, seq) {
|
||||
reason = "review_pruned"
|
||||
}
|
||||
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
|
||||
c := msg
|
||||
v.DroppedMsg = &c
|
||||
return v
|
||||
}
|
||||
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
|
||||
}
|
||||
|
||||
// drop builds a refused verdict from a single message that never entered the
|
||||
// buffer (rate-limited at the door), so the audit hook still sees it.
|
||||
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
|
||||
c := msg
|
||||
v := GateVerdict{Proceed: false, Reason: reason, DroppedMsg: &c, Pruned: []UnifiedMessage{msg}}
|
||||
g.auditMsg(msg, reason)
|
||||
return v
|
||||
}
|
||||
|
||||
// reviewRecently throttles review so it does not run on every message: it fires at
|
||||
// most once per window, which is the cost guard for the review model.
|
||||
func (g *FlowGate) reviewRecently(now time.Time) bool {
|
||||
if g.lastReview.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(g.lastReview) < g.window
|
||||
}
|
||||
|
||||
// trimHits drops event timestamps older than the sliding window.
|
||||
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
|
||||
cutoff := now.Add(-g.window)
|
||||
keep := 0
|
||||
for _, t := range hits {
|
||||
if t.After(cutoff) {
|
||||
hits[keep] = t
|
||||
keep++
|
||||
}
|
||||
}
|
||||
return hits[:keep]
|
||||
}
|
||||
|
||||
// applyReview keeps only the entries at the returned indices (validated, de-duped,
|
||||
// ascending) and returns the kept slice plus the dropped entries in original order.
|
||||
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
|
||||
seen := make(map[int]bool, len(keep))
|
||||
for _, i := range keep {
|
||||
if i >= 0 && i < len(buffer) {
|
||||
seen[i] = true
|
||||
}
|
||||
}
|
||||
out := make([]queuedMsg, 0, len(buffer))
|
||||
drop := make([]queuedMsg, 0)
|
||||
for i, entry := range buffer {
|
||||
if seen[i] {
|
||||
out = append(out, entry)
|
||||
} else {
|
||||
drop = append(drop, entry)
|
||||
}
|
||||
}
|
||||
return out, drop
|
||||
}
|
||||
|
||||
// dropFront removes n entries from the front (oldest) and returns the rest plus the
|
||||
// dropped ones.
|
||||
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
|
||||
if n >= len(buffer) {
|
||||
return nil, append([]queuedMsg(nil), buffer...)
|
||||
}
|
||||
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
|
||||
}
|
||||
|
||||
// containsSeq reports whether an entry with the given sequence is still buffered.
|
||||
func (g *FlowGate) containsSeq(seq uint64) bool {
|
||||
for _, e := range g.buffer {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsSeq reports whether seq appears among the dropped entries.
|
||||
func containsSeq(dropped []queuedMsg, seq uint64) bool {
|
||||
for _, e := range dropped {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auditDropped records every dropped entry to the OnPruned audit hook.
|
||||
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
|
||||
for _, e := range dropped {
|
||||
g.auditMsg(e.msg, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// auditMsg records one dropped message to the OnPruned audit hook.
|
||||
func (g *FlowGate) auditMsg(msg UnifiedMessage, reason string) {
|
||||
if g.onPruned != nil {
|
||||
g.onPruned(msg, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// queueMessages extracts the payload messages of queued entries in order.
|
||||
func queueMessages(queue []queuedMsg) []UnifiedMessage {
|
||||
out := make([]UnifiedMessage, 0, len(queue))
|
||||
for _, e := range queue {
|
||||
out = append(out, e.msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fixedClock returns a Now func pinned to a base time so tests can advance time by
|
||||
// controlling the returned value.
|
||||
func fixedClock(base time.Time) (func() time.Time, *time.Time) {
|
||||
cur := base
|
||||
return func() time.Time { return cur }, &cur
|
||||
}
|
||||
|
||||
func groupMsg(i int) UnifiedMessage {
|
||||
return UnifiedMessage{
|
||||
Platform: "kook",
|
||||
ChannelID: "-100",
|
||||
ChannelType: ChannelGroup,
|
||||
GroupID: "-100",
|
||||
SenderID: "u" + itoa(i),
|
||||
Content: "msg " + itoa(i),
|
||||
ContentType: ContentText,
|
||||
}
|
||||
}
|
||||
|
||||
func directMsg(i int) UnifiedMessage {
|
||||
return UnifiedMessage{
|
||||
Platform: "telegram",
|
||||
ChannelID: "100",
|
||||
ChannelType: ChannelDirect,
|
||||
SenderID: "u" + itoa(i),
|
||||
Content: "dm " + itoa(i),
|
||||
ContentType: ContentText,
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
func TestFlowGate_AllZeroAllowsEverything(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := NewFlowGate(FlowGateOptions{})
|
||||
for i := 0; i < 20; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d dropped by zero-option gate: %+v", i, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_GroupRateLimitDowngradesOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, clock := fixedClock(time.Unix(1000, 0))
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
GroupMsgPerMin: 6,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
})
|
||||
|
||||
// Six messages within the window are admitted; the seventh is downgraded.
|
||||
for i := 0; i < 6; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should be admitted within limit", i)
|
||||
}
|
||||
}
|
||||
v := g.Admit(context.Background(), groupMsg(6))
|
||||
if v.Proceed {
|
||||
t.Fatal("7th group message in the window must be rate limited")
|
||||
}
|
||||
if v.Reason != "rate_limited" {
|
||||
t.Fatalf("reason = %q, want rate_limited", v.Reason)
|
||||
}
|
||||
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 6" {
|
||||
t.Fatalf("dropped msg = %+v, want msg 6", v.DroppedMsg)
|
||||
}
|
||||
|
||||
// Advancing past the window frees capacity again.
|
||||
*clock = clock.Add(61 * time.Second)
|
||||
if v := g.Admit(context.Background(), groupMsg(7)); !v.Proceed {
|
||||
t.Fatalf("message after window roll should be admitted, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_GroupRateLimitDoesNotTouchDirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := NewFlowGate(FlowGateOptions{GroupMsgPerMin: 1, Window: time.Minute})
|
||||
// Direct/private messages bypass the group rate limit.
|
||||
for i := 0; i < 5; i++ {
|
||||
if v := g.Admit(context.Background(), directMsg(i)); !v.Proceed {
|
||||
t.Fatalf("direct message %d wrongly rate limited: %+v", i, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := NewFlowGate(FlowGateOptions{MsgPerMin: 2, Window: time.Minute})
|
||||
if v := g.Admit(context.Background(), directMsg(0)); !v.Proceed {
|
||||
t.Fatal("first dm should pass")
|
||||
}
|
||||
if v := g.Admit(context.Background(), directMsg(1)); !v.Proceed {
|
||||
t.Fatal("second dm should pass")
|
||||
}
|
||||
if v := g.Admit(context.Background(), directMsg(2)); v.Proceed {
|
||||
t.Fatal("third dm should exceed the total per-minute cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var audited []string
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 10,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
// Keep only the first two messages; drop the rest as noise.
|
||||
return []int{0, 1}, nil
|
||||
},
|
||||
OnPruned: func(_ UnifiedMessage, reason string) {
|
||||
audited = append(audited, reason)
|
||||
},
|
||||
})
|
||||
|
||||
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
|
||||
for i := 0; i < 7; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
|
||||
}
|
||||
}
|
||||
if len(audited) != 0 {
|
||||
t.Fatalf("review fired below threshold: %v", audited)
|
||||
}
|
||||
|
||||
// The 8th message crosses the 80% threshold → review runs and drops msg2..7,
|
||||
// including the just-admitted message 7.
|
||||
v := g.Admit(context.Background(), groupMsg(7))
|
||||
if v.Proceed {
|
||||
t.Fatal("just-admitted message 7 must be pruned by the reviewer")
|
||||
}
|
||||
if v.Reason != "review_pruned" {
|
||||
t.Fatalf("reason = %q, want review_pruned", v.Reason)
|
||||
}
|
||||
if len(v.Pruned) != 6 {
|
||||
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
|
||||
}
|
||||
if len(audited) != 6 {
|
||||
t.Fatalf("audit count = %d, want 6", len(audited))
|
||||
}
|
||||
for _, r := range audited {
|
||||
if r != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, clock := fixedClock(time.Unix(1000, 0))
|
||||
reviews := 0
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
reviews++
|
||||
return []int{0}, nil
|
||||
},
|
||||
})
|
||||
|
||||
// First threshold crossing triggers a review.
|
||||
for i := 0; i < 3; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews)
|
||||
}
|
||||
|
||||
// Within the same window the gate must not re-review even when it refills.
|
||||
for i := 3; i < 6; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews)
|
||||
}
|
||||
|
||||
// A fresh window allows a review again.
|
||||
*clock = clock.Add(2 * time.Minute)
|
||||
for i := 6; i < 9; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 2 {
|
||||
t.Fatalf("reviews after window roll = %d, want 2", reviews)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_FifoCapWhenNoReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
var audited []string
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
OnPruned: func(_ UnifiedMessage, reason string) {
|
||||
audited = append(audited, reason)
|
||||
},
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should be admitted at cap: %+v", i, v)
|
||||
}
|
||||
}
|
||||
// The 4th evicts the oldest (msg0) to honor the cap; the new message is admitted.
|
||||
v := g.Admit(context.Background(), groupMsg(3))
|
||||
if !v.Proceed {
|
||||
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
|
||||
}
|
||||
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
|
||||
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
|
||||
}
|
||||
if len(audited) != 1 || audited[0] != "capped" {
|
||||
t.Fatalf("audit = %v, want [capped]", audited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_Defaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
if FlowLimitDefault != 6 {
|
||||
t.Fatalf("FlowLimitDefault = %d, want 6", FlowLimitDefault)
|
||||
}
|
||||
if FlowCapDefault != 50 {
|
||||
t.Fatalf("FlowCapDefault = %d, want 50", FlowCapDefault)
|
||||
}
|
||||
g := NewFlowGate(FlowGateOptions{})
|
||||
if g.window != time.Minute {
|
||||
t.Fatalf("default window = %v, want 1m", g.window)
|
||||
}
|
||||
if g.reviewAt != 0 || g.maxMessages != 0 {
|
||||
t.Fatalf("zero-option gate should disable both cap and review, got reviewAt=%d max=%d", g.reviewAt, g.maxMessages)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,39 @@ func TestParseChannelAccounts_MultiAccountWithAllowDeny(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelAccounts_FlowLimitFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `{
|
||||
"telegram": [
|
||||
{"id":"conf0","config":{"bot_token":"t0"},"groupMsgPerMin":10,"maxMessages":80,"msgPerMin":30}
|
||||
]
|
||||
}`
|
||||
got, err := ParseChannelAccounts(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseChannelAccounts: %v", err)
|
||||
}
|
||||
a := got["telegram"][0]
|
||||
if a.GroupMsgPerMin != 10 || a.MsgPerMin != 30 || a.MaxMessages != 80 {
|
||||
t.Fatalf("flow fields = group:%d msg:%d max:%d, want 10/30/80",
|
||||
a.GroupMsgPerMin, a.MsgPerMin, a.MaxMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelAccounts_NoFlowFieldsDefaultsToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `{"telegram":[{"id":"conf0","config":{"bot_token":"t0"}}]}`
|
||||
got, err := ParseChannelAccounts(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseChannelAccounts: %v", err)
|
||||
}
|
||||
a := got["telegram"][0]
|
||||
// Absent fields stay zero; the bridge maps zero to the protective defaults
|
||||
// (6 group/min, 50 max), so old accounts are backward compatible.
|
||||
if a.GroupMsgPerMin != 0 || a.MsgPerMin != 0 || a.MaxMessages != 0 {
|
||||
t.Fatalf("absent flow fields should be zero, got %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelAccounts_Malformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := ParseChannelAccounts(`{`); err == nil {
|
||||
|
||||
@@ -104,11 +104,17 @@ type VoiceConfig struct {
|
||||
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||||
}
|
||||
|
||||
// BackgroundConfig holds the autonomous thinking settings.
|
||||
// BackgroundConfig holds the autonomous thinking settings. QuietStartHour and
|
||||
// QuietEndHour bound the do-not-disturb window in local clock hours; they are the
|
||||
// single source shared by the backthink service and the channel bridge's
|
||||
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
|
||||
// disable the window.
|
||||
type BackgroundConfig struct {
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||||
}
|
||||
|
||||
// WSHubConfig holds the WebSocket hub limits.
|
||||
@@ -193,6 +199,18 @@ type ChannelAccount struct {
|
||||
// DenyUsers is a black list of user uids: a direct message from a user in this
|
||||
// list is dropped at ingress.
|
||||
DenyUsers []string `json:"denyUsers,omitempty"`
|
||||
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
|
||||
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
|
||||
// value disables the group rate limit; a positive value pins it.
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||||
// MsgPerMin caps total inbound non-owner messages per minute for this account.
|
||||
// Zero or negative disables it.
|
||||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||||
// MaxMessages is the total pending/active message cap for this account
|
||||
// (design/12 §6 策略四). Zero follows the protective default (50); a negative
|
||||
// value disables the cap; a positive value pins it. When the pending set nears
|
||||
// this cap the review model prunes low-value messages.
|
||||
MaxMessages int `json:"maxMessages,omitempty"`
|
||||
}
|
||||
|
||||
// Load reads configuration, merging an optional .env file (development only)
|
||||
|
||||
@@ -104,10 +104,14 @@ type Config struct {
|
||||
}
|
||||
|
||||
// ConfigFromBackground maps a config.BackgroundConfig (the source of the
|
||||
// heartbeat interval) into a backthink.Config, leaving the push/decide knobs at
|
||||
// their defaults for normalize to fill in.
|
||||
// heartbeat interval and the shared quiet-hours window) into a backthink.Config,
|
||||
// leaving the push/decide knobs at their defaults for normalize to fill in.
|
||||
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
||||
return Config{IntervalSec: bg.IntervalSec}
|
||||
return Config{
|
||||
IntervalSec: bg.IntervalSec,
|
||||
QuietStartHour: bg.QuietStartHour,
|
||||
QuietEndHour: bg.QuietEndHour,
|
||||
}
|
||||
}
|
||||
|
||||
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
|
||||
@@ -308,12 +312,40 @@ func (b *Backthink) subscribe(ctx context.Context) {
|
||||
}
|
||||
|
||||
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
|
||||
// own worker goroutine, so it must not block on another subscriber.
|
||||
// own worker goroutine, so it must not block on another subscriber. It first
|
||||
// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system
|
||||
// event is downgraded to a registration-only record rather than waking a full
|
||||
// thinking pass, so platform/system noise cannot flood the thinker.
|
||||
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
|
||||
b.think(ctx, triggerForEvent(e))
|
||||
tr := triggerForEvent(e)
|
||||
if !b.shouldThink(tr) {
|
||||
b.log.Debug("backthink system event downgraded, no thinking",
|
||||
"source", tr.Source, "summary", tr.Summary)
|
||||
b.record(Record{
|
||||
Trigger: tr.Source,
|
||||
At: b.now(),
|
||||
Conclusion: "system event: low value, not worth waking a thinking pass",
|
||||
ShouldPush: false,
|
||||
PushResult: PushResult{Route: RouteNone, Detail: "downgraded"},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
b.think(ctx, tr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public
|
||||
// platform a generic system event is low value, so only a high-importance
|
||||
// (owner-relevant) system event wakes a pass. IoT device updates and reminder
|
||||
// triggers are owner-relevant / high value and are never gated, so the productive
|
||||
// triggers stay fully functional.
|
||||
func (b *Backthink) shouldThink(tr Trigger) bool {
|
||||
if tr.Source == SubjectSystemEvent {
|
||||
return tr.Import >= ImportanceHigh
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tickLoop is the heartbeat goroutine. It fires one thinking pass per tick and
|
||||
// exits when ctx is cancelled.
|
||||
func (b *Backthink) tickLoop(ctx context.Context) {
|
||||
|
||||
@@ -136,6 +136,63 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
|
||||
t.Parallel()
|
||||
// design/12 §6 策略一: a low-value system event on a public platform does not
|
||||
// wake a full thinking pass — it is downgraded to a registration-only record.
|
||||
thinker := &mockThinker{text: "should not be called"}
|
||||
bs := bus.New()
|
||||
b, _ := newBackthink(t, Config{}, Deps{
|
||||
Bus: bs,
|
||||
Thinker: thinker,
|
||||
Now: func() time.Time { return dayAt() },
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "member joined"}); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
|
||||
eventually(t, 2*time.Second, func() bool {
|
||||
recs, _ := b.List(context.Background(), 10)
|
||||
return len(recs) >= 1 && recs[0].Trigger == SubjectSystemEvent
|
||||
})
|
||||
if got := thinker.count(); got != 0 {
|
||||
t.Fatalf("thinker calls = %d, want 0 (low-value system event downgraded)", got)
|
||||
}
|
||||
recs, _ := b.List(context.Background(), 10)
|
||||
if recs[0].ShouldPush {
|
||||
t.Fatalf("ShouldPush = true for a downgraded system event")
|
||||
}
|
||||
if recs[0].PushResult.Route != RouteNone {
|
||||
t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A high-importance (owner-relevant) system event still wakes a thinking pass —
|
||||
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
|
||||
thinker := &mockThinker{text: "important notice"}
|
||||
bs := bus.New()
|
||||
b, _ := newBackthink(t, Config{}, Deps{
|
||||
Bus: bs,
|
||||
Thinker: thinker,
|
||||
Now: func() time.Time { return dayAt() },
|
||||
})
|
||||
|
||||
b.Start(context.Background())
|
||||
defer func() { _ = b.Stop(context.Background()) }()
|
||||
|
||||
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "owner tagged", Import: ImportanceHigh}); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
|
||||
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
|
||||
}
|
||||
|
||||
func TestBackthink_List_NewestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
thinker := &mockThinker{text: "nothing to report"}
|
||||
|
||||
@@ -17,6 +17,17 @@ const (
|
||||
SubjectSystemEvent = "system.event"
|
||||
)
|
||||
|
||||
// SystemEvent is the optional typed payload a caller may publish to
|
||||
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
|
||||
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
|
||||
// platform noise) is downgraded to a registration-only record and does not wake a
|
||||
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
|
||||
// so a busy public platform cannot trigger a flood of expensive reflections.
|
||||
type SystemEvent struct {
|
||||
Summary string
|
||||
Import Importance
|
||||
}
|
||||
|
||||
// Trigger describes what woke a thinking pass: the source subsystem, a
|
||||
// human-readable summary and the event's estimated importance.
|
||||
type Trigger struct {
|
||||
@@ -48,7 +59,14 @@ func triggerForEvent(e bus.Event) Trigger {
|
||||
case SubjectReminderTrigger:
|
||||
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh}
|
||||
case SubjectSystemEvent:
|
||||
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
|
||||
tr := Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
|
||||
if sy, ok := e.Payload.(SystemEvent); ok {
|
||||
if sy.Summary != "" {
|
||||
tr.Summary = sy.Summary
|
||||
}
|
||||
tr.Import = sy.Import
|
||||
}
|
||||
return tr
|
||||
default:
|
||||
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,12 +84,17 @@ func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage
|
||||
return s.sess, nil
|
||||
}
|
||||
|
||||
// directMessage is an owner-private direct message: the sender is tagged as the
|
||||
// owner (structural, not from text), so it exercises the owner-private path. The
|
||||
// single-account stub session in newTestBridge reports Owner=true to match, since
|
||||
// the production SessionManager derives Owner from the resolved actor.
|
||||
func directMessage() adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: "telegram",
|
||||
ChannelID: "100",
|
||||
ChannelType: adapter.ChannelDirect,
|
||||
SenderID: "100",
|
||||
Sender: adapter.Actor{Role: adapter.RoleOwner},
|
||||
Content: "hello",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
@@ -180,6 +185,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b, chat, _, ad := newTestBridge(t)
|
||||
// A non-owner group message that is addressed (mentioned) gets a response under
|
||||
// design/12 §6 策略二, so it still exercises group reply targeting.
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram",
|
||||
ChannelID: "-20",
|
||||
@@ -187,6 +194,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
||||
GroupID: "-20",
|
||||
GroupName: "Gang",
|
||||
SenderID: "9",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember},
|
||||
Mentions: []adapter.Mention{{UserID: "9"}},
|
||||
Content: "hello all",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
@@ -351,10 +360,13 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// Addressed (mentioned) group message → responds (design/12 §6 策略二), still
|
||||
// isolated to the group session.
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9", Content: "hello all",
|
||||
ContentType: adapter.ContentText,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
||||
Content: "hello all", ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
@@ -463,11 +475,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// Addressed (mentioned) group message → responds (design/12 §6 策略二), so the
|
||||
// third-party boundary and the [第三方 · 张三] prefix are injected.
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hello all",
|
||||
ContentType: adapter.ContentText,
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
||||
Content: "hello all", ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// gateFor returns the per-account flow gate for an adapter, building it lazily
|
||||
// from the adapter's Config snapshot on first use. Gates are keyed by the adapter
|
||||
// name so every account (platform:confId) gets an independent rate limit and
|
||||
// pending-cap (design/12 §6 策略三/四), and they survive adapter reconnects.
|
||||
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
|
||||
name := a.Name()
|
||||
b.gateMu.Lock()
|
||||
defer b.gateMu.Unlock()
|
||||
if g, ok := b.gates[name]; ok {
|
||||
return g
|
||||
}
|
||||
g := adapter.NewFlowGate(b.gateOptions(a))
|
||||
b.gates[name] = g
|
||||
return g
|
||||
}
|
||||
|
||||
// gateOptions resolves the per-account flow-limits out of the adapter's Config
|
||||
// snapshot into a FlowGate. An absent key (single-account env adapters) leaves the
|
||||
// limit off, so existing channels run unchanged; a present key with value 0 follows
|
||||
// the protective default (6 group/min, 50 cap) and a negative value disables it —
|
||||
// the backward-compatible contract for CHANNELS_JSON accounts (design/12 §6 实现要点).
|
||||
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
|
||||
opts := adapter.FlowGateOptions{
|
||||
Review: b.review,
|
||||
OnPruned: b.onRejected,
|
||||
Now: b.now,
|
||||
}
|
||||
cfg := a.Config()
|
||||
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
|
||||
opts.GroupMsgPerMin = flowOrDefault(grp, adapter.FlowLimitDefault)
|
||||
}
|
||||
if tot, ok := cfgInt(cfg, "msgPerMin"); ok && tot > 0 {
|
||||
opts.MsgPerMin = tot
|
||||
}
|
||||
if max, ok := cfgInt(cfg, "maxMessages"); ok {
|
||||
opts.MaxMessages = flowOrDefault(max, adapter.FlowCapDefault)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// flowOrDefault maps a configured limit value to an effective limit: a negative
|
||||
// value disables the limit (0), zero follows the protective default, and a positive
|
||||
// value pins it. This is the "old account without the field keeps prior behavior or
|
||||
// gets the safe default" contract.
|
||||
func flowOrDefault(v, def int) int {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v == 0:
|
||||
return def
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// cfgInt reads an integer field from an adapter config map, returning (0,false)
|
||||
// when the key is absent or of an unexpected type (so an absent field stays "off").
|
||||
func cfgInt(m map[string]any, key string) (int, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// reviewMaxTokens bounds the review model output so a noisy queue listing never
|
||||
// produces a giant completion (design/12 §6 策略四 cost guard).
|
||||
reviewMaxTokens = 256
|
||||
// reviewSnippetLen is how many bytes of each message's content are shown to the
|
||||
// reviewer, enough to judge importance without leaking the whole queue.
|
||||
reviewSnippetLen = 80
|
||||
// reviewPrompt instructs the review model to pick which queued messages matter.
|
||||
// It only ever decides noise/importance — never who the sender is (project red
|
||||
// line: identity comes from actor metadata, not the LLM).
|
||||
reviewPrompt = "你是群聊消息去噪助手。下面列出若干进入队列的消息(每条带编号)。" +
|
||||
"请判断哪些消息没有保留价值(灌水、无关、低信息量),只返回你认为【有必要保留】的编号 JSON 数组。" +
|
||||
"只判断信息价值,不判断发送者身份;不要编造编号;只输出 JSON 数组。"
|
||||
)
|
||||
|
||||
// NewLLMReviewer wraps a model.Adapter into an adapter.Reviewer (design/12 §6 策略四).
|
||||
// It asks the LLM, via a light single-turn prompt, which of the queued messages are
|
||||
// worth keeping and routes the answer back to the gate. The gate only invokes it
|
||||
// when a pending set nears its cap, so the LLM is never called per-message.
|
||||
func NewLLMReviewer(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return func(ctx context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
|
||||
resp, err := llm.Chat(ctx, model.Request{
|
||||
Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, reviewPrompt),
|
||||
model.TextMessage(model.RoleUser, buildReviewContent(msgs)),
|
||||
},
|
||||
Purpose: "noise_review",
|
||||
MaxTokens: reviewMaxTokens,
|
||||
}, model.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flowgate: review model: %w", err)
|
||||
}
|
||||
keep, err := parseKeepIndices(resp.Content, len(msgs))
|
||||
if err != nil {
|
||||
log.Warn("flow gate review output unparsable, falling back to FIFO",
|
||||
"error", err)
|
||||
return nil, err
|
||||
}
|
||||
return keep, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildReviewContent renders the queued messages as a numbered, truncated listing so
|
||||
// the reviewer can judge importance cheaply. No identity or LLM judgement is made
|
||||
// here — only a compact, index-labelled text summary.
|
||||
func buildReviewContent(msgs []adapter.UnifiedMessage) string {
|
||||
var b strings.Builder
|
||||
for i, m := range msgs {
|
||||
name := senderDisplayName(m, m.Sender)
|
||||
if name == "" {
|
||||
name = "(未标注)"
|
||||
}
|
||||
fmt.Fprintf(&b, "[%d] %s: %s\n", i, name, truncateText(m.Content, reviewSnippetLen))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateText shortens s to at most n bytes (by rune count) for display.
|
||||
func truncateText(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
// parseKeepIndices decodes a JSON array of indices from the review model and keeps
|
||||
// only in-range, de-duplicated values in ascending order. Out-of-range or duplicate
|
||||
// indices are dropped so a hallucinated number can never evict a real message.
|
||||
func parseKeepIndices(s string, bound int) ([]int, error) {
|
||||
var idx []int
|
||||
if err := json.Unmarshal([]byte(s), &idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[int]bool, len(idx))
|
||||
out := make([]int, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
if i >= 0 && i < bound && !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
func groupSender(i int) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: "kook",
|
||||
ChannelID: "-100",
|
||||
ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-100",
|
||||
SenderID: "u" + itoa(i),
|
||||
Content: "spam " + itoa(i),
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
}
|
||||
|
||||
func groupOwnerMsg(i int) adapter.UnifiedMessage {
|
||||
m := groupSender(i)
|
||||
m.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestFlowGate_RateLimitDropsNonOwnerAndAudits verifies the gate is wired into the
|
||||
// ingress (onMessage) and that a rate-limited non-owner message is dropped with the
|
||||
// reason surfaced through the OnRejected audit hook. The dropped path is synchronous
|
||||
// (no worker goroutine is spawned), so the assertion is deterministic.
|
||||
func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
||||
var rejected []string
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "msgPerMin": 1}}
|
||||
b.Add(ad)
|
||||
|
||||
// First message is admitted (async worker), only auditing the second, which the
|
||||
// total per-minute cap refuses.
|
||||
b.onMessage(context.Background(), groupSender(0), ad)
|
||||
b.onMessage(context.Background(), groupSender(1), ad)
|
||||
|
||||
if len(rejected) != 1 || rejected[0] != "rate_limited" {
|
||||
t.Fatalf("rejected = %v, want [rate_limited]", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_OwnerPrivateBypassesGate verifies owner messages bypass the flow gate
|
||||
// entirely: even with an aggressive rate limit, an owner message is never dropped as
|
||||
// rate_limited / review_pruned / capped.
|
||||
func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
var rejected []string
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "groupMsgPerMin": 1, "maxMessages": 1}}
|
||||
b.Add(ad)
|
||||
|
||||
// Three owner messages: if the gate applied, the 2nd and 3rd would be rate
|
||||
// limited / capped and reach OnRejected. Owner bypass leaves the audit empty.
|
||||
for i := 0; i < 3; i++ {
|
||||
b.onMessage(context.Background(), groupOwnerMsg(i), ad)
|
||||
}
|
||||
if len(rejected) != 0 {
|
||||
t.Fatalf("owner messages must bypass the gate, got rejected %v", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_GateOptionsDefaults pins the config-to-limit resolution that keeps
|
||||
// old CHANNELS_JSON accounts backward compatible: absent key → off, 0 → protective
|
||||
// default, negative → off, positive → pinned.
|
||||
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]any
|
||||
group, max int
|
||||
}{
|
||||
{"absent keys off", map[string]any{"bot_token": "x"}, 0, 0},
|
||||
{"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0},
|
||||
{"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0},
|
||||
{"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0},
|
||||
{"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50},
|
||||
{"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0},
|
||||
{"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
opts := b.gateOptions(&fakeAdapter{name: "kook", cfg: c.cfg})
|
||||
if opts.GroupMsgPerMin != c.group {
|
||||
t.Fatalf("GroupMsgPerMin = %d, want %d", opts.GroupMsgPerMin, c.group)
|
||||
}
|
||||
if opts.MaxMessages != c.max {
|
||||
t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockLLM is a model.Adapter stub returning a fixed completion.
|
||||
type mockLLM struct {
|
||||
content string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockLLM) Name() string { return "mock-llm" }
|
||||
func (m *mockLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
if m.err != nil {
|
||||
return model.Response{}, m.err
|
||||
}
|
||||
return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil
|
||||
}
|
||||
func (m *mockLLM) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return model.Response{}, nil
|
||||
}
|
||||
|
||||
// TestNewLLMReviewer verifies the review model is wrapped into a Reviewer that asks
|
||||
// the LLM which queued messages to keep and filters the returned indices.
|
||||
func TestNewLLMReviewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
llm := &mockLLM{content: `[0,1,99,0]`}
|
||||
review := NewLLMReviewer(llm, nil)
|
||||
|
||||
msgs := []adapter.UnifiedMessage{
|
||||
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
|
||||
{SenderID: "b", Content: "y", ContentType: adapter.ContentText},
|
||||
{SenderID: "c", Content: "z", ContentType: adapter.ContentText},
|
||||
}
|
||||
keep, err := review(context.Background(), msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("review: %v", err)
|
||||
}
|
||||
// Out-of-range index 99 and the duplicate 0 are dropped; keep = [0,1].
|
||||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 1 {
|
||||
t.Fatalf("keep = %v, want [0 1]", keep)
|
||||
}
|
||||
|
||||
if llm.err != nil {
|
||||
t.Fatal("unexpected mock error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLLMReviewerError verifies a review-model failure is surfaced to the gate,
|
||||
// which then falls back to FIFO eviction rather than fabricating a success.
|
||||
func TestNewLLMReviewerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
review := NewLLMReviewer(&mockLLM{err: context.DeadlineExceeded}, nil)
|
||||
if _, err := review(context.Background(), []adapter.UnifiedMessage{
|
||||
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
|
||||
}); err == nil {
|
||||
t.Fatal("expected error from failed review model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeepIndices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseKeepIndices(`[2,0,0,5]`, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
|
||||
t.Fatalf("keep = %v, want [0 2] (out-of-range/dup dropped)", got)
|
||||
}
|
||||
if _, err := parseKeepIndices(`not json`, 3); err == nil {
|
||||
t.Fatal("expected error for non-JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"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"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// stubLightJudge is a configurable LightJudge recording how often it was consulted.
|
||||
type stubLightJudge struct {
|
||||
resp LightDecision
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubLightJudge) Judge(context.Context, LightInput) (LightDecision, error) {
|
||||
s.calls++
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
// nonOwnerGroupMsg returns a third-party group message from a member sender, with
|
||||
// the given mentions. Content is left untouched so tests control the signal.
|
||||
func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
|
||||
Content: content, ContentType: adapter.ContentText,
|
||||
}
|
||||
}
|
||||
|
||||
// nonOwnerSession is the restricted (group / stranger) session binding used by the
|
||||
// non-owner tests: read-only memory, no owner.
|
||||
func nonOwnerSession() session.Session {
|
||||
return session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}
|
||||
}
|
||||
|
||||
func TestNonOwnerSilencedRegisteredOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// A non-owner message with no @/keyword/name signal is silenced (设计/12 §6 策略二):
|
||||
// it is registered to the isolated session log but never drives the loop.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
|
||||
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("loop ran for a no-signal non-owner message: session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("records = %d, want 1 (inbound registered, no assistant reply)", len(recs))
|
||||
}
|
||||
if recs[0].SessionID != "sess-g" || recs[0].Role != history.RoleUser || recs[0].Content != "hello all" {
|
||||
t.Fatalf("registered record = %+v, want isolated sess-g user 'hello all'", recs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerMentionResponds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// An addressed (mentioned) non-owner message is worth a response (策略二), so it
|
||||
// drives the full loop against the isolated group session.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
|
||||
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want isolated sess-g", chat.sessionID)
|
||||
}
|
||||
// Restricted group context → the third-party boundary system message plus the
|
||||
// user message (design/16 §4.6 §1), mirroring TestGroupMessageThirdPartySenderNameAndBoundary.
|
||||
if len(chat.messages) != 2 || chat.messages[1].Role != model.RoleUser {
|
||||
t.Fatalf("loop messages = %+v, want boundary + user", chat.messages)
|
||||
}
|
||||
if chat.messages[1].Content[0].Text != "[第三方 · 张三] hello" {
|
||||
t.Fatalf("loop user text = %q, want [第三方 · 张三] hello", chat.messages[1].Content[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// A high-value keyword (求助) is worth a response even without an @ (策略二).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerQuietHoursSilenced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
QuietStartHour: 23, QuietEndHour: 7,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// 策略五: during quiet hours a no-signal non-owner message is silenced — it is
|
||||
// registered but not answered.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
|
||||
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("loop ran during quiet hours for a no-signal message: session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
if len(hist.records()) != 1 {
|
||||
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||||
QuietStartHour: 23, QuietEndHour: 7,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// 策略五 is not a hard silence: a directly-addressed message during quiet hours
|
||||
// still gets a response.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUnaffectedInQuietHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
||||
QuietStartHour: 23, QuietEndHour: 7,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
|
||||
// An owner message is never gated, even during quiet hours (策略五 only ever
|
||||
// governs non-owner traffic): owner-private chat still routes to the main web
|
||||
// session and replies.
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if chat.sessionID != history.MainSessionID {
|
||||
t.Fatalf("owner loop session = %q, want %q", chat.sessionID, history.MainSessionID)
|
||||
}
|
||||
if len(ad.sends) != 1 || ad.sends[0].Content != "主人好" {
|
||||
t.Fatalf("owner reply = %+v, want '主人好'", ad.sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightJudgeConsultedWhenInconclusive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A no-signal daytime message is inconclusive to the rules, so the injected
|
||||
// LightJudge gets a chance to decide (设计/12 §6 策略二: 规则不确定才轻 LLM).
|
||||
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "值得回"}}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||||
Light: judge,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1 (inconclusive rules escalated)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want sess-g (judge said respond)", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightJudgeSilenceIsRespected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不值得"}}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Light: judge,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran after the judge said silence: session=%q", chat.sessionID)
|
||||
}
|
||||
if len(hist.records()) != 1 {
|
||||
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubLightJudge{err: context.DeadlineExceeded}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Light: judge,
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// A failing light judge is fail-closed toward silence (不打扰为第一原则).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("loop ran after judge error: session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
if len(hist.records()) != 1 {
|
||||
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user