M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在 算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求) 排队到自己的超时 ⇒ 召回全线降级。 - embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式, 批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令 写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 + 2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。 - embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动 首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。 - memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源), 并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。 - memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生** 的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增 recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。 - 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态 (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0; memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。 不改 preinsert*、不改 .env、不动判定规则与任何阈值。
428 lines
20 KiB
Go
428 lines
20 KiB
Go
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,
|
||
}
|
||
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 := 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())...)
|
||
}
|
||
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
|
||
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
|
||
if len(candidates) > t.cfg.CandidateLimit {
|
||
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
|
||
// 判出的,必须能从这里读出被丢掉了多少。
|
||
t.log.Warn("memory tidy candidates truncated by per-partition limit",
|
||
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
|
||
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
|
||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||
candidates = candidates[:t.cfg.CandidateLimit]
|
||
}
|
||
policy.sink(ctx, candidates, stats)
|
||
return nil
|
||
}
|
||
|
||
// 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, ",")
|
||
}
|