fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
This commit is contained in:
1 parent
03d7050e1e
commit
12031bd13c
14 files changed
+662
-149
No files matched your search
+10
-1
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user