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

476 lines
22 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"
"errors"
"fmt"
"sort"
"strings"
)
// 本文件是记忆整理(design/23-记忆整理与去重)的整理器与编排:逐分区扫描 →
// 候选召回 → 包含判定 → 规则否决 → 候选落库 + 运行留痕。
//
// 三条不可走样的口径(design/23 §0/§5.1):
// 1. **相似度只用来召回候选对**,永不直接当合并判据;
// 2. **合并的唯一条件是「事实集合的包含关系」**(A ⊆ B 才并入 B);
// 相似但并列的事实**什么都不做**(判 keep)—— link 只有提示、没有执行语义,
// 因此 2026-10-07 起不再产出它(详见 tidy_rules.go 的 containNone 分支);
// 3. **规则否决一票否决**(跨作用域/跨角色/跨项目/群聊来源/时效污染/
// 数值不一致/否定冲突/低置信),命中即踢出候选。
//
// 写入面是候选表 + 运行表(§7 的人工在环),外加 2026-10-07 起的**轮末自动落地**:
// 规则层已判为高置信的那一小撮(字面重复 / 事实等价 ∧ ≥0.95 / 一次性事件且不像
// 习惯)由 tidy_auto.go 直接落地,其余仍等人点。**任何路径都不改正文、不删行**。
// TidyAction 是整理建议的动作类别(design/23 §10 的 action 枚举)。
type TidyAction string
const (
// TidyMerge 表示 A 的事实集合是 B 的子集,建议把 A 并入 B(保留 B)。
// **高置信的一部分会由轮末自动档直接落地**(字面重复 / 事实等价 ∧ ≥0.95,
// 见 tidy_auto.go);其余仍等人点(design/23 §7 的人工在环)。
TidyMerge TidyAction = "merge"
// TidyLink 是 design/23 §1.3 给「两条相似但**互不包含**」这一判定结论起的动作名
// ([1]/[3]「红牛 vs 冰美式」),语义上代表「只建链接/分组,绝不合并」。
//
// 🔴 2026-10-07 起**规则层不再产出它**:design/23 §14 把「link 关系如何存储与
// 呈现」列为待细化 ⇒ 它没有落点、没有数据模型,产出来只会得到一条永远应用不了
// 的待办(活库曾积压 501 条)。常量保留是**为了读历史行**:库里既有的 link 候选
// 仍要能在面板展示、能被判定为「不可应用」(tidy_apply.go 的 applyable),
// 也能被按动作批量拒绝(RejectTidyCandidatesByAction)。它不参与任何新判定。
TidyLink TidyAction = "link"
// TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并
// (design/23 §5.1 T0-5)。
TidyArchive TidyAction = "archive"
// TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6):只改分类列,**不碰正文**。
// 2026-10-07「全自动挡吧」起,理由为 kind_mismatch 且形状完整(含可回滚的
// 改前 kind)的这类候选由轮末自动档直接落地(判据见 tidy_auto.go 的
// tidyAutoReclassifyKind);不满足判据的仍交人工。
TidyReclassify TidyAction = "reclassify"
// TidyKeep 是「拿不准 = 什么都不做」(design/23 §5.2-10)。它是合法动作,
// 但第一版不为它落库(只进否决计数与日志),以免候选队列被噪声填满。
TidyKeep TidyAction = "keep"
)
// TidyVetoReason 是规则否决的原因标签:进日志、进运行表的原因分布
// (design/23 §9「每轮记否决原因分布」),也是测试断言的抓手。
type TidyVetoReason string
const (
// VetoNone 表示未命中任何否决规则。
VetoNone TidyVetoReason = ""
// VetoScope 是跨作用域硬门(design/23 §5.2-7):user/persona/project 之间永不合。
VetoScope TidyVetoReason = "scope"
// VetoPersona 是跨角色硬门(design/23 §5.2-8)。
VetoPersona TidyVetoReason = "persona"
// VetoProject 是跨项目硬门(design/23 §5.2-8)。
VetoProject TidyVetoReason = "project"
// VetoSource 是群聊/陌生人来源(design/23 §5.2-9、§6):只读不写。
VetoSource TidyVetoReason = "source"
// VetoTemporal 是时效污染(design/23 §5.2-1):一次性事件/有期限状态
// 不等于长期事实([8]「9月8日晚」≠ 熬夜习惯)。
VetoTemporal TidyVetoReason = "temporal"
// VetoNumeric 是数值/配置不一致(design/23 §5.2-2):数值不同即不同事实。
VetoNumeric TidyVetoReason = "numeric"
// VetoConflict 是否定/冲突(design/23 §5.2-3):X vs 非X 是覆盖不是重复。
VetoConflict TidyVetoReason = "conflict"
// VetoLowConfidence 是低置信否决门(design/23 §5.2-6)。
VetoLowConfidence TidyVetoReason = "low_confidence"
)
// ErrNilStore 在整理器没有收到记忆存储时返回。
var ErrNilStore = errors.New("memory: tidy needs a store")
// ErrTidyEmbedCount 在 embedder 返回的向量条数与请求条数不一致时返回:
// 缺失向量会让相似度与条目错位,宁可整体回退到词项余弦。
var ErrTidyEmbedCount = errors.New("memory/tidy: embedder returned a mismatched vector count")
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *TidyStats)
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
// 余弦,试算轮如实上报错误。budget 是本轮的时间预算闸(见 tidy_budget.go),
// 由调用方一路传下来,使两条策略共享**同一个**预算(而不是各自从 ctx 再派生)。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error)
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
// 逐字共用。
type tidyRoundPolicy struct {
sink tidyCandidateSink
recall tidyRecall
}
// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。
//
// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
started := t.now().UTC()
// 运行 id 与轮次开始**同时**定下来:候选要按它归属(切片 TIDY-1 的按轮
// 批量应用),运行留痕也要用它,因此不能等到扫完再生成。
runID := mustNewTidyID()
stats, partitions, err := t.beginRound(ctx)
if err != nil {
return stats, err
}
stats.RunID = runID
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
ledger := &recallLedger{}
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats)
},
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
},
})
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
// 不再等人点。它排在留痕之前,因此 memory_tidy_runs.applied 这一轮就是真数
// (而不是先写 0、事后靠按 run 批量应用补记)。
auto := t.autoApply(ctx, &stats)
run := TidyRun{
ID: runID,
StartedAt: started,
FinishedAt: t.now().UTC(),
Scanned: stats.Scanned,
Candidates: stats.Candidates,
Applied: auto.Applied,
Skipped: stats.Skipped + stats.Duplicates,
Errors: stats.Errors,
Vetoed: stats.Vetoed,
VetoReasons: stats.VetoReasons,
RuleVersion: TidyRuleVersion,
}
stats.DurationMS = run.FinishedAt.Sub(run.StartedAt).Milliseconds()
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
failures = append(failures, err)
}
t.logRun(stats, run, ledger)
t.auditAutoApply(auto)
switch {
case len(failures) == 0:
return stats, nil
case len(failures) == len(partitions) && len(partitions) > 0:
return stats, fmt.Errorf("memory/tidy: every partition failed: %w", errors.Join(failures...))
default:
return stats, fmt.Errorf("memory/tidy: run finished with errors: %w", errors.Join(failures...))
}
}
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
//
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
// 在汇总行里被写成 embedding。现在:
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
fields := []any{
"run_id", run.ID,
"rule_version", run.RuleVersion,
"partitions", stats.Partitions,
"scanned", run.Scanned,
"compared", stats.Compared,
"pairs", stats.Pairs,
"candidates", run.Candidates,
"duplicates", stats.Duplicates,
"skipped", stats.Skipped,
"vetoed", run.Vetoed,
"veto_reasons", tidyReasonsLabel(run.VetoReasons),
"kept", stats.Kept,
"errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", ledger.label(),
"recall_degraded_partitions", ledger.degradedPartitions(),
"auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged,
"auto_archive", stats.AutoArchived,
"auto_reclassify", stats.AutoReclassified,
"auto_eligible", stats.AutoEligible,
"auto_truncated", stats.AutoTruncated,
"dedupe_groups", stats.DedupeGroups,
"dedupe_rows", stats.DedupeRows,
"dedupe_truncated", stats.DedupeTruncated,
}
if len(stats.Truncated) > 0 {
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
}
t.log.Info("memory tidy run finished", fields...)
}
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
stats := TidyStats{
VetoReasons: make(map[string]int),
ByAction: make(map[string]int),
}
partitions, err := t.deps.Store.listTidyPartitions(ctx)
if err != nil {
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
}
stats.Partitions = len(partitions)
return stats, partitions, nil
}
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
//
// 时间预算(tidy_budget.go)在**轮**级派生一次:ctx 有墙钟上限(同步/异步试算)
// 时,预算用尽后的分区不再开跑,而是如实登记为 (budget) 截断。
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
budget := t.newTidyBudget(ctx)
var failures []error
for _, p := range partitions {
if err := t.runPartition(ctx, p, stats, policy, budget); err != nil {
stats.Errors++
failures = append(failures, err)
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
}
}
return failures
}
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
//
// 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出,
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err == nil {
return pairs, nil
}
ledger.noteLexical(true)
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
// runPartition 整理一个分区(persona_id + scope + project_id)。
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
// 的路径。
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy, budget tidyBudget) error {
// 预算用尽 ⇒ 这个分区本轮根本不看。**如实登记**(响应 truncated + WARN):
// 「没扫到」与「扫了没有候选」必须能区分,否则面板会把预算截断读成
// 「这个分区没有建议」。
if budget.exhausted() {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy partition skipped by time budget", "partition", p.label())
return nil
}
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
if err != nil {
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
}
stats.Scanned += len(items)
if len(items) == t.cfg.ScanLimit {
// 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮
// 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须
// 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。
// 日志带**真总数**(LIMIT 之后的 len(items) 只是下限):另查一次 COUNT,
// 否则 WARN 会把「至少 5000」说成「正好 5000」。
total, totalErr := t.deps.Store.countTidyPartitionItems(ctx, p)
stats.Truncated = append(stats.Truncated, p.label()+"(scan)")
if totalErr != nil {
t.log.Warn("memory tidy partition hit the scan limit (total unknown)",
"partition", p.label(), "limit", t.cfg.ScanLimit, "error", totalErr)
} else {
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit, "total", total)
}
}
comparable, archives := t.classifyItems(items, stats)
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
compared := t.limitCompareItems(p, comparable, stats)
pairs, err := policy.recall(ctx, p, compared, stats, budget)
if err != nil {
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
}
candidates := t.mergeCandidates(archives, pairs, stats)
// dedupe_group(2026-10-24):机械簇(同 tag 集 / 前 12 字相同)**不依赖相似度
// 召回**,因此它跑在可比条目全集上(`comparable`)、**不受**比对截断影响 ——
// 截断是给 O(n²) 的两两余弦用的护栏,不该让机械分簇跟着少看一半条目。
//
// 它排在包含判定**之后**是为了**抑制重复待办**:一对已经拿到 archive/merge
// 候选的条目不再进簇 —— 否则同一对会同时出现「并入超集」与「并入簇存活行」
// 两条候选,第二条应用时必然 skip(source_not_active),面板上看像 bug。
// 覆盖面由前一步的**产物**决定(不是又一套判据),因此抑制是纯粹的减法。
dedupe, dedupeStats := t.dedupeInPartition(ctx, p, comparable, candidates)
stats.DedupeGroups += dedupeStats.Groups
stats.DedupeRows += dedupeStats.Rows
// dedupe 候选追加在既有两类之后:**不改变**归档/合并在候选上限里的优先序
// (那两类是既有的、被验证过的口径;新动作不许把它们挤出上限)。
candidates = append(candidates, dedupe...)
candidates = t.trimPartitionCandidates(p, candidates, len(dedupe), stats)
policy.sink(ctx, candidates, stats)
return nil
}
// mergeCandidates 把包含判定的产物收敛成候选:归档建议在前,merge 在后;
// 被否决的对进否决分布,互不包含的对进 Kept(**不产候选**,2026-10-07 口径)。
func (t *Tidier) mergeCandidates(archives []TidyCandidate, pairs []tidyPair, stats *TidyStats) []TidyCandidate {
candidates := archives
for _, pr := range pairs {
verdict := judgePair(pr.a, pr.b, pr.score)
if verdict.Veto != VetoNone {
stats.Vetoed++
stats.VetoReasons[string(verdict.Veto)]++
continue
}
if verdict.Action != TidyMerge {
// 相似但互不包含 ⇒ keep:**不产候选**(2026-10-07,见 tidy_rules.go 的
// containNone 分支)。它必须单独计数,否则「抛掉多少对」会消失。
stats.Kept++
continue
}
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
}
return candidates
}
// trimPartitionCandidates 施加每分区候选上限(design §9),并**如实登记**被丢掉的
// 部分:总量进 stats.Skipped 与 Truncated 标签,其中属于 dedupe 段的那部分单独进
// stats.DedupeTruncated(新动作被截断时面板必须看得到,不许「静默少做」)。
//
// dedupeCount 是本批候选**尾部**属于 dedupe 的条数(调用方把 dedupe 追加在最后)。
func (t *Tidier) trimPartitionCandidates(p tidyPartition, candidates []TidyCandidate,
dedupeCount int, stats *TidyStats) []TidyCandidate {
limit := t.cfg.CandidateLimit
if len(candidates) <= limit {
return candidates
}
dedupeStart := len(candidates) - dedupeCount
// 上限切在 dedupe 段之前 ⇒ 丢掉的**全部**是 dedupe 候选;切在段内 ⇒ 丢掉尾部。
keptDedupe := 0
if limit > dedupeStart {
keptDedupe = limit - dedupeStart
}
if keptDedupe > dedupeCount {
keptDedupe = dedupeCount
}
droppedDedupe := dedupeCount - keptDedupe
stats.DedupeTruncated += droppedDedupe
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则判出的,
// 必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", limit,
"dedupe_truncated", droppedDedupe)
stats.Skipped += len(candidates) - limit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
return candidates[:limit]
}
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」),
// 且**不参与任何 merge**(design §5.1-5);
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
func (t *Tidier) classifyItems(items []MemoryItem, stats *TidyStats) (comparable []MemoryItem, archives []TidyCandidate) {
comparable = make([]MemoryItem, 0, len(items))
for _, it := range items {
switch t.classifyForTidy(it, stats) {
case tidySkip:
continue
case tidyArchive:
archives = append(archives, archiveCandidate(it, t.now().UTC()))
continue
default:
comparable = append(comparable, it)
}
}
return comparable, archives
}
// tidyDisposition 是扫描时一条记忆的处置分流。
type tidyDisposition int
const (
tidyCompare tidyDisposition = iota
tidyArchive
tidySkip
)
// classifyForTidy 决定一条记忆进入比对、进归档候选,还是被排除:
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」);
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDisposition {
if nonOwnerSource(it.SourceConversation) {
stats.Vetoed++
stats.VetoReasons[string(VetoSource)]++
return tidySkip
}
if hasAbsoluteTimestamp(it.Content) {
return tidyArchive
}
if len([]rune(it.Content)) < minTidyContentRunes {
return tidySkip
}
return tidyCompare
}
// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates
// (design/23 §9「重复跑不重复产候选」),新建的计入 Candidates 并按动作进
// ByAction(回执的领域来源)。runID 是本轮的留痕 id:「按 run 批量应用」据此
// 找到这一轮产出的候选;幂等跳过的行保留它**首次**被产出的轮次,不被改写。
func (t *Tidier) persistCandidates(ctx context.Context, runID string, cands []TidyCandidate, stats *TidyStats) {
for _, c := range cands {
c.RunID = runID
created, err := t.deps.Store.insertTidyCandidate(ctx, c)
if err != nil {
stats.Errors++
t.log.Warn("memory tidy candidate insert failed", "action", string(c.Action), "error", err)
continue
}
if !created {
stats.Duplicates++
continue
}
stats.Candidates++
if stats.ByAction != nil {
stats.ByAction[string(c.Action)]++
}
}
}
// tidyReasonsLabel 把否决原因分布渲染成稳定的日志字符串(按原因排序)。
func tidyReasonsLabel(reasons map[string]int) string {
if len(reasons) == 0 {
return "none"
}
keys := make([]string, 0, len(reasons))
for k := range reasons {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, reasons[k]))
}
return strings.Join(parts, ",")
}