feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:29:36 +08:00
1 parent 012e9f3ad4
commit 91a3209c9b
25 files changed
+3418 -13

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
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
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)
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
}