feat(backthink): 事件驱动主动消息绕过PushGate——IoT等事件该发就发(heartbeat仍受60min节流)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- trigger.go: 新增 TriggerKind(TriggerHeartbeat/TriggerEvent) + Trigger.Kind 标记触发来源
- push.go: PushRequest.TriggerKind 分流——event 跳过 gate.Allow/RecordPush(该发就发),heartbeat 仍受 60min≤1/冷却/分类限频;PushResult.Bypassed 标记 + detail=event_bypass 区分
- backthink.go: think() 传 TriggerKind + 过同事件去重门
- event_dedup.go(新): 同事件(Source+Summary)去重防刷,无 60min 节流;不同事件/心跳后重置链
- 测试: event 连续 3 次全推 bypass / heartbeat 第2次 rate_limited / event 不消耗心跳限 / 同事件去重
- build/vet/test 全绿 + windows/darwin 交叉编译通过
This commit is contained in:
Aska committed 2026-09-08 13:46:43 +08:00
1 parent 3e4efa0e41
commit d09b25071d
6 files changed
+266 -22

No files matched your search

+24 -9
View File
@@ -12,10 +12,12 @@
// Each pass produces a conclusion, then consults Decide to judge whether the user
// should be interrupted at all (event importance / time-of-day / user state such
// as heart rate, sleep, work, busy / boredom). When it is worth interrupting the
// push goes to the active client first and falls back to the IM bridge, bounded
// by the proactive-push rate limit of DevDocs/design/11 §6. Every pass is
// persisted to SQLite via Store so the M9/M10 monitor panel can render the
// background-thinking page (design/12 §4).
// push goes to the active client first and falls back to the IM bridge. The
// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat
// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to
// same-event dedup (see event_dedup.go). Every pass is persisted to SQLite via
// Store so the M9/M10 monitor panel can render the background-thinking page
// (design/12 §4).
//
// The package is concurrency-safe: a mutex guards the lifecycle state, the
// injected store/pusher/thinker are required to be concurrency-safe by contract,
@@ -110,6 +112,11 @@ type Backthink struct {
cancel context.CancelFunc
subs []func()
wg sync.WaitGroup
// eventMu guards the same-event dedup guard (event_dedup.go). It is separate
// from the lifecycle lock so a dedup decision never blocks a sink swap.
eventMu sync.Mutex
lastEventKey string
}
// New builds a Backthink from cfg and deps, applying defaults and validating the
@@ -356,10 +363,18 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
pushRes := PushResult{Route: RouteNone, Detail: b.interruptReason(push, at, state, tr.Import, lastActivity, hasActivity)}
if shouldPush {
pushRes = b.currentPusher().Push(cctx, PushRequest{
Message: b.buildProactiveMessage(message, channelHint),
ChannelHint: channelHint,
})
// Same-event dedup: a repeating identical event is suppressed after the
// first push (防刷), while a genuinely new event still goes out. Heartbeats
// are never deduped (they reset the chain) and stay gate-governed.
if b.eventRedundant(tr) {
pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"}
} else {
pushRes = b.currentPusher().Push(cctx, PushRequest{
Message: b.buildProactiveMessage(message, channelHint),
ChannelHint: channelHint,
TriggerKind: tr.Kind,
})
}
}
b.record(Record{
@@ -371,7 +386,7 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
})
if shouldPush {
b.log.Info("backthink proactive push", "source", tr.Source, "route", pushRes.Route, "pushed", pushRes.Pushed, "reason", pushRes.Detail)
b.log.Info("backthink proactive push", "source", tr.Source, "route", pushRes.Route, "pushed", pushRes.Pushed, "bypass", pushRes.Bypassed, "reason", pushRes.Detail)
} else {
b.log.Debug("backthink did not interrupt", "source", tr.Source, "reason", pushRes.Detail)
}
@@ -3,6 +3,7 @@ package backthink
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -308,3 +309,66 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
t.Fatalf("thinker calls grew after stop (bus not unsubscribed): %d -> %d", before, got)
}
}
// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a
// repeating identical event (same source + summary) is suppressed after the first
// push (防刷), while a genuinely different event still goes out. The duplicate is
// recorded as suppressed (route none), not delivered.
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
Pusher: pusher,
Now: func() time.Time { return dayAt() },
})
ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev) // first: pushes
b.think(context.Background(), ev) // second: same event -> suppressed
b.think(context.Background(), ev) // third: same event -> suppressed
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(recs) < 3 {
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
}
// Newest first: the two most recent records are the suppressed duplicates.
for i := 0; i < 2; i++ {
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
}
if !strings.Contains(recs[i].PushResult.Detail, "duplicate") {
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
}
}
// The third-most-recent record is the first (delivered) push.
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
}
// A genuinely different event resets the chain and goes out.
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev2)
if got := pusher.count(); got != 2 {
t.Fatalf("pusher calls after a different event = %d, want 2", got)
}
// After the different event, the original identical event is no longer a
// consecutive duplicate, so it pushes again — the guard only dedups consecutively.
b.think(context.Background(), ev)
if got := pusher.count(); got != 3 {
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
}
}
@@ -0,0 +1,31 @@
package backthink
// eventRedundant reports whether tr is a duplicate of the immediately preceding
// event-driven push (same source + summary), so a repeating device/reminder event
// does not spam the owner (防刷). It is the one guard that still applies to an
// event-driven push while the PushGate itself is bypassed for events (owner
// mandate: "该发就发" — a genuine event goes out, only a true duplicate is held).
//
// It atomically records tr as the latest event key when the push proceeds, and
// clears the key on any non-event push (heartbeat) so a later repeat of a prior
// event is not spuriously suppressed by an intervening reflection.
//
// The caller runs it before handing the push to the Pusher; a true duplicate is
// recorded as suppressed (RouteNone) rather than delivered.
func (b *Backthink) eventRedundant(tr Trigger) bool {
if tr.Kind != TriggerEvent {
// A non-event push (heartbeat) breaks the same-event chain.
b.eventMu.Lock()
b.lastEventKey = ""
b.eventMu.Unlock()
return false
}
key := tr.Source + "\x00" + tr.Summary
b.eventMu.Lock()
defer b.eventMu.Unlock()
if b.lastEventKey == key {
return true
}
b.lastEventKey = key
return false
}
+36 -7
View File
@@ -50,6 +50,10 @@ type PushResult struct {
Pushed bool
// Route is the delivery route taken.
Route Route
// Bypassed reports whether this push was event-driven and skipped the PushGate
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
// bypassed.
Bypassed bool
// Detail is a short human-readable note (e.g. client count, decision reason).
Detail string
// Err is the underlying error text when delivery failed, or that of the
@@ -60,9 +64,9 @@ type PushResult struct {
// String returns a concise summary for logs.
func (p PushResult) String() string {
if p.Err != "" {
return fmt.Sprintf("pushed=%v route=%s detail=%q err=%q", p.Pushed, p.Route, p.Detail, p.Err)
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
}
return fmt.Sprintf("pushed=%v route=%s detail=%q", p.Pushed, p.Route, p.Detail)
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
}
// ActiveClient delivers a proactive message to every currently active web/native
@@ -106,6 +110,9 @@ type PushRequest struct {
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
ChannelHint string
// TriggerKind records what woke this pass. An event-driven push bypasses the
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
TriggerKind TriggerKind
}
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
@@ -148,7 +155,12 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
kind := classifyPush(req.Message.Content)
if p.gate != nil && !p.gate.Allow(ctx, kind) {
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
// by an empty reflection. Same-event dedup is applied upstream in think().
eventDriven := req.TriggerKind == TriggerEvent
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
p.log.Warn("backthink push rate limited", "kind", kind.String())
return PushResult{Pushed: false, Route: RouteRateLimited, Detail: "proactive push rate limited"}
}
@@ -174,12 +186,18 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
res = p.pushActive(ctx, msg)
}
}
// Only a real delivery advances the cooldown/per-kind windows, so an
// attempted-but-failed push does not suppress a later genuine one.
if res.Pushed && p.gate != nil {
// Only a real delivery advances the cooldown/per-kind windows (and only for a
// gate-governed push), so an attempted-but-failed push does not suppress a later
// genuine one, and an event push never contributes to the heartbeat throttle.
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
return annotateHint(res, req.ChannelHint)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
res.Bypassed = true
res = annotateBypass(res)
}
return res
}
// pushActive delivers only to the active web/native client.
@@ -246,6 +264,17 @@ func annotateHint(res PushResult, hint string) PushResult {
return res
}
// annotateBypass tags an event-driven push result so the record/panel shows it
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
} else {
res.Detail = res.Detail + ", event_bypass"
}
return res
}
// rateGate is the production PushGate: a sliding-window counter over
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never
+73
View File
@@ -237,3 +237,76 @@ func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
t.Fatal("same flavor after the kind window should be allowed")
}
}
// TestPusher_EventBypassesGate verifies an event-driven push skips the PushGate:
// with a limit of 1 push per window, three consecutive event pushes are ALL
// delivered (owner mandate "该发就发"), each marked Bypassed and each advancing
// no cooldown/kind window.
func TestPusher_EventBypassesGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
req := PushRequest{Message: adapter.UnifiedMessage{Content: "洗衣机洗好了"}, TriggerKind: TriggerEvent}
first := p.Push(context.Background(), req)
second := p.Push(context.Background(), req)
third := p.Push(context.Background(), req)
if !first.Pushed || !first.Bypassed {
t.Fatalf("first event push = %+v, want delivered + Bypassed", first)
}
if !second.Pushed || !second.Bypassed {
t.Fatalf("second event push = %+v, want delivered + Bypassed (gate bypassed)", second)
}
if !third.Pushed || !third.Bypassed {
t.Fatalf("third event push = %+v, want delivered + Bypassed (gate bypassed)", third)
}
if active.calls != 3 {
t.Fatalf("active calls = %d, want 3 (events are not rate-capped)", active.calls)
}
}
// TestPusher_HeartbeatStillRateLimited verifies a heartbeat-driven push stays
// gate-governed: with a limit of 1 push per window, the first passes and the
// second is refused as rate_limited, never Bypassed.
func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
req := PushRequest{Message: adapter.UnifiedMessage{Content: "想跟你说声晚安"}, TriggerKind: TriggerHeartbeat}
first := p.Push(context.Background(), req)
second := p.Push(context.Background(), req)
if !first.Pushed || first.Bypassed {
t.Fatalf("first heartbeat push = %+v, want delivered non-bypassed", first)
}
if second.Pushed || second.Route != RouteRateLimited || second.Bypassed {
t.Fatalf("second heartbeat push = %+v, want rate_limited non-bypassed", second)
}
if active.calls != 1 {
t.Fatalf("active calls = %d, want 1 (heartbeat still gated)", active.calls)
}
}
// TestPusher_EventBypass_DoesNotAdvanceGate verifies an event-driven push does not
// consume the heartbeat limit: after an event push, a following heartbeat push
// still gets its own full allowance.
func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
ev := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "洗衣机洗好了"}, TriggerKind: TriggerEvent})
hb := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "想跟你说声晚安"}, TriggerKind: TriggerHeartbeat})
if !ev.Pushed || !ev.Bypassed {
t.Fatalf("event push = %+v, want delivered + Bypassed", ev)
}
if !hb.Pushed || hb.Bypassed {
t.Fatalf("heartbeat push after an event = %+v, want delivered non-bypassed (event did not consume the gate)", hb)
}
if active.calls != 2 {
t.Fatalf("active calls = %d, want 2", active.calls)
}
}
+38 -6
View File
@@ -7,6 +7,33 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// TriggerKind classifies what woke a thinking pass, so the push path can apply the
// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an
// event is a real signal the owner wants to hear about and bypasses the rate gate.
type TriggerKind int
const (
// TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is
// throttled by the PushGate so the owner is never spammed by empty reflections.
TriggerHeartbeat TriggerKind = iota
// TriggerEvent is a real event (IoT device change, reminder, owner-relevant
// system event). Its push bypasses the PushGate — "该发就发" — subject only to
// same-event dedup.
TriggerEvent
)
// String returns a stable label for logs and records.
func (k TriggerKind) String() string {
switch k {
case TriggerEvent:
return "event"
case TriggerHeartbeat:
return "heartbeat"
default:
return fmt.Sprintf("triggerkind(%d)", int(k))
}
}
// Bus subjects the service subscribes to for event-triggered thinking. They are
// the contract defined by design/12 §1: an IoT state change, a reminder firing,
// or a generic system event.
@@ -34,17 +61,21 @@ type Trigger struct {
Source string
Summary string
Import Importance
// Kind records whether this pass was the periodic heartbeat or a real event, so
// the push path can bypass the rate gate for a genuine event (owner mandate).
Kind TriggerKind
}
// heartbeatTrigger is the periodic, low-importance trigger. Low importance means
// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息".
func heartbeatTrigger() Trigger {
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow}
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated
// as normal importance, a reminder firing as high importance, and a generic
// system event as normal importance.
// system event as normal importance. Every event is tagged TriggerEvent so its
// proactive push bypasses the heartbeat PushGate.
func triggerForEvent(e bus.Event) Trigger {
switch e.Subject {
case iot.DeviceUpdateSubject:
@@ -53,13 +84,14 @@ func triggerForEvent(e bus.Event) Trigger {
Source: e.Subject,
Summary: fmt.Sprintf("device %s (%s) %s", d.Device.ID, d.Device.Type, d.Action),
Import: ImportanceNormal,
Kind: TriggerEvent,
}
}
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
case SubjectReminderTrigger:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh}
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh, Kind: TriggerEvent}
case SubjectSystemEvent:
tr := Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
tr := Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
if sy, ok := e.Payload.(SystemEvent); ok {
if sy.Summary != "" {
tr.Summary = sy.Summary
@@ -68,6 +100,6 @@ func triggerForEvent(e bus.Event) Trigger {
}
return tr
default:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
}
}