Files
Cyrene_v2/internal/runtime/memory/tidy_auto_test.go
T
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

512 lines
21 KiB
Go
Raw 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"
"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
}