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 }