主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
512 lines
21 KiB
Go
512 lines
21 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 各一次)。
|
||
//
|
||
// 外加两条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与
|
||
// 群聊来源的候选在**执行面**被拒。
|
||
|
||
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
|
||
}
|