Files
Cyrene_v2/internal/runtime/memory/tidy_candidate.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。

① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
   `applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
   - 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
   - `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
   - 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
     (**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
   - `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
   - `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
     习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
   - **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
     (群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
   超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
   **可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
   (`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。

验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。

⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

204 lines
7.8 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
}
// 候选与判定结论的理由标签(落 memory_tidy_candidates.reason;`tidyAutoApplyEligible`
// 会把其中两条读回来当自动落地的判据)。抽成常量是因为它们各有两处以上使用:
// 「三次复制必须抽取」(AGENTS §10)。
const (
// tidyReasonSimilarNotContained 是「相似但互不包含」的判定结论。2026-10-07 起
// 这种对**不再产候选**(判 keep,见 tidy_rules.go),它只留在判定结果与日志里。
tidyReasonSimilarNotContained = "similar_but_not_contained"
// tidyReasonFactSubset 是包含式合并(A ⊆ B,把 A 并入 B)的理由。
tidyReasonFactSubset = "fact_subset"
// tidyReasonOneOffEvent 是一次性事件的归档理由,**也是自动档唯一认的归档理由**:
// 理由不同(例如将来新增的归档类别)就不在新口径的授权范围内,交人工。
tidyReasonOneOffEvent = "one_off_event"
// tidyReasonKindMismatch 是 kind 误分类的理由(只产候选,不自动改)。
tidyReasonKindMismatch = "kind_mismatch"
)
// 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: tidyReasonOneOffEvent,
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 {
// 非 merge 的判定结论**不产候选**:link 没有执行语义(design/23 §14 的待
// 细化项,见 tidy_rules.go 的 containNone 分支),keep 按 §5.2-10 就是
// 「什么都不做」。返回空切片而不是一条应用不了的候选 —— 后者只会污染待办。
return nil
}
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 = tidyReasonKindMismatch
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
}