feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「那么多 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:
AskaEth committed 2026-10-07 11:07:00 +08:00
1 parent 353293e807
commit 6d92536276
27 files changed
+2063 -197

No files matched your search

+40 -9
View File
@@ -148,14 +148,13 @@ func TestTidyServiceManualRunAndAudit(t *testing.T) {
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
type auditRow struct{ who, what, result string }
var rows []auditRow
var rows []tidyAuditRow
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, time.Now, func(who, what, result string) {
rows = append(rows, auditRow{who, what, result})
rows = append(rows, tidyAuditRow{who, what, result})
})
stats, err := svc.RunOnce(ctx)
@@ -165,21 +164,34 @@ func TestTidyServiceManualRunAndAudit(t *testing.T) {
if stats.Candidates != 1 {
t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates)
}
if len(rows) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(rows))
// 两条审计:① 领域侧记的那条 memory.tidy.run(design §8);② 2026-10-07 起
// 轮末自动档自己写的一条 memory.tidy.apply(result 带 auto=true)。本夹具的
// merge 候选是「真子集」(a_subset_b),不命中自动判据,因此第二条是**零落地**
// 的诚实回执 —— 这正是「自动档跑了但什么都没做」该有的形状。
// 两条的先后不构成契约(领域侧先写自动档那条),因此按 what 取,不按下标取。
if len(rows) != 2 {
t.Fatalf("审计行数 = %d, want 2(run + auto apply):%+v", len(rows), rows)
}
if rows[0].what != "memory.tidy.run" || rows[0].who != tidyAuditWho {
t.Fatalf("审计 who/what = %q/%q", rows[0].who, rows[0].what)
round := auditRowOf(t, rows, "memory.tidy.run")
auto := auditRowOf(t, rows, tidyAutoApplyVerb)
if round.who != tidyAuditWho {
t.Fatalf("审计 who = %q, want %q", round.who, tidyAuditWho)
}
if strings.Contains(rows[0].result, fixtureHabitShort) {
if strings.Contains(round.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文")
}
if !strings.Contains(auto.result, "auto=true") || !strings.Contains(auto.result, "applied=0") {
t.Fatalf("自动档审计 = %+v, want memory.tidy.apply + auto=true + applied=0", auto)
}
if strings.Contains(auto.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文(自动档那条也不行)")
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 {
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(不自动应用)", runs, TidyRuleVersion)
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(真子集候选不在自动档授权内)", runs, TidyRuleVersion)
}
}
@@ -216,3 +228,22 @@ func TestTidyRunRecordedVetoDistribution(t *testing.T) {
t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned)
}
}
// tidyAuditRow 是一条捕获到的审计行(本文件的用例只需这三个字段)。
type tidyAuditRow struct{ who, what, result string }
// auditRowOf 按动作名取一条审计行;缺了或重复了就当场失败(审计行必须**恰有一条**,
// 「多了」与「少了」都是缺陷)。
func auditRowOf(t *testing.T, rows []tidyAuditRow, what string) tidyAuditRow {
t.Helper()
var found []tidyAuditRow
for _, r := range rows {
if r.what == what {
found = append(found, r)
}
}
if len(found) != 1 {
t.Fatalf("审计 %s = %d 条, want 恰 1 条(全部:%+v)", what, len(found), rows)
}
return found[0]
}