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

+511
View File
@@ -0,0 +1,511 @@
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 各一次)。
//
// 外加两条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与
// 群聊来源的候选在**执行面**被拒。
const (
// fixtureHabitClock 是「被钟点误判」的典型:正文里带绝对时刻(23:30)⇒ 规则层
// 会把它当一次性事件产 archive 候选,但它明明白白是**作息习惯**(「常在」「习惯」)。
fixtureHabitClock = "主人常在深夜23:30后还醒着,有熬夜习惯"
// fixtureDupText 是字面重复用例的正文(两侧逐字相同)。
fixtureDupText = "主人喜欢在深夜喝冰美式"
// fixtureEquivA / fixtureEquivB 是**事实集合等价但不同措辞**的一对
// (关键要素逐一互相包含 ⇒ containBoth,正文不同 ⇒ 不是字面重复)。
fixtureEquivA = "主人喜欢喝红牛"
fixtureEquivB = "红牛,主人喜欢喝"
)
// newAutoTestTidier 构建整理器并把审计出口捕获下来(自动档的审计是断言面之一)。
func newAutoTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *[]tidyAuditRow) {
t.Helper()
rows := make([]tidyAuditRow, 0)
tr, err := NewTidier(cfg, TidyDeps{
Store: store,
Embedder: emb,
Log: testLogger(),
Audit: func(who, what, result string) {
rows = append(rows, tidyAuditRow{who: who, what: what, result: result})
},
})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr, &rows
}
// autoAuditRows 返回捕获到的 memory.tidy.apply(自动档)审计行。
func autoAuditRows(rows *[]tidyAuditRow) []tidyAuditRow {
out := make([]tidyAuditRow, 0)
for _, r := range *rows {
if r.what == tidyAutoApplyVerb {
out = append(out, r)
}
}
return out
}
// 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)
}
}
// countArchived 数给定 id 里已归档的行数。
func countArchived(t *testing.T, store *Store, ids []string) int {
t.Helper()
n := 0
for _, id := range ids {
if loadTidyItemRow(t, store, id).status == tidyStatusArchived {
n++
}
}
return n
}