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, sensitive 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, °raded, &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 }