fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
This commit is contained in:
1 parent
03d7050e1e
commit
12031bd13c
14 files changed
+662
-149
No files matched your search
@@ -27,48 +27,16 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
const (
|
||||
// defaultIntervalSec is the heartbeat period in seconds when unused.
|
||||
defaultIntervalSec = 300
|
||||
// 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 = 23
|
||||
defaultQuietEndHour = 7
|
||||
// defaultMaxPushWindow caps proactive pushes within defaultPushWindow to
|
||||
// honor the anti-spam rule of design/11 §6 (QQ wind control, iLink window).
|
||||
defaultMaxPushWindow = 3
|
||||
defaultPushWindow = 10 * time.Minute
|
||||
// defaultThinkTimeout bounds a single unattended thought so it can never
|
||||
// wedge a 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 = "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.
|
||||
const DefaultSessionID = "sess-backthink"
|
||||
|
||||
// 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 (
|
||||
@@ -90,39 +58,6 @@ type Thinker interface {
|
||||
Think(ctx context.Context, sessionID, trigger string) (string, error)
|
||||
}
|
||||
|
||||
// Config carries the autonomous-think tuning knobs. Zero values pick safe
|
||||
// defaults via normalize, so a partially filled Config is still usable.
|
||||
type Config struct {
|
||||
// Interval is the heartbeat period. When zero it is derived from
|
||||
// IntervalSec (or its default).
|
||||
Interval time.Duration
|
||||
// IntervalSec is the heartbeat period in seconds, mapped from
|
||||
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
|
||||
IntervalSec int
|
||||
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
|
||||
// clock hours. Equal non-zero hours disable the window.
|
||||
QuietStartHour int
|
||||
QuietEndHour int
|
||||
// MaxPushWindow bounds proactive pushes within PushWindow. Zero uses the
|
||||
// default. Non-positive PushWindow or MaxPushWindow disables the limit.
|
||||
MaxPushWindow int
|
||||
// PushWindow is the sliding window over which MaxPushWindow is enforced.
|
||||
PushWindow time.Duration
|
||||
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
|
||||
ThinkTimeout time.Duration
|
||||
}
|
||||
|
||||
// ConfigFromBackground maps a config.BackgroundConfig (the source of the
|
||||
// heartbeat interval and the shared quiet-hours window) into a backthink.Config,
|
||||
// leaving the push/decide knobs at their defaults for normalize to fill in.
|
||||
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
||||
return Config{
|
||||
IntervalSec: bg.IntervalSec,
|
||||
QuietStartHour: bg.QuietStartHour,
|
||||
QuietEndHour: bg.QuietEndHour,
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -134,6 +69,10 @@ type Deps struct {
|
||||
// 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
|
||||
@@ -196,7 +135,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
|
||||
// Resolve the gate up front so the sink setters can rebuild the pusher with it.
|
||||
gate := deps.Gate
|
||||
if gate == nil {
|
||||
gate = newRateGate(cfg.MaxPushWindow, cfg.PushWindow, log)
|
||||
gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log)
|
||||
}
|
||||
pusher := deps.Pusher
|
||||
if pusher == nil {
|
||||
@@ -404,16 +343,18 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||||
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 frequency gate (PushGate) is applied inside the pusher.
|
||||
shouldPush := push && b.disturbAllowed(at, state)
|
||||
// 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)}
|
||||
pushRes := PushResult{Route: RouteNone, Detail: b.interruptReason(push, at, state, tr.Import, lastActivity, hasActivity)}
|
||||
if shouldPush {
|
||||
pushRes = b.currentPusher().Push(cctx, PushRequest{
|
||||
Message: b.buildProactiveMessage(message, channelHint),
|
||||
@@ -450,6 +391,28 @@ 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).
|
||||
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) {
|
||||
@@ -464,41 +427,3 @@ func (b *Backthink) record(r Record) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalize fills a Config's zero fields with safe defaults.
|
||||
func normalize(cfg Config) Config {
|
||||
if cfg.IntervalSec <= 0 {
|
||||
cfg.IntervalSec = defaultIntervalSec
|
||||
}
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
|
||||
}
|
||||
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
|
||||
cfg.QuietStartHour = defaultQuietStartHour
|
||||
cfg.QuietEndHour = defaultQuietEndHour
|
||||
}
|
||||
if cfg.MaxPushWindow <= 0 {
|
||||
cfg.MaxPushWindow = defaultMaxPushWindow
|
||||
}
|
||||
if cfg.PushWindow <= 0 {
|
||||
cfg.PushWindow = defaultPushWindow
|
||||
}
|
||||
if cfg.ThinkTimeout <= 0 {
|
||||
cfg.ThinkTimeout = defaultThinkTimeout
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// quietHoursLabel renders the do-not-disturb window for logs.
|
||||
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.
|
||||
func baseLog(l *slog.Logger) *slog.Logger {
|
||||
if l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
Reference in new issue
Block a user