From 12031bd13c93b54d54dabbde6348ee95dd823da0 Mon Sep 17 00:00:00 2001 From: Aska Date: Tue, 8 Sep 2026 13:08:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(backthink):=20=E4=B8=BB=E5=8A=A8=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E9=99=8D=E9=A2=91(15min=E5=BF=83=E8=B7=B3+60min?= =?UTF-8?q?=E2=89=A41=E6=AC=A1+=E6=8E=A8=E5=90=8E30min=E5=86=B7=E5=8D=B4+?= =?UTF-8?q?=E5=88=9A=E8=81=8A=E8=BF=8710min=E5=86=B7=E9=81=BF)=20+=20?= =?UTF-8?q?=E6=83=85=E7=BB=AA=E7=BA=A6=E6=9D=9F(=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E9=99=90=E9=A2=91/=E6=B4=BB=E8=B7=83=E6=84=9F=E7=9F=A5)=20+=20?= =?UTF-8?q?=E5=89=A5=E7=A6=BB'=E6=88=91=E5=86=B3=E5=AE=9A:push'=E5=86=85?= =?UTF-8?q?=E9=83=A8=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 +- cmd/cyrene/wire.go | 1 + internal/config/config.go | 18 ++- internal/services/backthink/backthink.go | 141 ++++------------- internal/services/backthink/backthink_test.go | 29 ++++ internal/services/backthink/cfg.go | 147 ++++++++++++++++++ internal/services/backthink/context.go | 1 + internal/services/backthink/decide.go | 144 ++++++++++++++++- internal/services/backthink/decide_test.go | 77 +++++++++ internal/services/backthink/mocks_test.go | 3 +- internal/services/backthink/proactive.go | 37 +++-- internal/services/backthink/proactive_test.go | 56 +++++++ internal/services/backthink/push.go | 86 ++++++++-- internal/services/backthink/push_test.go | 60 ++++++- 14 files changed, 662 insertions(+), 149 deletions(-) create mode 100644 internal/services/backthink/cfg.go diff --git a/.env.example b/.env.example index 9eff37e..df0ec74 100644 --- a/.env.example +++ b/.env.example @@ -92,8 +92,17 @@ STT_PROVIDER=dashscope # ---- 后台思考 ---- ENABLE_BACKGROUND_THINKING=true -THINK_INTERVAL_SEC=300 +# 自主思考心跳周期(秒)。默认 900(15 分钟一次);数值越小越频繁打扰。 +THINK_INTERVAL_SEC=900 THINK_SILENCE_TIMEOUT_SEC=120 +# 主动推送频率(design/12 §6):窗口内最多 PushLimit 次;推后冷却 Cooldown 秒; +# 同一风味(问候/提醒/分享/关心)在 KindWindow 秒内最多一次;主人最近消息在 +# RecentChatCooldown 秒内则冷避不推。收敛默认值:1 次 / 60 分钟 + 推后 30 分钟冷却。 +THINK_PUSH_LIMIT=1 +THINK_PUSH_WINDOW_SEC=3600 +THINK_PUSH_COOLDOWN_SEC=1800 +THINK_RECENT_CHAT_COOLDOWN_SEC=600 +THINK_KIND_WINDOW_SEC=7200 # 安静时段(本地时钟小时,默认 23:00-07:00;两端相等且非 0 则关闭)。 # 同时用于 backthink 后台思考与 channelbridge 公共平台安静时段轻思考(design/12 §6 策略五)。 QUIET_START_HOUR=23 diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 0c5cbc5..a2578ab 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -535,6 +535,7 @@ func (a *app) wireAuxiliary() error { bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{ Bus: a.bus, Thinker: backthink.NewLoopThinker(a.loop, a.history), + Recent: a.history, State: backthink.NewIoTStateProvider(iotMgr, a.log), DB: a.db, Log: logger.WithSource(a.log, "services/backthink"), diff --git a/internal/config/config.go b/internal/config/config.go index 13ab830..36536f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -141,13 +141,19 @@ type VoiceConfig struct { // 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. +// disable the window. The Push*/Kind fields tune the proactive-push frequency of +// design/11 §6 + design/12 §6 and are mapped into backthink.Config. 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"` + Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"` + IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"` + 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"` + PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"` + PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"` + PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"` + RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"` + KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"` } // WSHubConfig holds the WebSocket hub limits. diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index a2b1185..82bc2d9 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -27,48 +27,16 @@ import ( "database/sql" "errors" "fmt" - "io" "log/slog" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" - "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// Defaults applied by normalize when a Config field is left at its zero value. -// They are compile-time constants so no magic number appears in the run path. -const ( - // defaultIntervalSec is the heartbeat period in seconds when unused. - defaultIntervalSec = 300 - // defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb - // window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may - // wrap midnight; two equal non-zero hours disable it). - defaultQuietStartHour = 23 - defaultQuietEndHour = 7 - // defaultMaxPushWindow caps proactive pushes within defaultPushWindow to - // honor the anti-spam rule of design/11 §6 (QQ wind control, iLink window). - defaultMaxPushWindow = 3 - defaultPushWindow = 10 * time.Minute - // defaultThinkTimeout bounds a single unattended thought so it can never - // wedge a worker goroutine. - defaultThinkTimeout = 30 * time.Second - // defaultSessionID scopes a think when the caller supplies none. It is the - // loop.Run durability key, deliberately distinct from history.MainSessionID so - // an autonomous think never collides with the live main session's checkpoint - // store (channelbridge saves/resumes under the main id). The think still sees - // the main conversation: LoopThinker reads history.MainSessionID for context and - // runs under loop.OwnerEnv (design/20 §2). - defaultSessionID = "sess-backthink" -) - -// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is -// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow -// graph, which the backthink page and the web-chat flow page both render. -const DefaultSessionID = "sess-backthink" - // Sentinel errors returned by this package. Use errors.Is to test for them; they // are returned directly or wrapped with %w so the underlying cause stays intact. var ( @@ -90,39 +58,6 @@ type Thinker interface { Think(ctx context.Context, sessionID, trigger string) (string, error) } -// Config carries the autonomous-think tuning knobs. Zero values pick safe -// defaults via normalize, so a partially filled Config is still usable. -type Config struct { - // Interval is the heartbeat period. When zero it is derived from - // IntervalSec (or its default). - Interval time.Duration - // IntervalSec is the heartbeat period in seconds, mapped from - // config.BackgroundConfig.IntervalSec. Ignored when Interval is set. - IntervalSec int - // QuietStartHour and QuietEndHour bound the do-not-disturb window in local - // clock hours. Equal non-zero hours disable the window. - QuietStartHour int - QuietEndHour int - // MaxPushWindow bounds proactive pushes within PushWindow. Zero uses the - // default. Non-positive PushWindow or MaxPushWindow disables the limit. - MaxPushWindow int - // PushWindow is the sliding window over which MaxPushWindow is enforced. - PushWindow time.Duration - // ThinkTimeout bounds a single thinking pass. Zero uses the default. - ThinkTimeout time.Duration -} - -// ConfigFromBackground maps a config.BackgroundConfig (the source of the -// 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, - QuietStartHour: bg.QuietStartHour, - QuietEndHour: bg.QuietEndHour, - } -} - // Deps is the injected dependency set for a Backthink. Only Thinker and DB are // required; the rest degrade gracefully (a nil Bus disables event triggers, a nil // State yields a neutral user state, and a nil Pusher is built from Active/IM). @@ -134,6 +69,10 @@ type Deps struct { // State supplies the user's current condition for the interrupt decision. // Nil yields a neutral state. State StateProvider + // Recent supplies the most recent main-session messages so the activity gate + // can sense whether the owner just chatted (recent-conversation cooldown and + // idle-window suppression). Nil disables activity sensing. + Recent recentProvider // Pusher delivers a proactive message. When nil it is built from Active (the // active-client sink) and IM (the IM bridge sink) plus Gate. Pusher Pusher @@ -196,7 +135,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) { // Resolve the gate up front so the sink setters can rebuild the pusher with it. gate := deps.Gate if gate == nil { - gate = newRateGate(cfg.MaxPushWindow, cfg.PushWindow, log) + gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log) } pusher := deps.Pusher if pusher == nil { @@ -404,16 +343,18 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { message, channelHint, push := parseProactive(conclusion) state := b.currentState(cctx) + lastActivity, hasActivity := b.lastUserActivity(cctx) // The "do not disturb" gate (asleep/working/busy/quiet) still applies as a hard - // safety; the frequency gate (PushGate) is applied inside the pusher. - shouldPush := push && b.disturbAllowed(at, state) + // safety; the activity gate (fresh conversation / long idle) softens it; the + // frequency gate (PushGate) is applied inside the pusher. + shouldPush := push && b.disturbAllowed(at, state) && b.activityAllowed(tr.Import, lastActivity, hasActivity, at) recordConclusion := message if recordConclusion == "" { recordConclusion = conclusion } - pushRes := PushResult{Route: RouteNone, Detail: b.interruptReason(push, at, state)} + 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), @@ -450,6 +391,28 @@ func (b *Backthink) currentState(ctx context.Context) UserState { return s } +// lastUserActivity reports the wall-clock time of the owner's most recent message +// in the main session, and whether any user message was found. It is the activity +// signal for the activity gate (design/12 §6): a nil provider, a failed read or an +// empty tail yield has=false so the activity gate falls through to the current +// behaviour rather than guessing. The most recent user message is the newest +// RoleUser entry in the recent tail (Recent returns oldest-first). +func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) { + if b.deps.Recent == nil { + return time.Time{}, false + } + msgs, err := b.deps.Recent.Recent(ctx, history.MainSessionID, recentContextCount) + if err != nil { + return time.Time{}, false + } + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == history.RoleUser { + return time.UnixMilli(msgs[i].CreatedAt), true + } + } + return time.Time{}, false +} + // record persists a thinking pass. It uses a background context so an audit row // is written even when the think's own context has already been cancelled. func (b *Backthink) record(r Record) { @@ -464,41 +427,3 @@ func (b *Backthink) record(r Record) { } } } - -// normalize fills a Config's zero fields with safe defaults. -func normalize(cfg Config) Config { - if cfg.IntervalSec <= 0 { - cfg.IntervalSec = defaultIntervalSec - } - if cfg.Interval <= 0 { - cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second - } - if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { - cfg.QuietStartHour = defaultQuietStartHour - cfg.QuietEndHour = defaultQuietEndHour - } - if cfg.MaxPushWindow <= 0 { - cfg.MaxPushWindow = defaultMaxPushWindow - } - if cfg.PushWindow <= 0 { - cfg.PushWindow = defaultPushWindow - } - if cfg.ThinkTimeout <= 0 { - cfg.ThinkTimeout = defaultThinkTimeout - } - return cfg -} - -// quietHoursLabel renders the do-not-disturb window for logs. -func quietHoursLabel(start, end int) string { - return fmt.Sprintf("%02d:00-%02d:00", start, end) -} - -// baseLog returns l when non-nil, otherwise a discard logger, so New always has a -// safe logger to tag. -func baseLog(l *slog.Logger) *slog.Logger { - if l != nil { - return l - } - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} diff --git a/internal/services/backthink/backthink_test.go b/internal/services/backthink/backthink_test.go index 5a9c422..b9d137f 100644 --- a/internal/services/backthink/backthink_test.go +++ b/internal/services/backthink/backthink_test.go @@ -33,6 +33,35 @@ func TestNew_Validation(t *testing.T) { } } +// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute +// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a +// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window. +func TestConfig_Defaults(t *testing.T) { + t.Parallel() + cfg := normalize(Config{}) + if cfg.IntervalSec != 900 { + t.Fatalf("IntervalSec = %d, want 900", cfg.IntervalSec) + } + if cfg.Interval != 900*time.Second { + t.Fatalf("Interval = %v, want 900s", cfg.Interval) + } + if cfg.PushLimit != 1 { + t.Fatalf("PushLimit = %d, want 1", cfg.PushLimit) + } + if cfg.PushWindow != 60*time.Minute { + t.Fatalf("PushWindow = %v, want 60m", cfg.PushWindow) + } + if cfg.PushCooldown != 30*time.Minute { + t.Fatalf("PushCooldown = %v, want 30m", cfg.PushCooldown) + } + if cfg.RecentChatCooldown != 10*time.Minute { + t.Fatalf("RecentChatCooldown = %v, want 10m", cfg.RecentChatCooldown) + } + if cfg.KindWindow != 2*time.Hour { + t.Fatalf("KindWindow = %v, want 2h", cfg.KindWindow) + } +} + func TestBackthink_Heartbeat_TriggersThink(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "nothing to report"} diff --git a/internal/services/backthink/cfg.go b/internal/services/backthink/cfg.go new file mode 100644 index 0000000..c9319b5 --- /dev/null +++ b/internal/services/backthink/cfg.go @@ -0,0 +1,147 @@ +package backthink + +import ( + "fmt" + "io" + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// Defaults applied by normalize when a Config field is left at its zero value. +// They are compile-time constants so no magic number appears in the run path. +const ( + // defaultIntervalSec is the heartbeat period in seconds when unused + // (15 minutes: the autonomous reflection is deliberately low-frequency). + defaultIntervalSec = 900 + // defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb + // window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may + // wrap midnight; two equal non-zero hours disable it). + defaultQuietStartHour = 23 + defaultQuietEndHour = 7 + // defaultPushLimit caps proactive pushes within defaultPushWindow to honor + // the anti-spam rule of design/11 §6 (QQ wind control, iLink window). The + // default drops to 1 so the owner is interrupted at most once per hour. + defaultPushLimit = 1 + defaultPushWindow = 60 * time.Minute + // defaultPushCooldown is the quiet gap after a successful proactive push, + // so a successful delivery is not immediately repeated. + defaultPushCooldown = 30 * time.Minute + // defaultRecentChatCooldown is the gap after the owner's last message within + // which an autonomous push is held, so a fresh conversation is not interrupted. + defaultRecentChatCooldown = 10 * time.Minute + // defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/ + // 关心) may repeat, so the same style cannot spam within it. + defaultKindWindow = 2 * time.Hour + // defaultThinkTimeout bounds a single unattended thought so it can never + // wedge a worker goroutine. + defaultThinkTimeout = 30 * time.Second + // defaultSessionID scopes a think when the caller supplies none. It is the + // loop.Run durability key, deliberately distinct from history.MainSessionID so + // an autonomous think never collides with the live main session's checkpoint + // store (channelbridge saves/resumes under the main id). The think still sees + // the main conversation: LoopThinker reads history.MainSessionID for context and + // runs under loop.OwnerEnv (design/20 §2). + defaultSessionID = "sess-backthink" +) + +// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is +// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow +// graph, which the backthink page and the web-chat flow page both render. +const DefaultSessionID = "sess-backthink" + +// Config carries the autonomous-think tuning knobs. Zero values pick safe +// defaults via normalize, so a partially filled Config is still usable. +type Config struct { + // Interval is the heartbeat period. When zero it is derived from + // IntervalSec (or its default). + Interval time.Duration + // IntervalSec is the heartbeat period in seconds, mapped from + // config.BackgroundConfig.IntervalSec. Ignored when Interval is set. + IntervalSec int + // QuietStartHour and QuietEndHour bound the do-not-disturb window in local + // clock hours. Equal non-zero hours disable the window. + QuietStartHour int + QuietEndHour int + // PushLimit bounds proactive pushes within PushWindow. Zero uses the default. + // Non-positive PushWindow or PushLimit disables the limit. + PushLimit int + // PushWindow is the sliding window over which PushLimit is enforced. + PushWindow time.Duration + // PushCooldown is the quiet gap after a successful proactive push before + // another is allowed. Zero uses the default. + PushCooldown time.Duration + // RecentChatCooldown is the gap after the owner's last message within which an + // autonomous push is held. Zero uses the default. + RecentChatCooldown time.Duration + // KindWindow bounds how often any single push flavor may repeat. Zero uses the + // default. + KindWindow time.Duration + // ThinkTimeout bounds a single thinking pass. Zero uses the default. + ThinkTimeout time.Duration +} + +// ConfigFromBackground maps a config.BackgroundConfig (the source of the heartbeat +// interval, the shared quiet-hours window and the proactive-push frequency knobs) +// into a backthink.Config, leaving anything the env does not expose at its +// defaults for normalize to fill in. +func ConfigFromBackground(bg config.BackgroundConfig) Config { + return Config{ + IntervalSec: bg.IntervalSec, + QuietStartHour: bg.QuietStartHour, + QuietEndHour: bg.QuietEndHour, + PushLimit: bg.PushLimit, + PushWindow: time.Duration(bg.PushWindowSec) * time.Second, + PushCooldown: time.Duration(bg.PushCooldownSec) * time.Second, + RecentChatCooldown: time.Duration(bg.RecentChatCooldownSec) * time.Second, + KindWindow: time.Duration(bg.KindWindowSec) * time.Second, + } +} + +// normalize fills a Config's zero fields with safe defaults. +func normalize(cfg Config) Config { + if cfg.IntervalSec <= 0 { + cfg.IntervalSec = defaultIntervalSec + } + if cfg.Interval <= 0 { + cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second + } + if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { + cfg.QuietStartHour = defaultQuietStartHour + cfg.QuietEndHour = defaultQuietEndHour + } + if cfg.PushLimit <= 0 { + cfg.PushLimit = defaultPushLimit + } + if cfg.PushWindow <= 0 { + cfg.PushWindow = defaultPushWindow + } + if cfg.PushCooldown <= 0 { + cfg.PushCooldown = defaultPushCooldown + } + if cfg.RecentChatCooldown <= 0 { + cfg.RecentChatCooldown = defaultRecentChatCooldown + } + if cfg.KindWindow <= 0 { + cfg.KindWindow = defaultKindWindow + } + if cfg.ThinkTimeout <= 0 { + cfg.ThinkTimeout = defaultThinkTimeout + } + return cfg +} + +// quietHoursLabel renders the do-not-disturb window for logs. +func quietHoursLabel(start, end int) string { + return fmt.Sprintf("%02d:00-%02d:00", start, end) +} + +// baseLog returns l when non-nil, otherwise a discard logger, so New always has a +// safe logger to tag. +func baseLog(l *slog.Logger) *slog.Logger { + if l != nil { + return l + } + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index c302fa7..3faae8f 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -97,6 +97,7 @@ func guidancePrompt(trigger string) string { "请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" + "比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" + "若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" + + "语言上:温暖、简短、克制、不粘人——一到两句为宜,不展开寒暄、不啰嗦、不抱怨、不频繁打扰,像想起她时轻轻说一句,说完就停。\n" + "请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" + "[我决定:push] 或 [我决定:no_push]\n" + "[渠道:web|im|kook|telegram|...|auto](可省略,auto/省略即交给默认:优先网页端、IM 兜底)\n" + diff --git a/internal/services/backthink/decide.go b/internal/services/backthink/decide.go index bd4f2a2..7be1b70 100644 --- a/internal/services/backthink/decide.go +++ b/internal/services/backthink/decide.go @@ -2,6 +2,7 @@ package backthink import ( "fmt" + "strings" "time" ) @@ -42,6 +43,11 @@ func (i Importance) String() string { // number appears in the decision path. const highBoredomThreshold = 0.7 +// longIdleThreshold is the gap after the owner's last message beyond which the +// activity gate only lets a genuinely important event through (a long-away owner +// should not be woken for trivia). Compile-time so no magic number appears. +const longIdleThreshold = 2 * time.Hour + // UserState describes the user's current condition as observed from device // telemetry (heart rate, sleep) and schedule (work, busy). It is the negative // signal in the interrupt decision: a sleeping, working or busy user is left @@ -78,6 +84,12 @@ type DecisionInput struct { At time.Time // UserState carries the user's physiological/schedule condition. UserState UserState + // LastActivity is the wall-clock time of the owner's most recent message. + // HasActivity reports whether a user message was found; when false the + // activity gate falls through without guessing. + LastActivity time.Time + // HasActivity reports whether LastActivity is meaningful. + HasActivity bool } // Decision is the outcome of Decide. @@ -89,11 +101,12 @@ type Decision struct { } // Decide applies the interrupt rule from design/12 §2 (and design/11 §6) with -// "不打扰 (do not interrupt) as the first principle" as its bias. It is a pure -// method on Backthink only so it can read the configured quiet-hours window; it -// does not touch state. +// "不打扰 (do not interrupt) as the first principle" as its bias, then softens it +// with the activity-aware gate (design/12 §6). It is a pure method on Backthink +// only so it can read the configured quiet-hours window; it does not touch state. // -// The evaluation order is deliberately fail-closed (prefer not interrupting): +// baseDecide's evaluation order is deliberately fail-closed (prefer not +// interrupting): // // 1. A sleeping user is never interrupted. // 2. A working or busy user is interrupted only by a critical event. @@ -101,7 +114,23 @@ type Decision struct { // interrupt; low/normal events are deferred. // 4. In the daytime, high/critical events always interrupt; a normal event // interrupts only when the user is quite bored; a low event never does. +// +// A would-push decision is then run through the activity gate, which holds a push +// when the owner just chatted (fresh conversation) or has been away a long while +// and the event is not genuinely important. func (b *Backthink) Decide(in DecisionInput) Decision { + d := b.baseDecide(in) + if !d.ShouldPush { + return d + } + if !b.activityAllowed(in.Import, in.LastActivity, in.HasActivity, in.At) { + return Decision{ShouldPush: false, Reason: "proactive push held: " + d.Reason} + } + return d +} + +// baseDecide is the do-not-interrupt cascade of design/12 §2. +func (b *Backthink) baseDecide(in DecisionInput) Decision { s := in.UserState if s.Sleeping { return Decision{ShouldPush: false, Reason: "user is asleep; not interrupting"} @@ -128,6 +157,32 @@ func (b *Backthink) Decide(in DecisionInput) Decision { } } +// activityAllowed is the activity-aware soft gate (design/12 §6). It widens the +// do-not-interrupt principle to the conversation's own rhythm: +// +// - Within RecentChatCooldown of the owner's last message, the conversation is +// fresh — a non-critical push is held so she is not interrupted mid-chat. +// - After longIdleThreshold without a message, the owner has been away — only a +// genuinely important event is worth waking them for. +// - Anywhere in between, and when activity is unknown, the decision is not +// softened (fall through). +// +// Activity is a soft signal: the hard do-not-disturb gate is separate, and the +// frequency gate (PushGate) applies separately inside the pusher. +func (b *Backthink) activityAllowed(imp Importance, lastActivity time.Time, hasActivity bool, at time.Time) bool { + if !hasActivity { + return true + } + since := at.Sub(lastActivity) + if since <= b.cfg.RecentChatCooldown { + return imp >= ImportanceCritical + } + if since >= longIdleThreshold { + return imp >= ImportanceHigh + } + return true +} + // disturbAllowed is the hard "do not disturb" safety gate for an autonomous push // (design/12 §2 first principle). Unlike Decide it deliberately ignores trigger // importance — which design/20 P2 replaces with her own judgment — and only blocks @@ -143,14 +198,16 @@ func (b *Backthink) disturbAllowed(at time.Time, s UserState) bool { // interruptReason is the short human-readable note recorded/logged when no push // goes out (or as the pre-push intent). It keeps the panel self-explanatory: -// either she concluded no push, or the do-not-disturb gate refused a push she -// wanted. -func (b *Backthink) interruptReason(push bool, at time.Time, s UserState) string { +// either she concluded no push, the do-not-disturb gate refused a push she +// wanted, or the activity gate held it. +func (b *Backthink) interruptReason(push bool, at time.Time, s UserState, imp Importance, lastActivity time.Time, hasActivity bool) string { switch { case !push: return "conclusion requested no push" case !b.disturbAllowed(at, s): return "proactive push blocked by do-not-disturb" + case !b.activityAllowed(imp, lastActivity, hasActivity, at): + return "proactive push held by activity gate" default: return "proactive push approved" } @@ -167,3 +224,76 @@ func (b *Backthink) inQuietHours(at time.Time) bool { } return h >= start || h < end } + +// PushKind classifies a proactive message into one of a few flavors so the +// frequency gate can limit each flavor within a window, avoiding same-style spam +// (design/12 §6 策略二). It is a soft tag derived from the message body's +// keywords — it affects pacing, never identity or permission. +type PushKind int + +const ( + // KindGreeting is a warm hello / good-morning / good-night opener. + KindGreeting PushKind = iota + // KindReminder is a nudge about a schedule, task or deadline. + KindReminder + // KindShare is a discovery, observation or interesting find. + KindShare + // KindCare is a warm, caring check-in about the owner's wellbeing. + KindCare +) + +// String returns a stable label for logs and records. +func (k PushKind) String() string { + switch k { + case KindGreeting: + return "greeting" + case KindReminder: + return "reminder" + case KindShare: + return "share" + case KindCare: + return "care" + default: + return fmt.Sprintf("pushkind(%d)", int(k)) + } +} + +// classifyPush assigns a PushKind to a proactive message from its keyword +// content. A message that matches no flavor defaults to KindCare (the warm, +// generic intent of an unsolicited proactive note), which is the least +// spam-prone grouping. Priority is reminder > care > greeting > share so the +// dominant intent wins when several keywords co-occur. +func classifyPush(message string) PushKind { + switch { + case hasAnyKeyword(message, kindReminderKeywords...): + return KindReminder + case hasAnyKeyword(message, kindCareKeywords...): + return KindCare + case hasAnyKeyword(message, kindGreetingKeywords...): + return KindGreeting + case hasAnyKeyword(message, kindShareKeywords...): + return KindShare + default: + return KindCare + } +} + +// keyword sets for classifyPush. They are package vars (not consts) so the +// classification stays flexible and testable, but they are immutable by +// convention and never mutated at run time. +var ( + kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"} + kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"} + kindGreetingKeywords = []string{"早安", "晚安", "早上好", "中午好", "晚上好", "午安", "回来", "吃饭", "睡了"} + kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"} +) + +// hasAnyKeyword reports whether s contains any of the given substrings. +func hasAnyKeyword(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/services/backthink/decide_test.go b/internal/services/backthink/decide_test.go index e3ec315..b738e34 100644 --- a/internal/services/backthink/decide_test.go +++ b/internal/services/backthink/decide_test.go @@ -137,3 +137,80 @@ func TestInQuietHours(t *testing.T) { }) } } + +// TestClassifyPush verifies the proactive message body maps to the intended flavor +// (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately. +func TestClassifyPush(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want PushKind + }{ + {in: "记得明天体检", want: KindReminder}, + {in: "提醒你到点吃药了", want: KindReminder}, + {in: "最近累不累,注意身体", want: KindCare}, + {in: "想你了,照顾好自己", want: KindCare}, + {in: "早安,昨晚睡得好吗", want: KindGreeting}, + {in: "晚上早点睡,晚安", want: KindGreeting}, + {in: "发现一个有意思的地方,分享给你", want: KindShare}, + {in: "跟往常一样没什么特别", want: KindCare}, // no keyword → generic fallback + } + for _, tc := range cases { + if got := classifyPush(tc.in); got != tc.want { + t.Fatalf("classifyPush(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +// TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh +// conversation holds trivia (unless critical), a long-idle owner is only woken by +// an important event, and the in-between band does not over-suppress. +func TestDecide_ActivityGate(t *testing.T) { + t.Parallel() + b := testBackthink() + + // Just chatted (within RecentChatCooldown=10min): a normal daytime boredom + // push is held unless the event is safety-critical. + fresh := DecisionInput{ + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-5 * time.Minute), + HasActivity: true, + } + if got := b.Decide(fresh); got.ShouldPush { + t.Fatalf("fresh conversation should hold a normal push, got %+v", got) + } + fresh.Import = ImportanceCritical + if got := b.Decide(fresh); !got.ShouldPush { + t.Fatalf("critical event should push even in a fresh conversation, got %+v", got) + } + + // Away a long while (> longIdleThreshold): only an important event is worth it. + idle := DecisionInput{ + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-3 * time.Hour), + HasActivity: true, + } + if got := b.Decide(idle); got.ShouldPush { + t.Fatalf("long idle should hold a normal push, got %+v", got) + } + idle.Import = ImportanceHigh + if got := b.Decide(idle); !got.ShouldPush { + t.Fatalf("long idle high-importance should push, got %+v", got) + } + + // Middling activity (30 min ago) is not over-suppressed. + middle := DecisionInput{ + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-30 * time.Minute), + HasActivity: true, + } + if got := b.Decide(middle); !got.ShouldPush { + t.Fatalf("middle-band activity should allow a normal push, got %+v", got) + } +} diff --git a/internal/services/backthink/mocks_test.go b/internal/services/backthink/mocks_test.go index c283704..5be7727 100644 --- a/internal/services/backthink/mocks_test.go +++ b/internal/services/backthink/mocks_test.go @@ -106,7 +106,8 @@ func (m *mockIMAdapter) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m // mockGate implements PushGate. type mockGate struct{ allow bool } -func (g *mockGate) Allow(context.Context) bool { return g.allow } +func (g *mockGate) Allow(context.Context, PushKind) bool { return g.allow } +func (g *mockGate) RecordPush(PushKind) {} // newTestDB opens an isolated in-memory SQLite handle for one test and closes it // on cleanup. diff --git a/internal/services/backthink/proactive.go b/internal/services/backthink/proactive.go index 238267f..831e7f8 100644 --- a/internal/services/backthink/proactive.go +++ b/internal/services/backthink/proactive.go @@ -19,15 +19,25 @@ import ( // marker fails safe to "no push". var ( // decideMarker matches the push/no_push decision token, tolerating a colon in - // either width and optional spaces. + // either width and optional spaces. It is used to recover the decision, so it + // stays strict on the bracketed form: a bare "我决定:push" is treated as prose, + // not as a decision (fail safe). decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`) - // channelMarker matches the target-channel token. + // channelMarker matches the target-channel token on the decision line. channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`) // decidePrefixes are the marker shapes that signal a decision was attempted // but could not be parsed, so the pass is treated as ambiguous (fail safe). It // is deliberately the bracketed form only, so a message that merely contains // "我决定:" in prose (common in Chinese) is not misread as a malformed marker. decidePrefixes = []string{"[我决定"} + + // stripDecideRe / stripChannelRe recognize the decision/channel markers as a + // token to delete from the user-facing body, in their intended bracketed form + // and with full-width brackets, so a well-formatted decision never leaks. + stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`) + stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`) + bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`) + bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`) ) // nonePhrases are conclusions that mean "nothing to report" when no marker is @@ -65,7 +75,7 @@ func parseProactive(conclusion string) (message, channelHint string, push bool) } channelHint = matchChannel(text) - message = stripMarkers(text) + message = stripInternalMarkers(text) if push && (message == "" || looksNone(message)) { push = false @@ -108,15 +118,24 @@ func matchChannel(text string) string { return strings.ToLower(m[1]) } -// stripMarkers removes the structured decision/channel markers and any lines that -// become empty, leaving the clean message body. It tolerates the markers on their -// own lines or inline with the body. -func stripMarkers(text string) string { +// stripInternalMarkers removes the structured decision/channel markers so a +// proactive message body never carries them to the user. It handles the intended +// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit — +// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then +// drops any line that becomes empty and collapses the remainder. It is applied +// both inside parseProactive (so the message and record are clean) and again as a +// final guard in buildProactiveMessage, so a marker cannot survive into the +// outbound body no matter how it was formatted. Only our logs/records may carry +// the marker; the user-facing message must be pure warm content. +func stripInternalMarkers(text string) string { lines := strings.Split(text, "\n") out := make([]string, 0, len(lines)) for _, ln := range lines { - ln = strings.TrimSpace(decideMarker.ReplaceAllString(ln, "")) - ln = strings.TrimSpace(channelMarker.ReplaceAllString(ln, "")) + ln = stripDecideRe.ReplaceAllString(ln, "") + ln = stripChannelRe.ReplaceAllString(ln, "") + ln = bareDecideRe.ReplaceAllString(ln, "") + ln = bareChannelRe.ReplaceAllString(ln, "") + ln = strings.TrimSpace(ln) if ln == "" { continue } diff --git a/internal/services/backthink/proactive_test.go b/internal/services/backthink/proactive_test.go index b7f84d6..dd93df0 100644 --- a/internal/services/backthink/proactive_test.go +++ b/internal/services/backthink/proactive_test.go @@ -307,3 +307,59 @@ func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) { t.Fatalf("Detail = %q, want it to include the kook hint", res.Detail) } } + +// TestStripInternalMarkers_CleanBody verifies the internal decision/channel +// markers are removed from a proactive body in every shape a model may emit +// (bracketed, full-width, bare), so a marker can never reach the user-facing text. +func TestStripInternalMarkers_CleanBody(t *testing.T) { + t.Parallel() + cases := []struct { + name, in, want string + }{ + {name: "bracketed own lines", in: "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检", want: "我想提醒你明天有体检"}, + {name: "inline bracketed", in: "[我决定:push] [渠道:WEB] 晚上早点睡", want: "晚上早点睡"}, + {name: "bare markers", in: "我决定:push\n渠道:kook\n记得喝水哦", want: "记得喝水哦"}, + {name: "full-width brackets", in: "【我决定:push】【渠道:telegram】早点休息", want: "早点休息"}, + {name: "marker trailing body", in: "今天风很大[我决定:push][渠道:kook]", want: "今天风很大"}, + {name: "no marker", in: "洗衣机洗好了,衣服可以收了", want: "洗衣机洗好了,衣服可以收了"}, + {name: "empty", in: "", want: ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := stripInternalMarkers(tc.in); got != tc.want { + t.Fatalf("stripInternalMarkers(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a +// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must +// still deliver a clean warm body, never the structured decision text. +func TestThink_BareMarkersStrippedFromBody(t *testing.T) { + t.Parallel() + conclusion := "我决定:push\n渠道:kook\n记得喝水哦" + thinker := &mockThinker{text: conclusion} + im := &mockIMAdapter{} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: &mockState{state: UserState{}}, + IM: im, + Gate: &mockGate{allow: true}, + Now: func() time.Time { return dayAt() }, + }) + + b.think(context.Background(), heartbeatTrigger()) + + if im.count() != 1 { + t.Fatalf("im calls = %d, want 1", im.count()) + } + if im.last.Content != "记得喝水哦" { + t.Fatalf("im content = %q, want the clean body", im.last.Content) + } + if strings.Contains(im.last.Content, "我决定") || strings.Contains(im.last.Content, "渠道") { + t.Fatalf("internal marker leaked into the message body: %q", im.last.Content) + } +} diff --git a/internal/services/backthink/push.go b/internal/services/backthink/push.go index a100f9c..30fcd7c 100644 --- a/internal/services/backthink/push.go +++ b/internal/services/backthink/push.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" @@ -82,11 +83,15 @@ type IMAdapter interface { } // PushGate decides whether a proactive push may proceed right now, applying the -// rate limits of design/11 §6. It is a seam over channel/session's -// ProactivePolicy/RateLimiter so the service can be tested with a mock. +// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It +// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can +// be tested with a mock. type PushGate interface { - // Allow reports whether a proactive push may be sent now. - Allow(ctx context.Context) bool + // Allow reports whether a proactive push of the given kind may be sent now. + Allow(ctx context.Context, kind PushKind) bool + // RecordPush notes a successful delivery so the cooldown/per-kind windows + // start from a real successful push, not a merely-attempted one. + RecordPush(kind PushKind) } // PushRequest carries one proactive push attempt: the outbound message plus the @@ -135,8 +140,9 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { if err := ctx.Err(); err != nil { return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()} } - if p.gate != nil && !p.gate.Allow(ctx) { - p.log.Warn("backthink push rate limited") + kind := classifyPush(req.Message.Content) + if p.gate != nil && !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"} } @@ -165,6 +171,11 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { res = p.pushDefault(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 { + p.gate.RecordPush(kind) + } return annotateHint(res, req.ChannelHint) } @@ -249,31 +260,74 @@ func annotateHint(res PushResult, hint string) PushResult { } // rateGate is the production PushGate: a sliding-window counter over -// session.RateLimiter (design/11 §6 anti-spam rule). +// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push +// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never +// spammed with the same style (design/12 §6 策略二). type rateGate struct { - log *slog.Logger - limiter *session.RateLimiter + log *slog.Logger + limiter *session.RateLimiter + cooldown time.Duration + kindWindow time.Duration + now func() time.Time + + mu sync.Mutex + lastPushTime time.Time + lastKind map[PushKind]time.Time } -// newRateGate builds a gate that allows at most limit pushes within window. A -// non-positive limit or window yields an always-allow gate (no rule configured). -func newRateGate(limit int, window time.Duration, log *slog.Logger) *rateGate { - return &rateGate{log: log, limiter: session.NewRateLimiter(limit, window)} +// newRateGate builds a gate that allows at most limit pushes within window, keeps +// pushes cooldown apart after a success, and limits each PushKind to one push per +// kindWindow. A non-positive limit or window yields an always-allow limiter (no +// rate rule); a non-positive cooldown or kindWindow disables that specific guard. +func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate { + return &rateGate{ + log: log, + limiter: session.NewRateLimiter(limit, window), + cooldown: cooldown, + kindWindow: kindWindow, + now: time.Now, + lastKind: make(map[PushKind]time.Time), + } } -// Allow implements PushGate. A canceled context denies (fail closed). -func (g *rateGate) Allow(ctx context.Context) bool { +// Allow implements PushGate. A canceled context denies (fail closed). It refuses +// when the last successful push is inside the cooldown, or when the same flavor +// was delivered within kindWindow, before consulting the sliding-window limiter. +func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool { if err := ctx.Err(); err != nil { return false } + now := g.now() + g.mu.Lock() + defer g.mu.Unlock() + if !g.lastPushTime.IsZero() && now.Sub(g.lastPushTime) < g.cooldown { + return false + } + if lt, ok := g.lastKind[kind]; ok && now.Sub(lt) < g.kindWindow { + return false + } return g.limiter.Allow() } +// RecordPush implements PushGate: it stamps the last successful push time and the +// per-flavor last time so the cooldown and kind window start from a real delivery. +func (g *rateGate) RecordPush(kind PushKind) { + now := g.now() + g.mu.Lock() + defer g.mu.Unlock() + g.lastPushTime = now + g.lastKind[kind] = now +} + // buildProactiveMessage assembles the normalized outbound message carrying a // think conclusion's clean body. It tags the platform with the requested channel // when the conclusion names a concrete platform, otherwise the default backthink // platform. The concrete active-client and IM adapters apply their own // routing/targeting to the owner (red line: owner only). +// +// The body is run through stripInternalMarkers as a final guard so a structured +// decision/channel marker that survived parsing can never reach the user-facing +// message (only our logs/records may carry it). func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage { platform := defaultProactivePlatform if h := normalizeChannelHint(channelHint); isSpecificChannel(h) { @@ -282,7 +336,7 @@ func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.U return adapter.UnifiedMessage{ Platform: platform, ChannelType: adapter.ChannelDirect, - Content: message, + Content: stripInternalMarkers(message), ContentType: adapter.ContentText, Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"}, Timestamp: b.now(), diff --git a/internal/services/backthink/push_test.go b/internal/services/backthink/push_test.go index 6bf1b81..fffeca7 100644 --- a/internal/services/backthink/push_test.go +++ b/internal/services/backthink/push_test.go @@ -6,6 +6,7 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" ) func TestPusher_ActiveFirst(t *testing.T) { @@ -126,7 +127,7 @@ func TestPusher_CancelledContext_Denied(t *testing.T) { func TestPusher_RateLimit(t *testing.T) { t.Parallel() active := &mockActive{n: 1} - p := newPusher(active, nil, newRateGate(1, time.Minute, discardLog()), discardLog()) + p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog()) first := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}}) second := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}}) @@ -145,3 +146,60 @@ func TestPusher_RateLimit(t *testing.T) { t.Fatalf("active calls = %d, want 1 (rate limit caps the sink)", active.calls) } } + +// TestRateGate_CooldownAfterSuccess verifies the post-push cooldown: after a +// successful delivery a further push is refused until the cooldown elapses, +// regardless of the configured window counter. +func TestRateGate_CooldownAfterSuccess(t *testing.T) { + t.Parallel() + t0 := time.Now() + g := &rateGate{ + log: discardLog(), + limiter: session.NewRateLimiter(10, time.Hour), + cooldown: 30 * time.Minute, + kindWindow: 0, // disabled to isolate the cooldown + now: func() time.Time { return t0 }, + lastKind: make(map[PushKind]time.Time), + } + if !g.Allow(context.Background(), KindCare) { + t.Fatal("first push should be allowed") + } + g.RecordPush(KindCare) + if g.Allow(context.Background(), KindReminder) { + t.Fatal("push within cooldown should be denied") + } + g.now = func() time.Time { return t0.Add(31 * time.Minute) } + if !g.Allow(context.Background(), KindReminder) { + t.Fatal("push after the cooldown has elapsed should be allowed") + } +} + +// TestRateGate_KindWindowLimitsSameFlavor verifies the per-flavor (问候/提醒/分享/ +// 关心) limit: the same flavor cannot repeat within the kind window, while a +// different flavor and a later same-flavor push (after the window) still pass. +func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) { + t.Parallel() + t0 := time.Now() + g := &rateGate{ + log: discardLog(), + limiter: session.NewRateLimiter(10, time.Hour), + cooldown: 0, // disabled to isolate the kind window + kindWindow: 2 * time.Hour, + now: func() time.Time { return t0 }, + lastKind: make(map[PushKind]time.Time), + } + if !g.Allow(context.Background(), KindReminder) { + t.Fatal("first reminder should be allowed") + } + g.RecordPush(KindReminder) + if g.Allow(context.Background(), KindReminder) { + t.Fatal("same flavor within the kind window should be denied") + } + if !g.Allow(context.Background(), KindShare) { + t.Fatal("a different flavor should be allowed") + } + g.now = func() time.Time { return t0.Add(3 * time.Hour) } + if !g.Allow(context.Background(), KindReminder) { + t.Fatal("same flavor after the kind window should be allowed") + } +}