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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
1 parent
353293e807
commit
6d92536276
27 files changed
+2063
-197
No files matched your search
@@ -240,6 +240,10 @@ func TestTidyApplyMergeUnderConcurrencyHasSingleWinner(t *testing.T) {
|
||||
|
||||
// TestTidyApplyArchiveAndRevert 钉住④:archive 是软删(status='archived',内容不变、
|
||||
// 行数不变),revert 复原为 active。
|
||||
//
|
||||
// 本用例刻意**关掉轮末自动档**:它验的是**人工应用/回滚**这条路径,而自动档会在
|
||||
// RunOnce 收尾时把这条一次性事件当场归档(那会让「apply 得到 applied」变成
|
||||
// already_decided)。自动档本身在 tidy_auto_test.go 里验。
|
||||
func TestTidyApplyArchiveAndRevert(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -247,7 +251,9 @@ func TestTidyApplyArchiveAndRevert(t *testing.T) {
|
||||
base := tidyTestTime(t)
|
||||
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.DisableAutoApply = true
|
||||
tr := newTestTidier(t, store, cfg, nil)
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
@@ -346,31 +352,37 @@ func TestTidyApplyReclassifyAndRevert(t *testing.T) {
|
||||
|
||||
// TestTidyApplyLinkIsNotApplyable 钉住⑤:link 没有执行语义(design/23 §14:
|
||||
// 链接的存储形态未定),应用它只能得到 skipped,且**零改动**。
|
||||
//
|
||||
// 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go),因此这里
|
||||
// 手工落一条历史形状的 link 候选来钉这条执行面口径 —— 库里既有的 link 行仍必须
|
||||
// 得到「不可应用」的诚实回答(它们的出路是按动作批量拒绝,见 tidy_reject_test.go)。
|
||||
func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
|
||||
seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
|
||||
redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
|
||||
iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureEnergyRedBull: {1, 0},
|
||||
fixtureEnergyIced: simVector(0.99),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
link := TidyCandidate{
|
||||
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Action: TidyLink, SourceIDs: []string{redBull.ID, iced.ID},
|
||||
Reason: tidyReasonSimilarNotContained,
|
||||
Evidence: TidyEvidence{
|
||||
Similarity: 0.99, RecallMode: tidyRecallEmbedding, Containment: string(containNone),
|
||||
ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced,
|
||||
},
|
||||
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if created, err := store.insertTidyCandidate(ctx, link); err != nil || !created {
|
||||
t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err)
|
||||
}
|
||||
stored, err := store.GetTidyCandidate(ctx, link.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
links := candidatesByAction(cands, TidyLink)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("link 候选 = %d, want 1", len(links))
|
||||
t.Fatalf("GetTidyCandidate: %v", err)
|
||||
}
|
||||
before := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
|
||||
out, err := store.ApplyTidyCandidate(ctx, links[0].ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
out, err := store.ApplyTidyCandidate(ctx, stored.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate(link): %v", err)
|
||||
}
|
||||
@@ -383,13 +395,16 @@ func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
|
||||
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); len(got) != len(before) {
|
||||
t.Fatalf("link 应用改动了记忆:%d → %d 行", len(before), len(got))
|
||||
}
|
||||
if cand, err := store.GetTidyCandidate(ctx, links[0].ID); err != nil || cand.Status != tidyCandidatePending {
|
||||
if cand, err := store.GetTidyCandidate(ctx, stored.ID); err != nil || cand.Status != tidyCandidatePending {
|
||||
t.Fatalf("不可应用的候选应保持 pending,实际 %q (err=%v)", cand.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated 钉住⑥:按 run 批量应用
|
||||
// 逐条给结果、上限生效、超限如实报 Truncated + Total,并把该轮留痕的 applied 累加。
|
||||
//
|
||||
// 本用例刻意**关掉轮末自动档**:它验的是批量应用这条**人工**入口的粒度与上限,
|
||||
// 而自动档会先把这 5 条一次性事件落地(那会让 Total 变成 0)。
|
||||
func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -402,7 +417,9 @@ func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
|
||||
ids = append(ids, seedTidyItem(t, store, item).ID)
|
||||
}
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.DisableAutoApply = true
|
||||
tr := newTestTidier(t, store, cfg, nil)
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
|
||||
Reference in new issue
Block a user