自动落地现在三类: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」(新行为)。
476 lines
20 KiB
Go
476 lines
20 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件锁定轮末自动档对 **reclassify**(kind 误分类修正,design/23 §5.1 T0-6)的
|
||
// 覆盖 —— 2026-10-07 主人追加口径「全自动挡吧」。它只改分类列:正文一字不动、
|
||
// 可回滚(靠候选证据 KindA)、计入每轮上限、开关一关就只产建议、
|
||
// 受限来源/跨分区一律跳过、畸形候选带的正文一律不采纳。
|
||
|
||
// TestTidyAutoReclassifyKindMismatch 是判据 ⑨:kind 误分类的候选由轮末自动档直接
|
||
// 落地 —— **只改 kind 一列**,正文/状态/行数一字不动,并如实写审计与留痕。
|
||
//
|
||
// 本用例走的是**规则层真实的产出路径**(RunOnce):包含关系成立但 kind 不同 ⇒
|
||
// 1 条 merge(真子集,不在自动档判据内 ⇒ 留人工)+ 1 条 reclassify(自动落地)。
|
||
func TestTidyAutoReclassifyKindMismatch(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
bad := seedWorldEventItem(t, store, fixtureHabitShort, base)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.95),
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.AutoApplied != 1 || stats.AutoReclassified != 1 {
|
||
t.Fatalf("自动档计数 = %+v, want applied/reclassified = 1/1", stats)
|
||
}
|
||
if stats.AutoMerged != 0 || stats.AutoArchived != 0 || stats.AutoTruncated {
|
||
t.Fatalf("自动档不该顺手动别的:%+v", stats)
|
||
}
|
||
|
||
after := loadTidyItemRow(t, store, bad.ID)
|
||
if after.kind != string(KindRelationship) {
|
||
t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship)
|
||
}
|
||
if after.content != fixtureHabitShort {
|
||
t.Fatalf("自动 reclassify 改了正文:%q(design:只改分类列)", after.content)
|
||
}
|
||
if after.status != tidyStatusActive || after.mergedInto != "" {
|
||
t.Fatalf("自动 reclassify 动了别的列:%+v", after)
|
||
}
|
||
if got := countMemoryItems(t, store); got != 2 {
|
||
t.Fatalf("行数 = %d, want 2(绝不物理删除)", got)
|
||
}
|
||
|
||
cands := allTidyCandidates(t, store, 10)
|
||
res := candidatesByAction(cands, TidyReclassify)
|
||
if len(res) != 1 || res[0].Status != tidyCandidateApplied {
|
||
t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 applied(自动档落的)", res)
|
||
}
|
||
merges := candidatesByAction(cands, TidyMerge)
|
||
if len(merges) != 1 || merges[0].Status != tidyCandidatePending {
|
||
t.Fatalf("真子集 merge 候选 = %+v, want 仍 pending(自动档不许扩权)", merges)
|
||
}
|
||
|
||
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, "reclassified=1") ||
|
||
!strings.Contains(auto[0].result, tidyAutoReasonKindMismatch) {
|
||
t.Fatalf("审计 result = %q, want 含 auto=true / reclassified=1 / %s",
|
||
auto[0].result, tidyAutoReasonKindMismatch)
|
||
}
|
||
if strings.Contains(auto[0].result, fixtureHabitShort) {
|
||
t.Fatal("审计里不得出现记忆正文")
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoReclassifyRevertRestoresKind 是护栏「可 revert」对 reclassify 的覆盖:
|
||
// 自动落地之后必须能把 kind 复原(靠候选证据里的 KindA),正文全程一字不动。
|
||
func TestTidyAutoReclassifyRevertRestoresKind(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
|
||
cand := autoReclassifyCandidate(row, KindRelationship, "", base)
|
||
insertAutoCandidateOrFail(t, store, cand)
|
||
before := loadTidyItemRow(t, store, row.ID)
|
||
|
||
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if batch.Applied != 1 || batch.Reclassified != 1 || batch.Skipped != 0 {
|
||
t.Fatalf("自动落地回执 = %+v, want applied/reclassified = 1/1", batch)
|
||
}
|
||
if got := loadTidyItemRow(t, store, row.ID); got.kind != string(KindRelationship) {
|
||
t.Fatalf("kind = %q, want %q", got.kind, KindRelationship)
|
||
}
|
||
|
||
res, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||
if err != nil {
|
||
t.Fatalf("RevertTidyCandidate: %v", err)
|
||
}
|
||
if res.Status != TidyApplyApplied {
|
||
t.Fatalf("回滚结果 = %+v, want applied", res)
|
||
}
|
||
restored := loadTidyItemRow(t, store, row.ID)
|
||
if restored.kind != before.kind {
|
||
t.Fatalf("回滚后 kind = %q, want %q(靠候选证据 KindA 复原)", restored.kind, before.kind)
|
||
}
|
||
if restored.content != before.content || restored.status != before.status {
|
||
t.Fatalf("回滚动了别的列:%+v(before=%+v)", restored, before)
|
||
}
|
||
if got, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || got.Status != tidyCandidatePending {
|
||
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", got.Status, err)
|
||
}
|
||
if got := countMemoryItems(t, store); got != 1 {
|
||
t.Fatalf("行数 = %d, want 1(回滚绝不删行)", got)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoReclassifyIsIdempotent 是护栏「幂等」对 reclassify 的覆盖:候选状态
|
||
// 的条件更新是执行闸 —— 第二轮(以及直接再 apply 一次)不产生第二次副作用。
|
||
func TestTidyAutoReclassifyIsIdempotent(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
|
||
cand := autoReclassifyCandidate(row, KindRelationship, "", base)
|
||
insertAutoCandidateOrFail(t, store, cand)
|
||
|
||
first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("第一次 AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if first.Applied != 1 {
|
||
t.Fatalf("第一次回执 = %+v, want applied=1", first)
|
||
}
|
||
settled := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
|
||
|
||
// 已 applied 的候选不再是 pending ⇒ 第二轮连看都不看它(EligibleTotal=0)。
|
||
second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("第二次 AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if second.EligibleTotal != 0 || second.Applied != 0 {
|
||
t.Fatalf("第二次回执 = %+v, want 零命中零落地(候选已不在待定集)", second)
|
||
}
|
||
// 直接再 apply 同一条 ⇒ 条件更新没命中 ⇒ 如实报 already_decided,且**零副作用**。
|
||
out, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||
if err != nil {
|
||
t.Fatalf("重复 ApplyTidyCandidate: %v", err)
|
||
}
|
||
if out.Status != TidyApplySkipped || out.Reason != tidySkipAlreadyDecided {
|
||
t.Fatalf("重复应用结果 = %+v, want skipped/%s", out, tidySkipAlreadyDecided)
|
||
}
|
||
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); !slices.Equal(got, settled) {
|
||
t.Fatalf("重复应用产生了第二次副作用:\n got=%v\nwant=%v", got, settled)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoPerRoundLimitCountsReclassify 是护栏「每轮上限」对 reclassify 的覆盖:
|
||
// 三类动作**同占一个池子**,超限截断如实(EligibleTotal 是真总数),下一轮把剩下的
|
||
// 排干 —— 绝不静默少做,也绝不因为动作不同而另开一份配额。
|
||
func TestTidyAutoPerRoundLimitCountsReclassify(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
oneA := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
|
||
oneB := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9))
|
||
reA := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(2*time.Minute))
|
||
reB := seedWorldEventItem(t, store, fixtureWorkLong, base.Add(3*time.Minute))
|
||
|
||
// 顺序由 created_at 决定(最新在前):reclassify 两条排在 archive 两条之前。
|
||
for _, c := range []TidyCandidate{
|
||
autoArchiveCandidate(oneA, base),
|
||
autoArchiveCandidate(oneB, base),
|
||
autoReclassifyCandidate(reA, KindRelationship, "", base.Add(time.Minute)),
|
||
autoReclassifyCandidate(reB, KindRelationship, "", base.Add(time.Minute)),
|
||
} {
|
||
insertAutoCandidateOrFail(t, store, c)
|
||
}
|
||
|
||
first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2})
|
||
if err != nil {
|
||
t.Fatalf("第一轮 AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if first.EligibleTotal != 4 || first.Applied != 2 || !first.Truncated {
|
||
t.Fatalf("第一轮回执 = %+v, want eligible_total=4 applied=2 truncated=true", first)
|
||
}
|
||
if first.Reclassified != 2 || first.Archived != 0 {
|
||
t.Fatalf("第一轮动作分解 = %+v, want Reclassified=2(reclassify 也算进上限)", first)
|
||
}
|
||
if got := loadTidyItemRow(t, store, reA.ID); got.kind == string(KindWorldEvent) {
|
||
t.Fatalf("被算进上限的两条 reclassify 该落地:%+v", got)
|
||
}
|
||
if got := loadTidyItemRow(t, store, oneA.ID); got.status == tidyStatusArchived {
|
||
t.Fatalf("超出上限的 archive 不该被做:%+v", got)
|
||
}
|
||
|
||
second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2})
|
||
if err != nil {
|
||
t.Fatalf("第二轮 AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if second.EligibleTotal != 2 || second.Applied != 2 || second.Truncated {
|
||
t.Fatalf("第二轮回执 = %+v, want eligible_total=2 applied=2 truncated=false(下一轮排干)", second)
|
||
}
|
||
if second.Archived != 2 {
|
||
t.Fatalf("第二轮动作分解 = %+v, want Archived=2", second)
|
||
}
|
||
if got := countArchived(t, store, []string{oneA.ID, oneB.ID}); got != 2 {
|
||
t.Fatalf("两轮后归档 = %d, want 2", got)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoSkipsRestrictedAndForeignReclassify 钉住两类「不许自动做的事」对
|
||
// reclassify 同样成立:受限来源(群聊/陌生人,design §5.2-9/§6)与跨分区候选在
|
||
// **执行面**被拒 —— 候选行是落库数据,可能来自更早的规则版本或手工改动。
|
||
func TestTidyAutoSkipsRestrictedAndForeignReclassify(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: KindWorldEvent,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
|
||
})
|
||
owner := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(time.Minute))
|
||
|
||
restricted := autoReclassifyCandidate(group, KindRelationship, "", base)
|
||
foreign := autoReclassifyCandidate(owner, KindRelationship, "", base)
|
||
foreign.Scope = ScopeUser // 候选说 user 区,行在 persona 区
|
||
for _, c := range []TidyCandidate{restricted, foreign} {
|
||
insertAutoCandidateOrFail(t, store, c)
|
||
}
|
||
|
||
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.kind != string(KindWorldEvent) {
|
||
t.Fatalf("群聊来源被改了分类:%+v(design §5.2-9 红线)", got)
|
||
}
|
||
if got := loadTidyItemRow(t, store, owner.ID); got.kind != string(KindWorldEvent) {
|
||
t.Fatalf("跨分区候选改动了行:%+v", got)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoDisabledSkipsReclassify 是护栏「一键回到只产建议」对 reclassify 的
|
||
// 覆盖:开关关闭时它连候选都不碰,只产建议(与 merge/archive 同一口径)。
|
||
func TestTidyAutoDisabledSkipsReclassify(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
bad := seedWorldEventItem(t, store, fixtureHabitShort, base)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.DisableAutoApply = true
|
||
tr, rows := newAutoTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.95),
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.AutoApplied != 0 || stats.AutoReclassified != 0 || stats.AutoEligible != 0 {
|
||
t.Fatalf("开关关闭却仍在自动落地:%+v", stats)
|
||
}
|
||
if got := loadTidyItemRow(t, store, bad.ID); got.kind != string(KindWorldEvent) {
|
||
t.Fatalf("开关关闭时分类被改了:%+v", got)
|
||
}
|
||
// 建议照旧产出(reclassify 候选在,只是等人点)。
|
||
res := candidatesByAction(allTidyCandidates(t, store, 10), TidyReclassify)
|
||
if len(res) != 1 || res[0].Status != tidyCandidatePending {
|
||
t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 pending 待人工", res)
|
||
}
|
||
if len(autoAuditRows(rows)) != 0 {
|
||
t.Fatalf("开关关闭时不该写自动档审计:%+v", *rows)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoReclassifyNeverAdoptsProposedContent 是判据 ⑨ 的**边界**:reclassify
|
||
// 的授权范围只有「分类列」。候选里就算带着 proposed_content / merged_content 之类
|
||
// 的正文(畸形行、手工塞进来的行),自动档也只采纳它的分类 —— 正文一个字都不许写。
|
||
//
|
||
// ⚠️ 变异用例:把「自动落地时只改 kind」改成「连正文也写入」(例如让
|
||
// reclassifyTidyItem 顺手把候选的 ProposedContent 写进 content)⇒ 本用例 RED。
|
||
func TestTidyAutoReclassifyNeverAdoptsProposedContent(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
const strayContent = "候选想改成的正文(不许被采纳)"
|
||
const strayMerged = "候选想写成的合并后正文(不许被采纳)"
|
||
|
||
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
|
||
cand := autoReclassifyCandidate(row, KindRelationship, strayContent, base)
|
||
cand.MergedContent = strayMerged
|
||
insertAutoCandidateOrFail(t, store, cand)
|
||
before := loadTidyItemRow(t, store, row.ID)
|
||
|
||
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("AutoApplyTidyCandidates: %v", err)
|
||
}
|
||
if batch.Applied != 1 || batch.Reclassified != 1 {
|
||
t.Fatalf("回执 = %+v, want applied/reclassified = 1/1(动作本身照做)", batch)
|
||
}
|
||
after := loadTidyItemRow(t, store, row.ID)
|
||
if after.kind != string(KindRelationship) {
|
||
t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship)
|
||
}
|
||
if after.content != before.content {
|
||
t.Fatalf("自动落地把候选的正文写进了记忆:\n got=%q\nwant=%q(reclassify 无权改正文)",
|
||
after.content, before.content)
|
||
}
|
||
for _, poison := range []string{strayContent, strayMerged} {
|
||
if strings.Contains(after.content, poison) {
|
||
t.Fatalf("正文里出现了候选带的 %q —— 畸形候选的正文被采纳了", poison)
|
||
}
|
||
}
|
||
if after.status != before.status || after.mergedInto != before.mergedInto ||
|
||
after.mergedFrom != before.mergedFrom || after.mergeCount != before.mergeCount {
|
||
t.Fatalf("reclassify 动了分类以外的列:%+v(before=%+v)", after, before)
|
||
}
|
||
if got := countMemoryItems(t, store); got != 1 {
|
||
t.Fatalf("行数 = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoReclassifyEligibilityBoundary 逐条钉住 reclassify 的**自动判据**本身
|
||
// (纯函数,零 IO):它只认「形状完整 + 理由 kind_mismatch + 建议分类合法 + 有可回滚
|
||
// 的改前 kind」四种条件同时成立的那些。判据在 tidy_auto.go 的 tidyAutoReclassifyKind。
|
||
//
|
||
// 逐条反例的意义:这几条任何一条被放宽,自动档就会开始动**拿不准**或**没法回滚**的行。
|
||
func TestTidyAutoReclassifyEligibilityBoundary(t *testing.T) {
|
||
t.Parallel()
|
||
valid := TidyCandidate{
|
||
ID: "cand", PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
|
||
Action: TidyReclassify, SourceIDs: []string{"item-1"}, Reason: tidyReasonKindMismatch,
|
||
Evidence: TidyEvidence{KindA: string(KindWorldEvent), ProposedKind: string(KindRelationship)},
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
mut func(*TidyCandidate)
|
||
want bool
|
||
}{
|
||
{"形状完整 ⇒ 自动", func(*TidyCandidate) {}, true},
|
||
{"带了 proposed_content(畸形正文)仍只采纳分类 ⇒ 自动", func(c *TidyCandidate) {
|
||
c.ProposedContent = "候选想改的正文"
|
||
c.MergedContent = "候选想写的合并后正文"
|
||
}, true},
|
||
{"缺 source ⇒ 不自动", func(c *TidyCandidate) { c.SourceIDs = nil }, false},
|
||
{"两个 source ⇒ 不自动", func(c *TidyCandidate) {
|
||
c.SourceIDs = []string{"item-1", "item-2"}
|
||
}, false},
|
||
{"指着第二条行(target_id 非空)⇒ 不自动", func(c *TidyCandidate) {
|
||
c.TargetID = "item-2"
|
||
}, false},
|
||
{"理由不是 kind_mismatch ⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Reason = "someone_else_reason"
|
||
}, false},
|
||
{"建议分类非法 ⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Evidence.ProposedKind = "topic"
|
||
}, false},
|
||
{"建议分类为空 ⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Evidence.ProposedKind = ""
|
||
}, false},
|
||
{"改前 kind 缺失(没法回滚)⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Evidence.KindA = ""
|
||
}, false},
|
||
{"改前 kind 非法 ⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Evidence.KindA = "topic"
|
||
}, false},
|
||
{"建议值与改前值相同(无事可做)⇒ 不自动", func(c *TidyCandidate) {
|
||
c.Evidence.ProposedKind = c.Evidence.KindA
|
||
}, false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
c := valid
|
||
tc.mut(&c)
|
||
got, reason := tidyAutoApplyEligible(c)
|
||
if got != tc.want {
|
||
t.Fatalf("tidyAutoApplyEligible = %t (%q), want %t", got, reason, tc.want)
|
||
}
|
||
if got && reason != tidyAutoReasonKindMismatch {
|
||
t.Fatalf("自动理由 = %q, want %q", reason, tidyAutoReasonKindMismatch)
|
||
}
|
||
if !got && reason != "" {
|
||
t.Fatalf("不该自动的候选却带了理由 %q(审计会把它记成落地理由)", reason)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTidyAutoReclassifyBeforeCompanionMerge 钉住现实里最常见的那条组合路径:规则层在
|
||
// **kind 不同**的一对上会同时产 1 条 merge + 1 条 reclassify(design/23 §12.1 P7),
|
||
// 两条都命中自动判据时必须在**同一轮**里都落地 —— 否则被吸收行先变成 'merged',那条
|
||
// reclassify 就永远卡在待定集里(每轮被重新选中、又永远跳过,白吃一次每轮上限)。
|
||
//
|
||
// 它同时是「reclassify 只改分类、merge 只软合并」两条护栏的合力验证:正文一字不动、
|
||
// 行数不变、没有候选被留在待定集里。
|
||
func TestTidyAutoReclassifyBeforeCompanionMerge(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
misclassified := seedWorldEventItem(t, store, fixtureDupText, base)
|
||
other := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9))
|
||
|
||
tr, _ := 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.AutoApplied != 2 || stats.AutoMerged != 1 || stats.AutoReclassified != 1 {
|
||
t.Fatalf("自动档计数 = %+v, want applied/merged/reclassified = 2/1/1(两条都要落地)", stats)
|
||
}
|
||
if stats.AutoTruncated {
|
||
t.Fatalf("没打满上限不该报截断:%+v", stats)
|
||
}
|
||
for _, c := range allTidyCandidates(t, store, 10) {
|
||
if c.Status == tidyCandidatePending {
|
||
t.Fatalf("有候选被留在待定集里(每轮都会白吃一次上限):%+v", c)
|
||
}
|
||
}
|
||
absorbed := loadTidyItemRow(t, store, other.ID)
|
||
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != misclassified.ID {
|
||
t.Fatalf("被吸收行 = %+v, want merged into %s", absorbed, misclassified.ID)
|
||
}
|
||
if absorbed.kind != string(KindWorldEvent) {
|
||
t.Fatalf("被吸收行的 kind = %q, want %q(reclassify 该在它被吸收之前落地)",
|
||
absorbed.kind, KindWorldEvent)
|
||
}
|
||
survivor := loadTidyItemRow(t, store, misclassified.ID)
|
||
if survivor.status != tidyStatusActive || survivor.content != fixtureDupText ||
|
||
absorbed.content != fixtureDupText {
|
||
t.Fatalf("正文/状态被动过:survivor=%+v absorbed=%+v", survivor, absorbed)
|
||
}
|
||
if got := countMemoryItems(t, store); got != 2 {
|
||
t.Fatalf("行数 = %d, want 2(绝不物理删除)", got)
|
||
}
|
||
}
|