执行方案阶段 2 · P2-2(`DevDocs/audit/_plan.md`)。四条裁决: - 1.8/07.4.2:`think()` 不再走忽略 importance 的 `disturbAllowed`(已删),**接回 `Decide` 级联**; 安静时段只挡常规推送、**critical 安全域穿透**;`DecisionInput` 增 `ConclusionPush`/`Heartbeat`。 睡眠仍绝对不打扰(裁决只豁免安静时段,此点已在设计与报告写明)。 - 07.4.3:长静默(>2h)分支对**心跳类**放行(常规事件仍需 ≥high),刚聊过天 10min 内扣留不变。 - 07.4.4:`tickLoop` **启动立即先思考一轮** + 拟人化启动消息;启动轮与心跳**同门同限频** (重启风暴不会刷屏),ctx 已取消则跳过。 - 07.4.5:核对一致(`THINK_INTERVAL_SEC` 默认 900s 与 design/20 §0 权威口径一致)⇒ **代码零改动**。 验证:go build 绿;backthink 包 vet/测试绿;`-count=20`(抗 flake)+ `-race` 绿; linux/amd64 + darwin/arm64 交叉编译绿;gofmt 干净;文件 ≤500 行、无 >80 行函数。 变异验证(三条,均带还原证明):① 安静时段改回"全拦" → 安全域穿透断言 FAIL(首次只删 critical 分支时仍绿——`critical >= high` 会从 high 分支漏过,故真实变异必须是"全拦"); ② 删心跳豁免 → 长静默心跳断言 FAIL;③ 删启动思考 → 启动即思考断言 FAIL。 未验证:无真实模型凭据 ⇒ 启动轮问候与 `[我决定:…]` 解析仅经 mock + 真 loop 验证,端到端 LLM 未验。
475 lines
17 KiB
Go
475 lines
17 KiB
Go
// Package backthink implements the M8 autonomous background-think service
|
||
// (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.
|
||
//
|
||
// 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).
|
||
//
|
||
// 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.
|
||
package backthink
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"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.
|
||
var (
|
||
// ErrNilDB is returned when Backthink or Store is built with a nil database.
|
||
ErrNilDB = errors.New("backthink: nil database handle")
|
||
// ErrNoThinker is returned when New is given no Thinker.
|
||
ErrNoThinker = errors.New("backthink: nil thinker")
|
||
// ErrNoLoop is returned by LoopThinker when it wraps a nil loop.
|
||
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.
|
||
type Thinker interface {
|
||
// Think invokes one autonomous thinking pass for the given session/trigger
|
||
// and returns the concluded text.
|
||
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).
|
||
type Deps struct {
|
||
// Bus is the event-trigger source. Nil disables event-driven thinking.
|
||
Bus bus.Bus
|
||
// Thinker runs one unattended thinking pass. Required.
|
||
Thinker Thinker
|
||
// State supplies the user's current condition for the interrupt decision.
|
||
// Nil yields a neutral state.
|
||
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 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 Pusher
|
||
// Active is the active web/native client sink, preferred for pushes.
|
||
Active ActiveClient
|
||
// IM is the IM bridge sink, used as the fallback when no active client is up.
|
||
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 PushExporter
|
||
// Gate is the proactive-push rate limiter. Nil builds a default from Config.
|
||
Gate PushGate
|
||
// DB is the SQLite handle for the think store. Required.
|
||
DB *sql.DB
|
||
// SessionID scopes a think. Empty uses 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 func() time.Time
|
||
// Log is the base logger, tagged with source "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.
|
||
type Backthink struct {
|
||
cfg Config
|
||
deps Deps
|
||
log *slog.Logger
|
||
store *Store
|
||
pusher Pusher
|
||
gate PushGate
|
||
now func() time.Time
|
||
sessionID string
|
||
|
||
mu sync.Mutex
|
||
running bool
|
||
cancel context.CancelFunc
|
||
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 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.
|
||
func New(cfg Config, deps Deps) (*Backthink, error) {
|
||
if deps.Thinker == nil {
|
||
return nil, ErrNoThinker
|
||
}
|
||
if deps.DB == nil {
|
||
return nil, ErrNilDB
|
||
}
|
||
cfg = normalize(cfg)
|
||
|
||
log := logger.WithSource(baseLog(deps.Log), "services/backthink")
|
||
store, err := NewStore(context.Background(), deps.DB, log)
|
||
if err != nil {
|
||
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)
|
||
}
|
||
pusher := deps.Pusher
|
||
if pusher == nil {
|
||
pusher = newPusher(deps.Active, deps.IM, gate, log).withExporter(deps.Exporter)
|
||
}
|
||
|
||
now := deps.Now
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
sessionID := deps.SessionID
|
||
if sessionID == "" {
|
||
sessionID = defaultSessionID
|
||
}
|
||
|
||
return &Backthink{
|
||
cfg: cfg,
|
||
deps: deps,
|
||
log: log,
|
||
store: store,
|
||
pusher: pusher,
|
||
gate: gate,
|
||
now: now,
|
||
sessionID: sessionID,
|
||
}, 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.
|
||
func (b *Backthink) Start(ctx context.Context) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
b.mu.Lock()
|
||
if b.running {
|
||
b.mu.Unlock()
|
||
return
|
||
}
|
||
b.running = true
|
||
cctx, cancel := context.WithCancel(ctx)
|
||
b.cancel = cancel
|
||
b.wg.Add(1)
|
||
go b.tickLoop(cctx)
|
||
b.mu.Unlock()
|
||
|
||
if b.deps.Bus != nil {
|
||
b.subscribe(cctx)
|
||
}
|
||
|
||
b.log.Info("backthink started",
|
||
"interval", b.cfg.Interval.String(),
|
||
"quiet_hours", quietHoursLabel(b.cfg.QuietStartHour, b.cfg.QuietEndHour),
|
||
"event_triggers", b.deps.Bus != nil,
|
||
"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.
|
||
func (b *Backthink) Stop(ctx context.Context) error {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
b.mu.Lock()
|
||
if !b.running {
|
||
b.mu.Unlock()
|
||
return nil
|
||
}
|
||
b.running = false
|
||
cancel := b.cancel
|
||
subs := b.subs
|
||
b.subs = nil
|
||
b.mu.Unlock()
|
||
|
||
cancel()
|
||
for _, unsub := range subs {
|
||
unsub()
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
b.wg.Wait()
|
||
close(done)
|
||
}()
|
||
select {
|
||
case <-done:
|
||
b.log.Info("backthink stopped")
|
||
return nil
|
||
case <-ctx.Done():
|
||
return fmt.Errorf("backthink: stop: %w", ctx.Err())
|
||
}
|
||
}
|
||
|
||
// List returns the most recent thinking records for the M9/M10 monitor panel,
|
||
// newest first. A non-positive limit selects the store default.
|
||
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.
|
||
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.
|
||
func (b *Backthink) subscribe(ctx context.Context) {
|
||
for _, subj := range b.triggers() {
|
||
unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent)
|
||
if err != nil {
|
||
b.log.Error("backthink subscribe failed", "subject", subj, "error", err)
|
||
continue
|
||
}
|
||
b.mu.Lock()
|
||
b.subs = append(b.subs, unsub)
|
||
b.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
|
||
tr := triggerForEvent(e)
|
||
if !b.shouldThink(tr) {
|
||
b.log.Debug("backthink system event downgraded, no thinking",
|
||
"source", tr.Source, "summary", tr.Summary)
|
||
b.record(Record{
|
||
Trigger: tr.Source,
|
||
At: b.now(),
|
||
Conclusion: "system event: low value, not worth waking a thinking pass",
|
||
ShouldPush: false,
|
||
PushResult: PushResult{Route: RouteNone, Detail: "downgraded"},
|
||
})
|
||
return nil
|
||
}
|
||
b.think(ctx, tr)
|
||
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.
|
||
func (b *Backthink) shouldThink(tr Trigger) bool {
|
||
if tr.Source == SubjectSystemEvent {
|
||
return tr.Import >= ImportanceHigh
|
||
}
|
||
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.
|
||
func (b *Backthink) tickLoop(ctx context.Context) {
|
||
defer b.wg.Done()
|
||
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
|
||
// 启动记录。
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
b.think(ctx, startupTrigger())
|
||
ticker := time.NewTicker(b.cfg.Interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
b.think(ctx, heartbeatTrigger())
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||
at := b.now()
|
||
cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout)
|
||
defer cancel()
|
||
|
||
b.log.Debug("backthink trigger", "source", tr.Source, "summary", tr.Summary)
|
||
|
||
conclusion, err := b.deps.Thinker.Think(cctx, b.sessionID, tr.Summary)
|
||
if err != nil {
|
||
b.log.Error("backthink think failed", "source", tr.Source, "error", err)
|
||
b.record(Record{
|
||
Trigger: tr.Source,
|
||
At: at,
|
||
Conclusion: fmt.Sprintf("thinking failed: %v", err),
|
||
ShouldPush: false,
|
||
PushResult: PushResult{Route: RouteNone, Detail: err.Error()},
|
||
})
|
||
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).
|
||
message, channelHint, push := parseProactive(conclusion)
|
||
|
||
state := b.currentState(cctx)
|
||
lastActivity, hasActivity := b.lastUserActivity(cctx)
|
||
// 裁决 07.4.2:importance 级联恢复为生产决策路径——她的结论回答「想不想说」,
|
||
// Decide 只判「此刻能不能说」:critical 安全事件穿透工作/忙碌/安静时段,安静
|
||
// 时段只挡常规推送;心跳类触发(含启动轮)在长期静默段不拦(裁决 07.4.3)。
|
||
// 频率门(PushGate)仍在推送器内部单独生效。
|
||
decision := b.Decide(DecisionInput{
|
||
Trigger: tr.Source,
|
||
Import: tr.Import,
|
||
At: at,
|
||
UserState: state,
|
||
LastActivity: lastActivity,
|
||
HasActivity: hasActivity,
|
||
ConclusionPush: push,
|
||
Heartbeat: tr.Kind == TriggerHeartbeat,
|
||
})
|
||
shouldPush := decision.ShouldPush
|
||
|
||
recordConclusion := message
|
||
if recordConclusion == "" {
|
||
recordConclusion = conclusion
|
||
}
|
||
|
||
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 {
|
||
pushRes = b.currentPusher().Push(cctx, PushRequest{
|
||
Message: b.buildProactiveMessage(message, channelHint),
|
||
ChannelHint: channelHint,
|
||
TriggerKind: tr.Kind,
|
||
})
|
||
}
|
||
}
|
||
|
||
b.record(Record{
|
||
Trigger: tr.Source,
|
||
At: at,
|
||
Conclusion: recordConclusion,
|
||
ShouldPush: shouldPush,
|
||
PushResult: pushRes,
|
||
})
|
||
|
||
if shouldPush {
|
||
b.log.Info("backthink proactive push", "source", tr.Source, "route", pushRes.Route, "pushed", pushRes.Pushed, "bypass", pushRes.Bypassed, "reason", pushRes.Detail)
|
||
} else {
|
||
b.log.Debug("backthink did not interrupt", "source", tr.Source, "reason", pushRes.Detail)
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func (b *Backthink) currentState(ctx context.Context) UserState {
|
||
if b.deps.State == nil {
|
||
return UserState{}
|
||
}
|
||
s, err := b.deps.State.Current(ctx)
|
||
if err != nil {
|
||
b.log.Warn("backthink state provider failed", "error", err)
|
||
return 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).
|
||
func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
|
||
if b.deps.Recent == nil {
|
||
return time.Time{}, false
|
||
}
|
||
msgs, err := b.deps.Recent.Recent(ctx, history.MainSessionID, recentContextCount)
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
for i := len(msgs) - 1; i >= 0; i-- {
|
||
if msgs[i].Role == history.RoleUser {
|
||
return time.UnixMilli(msgs[i].CreatedAt), true
|
||
}
|
||
}
|
||
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.
|
||
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.
|
||
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)
|
||
}
|
||
}
|
||
}
|