Files
Cyrene_v2/internal/runtime/memory/preinsert_store.go
T
AskaEth 765ef167bd fix(memory): 复审 p2 返工 —— SQL 空串字面量、两处过期注释、人工路径档位
复审 p2-1items(tip cc6904b)要求的三处,逐条:

1) SQL 双引号改单引号(唯一返工项):
   - preinsert_store.go 的 revertMergedSQL:`merged_into = ""` → `''`。今天功能等价,
     但靠的是 SQLite「双引号标识符解析不到 ⇒ 回退成字符串字面量」的 legacy 行为;
     这是一条**改数据**的语句,还被 RevertMerged 与整理侧回滚共用,不该依赖回退。
   - retention_migration_test.go 的 `WHERE retention_tier = ""` 一并统一成 `''`,
     并在原位留下「为什么」。
2) 两处过期注释:
   - retention_schema.go:「反过来(先自举、后迁移)也成立」是**假结论**,改为
     「生产顺序成立;反向顺序在 0019 处即失败(duplicate column name: status,既有问题)」。
   - store.go 的 ExpiresAt:「本字段目前只写不读」已半过期(Add 现在会把最终落库
     时刻回填到返回值),改成「读回语义是半开的」:Add 返回的条目有值,从库里读回的
     恒为零值。
3) 人工路径(塔裁定「人工动作默认应为 long」):包内的 `RememberManual`(「记住 X」)
   显式钉 `RetentionTier = long`,留存规则因此不许把人工明确要求记住的东西降成临时层
   (extract_test.go 加断言)。面板网关那条(adminAddMemory 直调 Add、不传档位)
   按塔的口径不在本片、留给后续切片 —— 本片对它的唯一影响见回报。

不动(按塔的裁定):preinsert.go 514 行(dev 既有 tech debt)、
design/03 §10.5 与 design/23 §18 的章节(塔的账)、gateway 人工路径。

回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/... ./internal/db/...
全绿,内存包 -race 亦绿。
2026-10-26 13:24:35 +08:00

441 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、
// 回滚与审查留痕。它复用 memory 包既有的 *sql.DB、分区口径(tidyPartition)与
// 格式化辅助,不新建存储机制,也不碰任何既有行的 content(design §8)。
//
// 唯一的写入口径(§15 D3-6「merge/modify 一律走 §8 的既有口径」):
// - 被吸收的行:status='merged' + merged_into=<存活 id>;
// - 存活行:merged_from 追加、merge_count +1、confidence 取较大者、
// updated_at 刷新(§15 D3-2 T0-1 的「只更新 updated_at/confidence」)。
// 行**永不物理删除**,因此 revert 只需把 status 复原。
// ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。
const ingestStatusMerged = "merged"
// 🔴 这条内部召回 SQL **刻意只过 status 闸、不过时效闸**(design/03 §11.2 第 3 条,
// 先例见 design/23 §10.1 ①;主会话 2026-10-13 裁定「保持不过时效闸」):它比对的是
// **正文**,与可见性无关 —— 加闸会让过期但未清扫的临时行退出比对,同一事实遂以
// 「过期行 + 新行」并存(正是主人报的「反复新建」)。过期行由后台清扫物理删除
// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
const markAbsorbedSQL = `UPDATE memory_items
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?`
const selectSurvivorMergeSQL = `SELECT merged_from, merge_count, confidence
FROM memory_items WHERE id = ?`
const updateSurvivorSQL = `UPDATE memory_items
SET merged_from = ?, merge_count = ?, confidence = ?, updated_at = ? WHERE id = ?`
// revertMergedSQL 把一条被软合并的行升回在册。`merged_into` 用**单引号空串**而不是
// 双引号:SQLite 会把 `""` 先当**标识符**解析,解析不到才回退成字符串字面量(legacy
// 行为)。今天功能等价,但这是一条**改数据**的语句、且被 RevertMerged 与整理侧回滚
// 共用,依赖「标识符解析失败的回退」不该出现在写路径上。
const revertMergedSQL = `UPDATE memory_items
SET status = ?, merged_into = '', updated_at = ? WHERE id = ? AND status = ?`
const selectMergedIntoSQL = `SELECT merged_into FROM memory_items
WHERE id = ? AND status = ?`
const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?`
// setIngestRetentionSQL 是「留多久」轴在**异步审查层**的落点:把一条已落库记忆
// 改成临时层(档位 + 有效期**同一条语句**写,两者因此不可能分叉),或按回滚口径
// 改回长期层(档位 long + 有效期清空)。
//
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把两列改回去
// (design/23 §18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条
// 有审计的清扫路径,不在这里)。
const setIngestRetentionSQL = `UPDATE memory_items
SET retention_tier = ?, expires_at = ? WHERE id = ?`
const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id,
merged_ids, score, recall_mode, degraded, rule_version, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const listIngestReviewsSQL = `SELECT id, item_id, persona_id, scope, project_id, decision,
reason, veto, target_id, merged_ids, score, recall_mode, degraded, rule_version,
session_id, created_at FROM memory_ingest_reviews
ORDER BY created_at DESC, rowid DESC LIMIT ?`
// AttachIngestReviewer 把入库前审查接到该 store 的写入路径:此后每条经 Add
// 落库的条目都会被投入审查队列(design §15 D3-1 的单一写入路径)。nil 是空操作
// (保持「未启用」的语义),可被并发调用,last-writer-wins。
func (s *Store) AttachIngestReviewer(r *PreInsertReviewer) {
if s == nil || r == nil {
return
}
s.reviewMu.Lock()
defer s.reviewMu.Unlock()
s.review = r
}
// ingestReviewer 返回当前接入的审查服务,未接入时为 nil。
func (s *Store) ingestReviewer() *PreInsertReviewer {
if s == nil {
return nil
}
s.reviewMu.RLock()
defer s.reviewMu.RUnlock()
return s.review
}
// ApplyIngestPanelTuning 把面板值**当场**叠到已接入的入库前审查上(切片 MEMPANEL / M4)。
//
// 网关握着的是记忆存储(`deps.Memories`),而不是审查服务本身,因此这条转调就是面板
// 写端点触达审查侧的**唯一**通道(不为此新开依赖面:没有新的 Deps 字段、没有新的
// Server 字段)。
//
// 未接入审查(`MEMORY_REVIEW_ENABLED=false`)时是**空操作 + nil**:那时两个键本来
// 就不生效,运行期没有任何旧值要改,返回错误只会让一次完全正常的保存报「热生效失败」。
// 指针的读写走既有的 `reviewMu`(与 AttachIngestReviewer 同一把锁),不会与并发接入
// 交手。
func (s *Store) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
reviewer := s.ingestReviewer()
if reviewer == nil {
return nil
}
return reviewer.ApplyPanelTuning(panel)
}
// enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记
// WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。
func (s *Store) enqueueReview(item MemoryItem) {
if r := s.ingestReviewer(); r != nil {
r.Enqueue(item)
}
}
// reviewItemStatus 读取一条记忆当前的在册状态。审查据此保证**每条记忆只审一次**:
// 已经被前一次审查合并掉的行再判一次会把存活行的 merge_count 重复加一
// (同一条事实被数两次),因此非 active 的行直接跳过。行不存在返回 ErrNotFound。
func (s *Store) reviewItemStatus(ctx context.Context, id string) (string, error) {
var status string
err := s.db.QueryRowContext(ctx, selectItemStatusSQL, id).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("memory: read review item status: %w", err)
}
return status, nil
}
// listReviewCandidates 读取同一分区内的在册候选(最新在前)。分区过滤写在
// SQL 里,而不是读全表再在内存里筛 —— design §6 要求在分区切分层就隔断。
func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, excludeID string, limit int) ([]MemoryItem, error) {
if limit <= 0 {
limit = defaultReviewScanLimit
}
rows, err := s.db.QueryContext(ctx, listReviewCandidatesSQL,
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive), excludeID, limit)
if err != nil {
return nil, fmt.Errorf("memory: list review candidates: %w", err)
}
return scanItems(rows, "review candidate")
}
// setIngestRetention 把一条记忆改成临时层(`retention_tier='temp'` +
// `expires_at=expiryText(...)`)或改回长期层(`long` + 空串)。
//
// 它是**档位与有效期同一条语句**的落点:两列因此不可能分叉(「temp 却没有有效期」
// 或「有有效期却标着长期」都写不出来)。三类调用:
//
// - 异步审查层(preinsert.go 的 applyVerdict):T1 在同一次调用里判 defer 时,
// 把一条已经落库的长期记忆改判成临时层;
// - 回滚(design/23 §18.4):把两列改回 `long` / `""` —— 清空有效期即升回长期;
// - 测试夹具需要「已经过期」的行时也走它(显式时刻由 expiryText 编码)。
//
// 同步入库路径**不调它**:`Store.Add` 已经把两列同一笔 INSERT 写下(见 retention.go
// 的 tempDeadline),所以那条路径上没有第二笔 UPDATE、也没有中间窗口。
//
// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里
// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔)
// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故,
// timegate_test.go 钉着那条反证)。
func (s *Store) setIngestRetention(ctx context.Context, id string, tier RetentionTier, expiresAt string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
if !tier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, tier)
}
if !tier.assigned() && expiresAt != "" {
// 未分型 + 非空有效期是自相矛盾的落点(会造出「没有档位却会到期」的行)。
return fmt.Errorf("%w: unset tier with expires_at", ErrRetentionConflict)
}
if _, err := s.db.ExecContext(ctx, setIngestRetentionSQL, string(tier), expiresAt, id); err != nil {
return fmt.Errorf("memory: set ingest retention: %w", err)
}
return nil
}
// absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 +
// 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/
// confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。
func (s *Store) absorbIngestItem(ctx context.Context, absorbed, survivor MemoryItem, now time.Time) error {
if absorbed.ID == "" || survivor.ID == "" || absorbed.ID == survivor.ID {
return fmt.Errorf("memory: absorb ingest item: invalid pair %q -> %q", absorbed.ID, survivor.ID)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: absorb ingest item: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, markAbsorbedSQL,
string(ingestStatusMerged), survivor.ID, formatTime(now.UTC()), absorbed.ID); err != nil {
return fmt.Errorf("memory: mark absorbed item: %w", err)
}
if err := bumpSurvivor(ctx, tx, survivor, absorbed, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory: absorb ingest item: commit: %w", err)
}
return nil
}
// bumpSurvivor 刷新存活行的合并元信息:merged_from 追加被吸收的 id、
// merge_count +1、confidence 取两者较大者、updated_at 刷新。
// 读-改-写在同一个事务内完成(连接池是单连接,天然串行),因此并发审查
// 不会互相覆盖。
func bumpSurvivor(ctx context.Context, tx *sql.Tx, survivor, absorbed MemoryItem, now time.Time) error {
var (
mergedFromJSON string
mergeCount int
confidence float64
)
if err := tx.QueryRowContext(ctx, selectSurvivorMergeSQL, survivor.ID).
Scan(&mergedFromJSON, &mergeCount, &confidence); err != nil {
return fmt.Errorf("memory: read survivor merge state: %w", err)
}
ids := make([]string, 0)
if strings.TrimSpace(mergedFromJSON) != "" {
if err := json.Unmarshal([]byte(mergedFromJSON), &ids); err != nil {
return fmt.Errorf("memory: decode survivor merged_from: %w", err)
}
}
if !containsIngestID(ids, absorbed.ID) {
ids = append(ids, absorbed.ID)
}
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("memory: encode survivor merged_from: %w", err)
}
if absorbed.Confidence > confidence {
// 置信度只升不降:同一条事实被再次观察到,是「它更可信」的证据,
// 而不是要把它调低(§15 D3-2 T0-1 的「只更新 confidence」)。
confidence = absorbed.Confidence
}
if _, err := tx.ExecContext(ctx, updateSurvivorSQL,
string(encoded), mergeCount+1, confidence, formatTime(now.UTC()), survivor.ID); err != nil {
return fmt.Errorf("memory: bump survivor item: %w", err)
}
return nil
}
// RevertMerged 撤销一次软合并(design/23 §8「回滚」):把被吸收行的 status 复原
// 为 active 并清空 merged_into,同时把它的 id 从存活行的 merged_from 里摘掉、
// 把 merge_count 减回去(下限 1)。行与被合并的原文都还在,因此这是无损回滚。
// 该行不在 merged 状态时返回 ErrNotFound(不假装回滚成功)。
func (s *Store) RevertMerged(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: revert merged item: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var survivorID string
err = tx.QueryRowContext(ctx, selectMergedIntoSQL, id, string(ingestStatusMerged)).Scan(&survivorID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("memory: read merged item: %w", err)
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, revertMergedSQL,
string(tidyStatusActive), formatTime(now), id, string(ingestStatusMerged)); err != nil {
return fmt.Errorf("memory: revert merged item: %w", err)
}
if survivorID != "" {
if err := dropSurvivorLink(ctx, tx, survivorID, id, now); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory: revert merged item: commit: %w", err)
}
return nil
}
// dropSurvivorLink 把 id 从存活行的合并登记里摘掉:merged_from 去掉它、
// merge_count 减 1(下限 1)。存活行不存在(例如已被删除)时是空操作 ——
// 回滚的要点是「被吸收行复原」,绝不因为它而失败。
func dropSurvivorLink(ctx context.Context, tx *sql.Tx, survivorID, id string, now time.Time) error {
var (
mergedFromJSON string
mergeCount int
confidence float64
)
err := tx.QueryRowContext(ctx, selectSurvivorMergeSQL, survivorID).
Scan(&mergedFromJSON, &mergeCount, &confidence)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("memory: read survivor for revert: %w", err)
}
ids := make([]string, 0)
if strings.TrimSpace(mergedFromJSON) != "" {
if err := json.Unmarshal([]byte(mergedFromJSON), &ids); err != nil {
return fmt.Errorf("memory: decode survivor merged_from: %w", err)
}
}
kept := make([]string, 0, len(ids))
for _, v := range ids {
if v != id {
kept = append(kept, v)
}
}
encoded, err := json.Marshal(kept)
if err != nil {
return fmt.Errorf("memory: encode survivor merged_from: %w", err)
}
next := mergeCount - 1
if next < 1 {
next = 1
}
if _, err := tx.ExecContext(ctx, updateSurvivorSQL,
string(encoded), next, confidence, formatTime(now), survivorID); err != nil {
return fmt.Errorf("memory: drop survivor link: %w", err)
}
return nil
}
// insertIngestReview 落一条审查留痕(design/23 §15 D3-8)。
func (s *Store) insertIngestReview(ctx context.Context, rev IngestReview) error {
if rev.ID == "" {
rev.ID = mustNewTidyID()
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
mergedJSON, err := json.Marshal(rev.MergedIDs)
if err != nil {
return fmt.Errorf("memory: encode review merged ids: %w", err)
}
if rev.CreatedAt.IsZero() {
rev.CreatedAt = time.Now().UTC()
}
if _, err := s.db.ExecContext(ctx, insertIngestReviewSQL,
rev.ID, rev.ItemID, rev.PersonaID, string(rev.Scope), rev.ProjectID, string(rev.Decision),
rev.Reason, string(rev.Veto), rev.TargetID, string(mergedJSON), rev.Score, rev.RecallMode,
boolToInt(rev.Degraded), rev.RuleVer, rev.SessionID, formatTime(rev.CreatedAt)); err != nil {
return fmt.Errorf("memory: insert ingest review: %w", err)
}
return nil
}
// ListIngestReviews 返回最近的审查留痕(最新在前,最多 limit 条;非正 limit
// 取 defaultReviewScanLimit)。读侧含命中 id 与依据,应由 owner-only 的
// 读侧使用(design §15 D3-8「面板可查」)。
func (s *Store) ListIngestReviews(ctx context.Context, limit int) ([]IngestReview, error) {
if limit <= 0 {
limit = defaultReviewScanLimit
}
rows, err := s.db.QueryContext(ctx, listIngestReviewsSQL, limit)
if err != nil {
return nil, fmt.Errorf("memory: list ingest reviews: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]IngestReview, 0)
for rows.Next() {
rev, err := scanIngestReview(rows)
if err != nil {
return nil, fmt.Errorf("memory: scan ingest review: %w", err)
}
out = append(out, rev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate ingest reviews: %w", err)
}
return out, nil
}
// scanIngestReview 读取一行审查留痕:merged_ids 以 JSON 数组存储,
// degraded 以 0/1 存储。
func scanIngestReview(sc rowScanner) (IngestReview, error) {
var (
rev IngestReview
scope, decision string
veto string
mergedJSON string
degraded int
createdAt string
)
if err := sc.Scan(&rev.ID, &rev.ItemID, &rev.PersonaID, &scope, &rev.ProjectID, &decision,
&rev.Reason, &veto, &rev.TargetID, &mergedJSON, &rev.Score, &rev.RecallMode, &degraded,
&rev.RuleVer, &rev.SessionID, &createdAt); err != nil {
return IngestReview{}, err
}
rev.Scope = Scope(scope)
rev.Decision = IngestDecision(decision)
rev.Veto = TidyVetoReason(veto)
rev.Degraded = degraded != 0
if err := json.Unmarshal([]byte(mergedJSON), &rev.MergedIDs); err != nil {
return IngestReview{}, fmt.Errorf("memory: decode review merged ids: %w", err)
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
parsed, err := parseTime(createdAt)
if err != nil {
return IngestReview{}, err
}
rev.CreatedAt = parsed
return rev, nil
}
// containsIngestID 报告 ids 里是否已有 id(避免 merged_from 重复登记同一条)。
func containsIngestID(ids []string, id string) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
// boolToInt 把布尔落成 SQLite 的 0/1。
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}