fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-08 13:08:57 +08:00
1 parent 03d7050e1e
commit 12031bd13c
14 files changed
+662 -149

No files matched your search

+10 -1
View File
@@ -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
+1
View File
@@ -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"),
+12 -6
View File
@@ -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.
+33 -108
View File
@@ -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"}
+147
View File
@@ -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))
}
+1
View File
@@ -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" +
+137 -7
View File
@@ -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)
}
}
+2 -1
View File
@@ -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.
+28 -9
View File
@@ -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)
}
}
+70 -16
View File
@@ -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(),
+59 -1
View File
@@ -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")
}
}