Files
Cyrene_v2/internal/runtime/memory/tidy_candidate.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。
根因两条(都在这片里修掉):
① **以前根本没有执行面** —— 只有只读试算,候选永远 pending
② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完

- 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并**
  (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计
  (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道)
- 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run),
  「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死**
- 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时)
- 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column)
- 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false)
⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行)
⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
2026-10-06 22:39:35 +08:00

190 lines
6.7 KiB
Go
Raw 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 (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
)
// 本文件是记忆整理的**候选**侧:候选与证据的落库形状、由判定结果构造候选、
// 以及候选的幂等键。它从 tidy.go 拆出来,让编排(扫描→召回→判定→落库)
// 保持在一屏内(AGENTS §10 文件 ≤500 行)。
// TidyEvidence 是一条候选的判定证据。design/23 §7 要求 diff 预览能展示
// 「A 原文 / B 原文 / 合并后内容 / A 被 B 包含的证据 / 命中或未命中的否决规则」,
// 因此这四项都在这里。它装的是主人自己的记忆正文,只落本机库、只走 owner-only
// 的读侧(design/23 §11)。
type TidyEvidence struct {
// Similarity 是召回用的余弦相似度(只用来召回,不是判定依据)。
Similarity float64 `json:"similarity"`
// RecallMode 记录本轮相似度的来源(embedding / lexical),便于运维判读。
RecallMode string `json:"recall_mode,omitempty"`
// Containment 是包含判定结论:a_subset_b / b_subset_a / both / none。
Containment string `json:"containment,omitempty"`
// ContainmentEvidence 是「A ⊆ B」的逐要素证据(缺哪一项就列哪一项)。
ContainmentEvidence []string `json:"containment_evidence,omitempty"`
// VetoReason 是命中的否决规则(未命中时为空)。
VetoReason string `json:"veto_reason,omitempty"`
// KindA/KindB/ProposedKind 供 reclassify 候选展示 kind 误分类建议。
KindA string `json:"kind_a,omitempty"`
KindB string `json:"kind_b,omitempty"`
ProposedKind string `json:"proposed_kind,omitempty"`
// ContentA/ContentB 是两条原文,供面板 diff 预览。
ContentA string `json:"content_a,omitempty"`
ContentB string `json:"content_b,omitempty"`
}
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
type TidyCandidate struct {
ID string
PersonaID string
Scope Scope
ProjectID string
Kind Kind
Action TidyAction
SourceIDs []string
TargetID string
// RunID 是产出这条候选的那一轮整理(切片 TIDY-1 补的列:批量应用的抓手)。
// 幂等跳过的候选保留**首次**产它的那一轮,不被后来的轮次改写。
RunID string
ProposedContent string
MergedContent string
Reason string
Evidence TidyEvidence
RuleVersion string
ScoreMin float64
ScoreMax float64
Status string
IdempotencyKey string
CreatedAt time.Time
}
// tidyVerdict 是一条候选对的判定结果。
type tidyVerdict struct {
Action TidyAction
Veto TidyVetoReason
Target *MemoryItem
Containment containment
Reason string
}
// archiveCandidate 为一次性事件产一条归档建议(design/23 §5.1 T0-5)。
func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate {
return TidyCandidate{
ID: mustNewTidyID(),
PersonaID: it.PersonaID,
Scope: it.Scope,
ProjectID: it.ProjectID,
Kind: it.Kind,
Action: TidyArchive,
SourceIDs: []string{it.ID},
ProposedContent: it.Content,
Reason: "one_off_event",
Evidence: TidyEvidence{
Containment: "none",
ContentA: it.Content,
KindA: string(it.Kind),
},
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
CreatedAt: now,
}
}
// buildCandidates 把一条判定结果转成候选:merge 需要一条存活条目(超集),
// kind 不一致时附带一条 reclassify 建议(design/23 §5.1 T0-6)。
func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate {
evidence := TidyEvidence{
Similarity: pr.score,
RecallMode: pr.mode,
Containment: string(v.Containment.Kind),
ContainmentEvidence: v.Containment.Missing,
KindA: string(pr.a.Kind),
KindB: string(pr.b.Kind),
ContentA: pr.a.Content,
ContentB: pr.b.Content,
}
base := TidyCandidate{
ID: mustNewTidyID(),
PersonaID: pr.a.PersonaID,
Scope: pr.a.Scope,
ProjectID: pr.a.ProjectID,
Evidence: evidence,
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
ScoreMin: pr.score,
ScoreMax: pr.score,
CreatedAt: now,
}
if v.Action != TidyMerge || v.Target == nil {
// 相似但互不包含:只建链接/分组,绝不合并(design §1.3 的 A-MEM 口径)。
base.Action = TidyLink
base.SourceIDs = []string{pr.a.ID, pr.b.ID}
base.Kind = pr.a.Kind
base.Reason = "similar_but_not_contained"
return []TidyCandidate{base}
}
absorbed, target := pr.a, *v.Target
if v.Target.ID == pr.a.ID {
absorbed, target = pr.b, pr.a
}
base.Action = TidyMerge
base.SourceIDs = []string{absorbed.ID}
base.TargetID = target.ID
base.Kind = target.Kind
base.ProposedContent = absorbed.Content
base.MergedContent = target.Content
base.Reason = v.Reason
out := []TidyCandidate{base}
if absorbed.Kind != target.Kind {
// kind 误分类:以存活条目(超集)的 kind 为建议值,交人工确认
// (design/23 §5.1 T0-6「取多数票或交人工,不自动改」)。
re := base
re.ID = mustNewTidyID()
re.Action = TidyReclassify
re.SourceIDs = []string{absorbed.ID}
re.TargetID = ""
re.ProposedContent = ""
re.MergedContent = ""
re.Reason = "kind_mismatch"
re.Evidence.ProposedKind = string(target.Kind)
// KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」:
// merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而
// reclassify 候选只有一个主体(absorbed),回滚必须知道它原来的 kind,
// 否则 revert 会拿另一条行的 kind 去覆盖(design/23 §8 的可逆口径)。
re.Evidence.KindA = string(absorbed.Kind)
out = append(out, re)
}
return out
}
// tidyIdempotencyKey 由 design/23 §9 的幂等键口径算出:
// hash(persona_id, scope, project_id, sorted(source_ids), action, rule_version)。
// 规则不变时同一批数据重复运行产出同一个键,因此候选绝不重复落库。
func tidyIdempotencyKey(c TidyCandidate) string {
ids := append([]string(nil), c.SourceIDs...)
sort.Strings(ids)
parts := []string{
c.PersonaID, string(c.Scope), c.ProjectID,
strings.Join(ids, ","), string(c.Action), c.RuleVersion,
}
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(sum[:])
}
// mustNewTidyID 生成候选 id。它复用与记忆条目同一个随机源;熵源损坏时回退到
// 时间戳,而不是让整理整轮失败(候选 id 只需要在本库唯一)。
func mustNewTidyID() string {
id, err := newID()
if err != nil {
return fmt.Sprintf("tidy-%d", time.Now().UnixNano())
}
return id
}