docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`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:
1 parent
f5cf117482
commit
3dc1232bc2
54 files changed
+1923
-1923
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:以角色口吻说一句拟人化的
|
||||
// 「回来」问候。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user