docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
This commit is contained in:
AskaEth committed 2026-10-04 16:27:54 +08:00
1 parent f5cf117482
commit 3dc1232bc2
54 files changed
+1923 -1923

No files matched your search

+122 -122
View File
@@ -1,31 +1,31 @@
// Package backthink implements the M8 autonomous background-think service
// (DevDocs/design/12-后台思考与主动.md §0-§3).
// backthink 包实现 M8 自主后台思考服务
// (DevDocs/design/12-后台思考与主动.md §0-§3)。
//
// It runs unattended thinking passes on two triggers:
// 它在两类触发下运行无人值守的思考轮:
//
// - A periodic heartbeat (config.Background.IntervalSec) that asks the injected
// runtime to reflect and is allowed to call tools (IoT, calendar, reminders)
// but must not emit chat-style messages by default.
// - Asynchronous events on the in-process bus (iot.device_update,
// reminder.trigger, system.event) that wake a thinking pass.
// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时
// 反思,并允许调用工具(IoT、日历、提醒),
// 但默认不得发出 chat 风格的消息。
// - 进程内总线上的异步事件(iot.device_update、
// reminder.trigger、system.event),它们唤醒一轮思考。
//
// Each pass produces a conclusion whose structured tail decides whether she
// wants to speak at all (design/20 §2 P2), then consults Decide — the importance
// cascade restored as the production decision path — to judge whether now is an
// acceptable moment (event importance / time-of-day / user state such as heart
// rate, sleep, work, busy). A critical safety event punches through work/busy
// and quiet hours (裁决 07.4.2), and a heartbeat pass is never held by a
// long-silent activity gap (裁决 07.4.3). When it is worth interrupting the
// push goes to the active client first and falls back to the IM bridge. The
// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat
// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to
// same-event dedup (see event_dedup.go). Every pass is persisted to SQLite via
// Store so the M9/M10 monitor panel can render the background-thinking page
// (design/12 §4).
// 每轮产出一段结论,其结构化尾段决定她
// 到底想不想说话(design/20 §2 P2),随后咨询 Decide——
// 作为生产决策路径恢复的重要度级联——来判断此刻是否
// 合适(事件重要度 / 时段 / 用户状态,如心率、
// 睡眠、工作、忙碌)。critical 安全事件穿透工作/忙碌
// 与安静时段(裁决 07.4.2),而心跳轮永远不会被长期
// 静默的活动间隔拦下(裁决 07.4.3)。当值得打扰时,
// 推送先走活跃客户端,再降级到 IM 桥。
// DevDocs/design/11 §6 的主动推送频率门只约束
// 周期性心跳;事件驱动的推送绕过它(主人要求:"该发就发"),
// 但受同一事件去重约束(见 event_dedup.go)。每轮都经
// Store 持久化到 SQLite,好让 M9/M10 监控面板能渲染后台思考页
// (design/12 §4)。
//
// The package is concurrency-safe: a mutex guards the lifecycle state, the
// injected store/pusher/thinker are required to be concurrency-safe by contract,
// and the internal publish path never waits on a slow consumer.
// 本包并发安全:一把互斥锁守护生命周期状态,
// 注入的 store/pusher/thinker 按契约要求并发安全,
// 且内部发布路径绝不会等待慢消费者。
package backthink
import (
@@ -43,68 +43,68 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// Sentinel errors returned by this package. Use errors.Is to test for them; they
// are returned directly or wrapped with %w so the underlying cause stays intact.
// 本包返回的哨兵错误。用 errors.Is 判定;它们被直接
// 返回或用 %w 包装,因此底层原因保持完整。
var (
// ErrNilDB is returned when Backthink or Store is built with a nil database.
// 用 nil 数据库构建 Backthink 或 Store 时返回 ErrNilDB。
ErrNilDB = errors.New("backthink: nil database handle")
// ErrNoThinker is returned when New is given no Thinker.
// New 未收到 Thinker 时返回 ErrNoThinker。
ErrNoThinker = errors.New("backthink: nil thinker")
// ErrNoLoop is returned by LoopThinker when it wraps a nil loop.
// LoopThinker 包装了 nil loop 时由它返回 ErrNoLoop。
ErrNoLoop = errors.New("backthink: nil loop")
)
// Thinker runs one unattended thinking pass over the injected runtime. It is the
// seam between the service and the loop/agent: the production wiring hands back a
// LoopThinker over a *loop.Loop, while tests inject a mock. Implementations may
// call tools (IoT, calendar, reminders) and must be concurrency-safe.
// Thinker 在注入的运行时上跑一轮无人值守的思考。它是
// 服务与 loop/agent 之间的接缝:生产接线交回一个基于
// *loop.Loop 的 LoopThinker,而测试注入 mock。实现可以
// 调用工具(IoT、日历、提醒),且必须并发安全。
type Thinker interface {
// Think invokes one autonomous thinking pass for the given session/trigger
// and returns the concluded text.
// Think 为给定的会话/触发调用一轮自主思考,
// 并返回结论文本。
Think(ctx context.Context, sessionID, trigger string) (string, error)
}
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
// required; the rest degrade gracefully (a nil Bus disables event triggers, a nil
// State yields a neutral user state, and a nil Pusher is built from Active/IM).
// Deps 是注入给 Backthink 的依赖集合。只有 Thinker 与 DB 是
// 必需的;其余优雅降级(Bus 为 nil 则关闭事件触发,State 为
// nil 则给出中性用户状态,Pusher 为 nil 则由 Active/IM 构建)。
type Deps struct {
// Bus is the event-trigger source. Nil disables event-driven thinking.
// Bus 是事件触发源。nil 则关闭事件驱动思考。
Bus bus.Bus
// Thinker runs one unattended thinking pass. Required.
// Thinker 跑一轮无人值守的思考。必需。
Thinker Thinker
// State supplies the user's current condition for the interrupt decision.
// Nil yields a neutral state.
// State 提供用户当前状况,用于打扰决策。
// nil 则给出中性状态。
State StateProvider
// Recent supplies the most recent main-session messages so the activity gate
// can sense whether the owner just chatted (recent-conversation cooldown and
// idle-window suppression). Nil disables activity sensing.
// Recent 提供主会话最近的消息,好让活动门
// 感知主人是否刚聊过(最近对话冷却与
// 静默窗口抑制)。nil 则关闭活动感知。
Recent recentProvider
// Pusher delivers a proactive message. When nil it is built from Active (the
// active-client sink) and IM (the IM bridge sink) plus Gate.
// Pusher 投递主动消息。为 nil 时由 Active(活跃客户端槽)
// 与 IM(IM 桥槽)加上 Gate 构建。
Pusher Pusher
// Active is the active web/native client sink, preferred for pushes.
// Active 是活跃 Web/原生客户端槽,推送首选。
Active ActiveClient
// IM is the IM bridge sink, used as the fallback when no active client is up.
// IM 是 IM 桥槽,在没有活跃客户端时用作降级。
IM IMAdapter
// Exporter mirrors a proactive message to the external push outlet (the
// owner's self-hosted unified push service, then Getui). Optional: nil
// disables the hook. It never blocks or breaks the Active/IM delivery.
// Exporter 把主动消息镜像到外部推送出口(主人的自托管
// 统一推送服务,其次是个推)。可选:nil 则
// 关闭该钩子。它绝不阻塞或破坏 Active/IM 投递。
Exporter PushExporter
// Gate is the proactive-push rate limiter. Nil builds a default from Config.
// Gate 是主动推送限流器。nil 则由 Config 构建默认值。
Gate PushGate
// DB is the SQLite handle for the think store. Required.
// DB 是思考存储所用的 SQLite 句柄。必需。
DB *sql.DB
// SessionID scopes a think. Empty uses defaultSessionID.
// SessionID 限定一次思考的作用域。为空则用 defaultSessionID。
SessionID string
// Now is the wall-clock source for decision/record timestamps. Nil uses
// time.Now, which makes time-dependent behaviour injectable in tests.
// Now 是决策/记录时间戳的墙钟来源。nil 则用
// time.Now,从而让依赖时间的行为在测试中可注入。
Now func() time.Time
// Log is the base logger, tagged with source "services/backthink".
// Log 是基础日志器,来源标记为 "services/backthink"。
Log *slog.Logger
}
// Backthink is the autonomous background-think service. Build it with New, start
// the heartbeat and subscriptions with Start, and stop them with Stop.
// Backthink 是自主后台思考服务。用 New 构建,用 Start 启动
// 心跳与订阅,用 Stop 停止它们。
type Backthink struct {
cfg Config
deps Deps
@@ -121,17 +121,17 @@ type Backthink struct {
subs []func()
wg sync.WaitGroup
// eventMu guards the same-event dedup guard (event_dedup.go). It is separate
// from the lifecycle lock so a dedup decision never blocks a sink swap.
// eventMu 守护同一事件去重器(event_dedup.go)。它与
// 生命周期锁分开,因此去重决策绝不会阻塞槽位换入。
eventMu sync.Mutex
lastEventKey string
}
// New builds a Backthink from cfg and deps, applying defaults and validating the
// required dependencies. It creates the SQLite think store (via Store) up front
// and resolves the push path (an injected Pusher, or a default built from
// Active/IM and the rate Gate). It returns ErrNilDB when DB is nil and
// ErrNoThinker when Thinker is nil.
// New 由 cfg 与 deps 构建 Backthink,套用默认值并校验
// 必需依赖。它先行创建 SQLite 思考存储(经 Store)
// 并解出推送路径(注入的 Pusher,或由
// Active/IM 与频率 Gate 构建的默认值)。DB 为 nil 时返回 ErrNilDB,
// Thinker 为 nil 时返回 ErrNoThinker。
func New(cfg Config, deps Deps) (*Backthink, error) {
if deps.Thinker == nil {
return nil, ErrNoThinker
@@ -147,7 +147,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
return nil, fmt.Errorf("backthink: new: %w", err)
}
// Resolve the gate up front so the sink setters can rebuild the pusher with it.
// 提前解出频率门,好让槽位设置器能与它一起重建推送器。
gate := deps.Gate
if gate == nil {
gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log)
@@ -178,10 +178,10 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
}, nil
}
// Start begins the background work: the heartbeat ticker and, when a Bus is
// configured, the event subscriptions. It is idempotent and safe to call twice; a
// second call is a no-op. The provided ctx (may be nil) is the parent of all
// internal work: cancelling it stops the service exactly like Stop.
// Start 启动后台工作:心跳定时器,以及(配置了 Bus 时)
// 事件订阅。它是幂等的,调用两次安全;
// 第二次调用是空操作。传入的 ctx(可为 nil)是所有
// 内部工作的父级:取消它会像 Stop 一样停止服务。
func (b *Backthink) Start(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
@@ -209,11 +209,11 @@ func (b *Backthink) Start(ctx context.Context) {
"session_id", b.sessionID)
}
// Stop halts the heartbeat and unsubscribes every bus subscription, then waits
// for the in-flight thinking pass (bounded by ThinkTimeout) to drain. It is
// idempotent; a second call returns nil immediately. When ctx is cancelled before
// the drain completes it returns a wrapped context error rather than leaking the
// goroutine.
// Stop 停止心跳、退订所有总线订阅,然后等待
// 进行中的思考轮(受 ThinkTimeout 约束)排空。它是
// 幂等的;第二次调用立即返回 nil。若在排空完成前 ctx 已被
// 取消,则返回包装后的 context 错误,而不是泄漏
// goroutine。
func (b *Backthink) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
@@ -248,22 +248,22 @@ func (b *Backthink) Stop(ctx context.Context) error {
}
}
// List returns the most recent thinking records for the M9/M10 monitor panel,
// newest first. A non-positive limit selects the store default.
// List 返回 M9/M10 监控面板用的最近思考记录,
// 最新的在前。非正的 limit 选用存储默认值。
func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) {
return b.store.List(ctx, limit)
}
// triggers returns the bus subjects the service watches for event-driven
// thinking. It is a method (not a package var) so no package-level global slice
// appears, per the coding constraint.
// triggers 返回本服务为事件驱动思考所监听的总线主题。
// 它是一个方法(而非包级变量),因此不会出现包级全局
// 切片,符合编码约束。
func (b *Backthink) triggers() []string {
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
}
// subscribe registers one handler per trigger subject. Each subscription is
// bound to ctx so cancelling it removes the registration; the unregister
// functions are kept so Stop can also tear them down explicitly.
// subscribe 为每个触发主题注册一个处理器。每个订阅都
// 绑定到 ctx,因此取消它会移除注册;退订函数
// 被保留下来,好让 Stop 也能显式拆除它们。
func (b *Backthink) subscribe(ctx context.Context) {
for _, subj := range b.triggers() {
unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent)
@@ -277,11 +277,11 @@ func (b *Backthink) subscribe(ctx context.Context) {
}
}
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
// own worker goroutine, so it must not block on another subscriber. It first
// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system
// event is downgraded to a registration-only record rather than waking a full
// thinking pass, so platform/system noise cannot flood the thinker.
// onEvent 是事件驱动思考的总线处理器。它跑在总线自己的
// worker goroutine 上,所以不得阻塞在另一个订阅者上。它先
// 套用最小打扰门(design/12 §6 策略一):低价值的系统
// 事件被降级为仅登记一条记录,而不唤醒一整轮
// 思考,因此平台/系统噪声无法淹没思考器。
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
tr := triggerForEvent(e)
if !b.shouldThink(tr) {
@@ -300,11 +300,11 @@ func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
return nil
}
// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public
// platform a generic system event is low value, so only a high-importance
// (owner-relevant) system event wakes a pass. IoT device updates and reminder
// triggers are owner-relevant / high value and are never gated, so the productive
// triggers stay fully functional.
// shouldThink 为事件驱动思考把关(design/12 §6 策略一)。在公开
// 平台上,泛化的系统事件价值低,所以只有高重要度
// (与主人相关)的系统事件才唤醒一轮。IoT 设备更新与提醒
// 触发与主人相关 / 价值高,从不受门控,因此这些有产出的
// 触发保持完全可用。
func (b *Backthink) shouldThink(tr Trigger) bool {
if tr.Source == SubjectSystemEvent {
return tr.Import >= ImportanceHigh
@@ -312,11 +312,11 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
return true
}
// tickLoop is the heartbeat goroutine. Per 裁决 07.4.4 (design/12 §1) it thinks
// once immediately on process start — producing a humanized startup greeting —
// before settling into one thinking pass per tick; it exits when ctx is
// cancelled. The accepted side effect is that every process restart wakes one
// immediate thinking round.
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
// 启动时立即思考一次——产出一句拟人化的启动问候——
// 随后稳定为每 tick 一轮思考;ctx 被取消时
// 退出。可接受的副作用是每次进程重启都会唤醒
// 一轮即时思考。
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
@@ -339,10 +339,10 @@ func (b *Backthink) tickLoop(ctx context.Context) {
}
}
// think runs one unattended thinking pass: invoke the runtime, read the user
// state, decide whether to interrupt, push when warranted and persist the record.
// It is concurrency-safe provided the injected Thinker, StateProvider and Pusher
// are concurrency-safe, which they are by contract.
// think 跑一轮无人值守的思考:调用运行时、读取用户
// 状态、判断是否打扰、值得时推送并持久化记录。
// 只要注入的 Thinker、StateProvider 与 Pusher
// 并发安全,它就是并发安全的——按契约它们都是。
func (b *Backthink) think(parent context.Context, tr Trigger) {
at := b.now()
cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout)
@@ -363,9 +363,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
return
}
// P2 (design/20 §2): her own conclusion decides whether she wants to speak
// and to which channel. parseProactive recovers the decision robustly and
// fails safe (malformed/ambiguous → no push).
// P2(design/20 §2):她自己的结论决定她想不想说、
// 以及发到哪个渠道。parseProactive 稳健地恢复该决策,
// 并失败安全(格式错乱/含糊 → 不推送)。
message, channelHint, push := parseProactive(conclusion)
state := b.currentState(cctx)
@@ -393,9 +393,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
pushRes := PushResult{Route: RouteNone, Detail: decision.Reason}
if shouldPush {
// Same-event dedup: a repeating identical event is suppressed after the
// first push (防刷), while a genuinely new event still goes out. Heartbeats
// are never deduped (they reset the chain) and stay gate-governed.
// 同一事件去重:重复的相同事件在首次推送后被抑制
// (防刷),而真正的新事件仍会发出。心跳
// 从不去重(它们会重置该链),并保持受门控管辖。
if b.eventRedundant(tr) {
pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"}
} else {
@@ -422,8 +422,8 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
}
}
// currentState reads the user's current condition for the interrupt decision,
// falling back to a neutral state (and a warn log) when the provider errors.
// currentState 读取用户当前状况用于打扰决策,
// 提供者报错时退回中性状态(并记一条 warn 日志)。
func (b *Backthink) currentState(ctx context.Context) UserState {
if b.deps.State == nil {
return UserState{}
@@ -436,12 +436,12 @@ func (b *Backthink) currentState(ctx context.Context) UserState {
return s
}
// lastUserActivity reports the wall-clock time of the owner's most recent message
// in the main session, and whether any user message was found. It is the activity
// signal for the activity gate (design/12 §6): a nil provider, a failed read or an
// empty tail yield has=false so the activity gate falls through to the current
// behaviour rather than guessing. The most recent user message is the newest
// RoleUser entry in the recent tail (Recent returns oldest-first).
// lastUserActivity 报告主人最近一条消息的墙钟时间
// (在主会话里),以及是否找到了任何用户消息。它是活动
// 门的活动信号(design/12 §6):提供者为 nil、读取失败或
// 尾部为空都得到 has=false,因此活动门会落到当前
// 行为而不是靠猜。最近的一条用户消息是最近尾部里
// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。
func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
if b.deps.Recent == nil {
return time.Time{}, false
@@ -458,14 +458,14 @@ func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
return time.Time{}, false
}
// record persists a thinking pass. It uses a background context so an audit row
// is written even when the think's own context has already been cancelled.
// record 持久化一轮思考。它使用后台 context,因此即便
// 该次思考自身的 context 已被取消,审计行仍会写入。
func (b *Backthink) record(r Record) {
if _, err := b.store.Append(context.Background(), r); err != nil {
b.log.Error("backthink record failed", "error", err)
}
// Surface the persisted pass on the WS stream so the frontend's backthink
// consumer (or its default fallback) observes it without polling the store.
// 把持久化的这一轮暴露到 WS 流上,好让前端的 backthink
// 消费者(或其默认兜底)无需轮询存储就能观测到它。
if b.deps.Bus != nil {
if err := b.deps.Bus.Publish(context.Background(), "ws.backthink", r); err != nil {
b.log.Warn("backthink bus publish failed", "error", err)
+27 -27
View File
@@ -11,8 +11,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// newBackthink builds a Backthink with a fresh in-memory DB and the given deps,
// returning the zeroed deps filled with the DB handle so tests can inspect deps.
// newBackthink 用一份新的内存 DB 与给定 deps 构建 Backthink,
// 并返回填好 DB 句柄的 deps,好让测试能检查 deps。
func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) {
t.Helper()
deps.DB = newTestDB(t)
@@ -34,9 +34,9 @@ func TestNew_Validation(t *testing.T) {
}
}
// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute
// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a
// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window.
// TestConfig_Defaults 验证收紧后的主动推送默认值:15 分钟
// 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、
// 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。
func TestConfig_Defaults(t *testing.T) {
t.Parallel()
cfg := normalize(Config{})
@@ -192,8 +192,8 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) {
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
t.Parallel()
// design/12 §6 策略一: a low-value system event on a public platform does not
// wake a full thinking pass — it is downgraded to a registration-only record.
// design/12 §6 策略一:公开平台上的低价值系统事件不会
// 唤醒一整轮思考——它被降级为仅登记一条记录。
thinker := &mockThinker{text: "should not be called"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
@@ -240,8 +240,8 @@ func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
t.Parallel()
// A high-importance (owner-relevant) system event still wakes a thinking pass —
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
// 高重要度(与主人相关)的系统事件仍会唤醒一轮思考——
// 降噪门只降级低价值噪声(design/12 §6 策略一)。
thinker := &mockThinker{text: "important notice"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
@@ -289,8 +289,8 @@ func TestBackthink_List_NewestFirst(t *testing.T) {
if err != nil {
t.Fatalf("List: %v", err)
}
// The single-row listing must be the same newest record as the head of the
// full listing, and the full listing must be strictly newest-first.
// 单行列表里的那一条必须与完整列表的头部是同一条最新
// 记录,且完整列表必须是严格的由新到旧。
if top[0].ID != all[0].ID {
t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID)
}
@@ -312,7 +312,7 @@ func TestBackthink_Stop_NoLeak(t *testing.T) {
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
// Idempotent.
// 幂等。
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("second Stop: %v", err)
}
@@ -331,9 +331,9 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker})
b.Start(context.Background())
// Confirm the subscription is live by triggering one think (identified by its
// persisted record, so the 07.4.4 startup round and heartbeat ticks with their
// own trigger strings cannot mask or overwrite it).
// 通过触发一次思考来确认订阅是活的(以它持久化的
// 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的
// 触发字符串不会掩盖或覆盖它)。
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil {
t.Fatalf("Publish: %v", err)
}
@@ -363,10 +363,10 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
}
}
// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a
// repeating identical event (same source + summary) is suppressed after the first
// push (防刷), while a genuinely different event still goes out. The duplicate is
// recorded as suppressed (route none), not delivered.
// TestBackthink_EventPush_DedupSameEvent 验证同一事件去重门:
// 重复的相同事件(同源 + 同摘要)在首次推送后
// 被抑制(防刷),而真正不同的事件仍会发出。重复的那条
// 被记为已抑制(route none),而不是投递出去。
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
@@ -382,9 +382,9 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev) // first: pushes
b.think(context.Background(), ev) // second: same event -> suppressed
b.think(context.Background(), ev) // third: same event -> suppressed
b.think(context.Background(), ev) // 第一次:推送
b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制
b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
@@ -396,7 +396,7 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
if len(recs) < 3 {
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
}
// Newest first: the two most recent records are the suppressed duplicates.
// 最新的在前:最近的这两条就是被抑制的重复。
for i := 0; i < 2; i++ {
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
@@ -405,12 +405,12 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
}
}
// The third-most-recent record is the first (delivered) push.
// 第三新的那条是首次(已投递的)推送。
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
}
// A genuinely different event resets the chain and goes out.
// 真正不同的事件会重置该链并发出。
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
}})
@@ -418,8 +418,8 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
if got := pusher.count(); got != 2 {
t.Fatalf("pusher calls after a different event = %d, want 2", got)
}
// After the different event, the original identical event is no longer a
// consecutive duplicate, so it pushes again — the guard only dedups consecutively.
// 在出现不同事件之后,原先那个相同事件不再是
// 连续重复,因此它会再次推送——该门只去重连续重复。
b.think(context.Background(), ev)
if got := pusher.count(); got != 3 {
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
+53 -53
View File
@@ -9,83 +9,83 @@ import (
"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.
// normalize 在 Config 字段留为零值时套用的默认值。
// 它们是编译期常量,因此运行路径里不出现魔法数字。
const (
// defaultIntervalSec is the heartbeat period in seconds when unused
// (15 minutes: the autonomous reflection is deliberately low-frequency).
// defaultIntervalSec 是未使用时的心跳周期秒数
// (15 分钟:自主反思刻意保持低频)。
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 与 defaultQuietEndHour 以本地钟点约束免打扰
// 窗口(00:00-07:00 对 23:00-07:00:窗口可以
// 跨午夜;两个相等的非零钟点会禁用该窗口)。
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 限制 defaultPushWindow 内的主动推送次数,以遵守
// design/11 §6 的反骚扰规则(QQ 风控、iLink 窗口)。
// 默认降到 1,因此主人每小时最多被打扰一次。
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 是一次成功主动推送之后的安静间隔,
// 以免一次成功投递被立即重复。
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 是主人最后一条消息之后的间隔,
// 在此期间自主推送被扣住,以免打断刚开始的对话。
defaultRecentChatCooldown = 10 * time.Minute
// defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/
// 关心) may repeat, so the same style cannot spam within it.
// defaultKindWindow 限制任何一种推送风格(问候/提醒/分享/
// 关心)的重复频率,使同一风格无法在窗口内刷屏。
defaultKindWindow = 2 * time.Hour
// defaultThinkTimeout bounds a single unattended thought so it can never
// wedge a worker goroutine.
// defaultThinkTimeout 约束单次无人值守思考,因此它绝不会
// 卡住 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 在调用方未提供时限定一次思考的作用域。它是
// loop.Run 的持久化键,刻意与 history.MainSessionID 不同,
// 这样自主思考绝不会与活跃主会话的检查点
// 存储冲突(channelbridge 在主 id 下保存/恢复)。该次思考仍能看到
// 主对话:LoopThinker 读 history.MainSessionID 取上下文,
// 并在 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.
// DefaultSessionID 是自主思考所用的、对外暴露的 loop.Run 会话键。它
// 之所以暴露,是为了让流程可视化发布器(cmd/cyrene)也能发布 backthink
// 流程图,供 backthink 页与 Web 聊天流程页共同渲染。
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.
// Config 携带自主思考的调优旋钮。零值会经 normalize
// 选取安全默认值,因此半填的 Config 仍可用。
type Config struct {
// Interval is the heartbeat period. When zero it is derived from
// IntervalSec (or its default).
// Interval 是心跳周期。为零时由
// IntervalSec(或其默认值)推导。
Interval time.Duration
// IntervalSec is the heartbeat period in seconds, mapped from
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
// IntervalSec 是心跳周期秒数,映射自
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
IntervalSec int
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
// clock hours. Equal non-zero hours disable the window.
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
// 两个相等的非零钟点会禁用该窗口。
QuietStartHour int
QuietEndHour int
// PushLimit bounds proactive pushes within PushWindow. Zero uses the default.
// Non-positive PushWindow or PushLimit disables the limit.
// PushLimit 限制 PushWindow 内的主动推送次数。为零用默认值。
// 非正的 PushWindow 或 PushLimit 会禁用该限制。
PushLimit int
// PushWindow is the sliding window over which PushLimit is enforced.
// PushWindow 是执行 PushLimit 的滑动窗口。
PushWindow time.Duration
// PushCooldown is the quiet gap after a successful proactive push before
// another is allowed. Zero uses the default.
// PushCooldown 是一次成功主动推送之后、允许下一次之前的
// 安静间隔。为零用默认值。
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 是主人最后一条消息之后的间隔,在此期间
// 自主推送被扣住。为零用默认值。
RecentChatCooldown time.Duration
// KindWindow bounds how often any single push flavor may repeat. Zero uses the
// default.
// KindWindow 限制任何一种推送风格可重复的频率。为零用
// 默认值。
KindWindow time.Duration
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
// ThinkTimeout 约束单轮思考。为零用默认值。
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.
// ConfigFromBackground 把 config.BackgroundConfig(心跳周期、
// 共享安静时段窗口与主动推送频次旋钮的来源)
// 映射成 backthink.Config,env 未暴露的项保持其
// 默认值,交给 normalize 填。
func ConfigFromBackground(bg config.BackgroundConfig) Config {
return Config{
IntervalSec: bg.IntervalSec,
@@ -99,7 +99,7 @@ func ConfigFromBackground(bg config.BackgroundConfig) Config {
}
}
// normalize fills a Config's zero fields with safe defaults.
// normalize 用安全默认值填充 Config 的零值字段。
func normalize(cfg Config) Config {
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = defaultIntervalSec
@@ -132,13 +132,13 @@ func normalize(cfg Config) Config {
return cfg
}
// quietHoursLabel renders the do-not-disturb window for logs.
// quietHoursLabel 为日志渲染免打扰窗口。
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.
// baseLog 在 l 非 nil 时返回 l,否则返回丢弃式日志器,好让 New 总是有
// 一个可标记的安全日志器。
func baseLog(l *slog.Logger) *slog.Logger {
if l != nil {
return l
+32 -32
View File
@@ -7,24 +7,24 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// recentContextCount is how many of the most recent main-session messages are
// folded into one autonomous think as conversation context. Compile-time so no
// magic number appears in the run path.
// recentContextCount 是折进一次自主思考作为对话上下文的最新
// 主会话消息条数。编译期常量,因此
// 运行路径里不出现魔法数字。
const recentContextCount = 20
// recentProvider returns the most recent messages of a session in oldest-first
// order, the tail an autonomous think reads to "see" the main conversation.
// *history.Store satisfies it; tests inject a fake. It is the narrow slice of
// history.HistoryStore that the thinker actually depends on, matching the loop's
// own checkpointStore style of minimal seam interfaces.
// recentProvider 以最旧在前的顺序返回会话最近的消息,
// 即一次自主思考为「看到」主对话而读取的尾部。
// *history.Store 满足它;测试注入 fake。它是 thinker 实际
// 依赖的那一小片 history.HistoryStore,与 loop 自己的
// checkpointStore 风格的最小接缝接口一致。
type recentProvider interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// mainContext returns the most recent main-session messages converted to model
// messages (oldest-first) for one autonomous think. A nil provider, a failed read
// or an empty tail all yield nil so the think degrades to the guidance prompt
// alone rather than failing the pass (design/20 §2).
// mainContext 把最近的主会话消息转换为 model 消息
// (最旧在前),供一次自主思考使用。提供者为 nil、读取失败
// 或尾部为空都得到 nil,因此该次思考降级为仅用引导提示词,
// 而不是让这一轮失败(design/20 §2)。
func mainContext(ctx context.Context, prov recentProvider) []model.Message {
if prov == nil {
return nil
@@ -40,9 +40,9 @@ func mainContext(ctx context.Context, prov recentProvider) []model.Message {
return msgs
}
// mapHistoryMessage converts one persisted chat message into a model message,
// translating the history role and preserving an image part when the message
// carries one so a multimodal main model still sees it.
// mapHistoryMessage 把一条持久化的 chat 消息转换为 model 消息,
// 翻译历史角色,并在消息携带图片时保留该图片片段,
// 因此多模态主模型仍能看到它。
func mapHistoryMessage(h history.Message) model.Message {
msg := model.Message{
Role: historyRoleToModel(h.Role),
@@ -54,8 +54,8 @@ func mapHistoryMessage(h history.Message) model.Message {
return msg
}
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
// defaulting unknown roles to the user lane.
// historyRoleToModel 把持久化的 chat 角色映射到 model 的角色词汇,
// 未知角色默认为 user 通道。
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
@@ -67,9 +67,9 @@ func historyRoleToModel(role string) model.Role {
}
}
// thinkInput assembles the full input for one unattended think: the recent
// main-session context (if any) followed by a single guidance prompt turn, so the
// runtime reflects on real conversation rather than an empty macro prompt.
// thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境
// (若有)后跟单个引导提示词回合,因此
// 运行时针对真实对话反思,而不是空泛的宏提示词。
func thinkInput(recent []model.Message, trigger string) []model.Message {
out := make([]model.Message, 0, len(recent)+1)
out = append(out, recent...)
@@ -77,19 +77,19 @@ func thinkInput(recent []model.Message, trigger string) []model.Message {
return out
}
// guidancePrompt is the instruction handed to the runtime for one unattended
// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a
// warm, targeted proactive message and drops the "do not produce a chat-style
// message" suppression. Identity/permission stay on the actor metadata + owner env,
// never on this prompt text (red line); the prompt only describes the running
// environment (memories readable/writable, tools callable) that the env already
// grants.
// guidancePrompt 是交给运行时的一次无人值守自主思考的
// 指令。与旧 backgroundPrompt 不同,它刻意鼓励
// 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格
// 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上,
// 绝不在这段提示词文本上(红线);提示词只描述运行
// 环境(记忆可读/可写、工具可调用),而那是 env 已经
// 授予的。
//
// The prompt asks for a compact structured closing — a push/no_push decision, an
// optional target channel and the message body — so the service can honor her own
// judgment instead of gating on the trigger's importance (design/20 §2 P2). The
// format is guidance, not a contract: parseProactive fails safe when she omits or
// mangles it.
// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、
// 可选的目标渠道与消息正文——好让服务尊重她自己的
// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该
// 格式是引导而非契约:她省略或
// 写乱该格式时,parseProactive 失败安全。
//
// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的
// 「回来」问候。
+63 -63
View File
@@ -6,26 +6,26 @@ import (
"time"
)
// Importance is the estimated severity of an event that woke a thinking pass.
// Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her
// conclusion does); it decides the hard-gate exemptions: a critical safety event
// punches through work/busy and quiet hours (裁决 07.4.2), and a high event still
// passes quiet hours (the pre-existing cascade semantics).
// Importance 是唤醒一轮思考的事件估计严重度。
// 在 design/20 §2 P2 之下,它不再决定她*想不想*说话(那由她的
// 结论决定);它决定硬门豁免:critical 安全事件
// 穿透工作/忙碌与安静时段(裁决 07.4.2),而 high 事件仍
// 通过安静时段(既有的级联语义)。
type Importance int
const (
// ImportanceLow is a routine trigger (e.g. the periodic heartbeat).
// ImportanceLow 是例行触发(如周期性心跳)。
ImportanceLow Importance = iota
// ImportanceNormal is an ordinary event worth reflecting on (device change).
// ImportanceNormal 是值得反思的普通事件(设备变化)。
ImportanceNormal
// ImportanceHigh is a notable event (a reminder the user set firing).
// ImportanceHigh 是值得注意的事件(用户设置的提醒触发)。
ImportanceHigh
// ImportanceCritical is a safety-critical event that punches through
// work/busy and quiet hours (裁决 07.4.2 安全域豁免).
// ImportanceCritical 是穿透
// 工作/忙碌与安静时段的安全关键事件(裁决 07.4.2 安全域豁免)。
ImportanceCritical
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (i Importance) String() string {
switch i {
case ImportanceLow:
@@ -41,55 +41,55 @@ func (i Importance) String() string {
}
}
// longIdleThreshold is the gap after the owner's last message beyond which the
// activity gate only lets a genuinely important event through — except heartbeat
// passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time
// so no magic number appears.
// longIdleThreshold 是主人最后一条消息之后的间隔,超过它活动门
// 只放行真正重要的事件——但心跳轮除外,
// 它们永远不会被长期静默的间隔拦下(裁决 07.4.3)。编译期
// 常量,因此不出现魔法数字。
const longIdleThreshold = 2 * time.Hour
// UserState describes the user's current condition as observed from device
// telemetry (heart rate, sleep) and schedule (work, busy). It is the negative
// signal in the interrupt decision: a sleeping, working or busy user is left
// alone. Boredom (0..1) is the soft positive dial that widens interruption.
// UserState 描述由设备遥测(心率、睡眠)与日程(工作、忙碌)
// 观察到的用户当前状况。它是打扰决策里的负向
// 信号:睡眠、工作或忙碌中的用户不被打扰。
// 无聊度(0..1)是放宽打扰的软性正向旋钮。
type UserState struct {
// HeartRateBPM is the current heart rate, 0 when unknown.
// HeartRateBPM 是当前心率,未知时为 0。
HeartRateBPM int
// Sleeping reports whether the user is currently asleep from sleep tracking.
// Sleeping 报告睡眠追踪显示用户当前是否睡着。
Sleeping bool
// Working reports whether the user is in a work/scheduled block.
// Working 报告用户是否处于工作/日程时段。
Working bool
// Busy reports whether the user is marked busy (calendar / do-not-disturb).
// Busy 报告用户是否被标记为忙碌(日历 / 免打扰)。
Busy bool
// Boredom ranges 0..1. Higher boredom means the user is idle and more open
// to a gentle interruption. Under design/20 §2 P2 the push intent is her own
// conclusion, so this dial no longer gates the decision; it is kept as a
// telemetry signal her judgment already absorbs.
// Boredom 取值 0..1。无聊度越高表示用户越空闲、越愿意
// 被温和地打扰。在 design/20 §2 P2 之下推送意图来自她自己的
// 结论,所以这个旋钮不再为决策把关;它作为一个
// 她的判断本就会吸收的遥测信号保留下来。
Boredom float64
}
// String returns a concise human-readable summary for logs/records.
// String 返回用于日志/记录的简洁可读摘要。
func (s UserState) String() string {
return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f",
s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom)
}
// DecisionInput is the set of signals Decide weighs: the event's importance, the
// current wall-clock time, the user's current state and their boredom.
// DecisionInput 是 Decide 所称量的信号集合:事件重要度、
// 当前墙钟时间、用户当前状态及其无聊度。
type DecisionInput struct {
// Trigger is a human-readable source label, recorded for traceability.
// Trigger 是可读的来源标签,为可追溯而记录。
Trigger string
// Import is the event's estimated importance.
// Import 是事件的估计重要度。
Import Importance
// At is the current wall-clock time, in the local timezone used for the
// do-not-disturb window.
// At 是当前墙钟时间,处于免打扰窗口所用的
// 本地时区。
At time.Time
// UserState carries the user's physiological/schedule condition.
// UserState 携带用户的生理/日程状况。
UserState UserState
// LastActivity is the wall-clock time of the owner's most recent message.
// HasActivity reports whether a user message was found; when false the
// activity gate falls through without guessing.
// LastActivity 是主人最近一条消息的墙钟时间。
// HasActivity 报告是否找到了用户消息;为 false 时
// 活动门直接落空、不靠猜。
LastActivity time.Time
// HasActivity reports whether LastActivity is meaningful.
// HasActivity 报告 LastActivity 是否有意义。
HasActivity bool
// ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」
// 由她的结论决定,本决策只回答「此刻能不能说」。
@@ -99,11 +99,11 @@ type DecisionInput struct {
Heartbeat bool
}
// Decision is the outcome of Decide.
// Decision 是 Decide 的结果。
type Decision struct {
// ShouldPush reports whether the user should be interrupted.
// ShouldPush 报告是否应打扰用户。
ShouldPush bool
// Reason is a short human-readable justification, surfaced on the panel.
// Reason 是简短可读的理由,会在面板上呈现。
Reason string
}
@@ -192,9 +192,9 @@ func (b *Backthink) activityHeld(in DecisionInput) (bool, string) {
return false, ""
}
// inQuietHours reports whether at falls inside the configured do-not-disturb
// window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero
// hours disable it.
// inQuietHours 报告 at 是否落在已配置的免打扰
// 窗口内。窗口可以跨午夜(如 23:00-07:00);两个相等的非零
// 钟点会禁用它。
func (b *Backthink) inQuietHours(at time.Time) bool {
h := at.Hour()
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
@@ -204,24 +204,24 @@ func (b *Backthink) inQuietHours(at time.Time) bool {
return h >= start || h < end
}
// PushKind classifies a proactive message into one of a few flavors so the
// frequency gate can limit each flavor within a window, avoiding same-style spam
// (design/12 §6 策略二). It is a soft tag derived from the message body's
// keywords — it affects pacing, never identity or permission.
// PushKind 把主动消息归类成几种风格,好让
// 频率门能在窗口内限制每种风格,避免同一风格刷屏
// (design/12 §6 策略二)。它是由消息正文关键词导出的软标签
// ——它只影响节奏,绝不影响身份或权限。
type PushKind int
const (
// KindGreeting is a warm hello / good-morning / good-night opener.
// KindGreeting 是温暖的打招呼 / 早安 / 晚安开场。
KindGreeting PushKind = iota
// KindReminder is a nudge about a schedule, task or deadline.
// KindReminder 是关于日程、任务或截止时间的提醒。
KindReminder
// KindShare is a discovery, observation or interesting find.
// KindShare 是发现、观察或有意思的见闻。
KindShare
// KindCare is a warm, caring check-in about the owner's wellbeing.
// KindCare 是对主人身心状况的温暖关心问候。
KindCare
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (k PushKind) String() string {
switch k {
case KindGreeting:
@@ -237,11 +237,11 @@ func (k PushKind) String() string {
}
}
// classifyPush assigns a PushKind to a proactive message from its keyword
// content. A message that matches no flavor defaults to KindCare (the warm,
// generic intent of an unsolicited proactive note), which is the least
// spam-prone grouping. Priority is reminder > care > greeting > share so the
// dominant intent wins when several keywords co-occur.
// classifyPush 按关键词内容给主动消息指派 PushKind。
// 不匹配任何风格的消息默认为 KindCare(一条主动问候的
// 温暖、泛化意图),那是最不容易
// 刷屏的分组。优先级为 reminder > care > greeting > share,
// 因此多个关键词共现时主流意图获胜。
func classifyPush(message string) PushKind {
switch {
case hasAnyKeyword(message, kindReminderKeywords...):
@@ -257,9 +257,9 @@ func classifyPush(message string) PushKind {
}
}
// keyword sets for classifyPush. They are package vars (not consts) so the
// classification stays flexible and testable, but they are immutable by
// convention and never mutated at run time.
// classifyPush 所用的关键词集合。它们是包级变量(非常量)以便
// 分类保持灵活可测,但按约定不可变,
// 从不在运行时改写。
var (
kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"}
kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"}
@@ -267,7 +267,7 @@ var (
kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"}
)
// hasAnyKeyword reports whether s contains any of the given substrings.
// hasAnyKeyword 报告 s 是否包含给定子串中的任意一个。
func hasAnyKeyword(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
+15 -15
View File
@@ -5,14 +5,14 @@ import (
"time"
)
// dayAt returns a mid-afternoon time in UTC (outside the quiet-hours window).
// dayAt 返回 UTC 的午后时间(在安静时段窗口之外)。
func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) }
// nightAt returns a 02:00 time in UTC (inside the default 23-7 quiet window).
// nightAt 返回 UTC 的 02:00(在默认 23-7 安静窗口之内)。
func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) }
// testBackthink builds a Backthink with a normalized empty Config so Decide uses
// the default quiet-hours window (23:00-07:00).
// testBackthink 用归一化的空 Config 构建 Backthink,因此 Decide 使用
// 默认安静时段窗口(23:00-07:00)。
func testBackthink() *Backthink {
return &Backthink{cfg: normalize(Config{})}
}
@@ -144,8 +144,8 @@ func TestInQuietHours(t *testing.T) {
}
}
// TestClassifyPush verifies the proactive message body maps to the intended flavor
// (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately.
// TestClassifyPush 验证主动消息正文映射到预期的风格
// (问候/提醒/分享/关心),使频率门能分别限制每种风格。
func TestClassifyPush(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -168,16 +168,16 @@ func TestClassifyPush(t *testing.T) {
}
}
// TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh
// conversation holds trivia (unless critical), a long-idle owner is only woken by
// an important event — except a heartbeat pass, which is never held by a
// long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress.
// TestDecide_ActivityGate 验证活动感知软门:刚开始的
// 对话会扣下琐碎内容(critical 除外),长期静默的主人只会被
// 重要事件唤醒——但心跳轮除外,它永远不会被长期
// 静默的间隔拦下(裁决 07.4.3)——而中间地带不会过度抑制。
func TestDecide_ActivityGate(t *testing.T) {
t.Parallel()
b := testBackthink()
// Just chatted (within RecentChatCooldown=10min): a normal daytime push is
// held unless the event is safety-critical.
// 刚聊过(在 RecentChatCooldown=10min 内):白天普通推送
// 会被扣下,除非该事件是安全关键。
fresh := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
@@ -194,8 +194,8 @@ func TestDecide_ActivityGate(t *testing.T) {
t.Fatalf("critical event should push even in a fresh conversation, got %+v", got)
}
// Away a long while (> longIdleThreshold): only an important event is worth
// it — but a heartbeat pass still goes through (裁决 07.4.3 长期静默段不拦心跳).
// 离开很久(> longIdleThreshold):只有重要事件值得
// 唤醒——但心跳轮仍会通过(裁决 07.4.3 长期静默段不拦心跳)。
idle := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
@@ -217,7 +217,7 @@ func TestDecide_ActivityGate(t *testing.T) {
t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got)
}
// Middling activity (30 min ago) is not over-suppressed.
// 中等活动(30 分钟前)不会被过度抑制。
middle := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
+11 -11
View File
@@ -1,20 +1,20 @@
package backthink
// eventRedundant reports whether tr is a duplicate of the immediately preceding
// event-driven push (same source + summary), so a repeating device/reminder event
// does not spam the owner (防刷). It is the one guard that still applies to an
// event-driven push while the PushGate itself is bypassed for events (owner
// mandate: "该发就发" — a genuine event goes out, only a true duplicate is held).
// eventRedundant 报告 tr 是否与前一次事件驱动的推送重复
// (同源 + 同摘要),因此重复的设备/提醒事件
// 不会刷屏主人(防刷)。当 PushGate 对事件本身被绕过时,
// 它是仅剩的一道仍适用于事件驱动推送的
// 门(主人要求:"该发就发"——真正的事件照发,只扣下真正的重复)。
//
// It atomically records tr as the latest event key when the push proceeds, and
// clears the key on any non-event push (heartbeat) so a later repeat of a prior
// event is not spuriously suppressed by an intervening reflection.
// 推送进行时它原子地把 tr 记为最新事件键,并在任何非事件推送
// (心跳)时清除该键,以免更早事件的再次重复
// 被中间一次反思错误地抑制。
//
// The caller runs it before handing the push to the Pusher; a true duplicate is
// recorded as suppressed (RouteNone) rather than delivered.
// 调用方把它跑在把推送交给 Pusher 之前;真正的重复
// 会被记为已抑制(RouteNone),而不是投递出去。
func (b *Backthink) eventRedundant(tr Trigger) bool {
if tr.Kind != TriggerEvent {
// A non-event push (heartbeat) breaks the same-event chain.
// 非事件推送(心跳)会打断同一事件链。
b.eventMu.Lock()
b.lastEventKey = ""
b.eventMu.Unlock()
+11 -11
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// mockThinker records every Think invocation and returns a fixed conclusion. It
// respects ctx cancellation so Stop can drain a hung pass.
// mockThinker 记录每次 Think 调用并返回固定结论。它
// 尊重 ctx 取消,好让 Stop 能排空挂住的轮次。
type mockThinker struct {
mu sync.Mutex
text string
@@ -45,12 +45,12 @@ func (m *mockThinker) Think(ctx context.Context, sessionID, trigger string) (str
func (m *mockThinker) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
func (m *mockThinker) lastTrigger() string { m.mu.Lock(); defer m.mu.Unlock(); return m.last }
// mockState returns a fixed UserState.
// mockState 返回固定的 UserState。
type mockState struct{ state UserState }
func (m *mockState) Current(context.Context) (UserState, error) { return m.state, nil }
// mockPusher records Push calls and returns a fixed result.
// mockPusher 记录 Push 调用并返回固定结果。
type mockPusher struct {
mu sync.Mutex
result PushResult
@@ -70,7 +70,7 @@ func (p *mockPusher) Push(ctx context.Context, req PushRequest) PushResult {
func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls }
// mockActive implements ActiveClient.
// mockActive 实现 ActiveClient。
type mockActive struct {
mu sync.Mutex
n int
@@ -87,7 +87,7 @@ func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessa
return m.n, m.err
}
// mockIMAdapter implements IMAdapter.
// mockIMAdapter 实现 IMAdapter。
type mockIMAdapter struct {
mu sync.Mutex
err error
@@ -105,14 +105,14 @@ func (m *mockIMAdapter) SendProactive(ctx context.Context, msg adapter.UnifiedMe
func (m *mockIMAdapter) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
// mockGate implements PushGate.
// mockGate 实现 PushGate。
type mockGate struct{ allow bool }
func (g *mockGate) Allow(context.Context, PushKind) bool { return g.allow }
func (g *mockGate) RecordPush(PushKind) {}
// newTestDB opens an isolated in-memory SQLite handle for one test and closes it
// on cleanup.
// newTestDB 为单个测试打开一个隔离的内存 SQLite 句柄,并在
// 清理时关闭它。
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
h, err := db.Open(context.Background(), ":memory:", nil)
@@ -123,12 +123,12 @@ func newTestDB(t *testing.T) *sql.DB {
return h
}
// discardLog returns a logger that writes nowhere.
// discardLog 返回一个什么都不写的日志器。
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// eventually polls cond until it is true or the deadline passes.
// eventually 轮询 cond 直到它为真或超过期限。
func eventually(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
+57 -57
View File
@@ -6,63 +6,63 @@ import (
"unicode"
)
// The autonomous think is guided (guidancePrompt in context.go) to conclude with
// a compact, machine-parseable decision so the service no longer has to infer
// whether to push from the trigger's importance alone (design/20 §2 P2):
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
// 机器可解析的决策收尾,好让服务不必再从触发的重要度
// 单独推断是否推送(design/20 §2 P2):
//
// [我决定:push] (or no_push)
// [我决定:push] (或 no_push)
// [渠道:web|im|kook|telegram|...|auto]
// <the warm message body>
// <温暖的正文内容>
//
// The markers are parsed as plain text so a model that formats them slightly
// differently still yields a usable decision; a malformed/ambiguous decision
// marker fails safe to "no push".
// 这些标记按纯文本解析,因此模型稍有不同地
// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策
// 标记失败安全为「不推送」。
var (
// decideMarker matches the push/no_push decision token, tolerating a colon in
// either width and optional spaces. It is used to recover the decision, so it
// stays strict on the bracketed form: a bare "我决定:push" is treated as prose,
// not as a decision (fail safe).
// decideMarker 匹配 push/no_push 决策词,容忍两种宽度的
// 冒号与可选空格。它用于恢复决策,所以对
// 带括号的形式保持严格:裸的 "我决定:push" 被当作散文,
// 而不是决策(失败安全)。
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
// channelMarker matches the target-channel token on the decision line.
// channelMarker 匹配决策行上的目标渠道词。
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
// decidePrefixes are the marker shapes that signal a decision was attempted
// but could not be parsed, so the pass is treated as ambiguous (fail safe). It
// is deliberately the bracketed form only, so a message that merely contains
// "我决定:" in prose (common in Chinese) is not misread as a malformed marker.
// decidePrefixes 是「尝试了决策但无法解析」的标记形状,
// 这类轮次被视为含糊(失败安全)。它
// 刻意只含带括号形式,因此仅仅在散文中出现
// "我决定:"(中文里很常见)的消息不会被误读为格式错乱的标记。
decidePrefixes = []string{"[我决定"}
// stripDecideRe / stripChannelRe recognize the decision/channel markers as a
// token to delete from the user-facing body, in their intended bracketed form
// and with full-width brackets, so a well-formatted decision never leaks.
// stripDecideRe / stripChannelRe 把决策/渠道标记识别为
// 需要从用户可见正文中删除的词,按它们预期的带括号形式
// 与全角括号形式处理,因此格式良好的决策永不泄漏。
stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`)
stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`)
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
)
// nonePhrases are conclusions that mean "nothing to report" when no marker is
// present. The body is normalized (case-folded, punctuation/spaces stripped)
// before comparison so "无", "无。" and "Nothing to report." all collapse to the
// same token.
// nonePhrases 是没有标记时表示「无可汇报」的结论。
// 正文在比较前先归一化(折叠大小写、去掉标点/空格),
// 因此 "无"、"无。" 与 "Nothing to report." 都收敛到
// 同一个词。
var nonePhrases = []string{
"无", "没什么", "没有", "无物可报", "暂无", "没有值得说的",
"nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew",
}
// parseProactive recovers the push decision, the target-channel hint and the
// clean message body from a think conclusion. It is a pure function: it reads no
// state and performs no I/O, so it is safe to call concurrently and cheap to test.
// parseProactive 从思考结论中恢复推送决策、目标渠道提示与
// 干净的正文消息。它是纯函数:不读状态、不做 I/O,
// 因此可安全并发调用、测试成本低。
//
// Semantics (design/20 §2 P2):
// - A well-formed [我决定:push|no_push] marker is authoritative.
// - A malformed/ambiguous decision marker → no push (fail safe).
// - No marker: a body that reads as "无"/"nothing to report" → no push;
// otherwise the body is treated as a proactive message (push=true), matching
// the pre-P2 default.
// - The channel hint is the [渠道:...] token, lowercased; empty/auto keeps the
// default active-client-first, IM-fallback route.
// - A push decision whose body is empty or still reads as "nothing" → no push
// (fail safe), so a lone marker never pushes an empty message.
// 语义(design/20 §2 P2):
// - 格式良好的 [我决定:push|no_push] 标记具有权威性。
// - 格式错乱/含糊的决策标记 → 不推送(失败安全)。
// - 无标记:正文读作 "无"/"nothing to report" → 不推送;
// 否则正文被当作主动消息(push=true),与
// P2 之前的默认一致。
// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持
// 默认的活跃客户端优先、IM 降级路由。
// - 决策为推送但正文为空或仍读作「无」 → 不推送
// (失败安全),因此单独一个标记绝不会推出空消息。
func parseProactive(conclusion string) (message, channelHint string, push bool) {
text := strings.TrimSpace(conclusion)
@@ -83,8 +83,8 @@ func parseProactive(conclusion string) (message, channelHint string, push bool)
return message, channelHint, push
}
// matchDecide returns the matched decision token (lowercased) and whether a
// well-formed decision marker was present.
// matchDecide 返回匹配到的决策词(已转小写)以及
// 是否存在格式良好的决策标记。
func matchDecide(text string) (string, bool) {
m := decideMarker.FindStringSubmatch(text)
if m == nil {
@@ -93,8 +93,8 @@ func matchDecide(text string) (string, bool) {
return strings.ToLower(m[1]), true
}
// hasAmbiguousDecide reports whether a decision marker was attempted but could
// not be parsed, which the caller treats as "do not push" (fail safe).
// hasAmbiguousDecide 报告是否尝试了决策标记但
// 无法解析,调用方把它当作「不推送」(失败安全)。
func hasAmbiguousDecide(text string) bool {
if decideMarker.MatchString(text) {
return false
@@ -107,9 +107,9 @@ func hasAmbiguousDecide(text string) bool {
return false
}
// matchChannel returns the lowercased target-channel token, or "" when the
// conclusion carries no (or a malformed) channel marker. A malformed channel
// marker degrades to the default route rather than failing the whole pass.
// matchChannel 返回已转小写的目标渠道词;结论未携带
// (或携带格式错乱的)渠道标记时返回 ""。格式错乱的渠道
// 标记降级为默认路由,而不是让整轮失败。
func matchChannel(text string) string {
m := channelMarker.FindStringSubmatch(text)
if m == nil {
@@ -118,15 +118,15 @@ func matchChannel(text string) string {
return strings.ToLower(m[1])
}
// stripInternalMarkers removes the structured decision/channel markers so a
// proactive message body never carries them to the user. It handles the intended
// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit —
// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then
// drops any line that becomes empty and collapses the remainder. It is applied
// both inside parseProactive (so the message and record are clean) and again as a
// final guard in buildProactiveMessage, so a marker cannot survive into the
// outbound body no matter how it was formatted. Only our logs/records may carry
// the marker; the user-facing message must be pure warm content.
// stripInternalMarkers 移除结构化的决策/渠道标记,因此
// 主动消息正文绝不会把它们带给用户。它处理预期的
// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后
// 丢弃因此变空的行并压缩剩余部分。它既在
// parseProactive 内部应用(使消息与记录干净),又作为
// buildProactiveMessage 里的最后一道守护,因此无论标记如何
// 被格式化,都无法存活到出站正文里。只有我们的日志/记录可以携带
// 该标记;面向用户的消息必须是纯温暖内容。
func stripInternalMarkers(text string) string {
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
@@ -144,8 +144,8 @@ func stripInternalMarkers(text string) string {
return strings.Join(out, "\n")
}
// looksNone reports whether the body says "nothing worth saying". It is used both
// to drive the no-marker default and to fail-safe a contradictory push marker.
// looksNone 报告正文是否表示「没什么值得说的」。它既用于
// 驱动无标记时的默认,也用于对矛盾的 push 标记失败安全。
func looksNone(text string) bool {
t := normalizeNone(text)
if t == "" {
@@ -159,8 +159,8 @@ func looksNone(text string) bool {
return false
}
// normalizeNone lowercases the body and strips spaces and punctuation so the
// nonePhrases compare cleanly across "无", "无。", "Nothing to report." and similar.
// normalizeNone 把正文转小写并去掉空格与标点,好让
// nonePhrases 在 "无"、"无。"、"Nothing to report." 之类之间干净比较。
func normalizeNone(text string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
+18 -18
View File
@@ -41,7 +41,7 @@ func TestParseProactive_NoPushMarker(t *testing.T) {
func TestParseProactive_ChannelColonWidth(t *testing.T) {
t.Parallel()
// Tolerate a full-width colon and trailing spaces, and uppercase channel value.
// 容忍全角冒号与尾随空格,以及大写渠道值。
conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡"
msg, hint, push := parseProactive(conclusion)
if !push {
@@ -104,11 +104,11 @@ func TestParseProactive_MalformedFailsSafe(t *testing.T) {
func TestParseProactive_MarkerWithoutBody(t *testing.T) {
t.Parallel()
// A lone push marker with no body must not push an empty message (fail safe).
// 单独一个 push 标记、正文为空时绝不能推出空消息(失败安全)。
if _, _, push := parseProactive("[我决定:push]"); push {
t.Fatalf("push = true for a marker with no body, want false")
}
// A push marker whose body says "nothing" is contradictory → fail safe.
// push 标记的正文写着「无」是自相矛盾的 → 失败安全。
if _, _, push := parseProactive("[我决定:push]\n无"); push {
t.Fatalf("push = true for a push marker with nothing body, want false")
}
@@ -131,8 +131,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
b.think(context.Background(), heartbeatTrigger())
// The channel hint "kook" routes to the IM bridge (a specific platform), not the
// active client.
// 渠道提示 "kook" 路由到 IM 桥(一个具体平台),而不是
// 活跃客户端。
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls)
}
@@ -172,8 +172,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) {
t.Parallel()
// No channel marker → auto → a single IM channel (the bridge picks her choice /
// her recent platform / the whitelist default), NOT the active web client.
// 无渠道标记 → auto → 单个 IM 渠道(由桥挑出她的选择 /
// 她最近的平台 / 白名单默认),而不是活跃 Web 客户端。
conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了"
thinker := &mockThinker{text: conclusion}
active := &mockActive{n: 2}
@@ -241,8 +241,8 @@ func TestThink_ConcludedNoPush_NotPushed(t *testing.T) {
func TestThink_DoNotDisturbBlocksPush(t *testing.T) {
t.Parallel()
// She wants to push, but the user is asleep → the hard safety gate refuses,
// regardless of her decision.
// 她想推送,但用户正在睡觉 → 硬安全门拒绝,
// 与她的决策无关。
conclusion := "[我决定:push]\n想跟你说晚安"
thinker := &mockThinker{text: conclusion}
b, _ := newBackthink(t, Config{}, Deps{
@@ -292,9 +292,9 @@ func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) {
func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
t.Parallel()
// When a specific platform is requested but no IM sink is wired, the pusher
// falls back to the default route so the owner still receives the message, and
// records the requested hint.
// 请求了具体平台但未接线 IM 槽时,推送器
// 降级到默认路由,好让主人仍能收到消息,并
// 记录下请求的提示。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{
@@ -309,9 +309,9 @@ func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
}
}
// TestStripInternalMarkers_CleanBody verifies the internal decision/channel
// markers are removed from a proactive body in every shape a model may emit
// (bracketed, full-width, bare), so a marker can never reach the user-facing text.
// TestStripInternalMarkers_CleanBody 验证内部决策/渠道
// 标记在模型可能发出的每种形状下(带括号、全角、裸写)
// 都被从主动正文中移除,因此标记永远无法到达面向用户的文本。
func TestStripInternalMarkers_CleanBody(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -336,9 +336,9 @@ func TestStripInternalMarkers_CleanBody(t *testing.T) {
}
}
// TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a
// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must
// still deliver a clean warm body, never the structured decision text.
// TestThink_BareMarkersStrippedFromBody 是 KOOK 泄漏的回归用例:
// 携带裸写(无括号)"我决定:push"/"渠道:..." 标记的结论仍须
// 投递干净的温暖正文,绝不是结构化的决策文本。
func TestThink_BareMarkersStrippedFromBody(t *testing.T) {
t.Parallel()
conclusion := "我决定:push\n渠道:kook\n记得喝水哦"
+129 -129
View File
@@ -12,56 +12,56 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// Route identifies how a proactive message was actually delivered, for the
// monitor panel and the push result record.
// Route 标识一条主动消息实际是如何投递的,供监控面板
// 与推送结果记录使用。
type Route string
const (
// RouteNone means no push was attempted or no target was available.
// RouteNone 表示未尝试推送,或无可用目标。
RouteNone Route = "none"
// RouteRateLimited means the push was refused by the rate limiter.
// RouteRateLimited 表示推送被限流器拒绝。
RouteRateLimited Route = "rate_limited"
// RouteActiveClient means the message went to an active web/native client.
// RouteActiveClient 表示消息发往了活跃 Web/原生客户端。
RouteActiveClient Route = "active_client"
// RouteIMBridge means the message fell back to the IM bridge.
// RouteIMBridge 表示消息降级到 IM 桥。
RouteIMBridge Route = "im_bridge"
)
// Channel-hint tokens recognized by the push router. They come from the
// conclusion's [渠道:...] marker (parseProactive) and select where a proactive
// message is delivered. An empty/unknown hint is treated as auto. A concrete
// platform id (kook/telegram/...) is a value other than these three.
// 推送路由器识别的渠道提示词。它们来自结论的
// [渠道:...] 标记(parseProactive),用于选择主动消息
// 的投递去向。为空/未知的提示按 auto 处理。具体的
// 平台 id(kook/telegram/...)是这三个之外的值。
const (
channelAuto = "auto"
channelIM = "im"
channelWeb = "web"
// defaultProactivePlatform is the Platform the outbound proactive message
// carries when the conclusion names no specific channel.
// defaultProactivePlatform 是结论未指明具体渠道时,
// 出站主动消息携带的 Platform。
defaultProactivePlatform = "cyrene-backthink"
)
// String returns the route as a plain label.
// String 把路由作为普通标签返回。
func (r Route) String() string { return string(r) }
// PushResult is the outcome of one proactive push attempt, recorded with each
// thinking pass (design/12 §4).
// PushResult 是一次主动推送尝试的结果,随每轮思考
// 一起记录(design/12 §4)。
type PushResult struct {
// Pushed reports whether the message was delivered.
// Pushed 报告消息是否已投递。
Pushed bool
// Route is the delivery route taken.
// Route 是实际采用的路由。
Route Route
// Bypassed reports whether this push was event-driven and skipped the PushGate
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
// bypassed.
// Bypassed 报告本次推送是否为事件驱动、跳过了 PushGate
// (真正的事件,"该发就发")。心跳推送受门控管辖,绝不
// 被绕过。
Bypassed bool
// Detail is a short human-readable note (e.g. client count, decision reason).
// Detail 是简短可读的备注(如客户端数量、决策理由)。
Detail string
// Err is the underlying error text when delivery failed, or that of the
// provider that decided not to push.
// Err 是投递失败时的底层错误文本,或决定不推送的
// 提供者的错误文本。
Err string
}
// String returns a concise summary for logs.
// String 返回用于日志的简洁摘要。
func (p PushResult) String() string {
if p.Err != "" {
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
@@ -69,110 +69,110 @@ func (p PushResult) String() string {
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
}
// ActiveClient delivers a proactive message to every currently active web/native
// client and reports how many received it. It is the preferred push target. The
// gateway WebSocket hub implements this in production; tests inject a mock.
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
// 客户端,并报告有多少个收到。它是首选的推送目标。
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
type ActiveClient interface {
// SendProactive pushes msg to each active client and returns the number of
// clients that accepted it.
// SendProactive 把 msg 推给每个活跃客户端,并返回
// 接受它的客户端数量。
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
}
// IMAdapter is the IM bridge fallback: it pushes a proactive message over an IM
// channel. Every adapter.PlatformAdapter satisfies this subset, so the service
// depends on the narrow interface rather than the concrete adapter type.
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
// 渠道推送。每个 adapter.PlatformAdapter 都满足这个子集,因此本服务
// 依赖这个窄接口而非具体适配器类型。
type IMAdapter interface {
// SendProactive pushes msg to the owner's IM channel.
// SendProactive 把 msg 推给主人的 IM 渠道。
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error
}
// PushGate decides whether a proactive push may proceed right now, applying the
// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It
// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can
// be tested with a mock.
// PushGate 判断一次主动推送此刻能否进行,套用
// design/11 §6 的限频规则,外加按风格限制与推送后冷却。它
// 是对 channel/session 的 ProactivePolicy/RateLimiter 的接缝,因此本服务
// 可以用 mock 测试。
type PushGate interface {
// Allow reports whether a proactive push of the given kind may be sent now.
// Allow 报告给定风格的主动推送此刻能否发送。
Allow(ctx context.Context, kind PushKind) bool
// RecordPush notes a successful delivery so the cooldown/per-kind windows
// start from a real successful push, not a merely-attempted one.
// RecordPush 记下一次成功投递,使冷却/按风格窗口
// 从一次真正的成功推送起算,而不是仅仅尝试过。
RecordPush(kind PushKind)
}
// PushRequest carries one proactive push attempt: the outbound message plus the
// channel target the autonomous conclusion asked for (design/20 §2 P2).
// ChannelHint is the lowercased [渠道:...] token: ""/auto pick the default (the
// owner mandate: ONE channel, chosen by her / her most recent platform / the
// whitelist default); "web" targets the active client; "im" targets the IM bridge;
// a concrete platform id (kook/telegram/weixin-ilink/...) targets the IM bridge
// with the message tagged for that platform.
// PushRequest 携带一次主动推送尝试:出站消息加上
// 自主结论所要求的目标渠道(design/20 §2 P2)。
// ChannelHint 是已转小写的 [渠道:...] 词:""/auto 选默认(
// 主人要求:只发一个渠道,由她 / 她最近的平台 /
// 白名单默认决定);"web" 指向活跃客户端;"im" 指向 IM 桥;
// 具体平台 id(kook/telegram/weixin-ilink/...)指向 IM 桥
// 并把消息标记为该平台。
type PushRequest struct {
// Message is the normalized outbound proactive message.
// Message 是归一化后的出站主动消息。
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
// ChannelHint 是请求的目标渠道,她未选择时为 ""。
ChannelHint string
// TriggerKind records what woke this pass. An event-driven push bypasses the
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
// TriggerKind 记录唤醒本轮的原因。事件驱动的推送绕过
// PushGate(主人要求:"该发就发");心跳保持受门控管辖。
TriggerKind TriggerKind
}
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
// rate gate and honors the channel hint the autonomous conclusion selected: "web"
// goes to the active client, everything else goes over the IM bridge a single time,
// where the bridge picks the one channel (her choice → her most recent platform →
// whitelist default, WeChat included).
// Pusher 把主动消息投递到一个目标渠道。它受
// 频率门约束,并尊重自主结论所选的渠道提示:"web"
// 去活跃客户端,其余都经 IM 桥走一次,
// 由桥挑出那一个渠道(她的选择 → 她最近的平台 →
// 白名单默认,微信含在内)。
type Pusher interface {
// Push delivers req to a single target channel, per the channel hint. It never
// blocks on a slow target.
// Push 按渠道提示把 req 投递到单个目标渠道。它绝不
// 阻塞在慢目标上。
Push(ctx context.Context, req PushRequest) PushResult
}
// pusher is the concrete Pusher: it consults the gate, then routes the message to
// exactly one target — the active client for a "web" hint, otherwise the IM bridge.
// After the local delivery it mirrors the message to the optional push outlet
// (self-hosted service → Getui fallback) without blocking that delivery.
// pusher 是具体的 Pusher:它先咨询频率门,再把消息路由到
// 恰好一个目标——"web" 提示走活跃客户端,否则走 IM 桥。
// 本地投递之后,它把消息镜像到可选的推送出口
// (自托管服务 → 个推降级),且不阻塞该投递。
type pusher struct {
active ActiveClient
im IMAdapter
gate PushGate
exporter PushExporter
log *slog.Logger
// exportDone, when non-nil, receives one value after each asynchronous export
// completes. It exists so tests can deterministically observe the hook; the
// send is non-blocking, so a full channel never stalls the exporter.
// exportDone 非 nil 时,会在每次异步导出完成后收到一个值。
// 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的,
// 所以通道满也不会卡住导出器。
exportDone chan struct{}
}
// newPusher builds a Pusher from the optional active-client sink, IM sink and
// rate gate. Any of them may be nil; a nil target simply makes that route
// unavailable. The push outlet is attached separately with withExporter.
// newPusher 由可选的活跃客户端槽、IM 槽与
// 频率门构建 Pusher。它们都可以为 nil;目标为 nil 只是让那条路由
// 不可用。推送出口用 withExporter 单独挂上。
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
return &pusher{active: active, im: im, gate: gate, log: log}
}
// withExporter attaches the push outlet hook and returns the pusher for fluent
// use. A nil exporter leaves the hook disabled.
// withExporter 挂上推送出口钩子并返回 pusher 以便链式
// 调用。exporter 为 nil 则钩子保持关闭。
func (p *pusher) withExporter(exporter PushExporter) *pusher {
p.exporter = exporter
return p
}
// Push implements Pusher: it applies the rate gate, then routes the message to a
// single target. A "web" hint reaches the active web/native client; every other
// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where
// the bridge selects one owner channel (her [渠道:...] choice, then the platform she
// used most recently, then the whitelist default). The IM bridge is primary, so a
// push never fans out to several channels. When no IM sink is wired the active
// client is the honest fallback, so the owner is still reached rather than the
// message being dropped.
// Push 实现 Pusher:它套用频率门,然后把消息路由到
// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何
// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由
// 桥挑出一个主人渠道(她的 [渠道:...] 选择,其次是她
// 最近用过的平台,再次是白名单默认)。IM 桥是主路径,所以一次
// 推送绝不扇出到多个渠道。未接线 IM 槽时,活跃
// 客户端就是诚实的降级,因此主人仍能被触达,而不是
// 消息被丢弃。
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if err := ctx.Err(); err != nil {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
kind := classifyPush(req.Message.Content)
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
// by an empty reflection. Same-event dedup is applied upstream in think().
// 真正事件驱动的推送绕过 PushGate:它应原样发出("该发就发"),
// 不受心跳限流约束(60min≤1 / 冷却 / 按风格窗口)。
// 只有周期性心跳保持受门控管辖,主人因此绝不会被
// 空洞的反思刷屏。同一事件去重在 think() 上游套用。
eventDriven := req.TriggerKind == TriggerEvent
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
p.log.Warn("backthink push rate limited", "kind", kind.String())
@@ -186,10 +186,10 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if hint == channelWeb {
res = p.pushActive(ctx, msg)
} else {
// "im"/"auto"/a concrete platform all travel over the IM bridge once. A
// generic hint hands the sentinel platform so the bridge falls through to its
// recent-active / default selection; a concrete hint is honored when the
// channel is whitelisted and connected.
// "im"/"auto"/具体平台都经 IM 桥走一次。
// 泛化提示交出哨兵平台,让桥落到它的
// 最近活跃 / 默认选择上;具体提示在渠道
// 已加入白名单且已连接时被尊重。
if hint == channelIM || hint == channelAuto {
msg.Platform = defaultProactivePlatform
} else {
@@ -200,16 +200,16 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
res = p.pushActive(ctx, msg)
}
}
// Only a real delivery advances the cooldown/per-kind windows (and only for a
// gate-governed push), so an attempted-but-failed push does not suppress a later
// genuine one, and an event push never contributes to the heartbeat throttle.
// 只有真正的投递才推进冷却/按风格窗口(且只对
// 受门控的推送),因此尝试过但失败的推送不会抑制之后的
// 真实推送,而事件推送也绝不计入心跳限流。
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
// D1: mirror the proactive message to the external push outlet (self-hosted
// unified push service → Getui fallback). Fire-and-forget: a slow or failing
// outlet never delays or breaks the local delivery above, and a rate-limited
// push returned before reaching here.
// D1:把主动消息镜像到外部推送出口(自托管
// 统一推送服务 → 个推降级)。发后不管:慢或失败的
// 出口绝不延迟或破坏上面的本地投递,而且限流被拒的
// 推送在此之前就已返回。
p.exportAsync(msg)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
@@ -219,9 +219,9 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
return res
}
// exportAsync reports msg to the push outlet on a detached, bounded goroutine so
// the proactive path never blocks on the outlet. A nil exporter (hook disabled)
// or a nil outlet result is a no-op; a delivery miss or error is only logged.
// exportAsync 在脱离的、有界的 goroutine 上把 msg 上报给推送出口,
// 因此主动路径绝不在出口上阻塞。exporter 为 nil(钩子关闭)
// 或出口结果为 nil 都是空操作;投递未命中或出错只记日志。
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
if p.exporter == nil {
return
@@ -246,7 +246,7 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
}()
}
// pushActive delivers only to the active web/native client.
// pushActive 只投递给活跃 Web/原生客户端。
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {
n, err := p.active.SendProactive(ctx, msg)
@@ -259,7 +259,7 @@ func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) Pus
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
}
// pushIM delivers only to the IM bridge.
// pushIM 只投递给 IM 桥。
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
@@ -271,9 +271,9 @@ func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushRes
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
}
// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to
// the canonical route tokens (auto/im/web); anything else is a concrete platform
// id (kook/telegram/...). Unknown/blank maps to auto.
// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到
// 规范路由词(auto/im/web);其它一切视为具体平台
// id(kook/telegram/...)。未知/空映射为 auto。
func normalizeChannelHint(hint string) string {
switch strings.ToLower(strings.TrimSpace(hint)) {
case "", "auto":
@@ -287,15 +287,15 @@ func normalizeChannelHint(hint string) string {
}
}
// isSpecificChannel reports whether hint names a concrete platform rather than a
// routing keyword (auto/im/web).
// isSpecificChannel 报告 hint 指的是具体平台,而不是
// 路由关键词(auto/im/web)。
func isSpecificChannel(hint string) bool {
return hint != channelAuto && hint != channelIM && hint != channelWeb
}
// annotateHint appends the requested channel hint to the push result detail so
// the record/panel surfaces which channel she chose, even when the route fell
// back. Blank/auto hints add nothing.
// annotateHint 把请求的渠道提示附加到推送结果 detail 上,
// 好让记录/面板显示她选了哪个渠道,即便路由发生
// 了降级。空白/auto 提示不加任何内容。
func annotateHint(res PushResult, hint string) PushResult {
h := strings.ToLower(strings.TrimSpace(hint))
if h == "" || h == channelAuto {
@@ -310,8 +310,8 @@ func annotateHint(res PushResult, hint string) PushResult {
return res
}
// annotateBypass tags an event-driven push result so the record/panel shows it
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它
// 跳过了 PushGate,与因限流被拒的心跳推送区分开。
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
@@ -321,10 +321,10 @@ func annotateBypass(res PushResult) PushResult {
return res
}
// rateGate is the production PushGate: a sliding-window counter over
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never
// spammed with the same style (design/12 §6 策略二).
// rateGate 是生产用的 PushGate:一个基于
// session.RateLimiter 的滑动窗口计数器(design/11 §6 反骚扰规则),
// 结合推送后冷却与按风格(问候/提醒/分享/关心)窗口,
// 使主人绝不会被同一风格刷屏(design/12 §6 策略二)。
type rateGate struct {
log *slog.Logger
limiter *session.RateLimiter
@@ -337,10 +337,10 @@ type rateGate struct {
lastKind map[PushKind]time.Time
}
// newRateGate builds a gate that allows at most limit pushes within window, keeps
// pushes cooldown apart after a success, and limits each PushKind to one push per
// kindWindow. A non-positive limit or window yields an always-allow limiter (no
// rate rule); a non-positive cooldown or kindWindow disables that specific guard.
// newRateGate 构建一个门:在 window 内最多放行 limit 次推送,
// 成功之后推送之间至少间隔 cooldown,且每种 PushKind 在
// kindWindow 内只允许一次。非正的 limit 或 window 得到始终放行的
// 限流器(无限频规则);非正的 cooldown 或 kindWindow 会禁用对应的那道守护。
func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate {
return &rateGate{
log: log,
@@ -352,9 +352,9 @@ func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slo
}
}
// Allow implements PushGate. A canceled context denies (fail closed). It refuses
// when the last successful push is inside the cooldown, or when the same flavor
// was delivered within kindWindow, before consulting the sliding-window limiter.
// Allow 实现 PushGate。已取消的 context 一律拒绝(fail closed)。
// 当最近一次成功推送仍在冷却期内,或同一风格
// 在 kindWindow 内已投递过,它就在咨询滑动窗口限流器之前拒绝。
func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
if err := ctx.Err(); err != nil {
return false
@@ -371,8 +371,8 @@ func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
return g.limiter.Allow()
}
// RecordPush implements PushGate: it stamps the last successful push time and the
// per-flavor last time so the cooldown and kind window start from a real delivery.
// RecordPush 实现 PushGate:它记下最近成功推送时间与
// 按风格的最近时间,使冷却与风格窗口从真实投递起算。
func (g *rateGate) RecordPush(kind PushKind) {
now := g.now()
g.mu.Lock()
@@ -381,15 +381,15 @@ func (g *rateGate) RecordPush(kind PushKind) {
g.lastKind[kind] = now
}
// buildProactiveMessage assembles the normalized outbound message carrying a
// think conclusion's clean body. It tags the platform with the requested channel
// when the conclusion names a concrete platform, otherwise the default backthink
// platform. The concrete active-client and IM adapters apply their own
// routing/targeting to the owner (red line: owner only).
// buildProactiveMessage 拼装承载思考结论文本的、
// 归一化的出站消息。当结论指明具体平台时,它用请求的渠道
// 给平台打标签,否则用 backthink 的默认
// 平台。具体的活跃客户端与 IM 适配器各自对主人施加
// 自己的路由/寻址(红线:只针对主人)。
//
// The body is run through stripInternalMarkers as a final guard so a structured
// decision/channel marker that survived parsing can never reach the user-facing
// message (only our logs/records may carry it).
// 正文会再过一遍 stripInternalMarkers 作为最后一道守护,因此
// 侥幸存活过解析的结构化决策/渠道标记绝不
// 能到达面向用户的消息(只有我们的日志/记录可以携带它)。
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
platform := defaultProactivePlatform
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
+56 -56
View File
@@ -15,58 +15,58 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// The push outlet (D1, design/12 §4/§6): proactive messages are mirrored to an
// external push service so the owner's mobile client is reached even when no
// WebSocket client or IM channel is live. Delivery is two-tier by design:
// 推送出口(D1,design/12 §4/§6):主动消息被镜像到
// 外部推送服务,使主人的移动端即便在没有
// WebSocket 客户端或 IM 渠道在线时也能被触达。按设计投递分两级:
//
// 1. the owner's self-hosted unified push service (primary; PUSH_EXPORTER_URL),
// 2. Getui (个推) as the fallback for when the self-hosted service is down.
// 1. 主人的自托管统一推送服务(主级;PUSH_EXPORTER_URL),
// 2. 个推(Getui)作为自托管服务不可用时的降级。
//
// Tier 2 has no SDK integration yet, so an unavailable primary logs an explicit
// "Getui fallback not integrated" note and reports not-delivered instead of
// pretending the message went out. The hook is fire-and-forget: it never delays
// or breaks the existing active-client / IM delivery.
// 第 2 级尚未接入 SDK,因此主级不可用时只记一条明确的
// "Getui fallback not integrated" 备注,并上报未投递,而不
// 假装消息已发出。该钩子是发后不管:它绝不延迟
// 或破坏既有的活跃客户端 / IM 投递。
// defaultExportTimeout bounds one export attempt so a hung endpoint cannot pin a
// goroutine forever.
// defaultExportTimeout 约束单次导出尝试,因此挂死的端点无法永久
// 占住一个 goroutine。
const defaultExportTimeout = 5 * time.Second
// maxExportResponseBytes caps how much of the outlet's response body is drained
// before the connection is reused, so a hostile/buggy endpoint cannot stream an
// unbounded body into memory.
// maxExportResponseBytes 限制连接复用前从出口响应体里
// 抽掉多少数据,因此恶意或有缺陷的端点无法把一个
// 无界的响应体流进内存。
const maxExportResponseBytes = 4 << 10
// PushExporter reports one proactive message to an external push outlet. Export
// must be safe for concurrent use and must honor ctx. A nil error with
// delivered=false means the outlet could not deliver (for example the fallback
// tier is not wired); the caller treats that as a logged miss, never a failure
// of the local push path.
// PushExporter 把一条主动消息上报给外部推送出口。Export
// 必须并发安全且必须尊重 ctx。nil 错误配上
// delivered=false 表示出口无法投递(例如降级
// 级尚未接线);调用方把它当作一次记账的未命中,绝不当作本地
// 推送路径的失败。
type PushExporter interface {
Export(ctx context.Context, msg adapter.UnifiedMessage) (delivered bool, err error)
}
// PushOutletConfig carries the push-outlet wiring. Enabled=false disables the
// hook entirely (NewPushOutlet returns nil). URL/Token/Timeout describe the
// self-hosted primary service; the Getui* fields are reserved for the deferred
// fallback and are not consumed yet.
// PushOutletConfig 携带推送出口的接线。Enabled=false 完全关闭
// 该钩子(NewPushOutlet 返回 nil)。URL/Token/Timeout 描述
// 自托管主服务;Getui* 字段保留给延后的
// 降级级,目前尚未消费。
type PushOutletConfig struct {
// Enabled turns the outlet hook on.
// Enabled 打开出口钩子。
Enabled bool
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
// Empty leaves the primary tier unconfigured.
// URL 是自托管统一推送服务端点(HTTP POST JSON)。
// 为空则主级保持未配置。
URL string
// Token is sent as "Authorization: Bearer <token>". It is never logged.
// Token 以 "Authorization: Bearer <token>" 发送。它绝不写日志。
Token string
// Timeout bounds one export attempt. Zero uses defaultExportTimeout.
// Timeout 约束单次导出尝试。为零用 defaultExportTimeout。
Timeout time.Duration
// Log is the base logger. Nil uses a discard logger.
// Log 是基础日志器。nil 则用丢弃式日志器。
Log *slog.Logger
}
// NewPushOutlet builds the two-tier push outlet from cfg. It returns nil when
// the hook is disabled, so the pusher simply skips the export step. When it is
// enabled the primary tier is the self-hosted HTTP endpoint (present only when
// URL is set) and the fallback tier is Getui, which is not integrated yet.
// NewPushOutlet 由 cfg 构建两级推送出口。钩子关闭时它返回 nil,
// 因此推送器直接跳过导出步骤。启用时,
// 主级是自托管 HTTP 端点(仅当
// URL 已设置时存在),降级级是个推,尚未接入。
func NewPushOutlet(cfg PushOutletConfig) PushExporter {
if !cfg.Enabled {
return nil
@@ -90,19 +90,19 @@ func NewPushOutlet(cfg PushOutletConfig) PushExporter {
return &pushOutlet{primary: primary, log: log}
}
// pushOutlet is the two-tier exporter: self-hosted service first, Getui second.
// The fallback is nil until the Getui SDK is integrated, so a primary miss is
// logged and reported as not delivered rather than silently dropped.
// pushOutlet 是两级导出器:先自托管服务,再个推。
// 在个推 SDK 接入前降级级为 nil,因此主级未命中时
// 只记日志并上报未投递,而不是静默丢弃。
type pushOutlet struct {
primary PushExporter
fallback PushExporter
log *slog.Logger
}
// Export implements PushExporter. It tries the primary tier and, when that does
// not deliver, the fallback tier. With no fallback wired it logs the pending
// integration and returns (false, nil): the caller treats that as a miss, never
// as a reason to fail the proactive push.
// Export 实现 PushExporter。它先试主级,主级
// 未投递时再试降级级。未接降级级时它记录待
// 接入状态并返回 (false, nil):调用方把这当作未命中,
// 绝不当作让主动推送失败的理由。
func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
if o.primary != nil {
delivered, err := o.primary.Export(ctx, msg)
@@ -123,34 +123,34 @@ func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bo
return false, nil
}
// pushExportPayload is the JSON body POSTed to the self-hosted push service. It
// is deliberately small and stable: the service owns device targeting, the
// message carries only what is needed to render and de-duplicate it.
// pushExportPayload 是 POST 到自托管推送服务的 JSON 体。它
// 刻意小而稳定:设备寻址归该服务,消息
// 只携带渲染与去重所需的内容。
type pushExportPayload struct {
// Source identifies the producer so the service can route/filter.
// Source 标识生产者,好让服务能路由/过滤。
Source string `json:"source"`
// Title is a short display title (the persona name).
// Title 是简短显示标题(角色名)。
Title string `json:"title"`
// Content is the proactive message body (already stripped of internal
// decision markers by buildProactiveMessage).
// Content 是主动消息正文(内部决策标记
// 已由 buildProactiveMessage 剥掉)。
Content string `json:"content"`
// Platform is the outbound platform tag (cyrene-backthink or a concrete IM
// platform).
// Platform 是出站平台标签(cyrene-backthink 或具体的 IM
// 平台)。
Platform string `json:"platform"`
// Timestamp is the message time in Unix milliseconds.
// Timestamp 是消息时间,Unix 毫秒。
Timestamp int64 `json:"timestamp"`
}
// httpPushExporter posts the message to the self-hosted push service. A 2xx
// response means delivered; any other status or transport error is an error.
// The token travels only in the Authorization header and never reaches a log.
// httpPushExporter 把消息 POST 到自托管推送服务。2xx
// 响应表示已投递;其它任何状态或传输错误都算错误。
// token 只出现在 Authorization 头里,绝不进入日志。
type httpPushExporter struct {
url string
token string
client *http.Client
}
// Export implements PushExporter over HTTP POST JSON.
// Export 实现 PushExporter,走 HTTP POST JSON。
func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
body, err := json.Marshal(pushExportPayload{
Source: "cyrene/backthink",
@@ -182,8 +182,8 @@ func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessag
return true, nil
}
// redact removes the endpoint URL from a transport error so a secret embedded in
// the URL (for example a query token) never reaches a log line.
// redact 从传输错误里移除端点 URL,因此 URL 中内嵌的
// 秘密(例如查询参数里的 token)绝不进入日志行。
func (e *httpPushExporter) redact(err error) error {
if err == nil || e.url == "" {
return err
+19 -19
View File
@@ -16,8 +16,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// mockExporter records exported messages and can block or fail on demand so the
// pusher hook's non-blocking / failure-tolerant behaviour is testable.
// mockExporter 记录已导出的消息,并可按需阻塞或失败,
// 使推送器钩子的非阻塞 / 容错行为可测。
type mockExporter struct {
mu sync.Mutex
msgs []adapter.UnifiedMessage
@@ -49,9 +49,9 @@ func (m *mockExporter) messages() []adapter.UnifiedMessage {
return append([]adapter.UnifiedMessage(nil), m.msgs...)
}
// TestPushOutletHTTPPrimaryDelivers proves the self-hosted service is the
// primary tier: the payload is POSTed with the bearer token and a 2xx response
// is reported as delivered.
// TestPushOutletHTTPPrimaryDelivers 证明自托管服务是
// 主级:负载带着 bearer token 被 POST,2xx 响应
// 被上报为已投递。
func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
t.Parallel()
type payload struct {
@@ -109,14 +109,14 @@ func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
}
}
// TestPushOutletUnreachableLogsGetuiFallback asserts an unreachable primary is a
// logged miss (not an error), names the pending Getui fallback, and never logs
// the token.
// TestPushOutletUnreachableLogsGetuiFallback 断言主级不可达是一次
// 记账的未命中(不是错误),会点出待接入的个推降级,且绝不记录
// token。
func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // now unreachable
srv.Close() // 此时已不可达
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
@@ -137,7 +137,7 @@ func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
}
}
// TestPushOutletHTTPErrorReportsMiss asserts a non-2xx primary response is a miss.
// TestPushOutletHTTPErrorReportsMiss 断言主级非 2xx 响应算未命中。
func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -155,8 +155,8 @@ func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
}
}
// TestNewPushOutletDisabledOrUnconfigured covers the two degraded shapes: the
// hook off (nil) and enabled without a primary URL (miss + Getui note).
// TestNewPushOutletDisabledOrUnconfigured 覆盖两种降级形态:钩子
// 关闭(nil)与启用但无主级 URL(未命中 + 个推备注)。
func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
t.Parallel()
if exp := NewPushOutlet(PushOutletConfig{Enabled: false, URL: "http://example.invalid"}); exp != nil {
@@ -178,8 +178,8 @@ func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
}
}
// TestPusherExportsProactiveToOutlet asserts the hook mirrors the delivered
// proactive message to the outlet after the IM delivery.
// TestPusherExportsProactiveToOutlet 断言钩子在 IM 投递之后
// 把已投递的主动消息镜像到出口。
func TestPusherExportsProactiveToOutlet(t *testing.T) {
t.Parallel()
exp := &mockExporter{delivered: true}
@@ -205,9 +205,9 @@ func TestPusherExportsProactiveToOutlet(t *testing.T) {
}
}
// TestPusherExportHookNonBlockingAndFailureTolerant asserts a hung/failing outlet
// never delays the push and never turns a delivered proactive message into a
// failure.
// TestPusherExportHookNonBlockingAndFailureTolerant 断言挂死/失败的出口
// 绝不延迟推送,也绝不把已投递的主动消息变成
// 失败。
func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
t.Parallel()
started := make(chan struct{})
@@ -238,8 +238,8 @@ func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
}
}
// TestPusherRateLimitedPushIsNotExported asserts a rate-limited push never
// reaches the outlet: the hook mirrors real proactive sends only.
// TestPusherRateLimitedPushIsNotExported 断言被限流的推送绝不
// 到达出口:该钩子只镜像真实的主动发送。
func TestPusherRateLimitedPushIsNotExported(t *testing.T) {
t.Parallel()
exp := &mockExporter{delivered: true}
+28 -28
View File
@@ -11,10 +11,10 @@ import (
func TestPusher_AutoRoutesIM(t *testing.T) {
t.Parallel()
// The owner mandate: a default (auto) push reaches ONE channel over the IM
// bridge (the bridge picks her [渠道:...] choice, then her most recent platform,
// then the whitelist default) — not the active web client by default.
active := &mockActive{n: 2} // a web client is online but is NOT the default target
// 主人要求:默认(auto)推送经 IM 桥到达 ONE 个渠道
//(桥依次挑她的 [渠道:...] 选择、她最近的平台、
// 白名单默认)——默认不是活跃 Web 客户端。
active := &mockActive{n: 2} // 有 Web 客户端在线,但它不是默认目标
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
@@ -58,8 +58,8 @@ func TestPusher_WebHintRoutesActive(t *testing.T) {
func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
t.Parallel()
// A concrete decision channel is honored over the IM bridge, and exactly ONE
// target is used (the IM bridge carries it; the active client is not touched).
// 具体的决策渠道经 IM 桥被尊重,且只用 ONE 个
// 目标(由 IM 桥承载;活跃客户端不被触碰)。
active := &mockActive{n: 2}
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
@@ -85,8 +85,8 @@ func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
func TestPusher_NoIMSink_DegradesToActive(t *testing.T) {
t.Parallel()
// With no IM sink wired (backthink IM seam absent) the push degrades to the
// active web client so the owner is still reached rather than dropped.
// 未接线 IM 槽(backthink 的 IM 接缝缺失)时推送降级到
// 活跃 Web 客户端,好让主人仍被触达而不是被丢弃。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
@@ -156,8 +156,8 @@ func TestPusher_CancelledContext_Denied(t *testing.T) {
}
}
// TestPusher_RateLimit exercises the production rateGate over session.RateLimiter:
// with a limit of 1 push per window, only the first of three attempts passes.
// TestPusher_RateLimit 在 session.RateLimiter 上验证生产 rateGate:
// 窗口内 limit 为 1 次推送时,三次尝试中只有第一次通过。
func TestPusher_RateLimit(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -181,9 +181,9 @@ func TestPusher_RateLimit(t *testing.T) {
}
}
// TestRateGate_CooldownAfterSuccess verifies the post-push cooldown: after a
// successful delivery a further push is refused until the cooldown elapses,
// regardless of the configured window counter.
// TestRateGate_CooldownAfterSuccess 验证推送后冷却:一次成功
// 投递之后,在冷却期结束前再推送会被拒绝,
// 与已配置的窗口计数无关。
func TestRateGate_CooldownAfterSuccess(t *testing.T) {
t.Parallel()
t0 := time.Now()
@@ -191,7 +191,7 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 30 * time.Minute,
kindWindow: 0, // disabled to isolate the cooldown
kindWindow: 0, // 置零以单独验证冷却
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
}
@@ -208,16 +208,16 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
}
}
// TestRateGate_KindWindowLimitsSameFlavor verifies the per-flavor (问候/提醒/分享/
// 关心) limit: the same flavor cannot repeat within the kind window, while a
// different flavor and a later same-flavor push (after the window) still pass.
// TestRateGate_KindWindowLimitsSameFlavor 验证按风格(问候/提醒/分享/
// 关心)限制:同一风格不能在风格窗口内重复,而
// 不同风格与更晚的同一风格推送(窗口之后)仍可通过。
func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
t.Parallel()
t0 := time.Now()
g := &rateGate{
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 0, // disabled to isolate the kind window
cooldown: 0, // 置零以单独验证风格窗口
kindWindow: 2 * time.Hour,
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
@@ -238,10 +238,10 @@ func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
}
}
// TestPusher_EventBypassesGate verifies an event-driven push skips the PushGate:
// with a limit of 1 push per window, three consecutive event pushes are ALL
// delivered (owner mandate "该发就发"), each marked Bypassed and each advancing
// no cooldown/kind window.
// TestPusher_EventBypassesGate 验证事件驱动的推送跳过 PushGate:
// 窗口内 limit 为 1 次推送时,连续三次事件推送全部
// 被投递(主人要求 "该发就发"),每次都标记 Bypassed,且都不推进
// 冷却/风格窗口。
func TestPusher_EventBypassesGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -266,9 +266,9 @@ func TestPusher_EventBypassesGate(t *testing.T) {
}
}
// TestPusher_HeartbeatStillRateLimited verifies a heartbeat-driven push stays
// gate-governed: with a limit of 1 push per window, the first passes and the
// second is refused as rate_limited, never Bypassed.
// TestPusher_HeartbeatStillRateLimited 验证心跳驱动的推送仍
// 受门控管辖:窗口内 limit 为 1 次推送时,第一次通过,
// 第二次作为 rate_limited 被拒,绝不是 Bypassed。
func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -289,9 +289,9 @@ func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
}
}
// TestPusher_EventBypass_DoesNotAdvanceGate verifies an event-driven push does not
// consume the heartbeat limit: after an event push, a following heartbeat push
// still gets its own full allowance.
// TestPusher_EventBypass_DoesNotAdvanceGate 验证事件驱动的推送不会
// 消耗心跳额度:一次事件推送之后,紧随的心跳推送
// 仍拿到自己那份完整额度。
func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
+14 -14
View File
@@ -1,14 +1,14 @@
package backthink
// The production composition root builds the gateway hub and the channel bridge
// *after* backthink.New (the hub needs the loop, the bridge needs the loop and
// owner profile, both of which are assembled later), so the ActiveClient and
// IMAdapter sinks are unavailable at construction time. These setters inject them
// once the gateway / bridge exist and rebuild the pusher, keeping the resolved
// rate gate so a later push routes correctly.
// 生产组合根在 backthink.New *之后* 才构建 gateway hub 与 channel
// bridge(hub 需要 loop,bridge 需要 loop 与主人档案,
// 两者都在更晚时才组装),因此构造时拿不到 ActiveClient 与
// IMAdapter 槽。这些设置器在 gateway / bridge 存在后注入它们,
// 并重建推送器,同时保留已解出的
// 频率门,使之后的推送路由正确。
// SetActiveSink wires the production active web/native client sink (the gateway
// hub) and rebuilds the pusher. Call it after the gateway exists, before Start.
// SetActiveSink 接入生产用的活跃 Web/原生客户端槽(gateway
// hub)并重建推送器。请在 gateway 存在之后、Start 之前调用。
func (b *Backthink) SetActiveSink(active ActiveClient) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -16,8 +16,8 @@ func (b *Backthink) SetActiveSink(active ActiveClient) {
b.rebuildPusherLocked()
}
// SetIMSink wires the production IM bridge sink (the channel bridge) and rebuilds
// the pusher. Call it after the channel bridge exists, before Start.
// SetIMSink 接入生产用的 IM 桥槽(channel bridge)并重建
// 推送器。请在 channel bridge 存在之后、Start 之前调用。
func (b *Backthink) SetIMSink(im IMAdapter) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -25,14 +25,14 @@ func (b *Backthink) SetIMSink(im IMAdapter) {
b.rebuildPusherLocked()
}
// rebuildPusherLocked replaces the pusher from the current sinks, gate and push
// outlet. The caller must hold b.mu.
// rebuildPusherLocked 用当前槽位、频率门与推送出口替换
// 推送器。调用方必须持有 b.mu。
func (b *Backthink) rebuildPusherLocked() {
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).withExporter(b.deps.Exporter)
}
// currentPusher returns the active pusher under the lifecycle lock, so a sink
// swap never races with a concurrent thinking pass that is about to push.
// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位
// 换入绝不会与即将推送的并发思考轮发生竞态。
func (b *Backthink) currentPusher() Pusher {
b.mu.Lock()
defer b.mu.Unlock()
+9 -9
View File
@@ -7,9 +7,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// TestBackthink_SetActiveSink_RebuildsPusher verifies the sink setters rebuild the
// pusher so a later push routes to the injected active client instead of the
// default nil-sink RouteNone pusher built at New.
// TestBackthink_SetActiveSink_RebuildsPusher 验证槽位设置器会重建
// 推送器,使之后的推送路由到注入的活跃客户端,而不是
// New 时构建的默认 nil 槽 RouteNone 推送器。
func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
@@ -42,8 +42,8 @@ func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
}
}
// TestBackthink_SetIMSink_Fallback verifies a wired IM sink receives the push when
// no active client is online (so the owner is still reached).
// TestBackthink_SetIMSink_Fallback 验证没有活跃客户端在线时,
// 已接线的 IM 槽会收到推送(主人因此仍被触达)。
func TestBackthink_SetIMSink_Fallback(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
@@ -56,7 +56,7 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
t.Fatalf("new: %v", err)
}
active := &mockActive{n: 0} // no active client online
active := &mockActive{n: 0} // 没有活跃客户端在线
im := &mockIMAdapter{}
bt.SetActiveSink(active)
bt.SetIMSink(im)
@@ -72,9 +72,9 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
}
}
// TestBackthink_SinkSetters_ConcurrentSafe verifies the setter and the read of the
// current pusher do not race under the lifecycle lock, so an in-flight pass never
// touches a half-rebuilt pusher.
// TestBackthink_SinkSetters_ConcurrentSafe 验证设置器与对
// 当前推送器的读取在生命周期锁下不产生竞态,因此进行中的轮次绝不会
// 碰到半重建的推送器。
func TestBackthink_SinkSetters_ConcurrentSafe(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
+17 -17
View File
@@ -11,33 +11,33 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// StateProvider supplies the user's current condition for the interrupt decision
// (design/12 §2). Production wires IoT/telemetry and calendar; tests inject a
// mock. It must be concurrency-safe.
// StateProvider 为打扰决策提供用户当前状况
// (design/12 §2)。生产接线 IoT/遥测与日历;测试注入
// mock。它必须并发安全。
type StateProvider interface {
// Current returns the user's current UserState. A nil-able error means "use
// a neutral state"; a hard error is logged by the caller and treated as
// neutral.
// Current 返回用户当前的 UserState。可 nil 的错误表示「用
// 中性状态」;硬错误由调用方记日志并按
// 中性处理。
Current(ctx context.Context) (UserState, error)
}
// IoTStateProvider is a best-effort StateProvider built over an iot.Manager. It
// scans the managed devices and reads heart-rate and sleep signals from any
// device whose id/name/type mentions the corresponding keyword (e.g. "heart",
// "心率", "sleep", "睡眠"). Work/busy come from a device/state flag; when nothing
// matches it returns a neutral state rather than an error, so the service still
// runs without full telemetry wiring.
// IoTStateProvider 是基于 iot.Manager 的尽力而为 StateProvider。它
// 扫描纳管设备,并从任何 id/名称/类型提到对应关键词的设备
// 读取心率与睡眠信号(例如 "heart"、
// "心率"、"sleep"、"睡眠")。工作/忙碌来自设备/状态标志;什么都不
// 匹配时它返回中性状态而不是错误,因此即便没有完整
// 遥测接线,服务也能运行。
type IoTStateProvider struct {
mgr *iot.Manager
log *slog.Logger
}
// NewIoTStateProvider wraps mgr as a StateProvider.
// NewIoTStateProvider 把 mgr 包装为 StateProvider。
func NewIoTStateProvider(mgr *iot.Manager, log *slog.Logger) *IoTStateProvider {
return &IoTStateProvider{mgr: mgr, log: logger.WithSource(baseLog(log), "services/backthink")}
}
// Current implements StateProvider.
// Current 实现 StateProvider。
func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
if p.mgr == nil {
return UserState{}, fmt.Errorf("backthink: state: %w", iot.ErrProviderNotRegistered)
@@ -71,7 +71,7 @@ func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
return state, nil
}
// numState reads the first numeric value among keys from a device's state map.
// numState 从设备状态 map 中按 keys 读取首个数值。
func numState(d iot.Device, keys ...string) (int, bool) {
for _, k := range keys {
v, ok := d.State[k]
@@ -94,13 +94,13 @@ func numState(d iot.Device, keys ...string) (int, bool) {
return 0, false
}
// boolState reads a bool (or a "sleep"/"asleep" string) among keys.
// boolState 在 keys 中读取一个 bool(或 "sleep"/"asleep" 字符串)。
func boolState(d iot.Device, keys ...string) bool {
b, _ := boolStateOK(d, keys...)
return b
}
// boolStateOK reports whether a bool-typed state flag was found and its value.
// boolStateOK 报告是否找到了 bool 类型的状态标志及其取值。
func boolStateOK(d iot.Device, keys ...string) (bool, bool) {
for _, k := range keys {
v, ok := d.State[k]
+4 -4
View File
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// stateMockProvider is a tiny iot.Provider that returns a heart-rate watch, a
// sleep band and a working-calendar device, to exercise the state decoding.
// stateMockProvider 是一个极小的 iot.Provider,返回心率手表、
// 睡眠手环与工作日历设备,用于演练状态解码。
type stateMockProvider struct{}
func (stateMockProvider) Name() string { return "state-mock" }
@@ -73,8 +73,8 @@ func TestIoTStateProvider_EmptyNeutral(t *testing.T) {
t.Fatalf("iot.New: %v", err)
}
p := NewIoTStateProvider(mgr, nil)
// The mock provider has no heart-rate/sleep/working device, so it must yield
// a zero state instead of an error, keeping the service operable.
// 该 mock 提供者没有心率/睡眠/工作设备,所以它必须给出
// 零值状态而不是错误,保持服务可运行。
st, err := p.Current(context.Background())
if err != nil {
t.Fatalf("Current: %v", err)
+23 -23
View File
@@ -10,14 +10,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// defaultListLimit is the number of most-recent records returned by List when the
// caller passes a non-positive limit.
// defaultListLimit 是调用方传入非正 limit 时,List 返回的
// 最近记录条数。
const defaultListLimit = 50
// DDL for the thinking record store. at/created_at follow the project timestamp
// convention (RFC3339 with a timezone); trigger and conclusion are the free-text
// fields the panel renders; the push_* columns carry a decomposed PushResult so
// List need not parse a blob. The primary key is the project-standard auto id.
// 思考记录存储的 DDL。at/created_at 遵循项目时间戳
// 约定(带时区的 RFC3339);trigger 与 conclusion 是面板渲染的
// 自由文本字段;push_* 列携带分解后的 PushResult,
// 使 List 不必解析 blob。主键是项目标准的自增 id。
const createRecordsSQL = `
CREATE TABLE IF NOT EXISTS backthink_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -46,10 +46,10 @@ FROM backthink_records
ORDER BY id DESC
LIMIT ?`
// Record is one persisted thinking pass (design/12 §4), returned by Store.List
// and the service's List. Trigger is the source subsystem label, At the decision
// timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision
// and PushResult how (or whether) the push went out.
// Record 是一轮已持久化的思考(design/12 §4),由 Store.List
// 与服务的 List 返回。Trigger 是来源子系统标签,At 是决策
// 时间戳,Conclusion 是运行时的输出,ShouldPush 是打扰决策,
// PushResult 是推送如何(或是否)发出。
type Record struct {
ID int64
Trigger string
@@ -59,17 +59,17 @@ type Record struct {
PushResult PushResult
}
// Store persists thinking records in SQLite. It creates its table and index in
// NewStore so the module is self-contained (no migration bookkeeping), mirrors
// how channel/session creates its own table. It is concurrency-safe by virtue of
// the underlying *sql.DB.
// Store 把思考记录持久化到 SQLite。它在
// NewStore 中建表与索引,因此本模块自包含(无需迁移记账),
// 与 channel/session 自建表的做法一致。它凭借底层的 *sql.DB
// 而并发安全。
type Store struct {
db *sql.DB
log *slog.Logger
}
// NewStore builds a Store, ensuring the backthink_records table and index exist.
// It returns ErrNilDB when db is nil. ctx is used for the DDL.
// NewStore 构建 Store,确保 backthink_records 表与索引存在。
// db 为 nil 时返回 ErrNilDB。ctx 用于 DDL。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
@@ -84,7 +84,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
return &Store{db: db, log: log}, nil
}
// Append persists r and returns the new record id.
// Append 持久化 r 并返回新记录 id。
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
res, err := s.db.ExecContext(ctx, insertRecordSQL,
r.Trigger,
@@ -109,8 +109,8 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
return id, nil
}
// List returns the most recent records newest-first. A non-positive limit selects
// defaultListLimit.
// List 返回最近的记录,最新的在前。非正的 limit 选用
// defaultListLimit。
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
if limit <= 0 {
limit = defaultListLimit
@@ -158,7 +158,7 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
return records, nil
}
// boolInt maps a bool to the SQLite INTEGER 1/0 representation.
// boolInt 把 bool 映射为 SQLite 的 INTEGER 1/0 表示。
func boolInt(b bool) int {
if b {
return 1
@@ -166,11 +166,11 @@ func boolInt(b bool) int {
return 0
}
// intBool maps a SQLite INTEGER to a bool.
// intBool 把 SQLite 的 INTEGER 映射为 bool。
func intBool(n int) bool { return n != 0 }
// parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to
// the zero time so a malformed row never breaks a listing.
// parseTime 解析存储的 RFC3339(带纳秒/时区)时间戳,失败时回退到
// 零值时间,因此格式错乱的行绝不会破坏列表查询。
func parseTime(s string) time.Time {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
+1 -1
View File
@@ -38,7 +38,7 @@ func TestStore_AppendList(t *testing.T) {
if len(records) != 3 {
t.Fatalf("len = %d, want 3", len(records))
}
// Newest first (descending id).
// 最新的在前(按 id 降序)。
if records[0].ID != 3 || records[1].ID != 2 || records[2].ID != 1 {
t.Fatalf("order = %d,%d,%d, want 3,2,1", records[0].ID, records[1].ID, records[2].ID)
}
+16 -16
View File
@@ -7,29 +7,29 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// LoopThinker adapts a *loop.Loop into a Thinker. It is the production wiring
// that lets an autonomous background pass drive the full tool loop (memory
// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing
// the main session's recent conversation and running under the owner environment.
// LoopThinker 把 *loop.Loop 适配为 Thinker。它是生产接线,
// 让一次自主后台轮驱动完整的工具循环(记忆
// 注入、模型步骤、IoT/日历/提醒等工具调用),同时看到
// 主会话的最近对话并在 owner 环境下运行。
type LoopThinker struct {
loop *loop.Loop
recent recentProvider
}
// NewLoopThinker wraps l as a Thinker over the given recent-messages provider
// (*history.Store in production; nil degrades the think to the guidance prompt
// alone, see mainContext).
// NewLoopThinker 把 l 包装成基于给定最近消息提供者的 Thinker
// (生产中是 *history.Store;nil 会让思考降级为仅用引导提示词,
// 见 mainContext)。
func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker {
return &LoopThinker{loop: l, recent: recent}
}
// Think runs one unattended think through the wrapped loop and returns the
// conclusion text. It inherits the main session's recent conversation as input
// context and injects the owner session environment (MemWritable=true, ActorOwner,
// full tool set) so the think can read/write memory and call tools. The loop's
// Run session id is the service's own (not the main session id) so the think never
// collides with the live main session's durable checkpoint store (design/20).
// It returns ErrNoLoop when no loop was injected.
// Think 经被包装的 loop 跑一轮无人值守思考并返回
// 结论文本。它继承主会话的最近对话作为输入
// 上下文,并注入 owner 会话环境(MemWritable=true、ActorOwner、
// 完整工具集),因此该次思考能读写记忆并调用工具。loop 的
// Run 会话 id 是本服务自己的(不是主会话 id),所以该次思考绝不会
// 与活跃主会话的持久化检查点存储冲突(design/20)。
// 未注入 loop 时返回 ErrNoLoop。
func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) {
if t.loop == nil {
return "", ErrNoLoop
@@ -37,8 +37,8 @@ func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (str
recent := mainContext(ctx, t.recent)
messages := thinkInput(recent, trigger)
envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv())
// Mute UI broadcast: background reflection must not leak its intermediate
// reasoning into the user-facing chat stream (it only needs the conclusion).
// 静音 UI 广播:后台反思不得把其中间
// 推理泄漏进面向用户的 chat 流(它只需要结论)。
res, err := t.loop.Run(loop.WithMutedBroadcast(envCtx), sessionID, messages)
if err != nil {
return "", fmt.Errorf("backthink: think: %w", err)
+28 -28
View File
@@ -14,8 +14,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fakeRecent is a recentProvider stub returning a fixed tail/error for the
// main-session context read.
// fakeRecent 是 recentProvider 的 stub,为
// 主会话语境读取返回固定的尾部/错误。
type fakeRecent struct {
msgs []history.Message
err error
@@ -25,8 +25,8 @@ func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, er
return f.msgs, f.err
}
// captureAdapter is a model.Adapter that records every request it is handed so a
// test can assert on the messages the loop actually fed to the model.
// captureAdapter 是一个 model.Adapter,记录交给它的每个请求,好让
// 测试能对 loop 实际喂给模型的消息做断言。
type captureAdapter struct {
reqs []model.Request
}
@@ -42,9 +42,9 @@ func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func
return c.Chat(context.Background(), req, model.Options{})
}
// newThinkLoop builds a real loop over ad with a working manual memory-extraction
// path so a test can prove the owner environment is in effect (extractMemory runs
// only when MemWritable=true).
// newThinkLoop 在 ad 之上构建一个真实 loop,并带一条可用的手动记忆抽取
// 路径,好让测试能证明 owner 环境确实生效(extractMemory 只在
// MemWritable=true 时运行)。
func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
t.Helper()
a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil))
@@ -61,7 +61,7 @@ func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
return l, store
}
// newMemoryStore opens an isolated in-memory memory store for one test.
// newMemoryStore 为单个测试打开一个隔离的内存记忆存储。
func newMemoryStore(t *testing.T) *memory.Store {
t.Helper()
d, err := db.Open(context.Background(), ":memory:", nil)
@@ -76,7 +76,7 @@ func newMemoryStore(t *testing.T) *memory.Store {
return s
}
// modelText returns the concatenated text of a model message's text parts.
// modelText 返回一条 model 消息各文本片段拼接后的文本。
func modelText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
@@ -87,9 +87,9 @@ func modelText(m model.Message) string {
return b.String()
}
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression guards the rewrite:
// the guidance must drop the old suppression phrases and push warm, targeted
// proactive expression (design/20 §2).
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression 守护这次改写:
// 引导必须去掉旧的抑制措辞,并推动温暖、有针对性的
// 主动表达(design/20 §2)。
func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
t.Parallel()
p := guidancePrompt("heartbeat")
@@ -105,8 +105,8 @@ func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
}
}
// TestMainContext_MapsRecentMessages proves the recent main-session tail is
// converted in order with roles mapped and image parts preserved.
// TestMainContext_MapsRecentMessages 证明主会话最近尾部被
// 按序转换、角色被映射、图片片段被保留。
func TestMainContext_MapsRecentMessages(t *testing.T) {
t.Parallel()
prov := &fakeRecent{msgs: []history.Message{
@@ -129,8 +129,8 @@ func TestMainContext_MapsRecentMessages(t *testing.T) {
}
}
// TestMainContext_Degrades ensures a nil provider or a failed read yields a nil
// tail so the think runs on the guidance prompt alone rather than failing.
// TestMainContext_Degrades 确保提供者为 nil 或读取失败都得到 nil
// 尾部,因此该次思考仅凭引导提示词运行,而不是失败。
func TestMainContext_Degrades(t *testing.T) {
t.Parallel()
if got := mainContext(context.Background(), nil); got != nil {
@@ -141,8 +141,8 @@ func TestMainContext_Degrades(t *testing.T) {
}
}
// TestThinkInput_AppendsGuidanceLast verifies the assembled input is the recent
// context followed by a single guidance turn.
// TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近
// 语境后跟单个引导回合。
func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
t.Parallel()
in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat")
@@ -154,10 +154,10 @@ func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
}
}
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts
// three things from design/20 P1: the model saw the main-session recent message,
// the guidance prompt carries no suppression, and the think ran under the owner
// environment (the manual extractor persists only when MemWritable=true).
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并
// 断言 design/20 P1 的三件事:模型看到了主会话的最近消息、
// 引导提示词不携带抑制措辞,且该次思考跑在 owner
// 环境之下(手动抽取器只在 MemWritable=true 时持久化)。
func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
ad := &captureAdapter{}
l, store := newThinkLoop(t, ad)
@@ -194,7 +194,7 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
t.Fatalf("input missing the guidance prompt: %+v", last.Messages)
}
// Owner env proved: ManualExtractor only persists under MemWritable=true.
// 已证明 owner 环境:ManualExtractor 只在 MemWritable=true 时持久化。
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
@@ -207,8 +207,8 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
}
}
// TestLoopThinker_NilHistoryDegrades keeps the pre-existing backpressure: with no
// history provider the think still runs (guidance prompt only) and never panics.
// TestLoopThinker_NilHistoryDegrades 保持既有的背压行为:没有
// 历史提供者时该次思考仍会运行(只用引导提示词)且绝不 panic。
func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
ad := &captureAdapter{}
l, _ := newThinkLoop(t, ad)
@@ -221,9 +221,9 @@ func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
t.Fatal("no model request captured")
}
last := ad.reqs[len(ad.reqs)-1]
// With no history provider the input carries the guidance prompt and no
// main-session messages. (The agent prepends its own instruction system message,
// so we assert presence rather than an exact count.)
// 没有历史提供者时,输入携带引导提示词、不含
// 主会话消息。(agent 会自行前置一条指令系统消息,
// 所以我们断言存在性而非精确条数。)
foundGuidance := false
for _, m := range last.Messages {
if strings.Contains(modelText(m), guidancePrompt("heartbeat")) {
+30 -30
View File
@@ -7,22 +7,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// TriggerKind classifies what woke a thinking pass, so the push path can apply the
// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an
// event is a real signal the owner wants to hear about and bypasses the rate gate.
// TriggerKind 归类唤醒一轮思考的原因,好让推送路径套用
// 正确的策略:心跳受门控管辖(60min≤1 / 冷却 / 按风格),
// 事件是主人确实想听到的真实信号、绕过频率门。
type TriggerKind int
const (
// TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is
// throttled by the PushGate so the owner is never spammed by empty reflections.
// TriggerHeartbeat 是周期性自主反思。它的主动推送受
// PushGate 限流,使主人绝不会被空洞的反思刷屏。
TriggerHeartbeat TriggerKind = iota
// TriggerEvent is a real event (IoT device change, reminder, owner-relevant
// system event). Its push bypasses the PushGate — "该发就发" — subject only to
// same-event dedup.
// TriggerEvent 是真实事件(IoT 设备变化、提醒、与主人相关的
// 系统事件)。它的推送绕过 PushGate——"该发就发"——只受
// 同一事件去重约束。
TriggerEvent
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (k TriggerKind) String() string {
switch k {
case TriggerEvent:
@@ -34,40 +34,40 @@ func (k TriggerKind) String() string {
}
}
// Bus subjects the service subscribes to for event-triggered thinking. They are
// the contract defined by design/12 §1: an IoT state change, a reminder firing,
// or a generic system event.
// 本服务为事件驱动思考订阅的总线主题。它们是
// design/12 §1 定义的契约:一次 IoT 状态变化、一次提醒触发,
// 或一次泛化的系统事件。
const (
// SubjectReminderTrigger is the bus subject on which reminders fire.
// SubjectReminderTrigger 是提醒触发所用的总线主题。
SubjectReminderTrigger = "reminder.trigger"
// SubjectSystemEvent is the bus subject for generic system events.
// SubjectSystemEvent 是泛化系统事件的总线主题。
SubjectSystemEvent = "system.event"
)
// SystemEvent is the optional typed payload a caller may publish to
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
// platform noise) is downgraded to a registration-only record and does not wake a
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
// so a busy public platform cannot trigger a flood of expensive reflections.
// SystemEvent 是调用方可发布到 SubjectSystemEvent 以传达
// 系统通知的可选类型化负载。Import 默认为 Normal。在
// design/12 §6 策略一之下,低价值的系统通知(加入/退出 ping、泛化的
// 平台噪声)被降级为仅登记一条记录,不唤醒
// 一整轮思考;只有高重要度(与主人相关)的系统事件才思考,
// 因此繁忙的公开平台无法触发昂贵反思的洪流。
type SystemEvent struct {
Summary string
Import Importance
}
// Trigger describes what woke a thinking pass: the source subsystem, a
// human-readable summary and the event's estimated importance.
// Trigger 描述唤醒一轮思考的原因:来源子系统、
// 可读摘要与事件的估计重要度。
type Trigger struct {
Source string
Summary string
Import Importance
// Kind records whether this pass was the periodic heartbeat or a real event, so
// the push path can bypass the rate gate for a genuine event (owner mandate).
// Kind 记录本轮是周期性心跳还是真实事件,好让
// 推送路径能为真实事件绕过频率门(主人要求)。
Kind TriggerKind
}
// heartbeatTrigger is the periodic, low-importance trigger. Low importance means
// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息".
// heartbeatTrigger 是周期性、低重要度的触发。低重要度意味着
// 该轮很少打扰用户,遵守 "默认不发送 chat 类型消息"。
func heartbeatTrigger() Trigger {
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
}
@@ -84,10 +84,10 @@ func startupTrigger() Trigger {
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated
// as normal importance, a reminder firing as high importance, and a generic
// system event as normal importance. Every event is tagged TriggerEvent so its
// proactive push bypasses the heartbeat PushGate.
// triggerForEvent 把总线事件映射为 Trigger。IoT 设备变化按
// 普通重要度处理,提醒触发按高重要度,泛化的
// 系统事件按普通重要度。每个事件都打上 TriggerEvent,因此其
// 主动推送绕过心跳的 PushGate。
func triggerForEvent(e bus.Event) Trigger {
switch e.Subject {
case iot.DeviceUpdateSubject: