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
@@ -114,6 +114,26 @@ func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidat
|
||||
return out
|
||||
}
|
||||
|
||||
// allTidyCandidates 读一份**不分状态**的候选集合(pending + applied + rejected)。
|
||||
//
|
||||
// 2026-10-07 起 RunOnce 会跑**轮末自动档**:命中判据的候选(字面重复 / 事实等价 ∧
|
||||
// ≥0.95 / 一次性事件且不像习惯)当场就被落地成 applied。因此「这一轮的规则产出了
|
||||
// 什么候选」这类断言必须看全部状态 —— 只看待办队列会把「已经自动做完了」读成
|
||||
// 「什么都没产出」。
|
||||
func allTidyCandidates(t *testing.T, store *Store, limit int) []TidyCandidate {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
out := make([]TidyCandidate, 0)
|
||||
for _, status := range []string{tidyCandidatePending, tidyCandidateApplied, tidyCandidateRejected} {
|
||||
cands, err := store.ListTidyCandidates(ctx, status, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates(%s): %v", status, err)
|
||||
}
|
||||
out = append(out, cands...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestTidyContainedFactProducesMergeCandidate 是 design/23 §12.1 P1 的口径:
|
||||
// A 的事实集合是 B 的子集 ⇒ 产 merge 候选,存活条目是超集 B,被并入的是 A。
|
||||
func TestTidyContainedFactProducesMergeCandidate(t *testing.T) {
|
||||
@@ -165,10 +185,16 @@ func TestTidyContainedFactProducesMergeCandidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyParallelFactsOnlyLink 是主人红线「不要什么内容都合并」与
|
||||
// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)只建链接,
|
||||
// 绝不合并 —— 即使召回分数很高。
|
||||
func TestTidyParallelFactsOnlyLink(t *testing.T) {
|
||||
// TestTidyParallelFactsProduceNoCandidate 是主人红线「不要什么内容都合并」与
|
||||
// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)**既不合并,也不产
|
||||
// link 候选**(2026-10-07 主人口径「那么多 link 是干什么的?该删就删」)。
|
||||
//
|
||||
// 为什么连 link 候选都不产:design/23 §14 把「link 关系如何存储与呈现」列为**待细化**
|
||||
// ⇒ 没有落点、没有数据模型;执行面只认 merge/archive/reclassify(tidy_apply.go 的
|
||||
// applyable())⇒ 一条 link 候选的应用结果永远是 skipped/action_not_applyable。
|
||||
// 老的用例名是 TestTidyParallelFactsOnlyLink(曾经断言「恰产一条 link」),那正是
|
||||
// 活库积压 501 条无执行语义候选的来源。
|
||||
func TestTidyParallelFactsProduceNoCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
@@ -180,27 +206,23 @@ func TestTidyParallelFactsOnlyLink(t *testing.T) {
|
||||
fixtureEnergyRedBull: {1, 0},
|
||||
fixtureEnergyIced: simVector(0.99),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
cands := allTidyCandidates(t, store, 50)
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("并列事实不该产任何候选(含 link):%+v(主人红线:不要什么内容都合并)", cands)
|
||||
}
|
||||
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
|
||||
t.Fatalf("并列事实被合并了:%+v(主人红线:不要什么内容都合并)", merges)
|
||||
if stats.Kept != 1 {
|
||||
t.Fatalf("stats.Kept = %d, want 1(抛掉的对必须单独计数,否则口径上是消失)", stats.Kept)
|
||||
}
|
||||
links := candidatesByAction(cands, TidyLink)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("link candidates = %d, want 1 (%+v)", len(links), cands)
|
||||
}
|
||||
ids := links[0].SourceIDs
|
||||
if len(ids) != 2 || !containsID(ids, redBull.ID) || !containsID(ids, iced.ID) {
|
||||
t.Fatalf("link source ids = %v, want 两条并列事实", ids)
|
||||
}
|
||||
if links[0].MergedContent != "" || links[0].TargetID != "" {
|
||||
t.Fatalf("link 候选不得带合并结果:%+v", links[0])
|
||||
// 「什么都不做」必须是**真什么都没做**:两行都还在册。
|
||||
for _, id := range []string{redBull.ID, iced.ID} {
|
||||
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
|
||||
t.Fatalf("并列事实被改动了:%+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,10 +303,7 @@ func TestTidyTemporalPollutionVetoed(t *testing.T) {
|
||||
t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons)
|
||||
}
|
||||
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
cands := allTidyCandidates(t, store, 50)
|
||||
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
|
||||
t.Fatalf("时效污染被合并:%+v", merges)
|
||||
}
|
||||
@@ -335,6 +354,11 @@ func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) {
|
||||
|
||||
// TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮
|
||||
// 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。
|
||||
//
|
||||
// 本用例刻意**关掉轮末自动档**:自动档会把一次性事件当场归档,于是第二轮扫描范围
|
||||
// 本身就变小了(归档行不再参与比对),duplicates 会被自动档的结果影响 ——
|
||||
// 那是自动档的语义(另有 tidy_auto_test.go 专门验),不是「候选产出」这条幂等性的
|
||||
// 一部分。这里要证明的是:规则不变时同一批数据绝不重复产候选。
|
||||
func TestTidyRerunIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -344,7 +368,9 @@ func TestTidyRerunIsIdempotent(t *testing.T) {
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.DisableAutoApply = true
|
||||
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
fixtureOneOffEvent: simVector(0.9),
|
||||
|
||||
Reference in new issue
Block a user