Files
AskaEth ed5005307a feat(backthink): 自排 C1-2 本体 + TriggerUnknown 修 fail-open(V1)
- selfwake.go:WakeNow + 待跑槽容量 1(先探槽合并不过闸、槽空才过闸、同锁)+ 闸 4 轮/小时
  ($DATA_DIR/config/backthink.json,零新 env 键)+ 审计四条 what + 启动日志两键 + 组合根接线
- G-A(主人已批):TriggerUnknown 作零值、既有两值编号后移(语义不动),未知触发走**最紧分支**
- 5 个变异自检(拆闸/≤0 未回落/过闸挪到探槽前=G1 失效形态/槽无界/零值挪走)
⚠️ 服务侧已就绪但**她仍调不到**(自排工具面属 C1-3)⇒ 不得写「C 已完成」
2026-10-06 20:25:04 +08:00

192 lines
8.4 KiB
Go
Raw Permalink 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
// 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))
}