feat(backthink): 后台自主思考(heartbeat+事件触发 + 打扰决策 fail-closed + 主动推送活跃→IM 兜底 + 限制 + 思考记录)
This commit is contained in:
1 parent
05e23ea590
commit
6f97fded3a
13 files changed
+1968
No files matched your search
@@ -0,0 +1,181 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
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.
|
||||
const createRecordsSQL = `
|
||||
CREATE TABLE IF NOT EXISTS backthink_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trigger TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
conclusion TEXT NOT NULL,
|
||||
should_push INTEGER NOT NULL,
|
||||
push_pushed INTEGER NOT NULL DEFAULT 0,
|
||||
push_route TEXT NOT NULL,
|
||||
push_detail TEXT NOT NULL DEFAULT '',
|
||||
push_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)`
|
||||
|
||||
const createRecordsIndexSQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_backthink_records_at ON backthink_records(at)`
|
||||
|
||||
const insertRecordSQL = `
|
||||
INSERT INTO backthink_records
|
||||
(trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const selectRecordsSQL = `
|
||||
SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error
|
||||
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.
|
||||
type Record struct {
|
||||
ID int64
|
||||
Trigger string
|
||||
At time.Time
|
||||
Conclusion string
|
||||
ShouldPush bool
|
||||
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.
|
||||
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.
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
log = logger.WithSource(baseLog(log), "services/backthink")
|
||||
if _, err := db.ExecContext(ctx, createRecordsSQL); err != nil {
|
||||
return nil, fmt.Errorf("backthink: ensure backthink_records: %w", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, createRecordsIndexSQL); err != nil {
|
||||
return nil, fmt.Errorf("backthink: ensure backthink_records index: %w", err)
|
||||
}
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
// Append persists r and returns the new record id.
|
||||
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, insertRecordSQL,
|
||||
r.Trigger,
|
||||
r.At.UTC().Format(time.RFC3339Nano),
|
||||
r.Conclusion,
|
||||
boolInt(r.ShouldPush),
|
||||
boolInt(r.PushResult.Pushed),
|
||||
string(r.PushResult.Route),
|
||||
r.PushResult.Detail,
|
||||
r.PushResult.Err,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("backthink: insert record: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("backthink: record id: %w", err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("backthink record appended", "id", id, "trigger", r.Trigger, "should_push", r.ShouldPush, "route", r.PushResult.Route)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// List returns the most recent records newest-first. A non-positive limit selects
|
||||
// defaultListLimit.
|
||||
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultListLimit
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, selectRecordsSQL, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backthink: list records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]Record, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r Record
|
||||
at string
|
||||
route string
|
||||
pushed int
|
||||
shouldPush int
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&r.ID,
|
||||
&r.Trigger,
|
||||
&at,
|
||||
&r.Conclusion,
|
||||
&shouldPush,
|
||||
&pushed,
|
||||
&route,
|
||||
&r.PushResult.Detail,
|
||||
&r.PushResult.Err,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("backthink: scan record: %w", err)
|
||||
}
|
||||
r.ShouldPush = intBool(shouldPush)
|
||||
r.PushResult.Pushed = intBool(pushed)
|
||||
r.PushResult.Route = Route(route)
|
||||
r.At = parseTime(at)
|
||||
records = append(records, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("backthink: iterate records: %w", err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// boolInt maps a bool to the SQLite INTEGER 1/0 representation.
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// intBool maps a SQLite INTEGER to a 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.
|
||||
func parseTime(s string) time.Time {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
Reference in new issue
Block a user