Files
Cyrene_v2/internal/services/backthink/cfg.go
T
AskaEth 236b202fda feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
2026-10-05 22:38:17 +08:00

164 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backthink
import (
"fmt"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// normalize 在 Config 字段留为零值时套用的默认值。
// 它们是编译期常量,因此运行路径里不出现魔法数字。
const (
// defaultIntervalSec 是未使用时的心跳周期秒数
// (15 分钟:自主反思刻意保持低频)。
defaultIntervalSec = 900
// defaultQuietStartHour 与 defaultQuietEndHour 以本地钟点约束免打扰
// 窗口(00:00-07:00 对 23:00-07:00:窗口可以
// 跨午夜;两个相等的非零钟点会禁用该窗口)。
defaultQuietStartHour = 23
defaultQuietEndHour = 7
// defaultPushLimit 限制 defaultPushWindow 内的主动推送次数,以遵守
// design/11 §6 的反骚扰规则(QQ 风控、iLink 窗口)。
// 默认降到 1,因此主人每小时最多被打扰一次。
defaultPushLimit = 1
defaultPushWindow = 60 * time.Minute
// defaultPushCooldown 是一次成功主动推送之后的安静间隔,
// 以免一次成功投递被立即重复。
defaultPushCooldown = 30 * time.Minute
// defaultRecentChatCooldown 是主人最后一条消息之后的间隔,
// 在此期间自主推送被扣住,以免打断刚开始的对话。
defaultRecentChatCooldown = 10 * time.Minute
// defaultKindWindow 限制任何一种推送风格(问候/提醒/分享/
// 关心)的重复频率,使同一风格无法在窗口内刷屏。
defaultKindWindow = 2 * time.Hour
// defaultThinkTimeout 约束单次无人值守思考,因此它绝不会
// 卡住 worker goroutine。
defaultThinkTimeout = 30 * time.Second
// defaultSessionID 在调用方未提供时限定一次思考的作用域。它是
// loop.Run 的持久化键,刻意与 history.MainSessionID 不同,
// 这样自主思考绝不会与活跃主会话的检查点
// 存储冲突(channelbridge 在主 id 下保存/恢复)。该次思考仍能看到
// 主对话:LoopThinker 读 history.MainSessionID 取上下文,
// 并在 loop.OwnerEnv 下运行(design/20 §2)。
defaultSessionID = "sess-backthink"
)
// DefaultSessionID 是自主思考所用的、对外暴露的 loop.Run 会话键。它
// 之所以暴露,是为了让流程可视化发布器(cmd/cyrene)也能发布 backthink
// 流程图,供 backthink 页与 Web 聊天流程页共同渲染。
const DefaultSessionID = "sess-backthink"
// Config 携带自主思考的调优旋钮。零值会经 normalize
// 选取安全默认值,因此半填的 Config 仍可用。
type Config struct {
// Interval 是心跳周期。为零时由
// IntervalSec(或其默认值)推导。
Interval time.Duration
// IntervalSec 是心跳周期秒数,映射自
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
IntervalSec int
// QuietInterval 是**安静窗口内**的心跳周期(design/12 §N A1)。
// 为零时由 QuietIntervalSec 推导;**≤0 表示关闭降频**(回落 Interval),
// 因此这里刻意不做默认填充——生产默认 3600 由 env 标签给出
// (config.BackgroundConfig.QuietIntervalSec)。
QuietInterval time.Duration
// QuietIntervalSec 是安静窗口内的心跳周期秒数,映射自
// config.BackgroundConfig.QuietIntervalSec。设置了 QuietInterval 时忽略。
// ≤0 = 关闭降频(回落 Interval,design/12 §N A3)。
QuietIntervalSec int
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
// 两个相等的非零钟点会禁用该窗口。
QuietStartHour int
QuietEndHour int
// PushLimit 限制 PushWindow 内的主动推送次数。为零用默认值。
// 非正的 PushWindow 或 PushLimit 会禁用该限制。
PushLimit int
// PushWindow 是执行 PushLimit 的滑动窗口。
PushWindow time.Duration
// PushCooldown 是一次成功主动推送之后、允许下一次之前的
// 安静间隔。为零用默认值。
PushCooldown time.Duration
// RecentChatCooldown 是主人最后一条消息之后的间隔,在此期间
// 自主推送被扣住。为零用默认值。
RecentChatCooldown time.Duration
// KindWindow 限制任何一种推送风格可重复的频率。为零用
// 默认值。
KindWindow time.Duration
// ThinkTimeout 约束单轮思考。为零用默认值。
ThinkTimeout time.Duration
}
// ConfigFromBackground 把 config.BackgroundConfig(心跳周期、
// 共享安静时段窗口与主动推送频次旋钮的来源)
// 映射成 backthink.Config,env 未暴露的项保持其
// 默认值,交给 normalize 填。
func ConfigFromBackground(bg config.BackgroundConfig) Config {
return Config{
IntervalSec: bg.IntervalSec,
QuietIntervalSec: bg.QuietIntervalSec,
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 用安全默认值填充 Config 的零值字段。
func normalize(cfg Config) Config {
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = defaultIntervalSec
}
if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
}
// QuietInterval 不从零值套默认:≤0 是本设计里「关闭降频」的**语义**
// (design/12 §N A3),把 0 改写成 3600 会让用户设的 0 静默失效。
// 生产默认 3600 由 config 层的 env 标签供值。
if cfg.QuietInterval <= 0 {
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * 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 为日志渲染免打扰窗口。
func quietHoursLabel(start, end int) string {
return fmt.Sprintf("%02d:00-%02d:00", start, end)
}
// baseLog 在 l 非 nil 时返回 l,否则返回丢弃式日志器,好让 New 总是有
// 一个可标记的安全日志器。
func baseLog(l *slog.Logger) *slog.Logger {
if l != nil {
return l
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}