r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
454 lines
21 KiB
Go
454 lines
21 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。
|
||
//
|
||
// 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、
|
||
// 两天一条没应用。本文件把候选真正落地,五条不可走样的口径:
|
||
//
|
||
// 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活
|
||
// id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删
|
||
// (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、
|
||
// **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert);
|
||
// 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` +
|
||
// RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸
|
||
// —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用;
|
||
// 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把
|
||
// archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending;
|
||
// 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑
|
||
// (design/23 §9 的失败隔离同口径);
|
||
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
|
||
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
|
||
// 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go);
|
||
// 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」,
|
||
// 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。
|
||
//
|
||
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
|
||
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。受限来源
|
||
// (群聊/陌生人)同样三类动作一律拒做(tidySkipSourceRestricted,design/23 §5.2-9/§6)。
|
||
|
||
// TidyApplyStatus 是一条候选应用结果的状态类别。
|
||
type TidyApplyStatus string
|
||
|
||
const (
|
||
// TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。
|
||
TidyApplyApplied TidyApplyStatus = "applied"
|
||
// TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、
|
||
// 动作不可应用、候选形状不完整。
|
||
TidyApplySkipped TidyApplyStatus = "skipped"
|
||
// TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。
|
||
TidyApplyFailed TidyApplyStatus = "failed"
|
||
)
|
||
|
||
// 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。
|
||
const (
|
||
// tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。
|
||
tidySkipAlreadyDecided = "already_decided"
|
||
// tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。
|
||
tidySkipNotApplied = "not_applied"
|
||
// tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。
|
||
tidySkipActionNotApplyable = "action_not_applyable"
|
||
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
|
||
tidySkipSourceNotActive = "source_not_active"
|
||
// tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源
|
||
// (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御:
|
||
// 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」
|
||
// 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。
|
||
tidySkipSourceRestricted = "restricted_source"
|
||
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
|
||
tidySkipSourceMissing = "source_missing"
|
||
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
|
||
tidySkipPartitionMismatch = "partition_mismatch"
|
||
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
|
||
tidySkipInvalidCandidate = "invalid_candidate"
|
||
// tidySkipTemporaryRow 表示簇里有行在**临时层**(`expires_at` 非空):
|
||
// 临时行 7 天后会被后台物理删除(design/23 §19),既不许当存活行(会让被吸收行
|
||
// 指向一个不存在的 id),也不值得当被吸收行(它本来就要消失)。整簇原子的 skip。
|
||
tidySkipTemporaryRow = "temporary_row"
|
||
)
|
||
|
||
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
|
||
// 调用方用 errors.Is 判断。
|
||
var (
|
||
// ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。
|
||
ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found")
|
||
// ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 ——
|
||
// 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。
|
||
// (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。)
|
||
ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found")
|
||
// errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中,
|
||
// 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。
|
||
errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided")
|
||
// errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册
|
||
// (已被别的动作改过)。同样不越过本包。
|
||
errTidyItemNotActive = errors.New("memory/tidy: item is not active")
|
||
)
|
||
|
||
// TidyApplyOptions 是一次应用/回滚的请求口径。
|
||
type TidyApplyOptions struct {
|
||
// Actor 是决定者标签,落 memory_tidy_candidates.decided_by。
|
||
Actor string
|
||
// Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit,
|
||
// 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。
|
||
Limit int
|
||
}
|
||
|
||
// normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的
|
||
// limit 约定一致:钳位而不是报错)。
|
||
func (o TidyApplyOptions) normalizedLimit() int {
|
||
if o.Limit <= 0 {
|
||
return defaultTidyApplyBatchLimit
|
||
}
|
||
if o.Limit > maxTidyApplyBatchLimit {
|
||
return maxTidyApplyBatchLimit
|
||
}
|
||
return o.Limit
|
||
}
|
||
|
||
// TidyApplyResult 是一条候选的应用/回滚结果。
|
||
type TidyApplyResult struct {
|
||
CandidateID string `json:"candidateId"`
|
||
Action TidyAction `json:"action"`
|
||
Status TidyApplyStatus `json:"status"`
|
||
Reason string `json:"reason,omitempty"`
|
||
TargetID string `json:"targetId,omitempty"`
|
||
// AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行;
|
||
// archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手,
|
||
// 也是「软删除后总行数不变」这条断言的证据面。
|
||
AffectedIDs []string `json:"affectedIds"`
|
||
// Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是
|
||
// len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的
|
||
// 诚实口径一致(超限绝不静默)。
|
||
type TidyApplyBatch struct {
|
||
RunID string `json:"runId"`
|
||
Limit int `json:"limit"`
|
||
Total int `json:"total"`
|
||
Truncated bool `json:"truncated"`
|
||
Results []TidyApplyResult `json:"results"`
|
||
Applied int `json:"applied"`
|
||
Skipped int `json:"skipped"`
|
||
Failed int `json:"failed"`
|
||
// Merged / Archived / Reclassified 是**成功应用**的候选按动作分解
|
||
// (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。
|
||
Merged int `json:"merged"`
|
||
Archived int `json:"archived"`
|
||
Reclassified int `json:"reclassified"`
|
||
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开:
|
||
// 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。
|
||
Deduped int `json:"deduped"`
|
||
// RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行,
|
||
// dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计,
|
||
// 因此这个数字是「这一次点击改了多少行记忆」的直接回答。
|
||
RowsAffected int `json:"rowsAffected"`
|
||
}
|
||
|
||
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
|
||
// 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以
|
||
// TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。
|
||
func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
cand, err := s.GetTidyCandidate(ctx, id)
|
||
if err != nil {
|
||
return TidyApplyResult{}, err
|
||
}
|
||
return s.applyCandidate(ctx, cand, opts)
|
||
}
|
||
|
||
// applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。
|
||
func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID,
|
||
Action: cand.Action,
|
||
TargetID: cand.TargetID,
|
||
AffectedIDs: []string{},
|
||
}
|
||
if !cand.Action.applyable() {
|
||
// link / keep 没有执行语义(design/23 §14;2026-10-07 起 link 也不再产出,
|
||
// 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
now := time.Now().UTC()
|
||
switch cand.Action {
|
||
case TidyMerge:
|
||
return s.applyTidyMerge(ctx, cand, opts, now)
|
||
case TidyArchive:
|
||
return s.applyTidyArchive(ctx, cand, opts, now)
|
||
case TidyReclassify:
|
||
return s.applyTidyReclassify(ctx, cand, opts, now)
|
||
case TidyDedupeGroup:
|
||
return s.applyTidyDedupeGroup(ctx, cand, opts, now)
|
||
default:
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
}
|
||
|
||
// applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false:
|
||
// 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务
|
||
// 库里既有的历史行与「不许假装做过」的诚实口径。
|
||
func (a TidyAction) applyable() bool {
|
||
switch a {
|
||
case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。
|
||
func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand)
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil {
|
||
return err
|
||
}
|
||
return bumpSurvivor(ctx, tx, survivor, absorbed, now)
|
||
})
|
||
return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改,
|
||
// 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。
|
||
func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if nonOwnerSource(item.SourceConversation) {
|
||
// 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御,
|
||
// 规则层本就把这类条目排除在扫描范围外。
|
||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
return archiveTidyItem(ctx, tx, item.ID, now)
|
||
})
|
||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):**只改 kind 一列**,
|
||
// 建议值来自候选证据里的 ProposedKind。
|
||
//
|
||
// 边界(2026-10-07 纳入自动档时写死):它不能改正文、不能改作用域、不能碰第二条行。
|
||
// 候选上的 ProposedContent / MergedContent 之类正文字段**一律不采纳** —— 落地语句
|
||
// 只写 kind/updated_at(reclassifyTidyItemSQL),因此即使候选是手工塞进来的畸形行,
|
||
// 也只会得到一次分类修正,不会有任何一个字节的正文被写入。
|
||
func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
kind := Kind(cand.Evidence.ProposedKind)
|
||
if !kind.valid() {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if nonOwnerSource(item.SourceConversation) {
|
||
// 只读来源(群聊/陌生人)绝不改(design/23 §5.2-9/§6):纵深防御,规则层本就
|
||
// 把这类条目排除在扫描范围外。archive/merge 早就有这道门,reclassify 在
|
||
// 2026-10-07 纳入自动档时必须补上同一道 —— 三类动作的只读来源口径必须一致,
|
||
// 否则「自动档只碰主人自己的记忆」这句话就不成立。
|
||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||
}
|
||
if item.Kind == kind {
|
||
// 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里
|
||
// 用 already_decided 更像「无需再做」。
|
||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
return reclassifyTidyItem(ctx, tx, item.ID, kind, now)
|
||
})
|
||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过
|
||
// (原因由它给出),此时两行不可用。
|
||
func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) {
|
||
absorbed, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||
}
|
||
if err != nil {
|
||
return MemoryItem{}, MemoryItem{}, "", err
|
||
}
|
||
survivor, err := s.Get(ctx, cand.TargetID)
|
||
if errors.Is(err, ErrNotFound) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||
}
|
||
if err != nil {
|
||
return MemoryItem{}, MemoryItem{}, "", err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, absorbed); skip != "" {
|
||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
|
||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||
}
|
||
// 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。
|
||
// 规则层已把这类条目排除在扫描外,这里是纵深防御。
|
||
if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, nil
|
||
}
|
||
if absorbed.ID == survivor.ID {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
|
||
}
|
||
return absorbed, survivor, "", nil
|
||
}
|
||
|
||
// tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。
|
||
// 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据,
|
||
// 而落库数据可能来自更早的规则版本或手工改动。
|
||
func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string {
|
||
if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID {
|
||
return tidySkipPartitionMismatch
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的
|
||
// candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定),
|
||
// 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。
|
||
func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
cand, err := s.GetTidyCandidate(ctx, id)
|
||
if err != nil {
|
||
return TidyApplyResult{}, err
|
||
}
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||
}
|
||
if !cand.Action.applyable() {
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
if cand.Status != tidyCandidateApplied {
|
||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||
}
|
||
now := time.Now().UTC()
|
||
var reverted []string
|
||
err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error {
|
||
out, err := revertTidyAction(ctx, tx, cand, now)
|
||
reverted = out
|
||
return err
|
||
})
|
||
if errors.Is(err, errTidyCandidateDecided) {
|
||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||
}
|
||
if err != nil {
|
||
return tidyFailed(base, err), nil
|
||
}
|
||
base.Status = TidyApplyApplied
|
||
base.AffectedIDs = reverted
|
||
return base, nil
|
||
}
|
||
|
||
// ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三):
|
||
// runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒
|
||
// 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报
|
||
// Truncated + Total(绝不静默少应用)。
|
||
//
|
||
// 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。
|
||
func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) {
|
||
limit := opts.normalizedLimit()
|
||
if strings.TrimSpace(runID) != "" {
|
||
if _, err := s.GetTidyRun(ctx, runID); err != nil {
|
||
return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err)
|
||
}
|
||
}
|
||
total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID)
|
||
if err != nil {
|
||
return TidyApplyBatch{}, err
|
||
}
|
||
cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit)
|
||
if err != nil {
|
||
return TidyApplyBatch{}, err
|
||
}
|
||
batch := TidyApplyBatch{
|
||
RunID: runID,
|
||
Limit: limit,
|
||
Total: total,
|
||
Truncated: total > len(cands),
|
||
Results: make([]TidyApplyResult, 0, len(cands)),
|
||
}
|
||
rows := 0
|
||
for _, cand := range cands {
|
||
if err := ctx.Err(); err != nil {
|
||
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
|
||
break
|
||
}
|
||
// 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选
|
||
// 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下,
|
||
// 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。
|
||
if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch {
|
||
batch.Truncated = true
|
||
break
|
||
} else {
|
||
rows += cost
|
||
}
|
||
res, err := s.applyCandidate(ctx, cand, opts)
|
||
if err != nil {
|
||
res = tidyFailed(TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
|
||
AffectedIDs: []string{},
|
||
}, err)
|
||
}
|
||
batch.Results = append(batch.Results, res)
|
||
batch.RowsAffected += len(res.AffectedIDs)
|
||
tallyTidyApply(&batch, res)
|
||
}
|
||
if len(batch.Results) < len(cands) {
|
||
// 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。
|
||
batch.Truncated = true
|
||
}
|
||
if batch.RunID != "" && batch.Applied > 0 {
|
||
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
|
||
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
|
||
if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil {
|
||
return batch, err
|
||
}
|
||
}
|
||
return batch, nil
|
||
}
|