feat(backthink): 存档落「紧急度」精确档位(裁决 14 的精确化前置)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- backthink_records 自建表加 urgency TEXT NOT NULL DEFAULT ''(**不开迁移号、不动 internal/db**;对既有库幂等补列:PRAGMA 判存在 + 容忍 duplicate column,其它错误照报)
- 落库值 = 该轮**实际生效**的档位(escalatedImport 折叠后的 roundImport;她自报可上抬、事件自带高档不被她压低)——「生效值优先」而非「她的话优先」,避免与既有 escalateImport 口径打架
- Record.Urgency 的 JSON 键刻意是 urgency ⇒ WS 实时帧自然带上、前端防御性读取即刻显示(不必等重开页面)
- 思考报错行 / 未唤醒降级行 / 历史旧行 = **空串(未知)**,绝不反推(变异:【空串当 routine】⇒ 4 条测试红;【补列非幂等】⇒ 41 条红,新库启动即失败)
- 生产影响:下次启动对真实 data/cyrene.db 执行一次单列 ALTER,并记 source=services/backthink 的 Info「backthink records table upgraded」
This commit is contained in:
AskaEth committed 2026-10-05 14:10:25 +08:00
1 parent afe9afb1b5
commit 213aff2e33
5 files changed
+760 -8

No files matched your search

+104 -8
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -17,7 +18,8 @@ const defaultListLimit = 50
// 思考记录存储的 DDL。at/created_at 遵循项目时间戳
// 约定(带时区的 RFC3339);trigger 与 conclusion 是面板渲染的
// 自由文本字段;push_* 列携带分解后的 PushResult,
// 使 List 不必解析 blob。主键是项目标准的自增 id。
// 使 List 不必解析 blob;urgency 是该轮**实际生效**的紧急度档位
// (空串 = 未知,见 Record.Urgency)。主键是项目标准的自增 id。
const createRecordsSQL = `
CREATE TABLE IF NOT EXISTS backthink_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -29,23 +31,38 @@ CREATE TABLE IF NOT EXISTS backthink_records (
push_route TEXT NOT NULL,
push_detail TEXT NOT NULL DEFAULT '',
push_error TEXT NOT NULL DEFAULT '',
urgency TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`
// alterRecordsAddUrgencySQL 给**既有**库补上 urgency 列:CREATE TABLE IF NOT EXISTS
// 对已经存在的表不加列,缺了它每一条 INSERT 都会以「no such column」失败
// (思考存档整条链路断掉)。它属于本模块自建的表的演进,因此**不开迁移号**、
// 也不动 internal/db 的迁移记账(那是 schema 所有者那边的领域,见包注释与 NewStore)。
const alterRecordsAddUrgencySQL = `
ALTER TABLE backthink_records ADD COLUMN urgency TEXT NOT NULL DEFAULT ''`
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 (?, ?, ?, ?, ?, ?, ?, ?)`
(trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error, urgency)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
const selectRecordsSQL = `
SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error
SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error, urgency
FROM backthink_records
ORDER BY id DESC
LIMIT ?`
// recordsTableName 是本模块自建表的物理名(DDL 与判列共用一处)。
const recordsTableName = "backthink_records"
// tableInfoSQL 列举一张表的列。pragma_table_info 是 SQLite 3.16+ 的表值函数,
// 因此表名也能走占位符,不必把名字拼进 SQL。
const tableInfoSQL = `SELECT name FROM pragma_table_info(?)`
// Record 是一轮已持久化的思考(design/12 §4),由 Store.List
// 与服务的 List 返回。Trigger 是来源子系统标签,At 是决策
// 时间戳,Conclusion 是运行时的输出,ShouldPush 是打扰决策,
@@ -57,6 +74,19 @@ type Record struct {
Conclusion string
ShouldPush bool
PushResult PushResult
// Urgency 是**该轮实际生效**的紧急度档位,用 internal/urgency 的词汇字符串
// (routine / warning / critical):面板因此能区分 high 与 critical,而此前
// 推送成功行的 PushResult.Detail 已被投递说明覆盖、档位在存档里丢失。
//
// 空串**如实表示「未知」**:本列出现之前落下的旧行,以及从未走到档位判定点的
// 轮次(思考本身失败)都读作未知 —— 绝不反推、绝不编成 routine(与 P3+P4 的
// fail-safe 同口径:宁可不显示,也不谎报)。
//
// JSON 键名刻意是 `urgency`:WS 实时帧发出去的正是本结构体,前端
// (wsParse.ts)已按 `r.urgency` 防御性读取,带上它实时行才会显示档位,
// 而不必等重开页面由历史拉取补齐。omitempty 让未知保持「键缺席」——
// 与「例常」可区分。
Urgency string `json:"urgency,omitempty"`
}
// Store 把思考记录持久化到 SQLite。它在
@@ -68,7 +98,8 @@ type Store struct {
log *slog.Logger
}
// NewStore 构建 Store,确保 backthink_records 表与索引存在。
// NewStore 构建 Store,确保 backthink_records 表(含既有库缺的 urgency 列,
// 见 ensureRecordsUrgencyColumn)与索引存在。
// db 为 nil 时返回 ErrNilDB。ctx 用于 DDL。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
@@ -78,13 +109,74 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if _, err := db.ExecContext(ctx, createRecordsSQL); err != nil {
return nil, fmt.Errorf("backthink: ensure backthink_records: %w", err)
}
if err := ensureRecordsUrgencyColumn(ctx, db, log); err != nil {
return nil, 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 持久化 r 并返回新记录 id。
// ensureRecordsUrgencyColumn **幂等**地保证 backthink_records 带 urgency 列。
// 既有部署的库是先建好的,CREATE TABLE IF NOT EXISTS 那句不会给它加列,所以这里
// 先按 PRAGMA 判存在、缺了才 ALTER。
//
// 判到「缺列」与真的执行 ALTER 之间可能有另一个进程同时启动(同库双开),那次
// ALTER 会以 duplicate column 失败 —— 那是同一个目标的另一次成功,容忍它;
// **其余任何错误都照实上报**(吞掉会让「列其实没加上」静默过关,随后每条 INSERT
// 都失败,而启动日志里什么也看不到)。
func ensureRecordsUrgencyColumn(ctx context.Context, db *sql.DB, log *slog.Logger) error {
exists, err := columnExists(ctx, db, recordsTableName, "urgency")
if err != nil {
return err
}
if exists {
return nil
}
if _, err := db.ExecContext(ctx, alterRecordsAddUrgencySQL); err != nil {
if isDuplicateColumnErr(err) {
return nil
}
return fmt.Errorf("backthink: add backthink_records.urgency: %w", err)
}
if log != nil {
log.Info("backthink records table upgraded", "column", "urgency", "reason", "existing database predates the urgency column")
}
return nil
}
// columnExists 报告 table 是否已有 column。
func columnExists(ctx context.Context, db *sql.DB, table, column string) (bool, error) {
rows, err := db.QueryContext(ctx, tableInfoSQL, table)
if err != nil {
return false, fmt.Errorf("backthink: inspect %s columns: %w", table, err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, fmt.Errorf("backthink: scan %s column: %w", table, err)
}
if name == column {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("backthink: iterate %s columns: %w", table, err)
}
return false, nil
}
// isDuplicateColumnErr 报告 err 是否为「这一列已经存在」。SQLite 的措辞是
// "duplicate column name: xxx",驱动原样带回该串,故按子串判定;判错方向的代价
// 不对称(把真错误当重复会掩盖缺列),因此这里只认这一种措辞。
func isDuplicateColumnErr(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate column name")
}
// Append 持久化 r 并返回新记录 id。r.Urgency 原样落库(空串 = 未知,
// 见 Record.Urgency)——存储层不替调用方猜一个档位。
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
res, err := s.db.ExecContext(ctx, insertRecordSQL,
r.Trigger,
@@ -95,6 +187,7 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
string(r.PushResult.Route),
r.PushResult.Detail,
r.PushResult.Err,
r.Urgency,
)
if err != nil {
return 0, fmt.Errorf("backthink: insert record: %w", err)
@@ -104,13 +197,15 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
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)
s.log.Debug("backthink record appended", "id", id, "trigger", r.Trigger,
"should_push", r.ShouldPush, "route", r.PushResult.Route, "urgency", r.Urgency)
}
return id, nil
}
// List 返回最近的记录,最新的在前。非正的 limit 选用
// defaultListLimit。
// defaultListLimit。urgency 列的空串如实读成「未知」(见
// Record.Urgency),不做任何补齐或推断。
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
if limit <= 0 {
limit = defaultListLimit
@@ -143,6 +238,7 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
&route,
&r.PushResult.Detail,
&r.PushResult.Err,
&r.Urgency,
); err != nil {
return nil, fmt.Errorf("backthink: scan record: %w", err)
}