Files
Cyrene_v2/internal/runtime/memory/preinsert_store.go
T
AskaEth eddf921229 feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。

此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。

后端(读写端点,契约待塔登记 v0.92):
- GET  /api/v1/admin/memories/tidy/tuning  读盘上 7 键值 + 取值规则 specs +
  缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT  同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
  个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
  memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
  SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
  (t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
  重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
  未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」

前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
  env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
  mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
  error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
  「没有候选可比」显式区分)

验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
2026-10-13 21:59:18 +08:00

418 lines
16 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 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 = ?`
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 = ?`
// setIngestExpirySQL 是第五态(design/23 §18.1)的落点:**只写 expires_at 一列**。
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把该列清空
// (§18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条有审计的
// 清扫路径,不在这里)。
const setIngestExpirySQL = `UPDATE memory_items SET 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")
}
// setIngestExpiry 把一条记忆的 `expires_at` 写成 expiryText(**定宽 30 字节 UTC**),
// 空白串表示「不设过期」。它是第五态 defer 的**唯一**写口,两处调用:
//
// - 同步入库闸(preinsert_gate.go 的 finishIngest):INSERT 之后补这一列;
// - 异步审查层(preinsert.go 的 applyVerdict):T1 判 defer 时改这一列。
//
// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里
// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔)
// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故,
// timegate_test.go 钉着那条反证)。
func (s *Store) setIngestExpiry(ctx context.Context, id, expiresAt string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
if _, err := s.db.ExecContext(ctx, setIngestExpirySQL, expiresAt, id); err != nil {
return fmt.Errorf("memory: set ingest expiry: %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
}