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 // defaultSelfWakeLimit / defaultSelfWakeWindow 是她**自排**一轮思考的上限: // 默认每窗口 4 轮、窗口 1 小时(design/12 §N C.5)。它是**成本闸**, // 与推送频次是两件事 —— PushGate 管「能不能打扰主人」,管不了 LLM 花费。 defaultSelfWakeLimit = 4 defaultSelfWakeWindow = time.Hour // 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 // SelfWakeLimit 限制 SelfWakeWindow 内她自己排起的思考轮数 // (design/12 §N C.5)。**≤0 ⇒ 回落默认**:没有「关闭上限」语义—— // 一次笔误不许把成本保护关掉(与 channelbridge 的 directLimit 同一裁决口径)。 // 它的取值来自 `$DATA_DIR/config/backthink.json`(零新 env 键,`.env` 冻结), // 缺失/为零时由 normalize 填默认。 SelfWakeLimit int // SelfWakeWindow 是执行 SelfWakeLimit 的滑动窗口。**≤0 ⇒ 回落默认**。 SelfWakeWindow time.Duration } // ConfigFromBackground 把 config.BackgroundConfig(心跳周期、 // 共享安静时段窗口与主动推送频次旋钮的来源) // 映射成 backthink.Config,env 未暴露的项保持其 // 默认值,交给 normalize 填。 // // 它**刻意不带**自排上限两个旋钮(design/12 §N C.5,2026-10-06 `.env` 冻结红线): // 自排的成本旋钮走 `$DATA_DIR/config/backthink.json`,进 `.env` 就是给冻结面开新键。 // 组合根读到配置文件后直接赋在返回的 Config 上。 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 } // 自排闸的旋钮与 PushLimit 同形:**≤0 一律回落默认**。这不是可有可无的填充—— // session.RateLimiter 在 limit<=0 || window<=0 时 Allow() 恒 true 且一条事件都不记录 // (它自称是「无规则」的安全默认),若把未 normalize 的 ≤0 直达滑窗, // 「回落默认」就**静默**变成「无上限」(无报错、无 WARN)。闸必须在构造前就 // 拿到已回落的值(design/12 §N C.5 的 G3;探针 = C.8 判据 14)。 if cfg.SelfWakeLimit <= 0 { cfg.SelfWakeLimit = defaultSelfWakeLimit } if cfg.SelfWakeWindow <= 0 { cfg.SelfWakeWindow = defaultSelfWakeWindow } 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)) }