package memory import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件锁定记忆整理的**轮末自动落地**(2026-10-07 主人口径「那么多 link 是干什么的? // 该删就删,怎么这个记忆整理机制这么保守呢」+ 同日追加「全自动挡吧」;实现见 // tidy_auto.go)。判据: // // ① 字面重复(两侧逐字相同)⇒ 自动 merge; // ② 事实集合等价 ∧ 相似度 ≥ 0.95 ⇒ 自动 merge; // ③ 低置信 ⇒ 不自动(候选在**规则层**就被否决,自动档根本看不到它); // ④ 一次性事件 ⇒ 自动 archive; // ⑤ 习惯类被钟点误判成一次性事件 ⇒ **不**自动 archive(红线守卫,不许动); // ⑥ 每轮上限生效、超限如实截断,下一轮继续排干(三类动作同占一个池子); // ⑦ 开关关闭 ⇒ 回到只产建议(一条都不落地); // ⑧ revert 能回滚自动落地的那一条(merge 与 archive 各一次); // ⑨ kind 误分类 ⇒ 自动 reclassify(实现见 TestTidyAutoReclassifyKindMismatch), // 只改 kind 一列:**正文一字不动**、**回滚能把 kind 复原**、**畸形候选里的 // proposed_content 一律不采纳**(TestTidyAutoReclassifyNeverAdoptsProposedContent)。 // // 外加几条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与 // 群聊/陌生人来受限来源的候选在**执行面**被拒(三类动作一视同仁,reclassify 也一样)。 // TestTidyAutoMergeLiteralDuplicate 是判据 ①:两侧正文逐字相同 ⇒ 候选由轮末自动档 // 直接落地(软合并),且落地时**只动 status/merged_***,绝不改正文、绝不删行。 func TestTidyAutoMergeLiteralDuplicate(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) first := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)) second := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9)) tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureDupText: {1, 0}, }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.AutoEligible != 1 || stats.AutoApplied != 1 || stats.AutoMerged != 1 { t.Fatalf("自动档计数 = %+v, want eligible/applied/merged = 1/1/1", stats) } if stats.AutoTruncated { t.Fatalf("没打满上限不该报截断:%+v", stats) } // 存活的是更早创建的那条(pickMergeTarget:first_seen 落在存活行上)。 survivor, absorbed := loadTidyItemRow(t, store, first.ID), loadTidyItemRow(t, store, second.ID) if absorbed.status != ingestStatusMerged || absorbed.mergedInto != first.ID { t.Fatalf("被吸收行 = %+v, want merged into %s", absorbed, first.ID) } if survivor.status != tidyStatusActive || survivor.mergeCount != 2 { t.Fatalf("存活行 = %+v, want active + merge_count=2", survivor) } if absorbed.content != fixtureDupText || survivor.content != fixtureDupText { t.Fatal("自动合并改了正文(design/23 §8:软动作绝不改 content)") } if got := countMemoryItems(t, store); got != 2 { t.Fatalf("行数 = %d, want 2(软删除:行永不物理删除)", got) } // 审计:一条 memory.tidy.apply,带 auto=true 与理由分布;不带任何正文。 auto := autoAuditRows(rows) if len(auto) != 1 { t.Fatalf("自动档审计 = %d 条, want 1(%+v)", len(auto), *rows) } if !strings.Contains(auto[0].result, "auto=true") || !strings.Contains(auto[0].result, tidyAutoReasonLiteralDuplicate) { t.Fatalf("审计 result = %q, want 含 auto=true 与 %s", auto[0].result, tidyAutoReasonLiteralDuplicate) } if strings.Contains(auto[0].result, fixtureDupText) { t.Fatal("审计里不得出现记忆正文") } if auto[0].who != tidyAuditWho { t.Fatalf("审计 who = %q, want %q(系统任务,不是角色)", auto[0].who, tidyAuditWho) } // 该落地写进了这一轮的留痕(memory_tidy_runs.applied 是真数,不是 0)。 runs, err := store.ListTidyRuns(ctx, 1) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 1 || runs[0].Applied != 1 { t.Fatalf("运行留痕 = %+v, want applied=1", runs) } } // TestTidyAutoMergeEquivalentFactsHighSimilarity 是判据 ②:事实集合等价(双向包含) // 且相似度 ≥ 0.95 ⇒ 自动合并。 func TestTidyAutoMergeEquivalentFactsHighSimilarity(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) a := seedTidyItem(t, store, habitItem(fixtureEquivA, base, 0.9)) b := seedTidyItem(t, store, habitItem(fixtureEquivB, base.Add(time.Minute), 0.9)) tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureEquivA: {1, 0}, fixtureEquivB: simVector(0.96), }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.AutoApplied != 1 || stats.AutoMerged != 1 { t.Fatalf("自动档计数 = %+v, want applied/merged = 1/1(夹具:等价 ∧ 0.96)", stats) } if got := loadTidyItemRow(t, store, b.ID); got.status != ingestStatusMerged || got.mergedInto != a.ID { t.Fatalf("被吸收行 = %+v, want merged into %s", got, a.ID) } } // TestTidyAutoSkipsSubsetMerge 是判据 ② 的反面,也是「不许自动做的事」之一: // **真子集**(A ⊆ B 但不等价、也不是字面重复)不自动 —— 它可能丢信息,交人工。 func TestTidyAutoSkipsSubsetMerge(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) emb := stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.99), // 即使相似度极高 }} tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), emb) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Candidates != 1 { t.Fatalf("候选 = %d, want 1(真子集仍产 merge 候选,交人工)", stats.Candidates) } if stats.AutoEligible != 0 || stats.AutoApplied != 0 { t.Fatalf("真子集被自动合并了:%+v(不许自动做的事)", stats) } for _, id := range []string{short.ID, long.ID} { if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive { t.Fatalf("行 %s 被自动改了:%+v", id, got) } } cands := allTidyCandidates(t, store, 10) if len(cands) != 1 || cands[0].Status != tidyCandidatePending { t.Fatalf("候选 = %+v, want 恰 1 条 pending(等人点)", cands) } } // TestTidyAutoLowConfidenceNeverLands 是判据 ③:低置信**不自动**。 // // 它的形状值得一提:低置信的对在**规则层**就被否决(design §5.2-6 的 VetoLowConfidence), // 因此连候选都不会有 —— 自动档根本看不到它。这条断言比「自动档里加一层低置信过滤」 // 更强:它证明低置信根本进不了自动档的输入面。 func TestTidyAutoLowConfidenceNeverLands(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) low := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.6)) highText := fixtureDupText + ",提神用" high := seedTidyItem(t, store, habitItem(highText, base.Add(time.Minute), 0.9)) tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureDupText: {1, 0}, highText: simVector(0.98), }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.VetoReasons[string(VetoLowConfidence)] < 1 { t.Fatalf("低置信否决没有发生:%+v", stats.VetoReasons) } if stats.Candidates != 0 || stats.AutoEligible != 0 || stats.AutoApplied != 0 { t.Fatalf("低置信对产出了候选或落地:%+v", stats) } cands := allTidyCandidates(t, store, 10) if len(cands) != 0 { t.Fatalf("低置信对不该产候选:%+v", cands) } for _, id := range []string{low.ID, high.ID} { if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive { t.Fatalf("低置信对改变了行 %s:%+v", id, got) } } if len(autoAuditRows(rows)) != 1 { t.Fatalf("自动档审计 = %d 条, want 1(跑了但零落地也要如实留痕):%+v", len(autoAuditRows(rows)), *rows) } if !strings.Contains(autoAuditRows(rows)[0].result, "applied=0") { t.Fatalf("自动档审计 = %q, want applied=0", autoAuditRows(rows)[0].result) } } // TestTidyAutoArchiveOneOffEvent 是判据 ④:一次性事件(含绝对日期/时刻)⇒ 自动软删 // (status='archived',行与正文都还在)。 func TestTidyAutoArchiveOneOffEvent(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9)) tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), nil) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.AutoApplied != 1 || stats.AutoArchived != 1 { t.Fatalf("自动档计数 = %+v, want applied/archived = 1/1", stats) } got := loadTidyItemRow(t, store, oneOff.ID) if got.status != tidyStatusArchived || got.content != fixtureOneOffEvent { t.Fatalf("归档结果 = %+v, want archived 且正文不变", got) } if count := countMemoryItems(t, store); count != 1 { t.Fatalf("行数 = %d, want 1(软删除)", count) } auto := autoAuditRows(rows) if len(auto) != 1 || !strings.Contains(auto[0].result, tidyAutoReasonOneOffEvent) { t.Fatalf("自动档审计 = %+v, want 含 %s", auto, tidyAutoReasonOneOffEvent) } } // TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff 是判据 ⑤,也是本次改动最要紧的 // 那条守卫(design/23 §5.2-1:一次性事件 ≠ 长期习惯):正文里带钟点会让规则层把它 // 当成一次性事件,但只要正文同时是**习惯类**,自动档就必须住手 —— 归档主人的作息 // 习惯是错的。 // // ⚠️ 变异用例:去掉 tidy_auto.go 里 `looksLikeHabit` 那道守卫 ⇒ 本用例 RED。 func TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) habit := seedTidyItem(t, store, habitItem(fixtureHabitClock, base, 0.9)) tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), nil) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if got := loadTidyItemRow(t, store, habit.ID); got.status != tidyStatusActive { t.Fatalf("作息习惯被自动归档了:%+v(design §5.2-1 红线)", got) } if stats.AutoApplied != 0 { t.Fatalf("习惯类不该被自动落地:%+v", stats) } // 候选仍要产出(交人工看),只是不自动。 cands := allTidyCandidates(t, store, 10) if len(cands) != 1 || cands[0].Action != TidyArchive || cands[0].Reason != tidyReasonOneOffEvent { t.Fatalf("候选 = %+v, want 恰 1 条 archive/one_off_event 待人工", cands) } if cands[0].Status != tidyCandidatePending { t.Fatalf("习惯类的归档候选不该被自动应用:%+v", cands[0]) } } // TestTidyAutoPerRoundLimitTruncatesAndDrains 是判据 ⑥:每轮上限生效、超限如实报 // Truncated + 真总数,且**下一轮继续排干**(不吞掉剩下的)。 // // ⚠️ 变异用例:把 AutoApplyPerRound 的上限判断去掉(一次全做)⇒ 本用例 RED。 func TestTidyAutoPerRoundLimitTruncatesAndDrains(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) const oneOffs = 5 ids := make([]string, 0, oneOffs) for i := 0; i < oneOffs; i++ { item := habitItem(fixtureOneOffEvent, base.Add(time.Duration(i)*time.Minute), 0.9) ids = append(ids, seedTidyItem(t, store, item).ID) } cfg := tidyTestCfg(0.80) cfg.AutoApplyPerRound = 2 tr, _ := newAutoTestTidier(t, store, cfg, nil) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("第一轮 RunOnce: %v", err) } if stats.AutoEligible != oneOffs || stats.AutoApplied != 2 || !stats.AutoTruncated { t.Fatalf("第一轮自动档 = %+v, want eligible=5 applied=2 truncated=true", stats) } if got := countArchived(t, store, ids); got != 2 { t.Fatalf("实际归档 = %d, want 2(上限必须真的拦住)", got) } stats, err = tr.RunOnce(ctx) if err != nil { t.Fatalf("第二轮 RunOnce: %v", err) } if stats.AutoApplied != 2 || stats.AutoEligible != oneOffs-2 { t.Fatalf("第二轮自动档 = %+v, want applied=2 eligible=3(剩下的下一轮继续)", stats) } if got := countArchived(t, store, ids); got != 4 { t.Fatalf("两轮后归档 = %d, want 4", got) } } // TestTidyAutoDisabledKeepsSuggestOnly 是判据 ⑦:关掉开关 ⇒ **一键回到只产建议** // (一条都不落地,连自动档的审计都不写)。 func TestTidyAutoDisabledKeepsSuggestOnly(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9)) dupA := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9)) cfg := tidyTestCfg(0.80) cfg.DisableAutoApply = true tr, rows := newAutoTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureDupText: {1, 0}, }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.AutoEligible != 0 || stats.AutoApplied != 0 { t.Fatalf("开关关闭却仍在自动落地:%+v", stats) } for _, id := range []string{oneOff.ID, dupA.ID} { if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive { t.Fatalf("开关关闭时行 %s 被改了:%+v", id, got) } } if len(autoAuditRows(rows)) != 0 { t.Fatalf("开关关闭时不该写自动档审计:%+v", *rows) } // 建议照旧产出(只产建议 = 候选在、记忆不动)。 cands := allTidyCandidates(t, store, 10) if len(cands) == 0 { t.Fatal("关闭自动档不该连候选都不产(那是关闭整理,不是关闭自动档)") } } // TestTidyAutoRevertRestoresBothActions 是判据 ⑧:自动落地的 merge 与 archive 都能 // 回滚(候选退回 pending、记忆行复原、行数不变)。 func TestTidyAutoRevertRestoresBothActions(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) first := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)) second := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9)) oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9)) rowsBefore := countMemoryItems(t, store) tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureDupText: {1, 0}, }}) if _, err := tr.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } if got := loadTidyItemRow(t, store, second.ID); got.status != ingestStatusMerged { t.Fatalf("前置条件不成立(该条没被自动合并):%+v", got) } if got := loadTidyItemRow(t, store, oneOff.ID); got.status != tidyStatusArchived { t.Fatalf("前置条件不成立(该条没被自动归档):%+v", got) } for _, c := range allTidyCandidates(t, store, 10) { if c.Status != tidyCandidateApplied { t.Fatalf("自动落地的候选应当是 applied:%+v", c) } res, err := store.RevertTidyCandidate(ctx, c.ID, TidyApplyOptions{Actor: "owner:test"}) if err != nil { t.Fatalf("RevertTidyCandidate(%s): %v", c.ID, err) } if res.Status != TidyApplyApplied { t.Fatalf("回滚结果 = %+v, want applied", res) } if cand, err := store.GetTidyCandidate(ctx, c.ID); err != nil || cand.Status != tidyCandidatePending { t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", cand.Status, err) } } restored := loadTidyItemRow(t, store, second.ID) if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.mergeCount != 1 { t.Fatalf("软合并未复原:%+v", restored) } if got := loadTidyItemRow(t, store, oneOff.ID); got.status != tidyStatusActive { t.Fatalf("软删除未复原:%+v", got) } if got := countMemoryItems(t, store); got != rowsBefore { t.Fatalf("回滚前后行数 %d → %d,want 不变", rowsBefore, got) } survivor := loadTidyItemRow(t, store, first.ID) if survivor.mergeCount != 1 { t.Fatalf("存活行 merge_count = %d, want 1(回滚要摘掉那次合并)", survivor.mergeCount) } } // TestTidyAutoSkipsRestrictedSourceAndForeignPartition 钉住两条「不许自动做的事」: // 受限来源(群聊/陌生人,design §5.2-9/§6)与跨分区候选,在**执行面**被拒 —— // 候选行是落库数据,可能来自更早的规则版本或手工改动,因此纵深防御必须有。 func TestTidyAutoSkipsRestrictedSourceAndForeignPartition(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) group := seedTidyItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: fixtureOneOffEvent, Confidence: 0.9, CreatedAt: base, SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"), }) owner := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9)) // ① 受限来源的归档候选(手工落库,模拟历史行 / 被别处塞进来的行)。 restricted := TidyCandidate{ ID: mustNewTidyID(), PersonaID: group.PersonaID, Scope: group.Scope, Kind: group.Kind, Action: TidyArchive, SourceIDs: []string{group.ID}, ProposedContent: group.Content, Reason: tidyReasonOneOffEvent, Evidence: TidyEvidence{Containment: string(containNone), ContentA: group.Content}, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base, } // ② 跨分区的合并候选:候选说 user 区,行在 persona 区。 foreign := TidyCandidate{ ID: mustNewTidyID(), PersonaID: owner.PersonaID, Scope: ScopeUser, Kind: owner.Kind, Action: TidyMerge, SourceIDs: []string{owner.ID}, TargetID: group.ID, ProposedContent: owner.Content, MergedContent: group.Content, Reason: tidyReasonFactSubset, Evidence: TidyEvidence{ Similarity: 1, Containment: string(containBoth), ContentA: owner.Content, ContentB: owner.Content, }, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base, } for _, c := range []TidyCandidate{restricted, foreign} { if created, err := store.insertTidyCandidate(ctx, c); err != nil || !created { t.Fatalf("落候选 %s = (%t, %v), want (true, nil)", c.ID, created, err) } } batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) if err != nil { t.Fatalf("AutoApplyTidyCandidates: %v", err) } if batch.EligibleTotal != 2 { t.Fatalf("命中判据 = %d, want 2(两条都看似高置信)", batch.EligibleTotal) } if batch.Applied != 0 || batch.Skipped != 2 { t.Fatalf("回执 = %+v, want applied=0 skipped=2(执行面的硬门拦住)", batch) } if got := loadTidyItemRow(t, store, group.ID); got.status != tidyStatusActive { t.Fatalf("群聊来源被归档了:%+v(design §5.2-9 红线)", got) } if got := loadTidyItemRow(t, store, owner.ID); got.status != tidyStatusActive { t.Fatalf("跨分区候选改动了行:%+v", got) } }