Files
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00

297 lines
12 KiB
Go
Raw Permalink 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"
"errors"
"fmt"
"time"
)
// 本文件是记忆整理**应用路径**(切片 TIDY-1)的存储层:候选认领、软合并 /
// 软删 / 改 kind 的事务写入,以及回滚。它复用 memory 包既有的 *sql.DB、
// 分区口径(tidyPartition / tidyStatusActive)与软合并且已存在的辅助
// (preinsert_store.go 的 bumpSurvivor / dropSurvivorLink / revertMergedSQL /
// selectMergedIntoSQL —— 那是 design/23 §8 的**同一套**软合并列语义,不是第二套)。
//
// 与 D3(入库前审查)那套的关系:语义同源(status/merged_into/merged_from/
// merge_count),但**闸口不同** —— D3 的顺序队列保证一条记忆只审一次,因此它的
// markAbsorbed 不带 `AND status='active'`;整理的候选是**事后**执行的,可能
// 过时(源行已被别的动作改过),所以这里每一条 UPDATE 都带状态条件,并把它
// 当作幂等/过时的判据(RowsAffected == 0)。
const (
// tidyCandidateApplied 是候选「已应用」状态(design/23 §10 的 status 闭集)。
tidyCandidateApplied = "applied"
// tidyStatusArchived 是软删后的行状态(design/23 §10:active/merged/archived)。
// 归档行不再参与检索与整理,但**行与原文都还在**,因此可 revert(§8)。
tidyStatusArchived = "archived"
)
// claimTidyCandidateSQL 是**执行闸**:只有把候选从 pending 原子地推到
// applied 的那一次调用才继续往下改记忆。WHERE 里的状态条件就是幂等性的根,
// 因此它绝不能省(少了它,重复应用会再写一次 memory_items)。
const claimTidyCandidateSQL = `UPDATE memory_tidy_candidates
SET status = ?, decided_at = ?, decided_by = ? WHERE id = ? AND status = ?`
// markTidyAbsorbedSQL 标记被吸收行:status='merged' + merged_into=存活 id。
// `AND status='active'` 是第二道闸:源行已被别的动作改过时 RowsAffected==0,
// 调用方据此回滚整个事务(不产生第二次合并计数)。
const markTidyAbsorbedSQL = `UPDATE memory_items
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ? AND status = ?`
// setTidyItemStatusSQL 是软删 / 复原共用的一条:只改 status 与 updated_at,
// 带 from 状态条件。
const setTidyItemStatusSQL = `UPDATE memory_items
SET status = ?, updated_at = ? WHERE id = ? AND status = ?`
// reclassifyTidyItemSQL 只改 kind 一列(design/23 §5.1 T0-6),带状态条件。
const reclassifyTidyItemSQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ?`
// undoTidyReclassifySQL 撤回一次 kind 修正:只在当前 kind 仍是当时写的建议值、
// 且行仍在册时生效(否则说明有人后来手动改过,不该被回滚覆盖)。
const undoTidyReclassifySQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ? AND kind = ?`
// tidyApplyTx 在**一个事务**里跑一次应用:先认领候选(条件更新 = 执行闸),
// 再执行 mutate,最后提交。任何一步失败都回滚 —— 因此「候选被标 applied 但
// 记忆没改」这种半成品状态不可能落库。
func (s *Store) tidyApplyTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
now time.Time, mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: apply candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidatePending, tidyCandidateApplied, opts.Actor, now)
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: apply candidate commit: %w", err)
}
return nil
}
// tidyRevertTx 与 tidyApplyTx 同构,只是认领的方向相反(applied → pending)。
func (s *Store) tidyRevertTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: revert candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidateApplied, tidyCandidatePending, opts.Actor, time.Now().UTC())
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: revert candidate commit: %w", err)
}
return nil
}
// claimTidyCandidate 原子地把候选从**当前状态 from** 推到 to。返回是否真的改了行:
// false 表示它已经被决定过(幂等命中,不是错误)。
//
// from 参数是这道闸的关键:应用要求 from='pending',回滚要求 from='applied'。
// 两者共用一个语句,但**不能共用一个硬编码的 from** —— 那会让回滚永远匹配不到行。
func claimTidyCandidate(ctx context.Context, tx *sql.Tx, id, from, to, actor string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, claimTidyCandidateSQL, to, formatTime(now), actor, id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s rows: %w", id, err)
}
return n > 0, nil
}
// markTidyAbsorbed 标记被吸收行并刷新存活行的合并元信息。源行不在册时返回
// errTidyItemNotActive(事务因此回滚)。
func markTidyAbsorbed(ctx context.Context, tx *sql.Tx, absorbedID, survivorID string, now time.Time) error {
res, err := tx.ExecContext(ctx, markTidyAbsorbedSQL,
string(ingestStatusMerged), survivorID, formatTime(now), absorbedID, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// setTidyItemStatus 在 from → to 之间改一行的 status。返回是否真的改了行。
func setTidyItemStatus(ctx context.Context, tx *sql.Tx, id, from, to string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, setTidyItemStatusSQL, to, formatTime(now), id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status rows: %w", err)
}
return n > 0, nil
}
// archiveTidyItem 把一条在册记忆软删(active → archived)。不在册时返回
// errTidyItemNotActive。它**只改 status/updated_at**,绝不碰 content。
func archiveTidyItem(ctx context.Context, tx *sql.Tx, id string, now time.Time) error {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusActive, tidyStatusArchived, now)
if err != nil {
return err
}
if !changed {
return errTidyItemNotActive
}
return nil
}
// reclassifyTidyItem 把一条在册记忆的 kind 改为 kind。不在册时返回
// errTidyItemNotActive。
func reclassifyTidyItem(ctx context.Context, tx *sql.Tx, id string, kind Kind, now time.Time) error {
res, err := tx.ExecContext(ctx, reclassifyTidyItemSQL, string(kind), formatTime(now), id, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// addTidyRunApplied 把该轮留痕的 applied 计数累加(design/23 §10)。
func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int) error {
if _, err := s.db.ExecContext(ctx, incrementTidyRunAppliedSQL, applied, runID); err != nil {
return fmt.Errorf("memory/tidy: bump run applied %s: %w", runID, err)
}
return nil
}
// revertTidyAction 在事务里撤销一条已应用候选对记忆的改动,返回真正被复原的
// item id(一条都没复原时返回空切片 —— 那说明用户已经用 /memories/{id}/revert
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
switch cand.Action {
case TidyMerge, TidyDedupeGroup:
// dedupe_group 与 merge 是**同一套软合并**(被吸收行 → merged + merged_into),
// 因此回滚逐字复用 revertTidyMerge:它本来就按 SourceIDs 逐条复原。
return revertTidyMerge(ctx, tx, cand, now)
case TidyArchive:
return revertTidyArchive(ctx, tx, cand, now)
case TidyReclassify:
return revertTidyReclassify(ctx, tx, cand, now)
default:
return []string{}, nil
}
}
// revertTidyMerge 复原一次软合并:逐条把被吸收行从 merged 复原为 active,并从
// 存活行的 merged_from 里摘掉它(dropSurvivorLink)。只复原**这条候选做的那次**
// 合并(merged_into 必须等于候选的 target)。
func revertTidyMerge(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
ok, err := revertTidyMergeInTx(ctx, tx, id, cand.TargetID, now)
if err != nil {
return nil, err
}
if ok {
out = append(out, id)
}
}
return out, nil
}
// revertTidyMergeInTx 复原单条被吸收行。它只认 `status='merged'` 且
// `merged_into=<该候选的存活 id>` 的行;targetID 为空时接受任意存活 id
// (容错:老候选可能没落 target)。
func revertTidyMergeInTx(ctx context.Context, tx *sql.Tx, absorbedID, targetID string, now time.Time) (bool, error) {
var survivorID string
err := tx.QueryRowContext(ctx, selectMergedIntoSQL, absorbedID, string(ingestStatusMerged)).Scan(&survivorID)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("memory/tidy: read merged item %s: %w", absorbedID, err)
}
if targetID != "" && survivorID != targetID {
// 它现在挂在**别的**存活行上(后来又被合并过一次):这条候选的回滚
// 不该把它从新关系里拽出来。
return false, nil
}
if _, err := tx.ExecContext(ctx, revertMergedSQL,
string(tidyStatusActive), formatTime(now), absorbedID, string(ingestStatusMerged)); err != nil {
return false, fmt.Errorf("memory/tidy: restore merged item %s: %w", absorbedID, err)
}
if survivorID != "" {
if err := dropSurvivorLink(ctx, tx, survivorID, absorbedID, now); err != nil {
return false, err
}
}
return true, nil
}
// revertTidyArchive 复原被软删(archived)的行:archived → active。
func revertTidyArchive(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusArchived, tidyStatusActive, now)
if err != nil {
return nil, err
}
if changed {
out = append(out, id)
}
}
return out, nil
}
// revertTidyReclassify 撤回一次 kind 修正:只在当前 kind 仍等于当时写的建议值、
// 且行仍在册时生效(有人后来手动改过就保持不动)。旧 kind 来自候选证据 KindA。
func revertTidyReclassify(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
kind := Kind(cand.Evidence.ProposedKind)
previous := Kind(cand.Evidence.KindA)
if !previous.valid() || !kind.valid() || len(cand.SourceIDs) != 1 {
return []string{}, nil
}
res, err := tx.ExecContext(ctx, undoTidyReclassifySQL,
string(previous), formatTime(now), cand.SourceIDs[0], tidyStatusActive, string(kind))
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify %s: %w", cand.SourceIDs[0], err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify rows: %w", err)
}
if n == 0 {
return []string{}, nil
}
return []string{cand.SourceIDs[0]}, nil
}