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