Files
Cyrene_v2/internal/services/backthink/backthink.go
T
AskaEth 2dee372fb2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
2026-09-09 00:08:29 +08:00

449 lines
16 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, then consults Decide to judge whether the user
// should be interrupted at all (event importance / time-of-day / user state such
// as heart rate, sleep, work, busy / boredom). 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. It fires one thinking pass per tick and
// exits when ctx is cancelled.
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
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 to push and to which
// channel — not the trigger's importance. 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)
// The "do not disturb" gate (asleep/working/busy/quiet) still applies as a hard
// safety; the activity gate (fresh conversation / long idle) softens it; the
// frequency gate (PushGate) is applied inside the pusher.
shouldPush := push && b.disturbAllowed(at, state) && b.activityAllowed(tr.Import, lastActivity, hasActivity, at)
recordConclusion := message
if recordConclusion == "" {
recordConclusion = conclusion
}
pushRes := PushResult{Route: RouteNone, Detail: b.interruptReason(push, at, state, tr.Import, lastActivity, hasActivity)}
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)
}
}
}