Files
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

135 lines
5.6 KiB
Go
Raw Permalink 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 (
"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)
}
}