Files
AskaEth f310173b4c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
自动落地现在三类:merge(字面重复 ∨ 事实等价 ∧ ≥0.95)· archive(one_off_event
且不像习惯)· **reclassify(新增)**。reclassify 只改 `kind` 一列
(`reclassifyTidyItemSQL` 只写 kind+updated_at),**不动正文、不动作用域、不碰第二条行**。

判据(纯函数 `tidyAutoReclassifyKind`,四个条件全中才自动):
① 恰 1 个 source 且无 target;② 理由 = `kind_mismatch`;③ 建议 kind 合法;
④ **改前 kind 合法且 ≠ 建议值** —— ③④ 是 **revert 抓手**:没有它自动落地就不可回滚。
**边界**:只读 ProposedKind / KindA,**一概不读** ProposedContent / MergedContent /
ContentA/B / 分区 / 时间字段。

六条护栏逐条覆盖它:软动作(同一条 apply 路径)/ 每轮上限(三类同占一个池子,
默认 50 硬顶 500,混合时截断如实)/ 审计 `memory.tidy.apply`(`auto=true`,
`reclassified=%d` + stats + 日志 + run 审计,**只带计数与理由、不带正文**)/
**可 revert**(`revertTidyReclassify` 按 KindA 写回,且只在当前 kind 仍等于建议值时生效)/
幂等(候选条件更新 + 行 `AND status='active'` 双闸)/ 开关关闭 ⇒ 连查询都不发。
另补一道本片新增的门:**受限来源**(群聊/陌生人)与跨分区的 reclassify **skipped**
(原先只有 merge/archive 有这道门)。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + vet 绿;memory/gateway 两包
测试绿;我用 -overlay 禁掉「必须真的变了」那一格 ⇒ `…EligibilityBoundary` **3 条子例变红**
(改前 kind 缺失/非法/与建议值相同 ⇒ 都"没法回滚")。子代理另做 4 个变异(连正文一起写 /
摘受限来源门 / 去掉 reclassified 计数 / 不要求可回滚抓手)全红。

🔴 **未动的红线(一行未改)**:`archive` 的「习惯类不归档」守卫(design/23 §5.2-1)
—— `looksLikeHabit` / `tidyHabitMarkers` / `tidyReasonOneOffEvent` 零改动,
`TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff` 仍绿。要连它也自动请主人明说。

⚠️ 未部署(要一次 build + restart);⚠️ design/23 的 §7「reclassify 交人工」与
§13 开放点 5「全部人工应用」**现已过期**,主会话同步。
⚠️ 已知遗留(需新授权):历史积压里指向**已被吸收行**的 reclassify 候选会每轮被选中
又永远 `source_not_active` 跳过,白占一次每轮名额;彻底解法是「永久不可用的候选自动
标 rejected」(新行为)。
2026-10-07 11:26:34 +08:00

463 lines
19 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"
"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)
}
}