Files
Cyrene_v2/internal/services/backthink/cfg.go
T
Aska 12031bd13c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
2026-09-08 13:08:57 +08:00

148 lines
5.8 KiB
Go

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))
}