主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
135 lines
5.6 KiB
Go
135 lines
5.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件锁定整理候选的**按动作批量拒绝**(tidy_reject.go):给没有执行语义的候选
|
||
// (典型是 link,活库曾积压 501 条)一条干净的出路。四条判据:
|
||
//
|
||
// ① 只动指定动作的 pending 行,**不碰其他动作、不碰已决定的行**;
|
||
// ② 幂等:重复调用第二次是 0 条,不是错误;
|
||
// ③ 上限生效、超限如实报 total/truncated;
|
||
// ④ 动作名不在闭集内 ⇒ 分类错误(调用方据此回 400),绝不静默改 0 条。
|
||
|
||
// insertLinkCandidate 落一条 link 候选(历史形状:无执行语义)。
|
||
func insertLinkCandidate(t *testing.T, store *Store, ids []string) TidyCandidate {
|
||
t.Helper()
|
||
c := TidyCandidate{
|
||
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Action: TidyLink, SourceIDs: ids, Reason: tidyReasonSimilarNotContained,
|
||
Evidence: TidyEvidence{
|
||
Similarity: 0.9, Containment: string(containNone),
|
||
ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced,
|
||
},
|
||
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: tidyTestTime(t),
|
||
}
|
||
if created, err := store.insertTidyCandidate(context.Background(), c); err != nil || !created {
|
||
t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err)
|
||
}
|
||
return c
|
||
}
|
||
|
||
// TestTidyRejectByActionIsIdempotentAndScoped 是判据 ①②:只决定指定动作的 pending 行,
|
||
// 重复调用第二次 0 条;其他动作的候选与记忆行**一行不动**。
|
||
func TestTidyRejectByActionIsIdempotentAndScoped(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
items := make([]string, 0, 4)
|
||
for i := 0; i < 4; i++ {
|
||
items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID)
|
||
}
|
||
// 两条 link 候选的 source_ids 必须不同:幂等键含 source_ids,否则第二条会被
|
||
// INSERT OR IGNORE 静默挡下(那是幂等的正确行为,但会让夹具失效)。
|
||
links := []TidyCandidate{
|
||
insertLinkCandidate(t, store, items[0:2]),
|
||
insertLinkCandidate(t, store, items[2:4]),
|
||
}
|
||
// 一条**别的动作**的候选:它必须原封不动。
|
||
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9))
|
||
other := TidyCandidate{
|
||
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Action: TidyArchive, SourceIDs: []string{"mem-nonexistent"}, Reason: tidyReasonOneOffEvent,
|
||
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
|
||
}
|
||
if created, err := store.insertTidyCandidate(ctx, other); err != nil || !created {
|
||
t.Fatalf("落 archive 候选 = (%t, %v)", created, err)
|
||
}
|
||
itemsBefore := countMemoryItems(t, store)
|
||
|
||
batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("RejectTidyCandidatesByAction: %v", err)
|
||
}
|
||
if batch.Total != 2 || batch.Rejected != 2 || batch.Truncated {
|
||
t.Fatalf("回执 = %+v, want total=2 rejected=2 truncated=false", batch)
|
||
}
|
||
for _, c := range links {
|
||
got, err := store.GetTidyCandidate(ctx, c.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetTidyCandidate: %v", err)
|
||
}
|
||
if got.Status != tidyCandidateRejected {
|
||
t.Fatalf("link 候选状态 = %q, want %q", got.Status, tidyCandidateRejected)
|
||
}
|
||
}
|
||
if got, err := store.GetTidyCandidate(ctx, other.ID); err != nil || got.Status != tidyCandidatePending {
|
||
t.Fatalf("别的动作的候选被动了:%q (err=%v)", got.Status, err)
|
||
}
|
||
// 拒绝是**决定**不是删除:记忆一行不动、正文不动。
|
||
if got := countMemoryItems(t, store); got != itemsBefore {
|
||
t.Fatalf("memory_items 行数 %d → %d,拒绝不该碰记忆", itemsBefore, got)
|
||
}
|
||
pending, err := store.ListTidyCandidates(ctx, tidyCandidatePending, 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(pending) != 1 || pending[0].ID != other.ID {
|
||
t.Fatalf("待办队列 = %+v, want 只剩那条 archive(噪声被清掉)", pending)
|
||
}
|
||
|
||
// 幂等:再来一次,0 条且不是错误。
|
||
again, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test"})
|
||
if err != nil {
|
||
t.Fatalf("第二次 RejectTidyCandidatesByAction: %v", err)
|
||
}
|
||
if again.Rejected != 0 || again.Total != 0 || again.Truncated {
|
||
t.Fatalf("第二次回执 = %+v, want 全 0(幂等)", again)
|
||
}
|
||
}
|
||
|
||
// TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction 是判据 ③④:
|
||
// 上限生效且超限如实报 truncated;动作名不在闭集内返回分类错误。
|
||
func TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
items := make([]string, 0, 4)
|
||
for i := 0; i < 4; i++ {
|
||
items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID)
|
||
}
|
||
// 三条 link 候选各用不同的 source_ids(幂等键含 source_ids,见上一个用例)。
|
||
for i := 0; i < 3; i++ {
|
||
insertLinkCandidate(t, store, items[i:i+2])
|
||
}
|
||
|
||
batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 2})
|
||
if err != nil {
|
||
t.Fatalf("RejectTidyCandidatesByAction: %v", err)
|
||
}
|
||
if batch.Limit != 2 || batch.Total != 3 || batch.Rejected != 2 || !batch.Truncated {
|
||
t.Fatalf("回执 = %+v, want limit=2 total=3 rejected=2 truncated=true", batch)
|
||
}
|
||
|
||
if _, err := store.RejectTidyCandidatesByAction(ctx, TidyAction("delete-everything"),
|
||
TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyUnknownAction) {
|
||
t.Fatalf("未知动作的 err = %v, want ErrTidyUnknownAction", err)
|
||
}
|
||
}
|