主人 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 通过。
418 lines
16 KiB
Go
418 lines
16 KiB
Go
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, °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
|
||
}
|