降噪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包)+交叉编译, 零新依赖, 前端无涉
254 lines
6.9 KiB
Go
254 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|