Files
Cyrene_v2/internal/runtime/memory/tidy_candidate.go
T
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

182 lines
6.1 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
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
}