自动落地现在三类: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」(新行为)。
463 lines
19 KiB
Go
463 lines
19 KiB
Go
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)
|
||
}
|
||
}
|