自动落地现在三类: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」(新行为)。
265 lines
9.7 KiB
Go
265 lines
9.7 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、
|
||
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→不产候选 /
|
||
// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。
|
||
// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人
|
||
// 来源的条目既不合并也不归档,只读不写。
|
||
func TestTidyGroupSourceExcluded(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
groupItem := seedTidyItem(t, store, MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
|
||
})
|
||
ownerItem := seedTidyItem(t, store, habitItem(fixtureHabitShort, base.Add(time.Minute), 0.9))
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.VetoReasons[string(VetoSource)] < 1 {
|
||
t.Fatalf("否决原因分布里没有 source:%+v", stats.VetoReasons)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
for _, c := range cands {
|
||
if containsID(c.SourceIDs, groupItem.ID) {
|
||
t.Fatalf("群聊来源条目进了候选:%+v", c)
|
||
}
|
||
}
|
||
if ownerItem.ID == "" {
|
||
t.Fatal("owner 条目应当落库成功")
|
||
}
|
||
}
|
||
|
||
// TestTidyLowConfidenceVetoed 是 design/23 §12.2 N4 与 §5.2-6:confidence 不是
|
||
// 合并门,但必须是否决门。
|
||
func TestTidyLowConfidenceVetoed(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
low := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.6))
|
||
high := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
if v := judgePair(low, high, 0.99); v.Veto != VetoLowConfidence {
|
||
t.Fatalf("低置信 veto = %q, want %q", v.Veto, VetoLowConfidence)
|
||
}
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.97),
|
||
}})
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(cands) != 0 {
|
||
t.Fatalf("低置信对产出了候选:%+v", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径:
|
||
// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选。
|
||
//
|
||
// 2026-10-07「全自动挡吧」起两条的**归宿不同**:这里的 merge 是真子集(不在自动档
|
||
// 判据内)⇒ 留待人工;reclassify 形状完整 ⇒ 由轮末自动档当场落地(只改 kind 那一行)。
|
||
// 因此候选要按**全部状态**读,只看待办队列会把「已经自动改完了」读成「没产出」。
|
||
func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
short := MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
}
|
||
long := MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour),
|
||
}
|
||
seededShort := seedTidyItem(t, store, short)
|
||
seedTidyItem(t, store, long)
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.95),
|
||
}})
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
cands := allTidyCandidates(t, store, 50)
|
||
merges := candidatesByAction(cands, TidyMerge)
|
||
reclasses := candidatesByAction(cands, TidyReclassify)
|
||
if len(merges) != 1 || len(reclasses) != 1 {
|
||
t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands)
|
||
}
|
||
if reclasses[0].Status != tidyCandidateApplied {
|
||
t.Fatalf("shape 完整的 reclassify 候选该被自动档落地:%+v", reclasses[0])
|
||
}
|
||
if merges[0].Status != tidyCandidatePending {
|
||
t.Fatalf("真子集 merge 不在自动档判据内,该留待人工:%+v", merges[0])
|
||
}
|
||
if got := loadTidyItemRow(t, store, seededShort.ID); got.kind != string(KindRelationship) {
|
||
t.Fatalf("被误分类的行 kind = %q, want %q(自动档只改分类列)", got.kind, KindRelationship)
|
||
}
|
||
if got := loadTidyItemRow(t, store, seededShort.ID); got.content != fixtureHabitShort {
|
||
t.Fatalf("reclassify 改了正文:%q", got.content)
|
||
}
|
||
}
|
||
|
||
// TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限:
|
||
// 超出的只登记不入队,且绝不静默。
|
||
func TestTidyCandidateLimitPerPartition(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
shortA, longA := fixtureWorkShort, fixtureWorkLong
|
||
shortB, longB := "主人喜欢被叫小笨蛋", "主人喜欢被叫小笨蛋,还喜欢被摸摸头"
|
||
seedTidyItem(t, store, habitItem(shortA, base, 0.9))
|
||
seedTidyItem(t, store, habitItem(longA, base.Add(time.Minute), 0.9))
|
||
seedTidyItem(t, store, habitItem(shortB, base.Add(2*time.Minute), 0.9))
|
||
seedTidyItem(t, store, habitItem(longB, base.Add(3*time.Minute), 0.9))
|
||
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.CandidateLimit = 1
|
||
// 两族向量彼此近似正交(跨族 cosine ≈ 0.31 < 0.80),因此恰好召回 2 对。
|
||
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||
shortA: {1, 0},
|
||
longA: simVector(0.95),
|
||
shortB: {0, 1},
|
||
longB: {float32(math.Sqrt(1 - 0.95*0.95)), 0.95},
|
||
}})
|
||
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.Candidates != 1 {
|
||
t.Fatalf("候选 = %d, want 1(每分区上限)", stats.Candidates)
|
||
}
|
||
if stats.Skipped < 1 {
|
||
t.Fatalf("超限未登记:skipped=%d", stats.Skipped)
|
||
}
|
||
}
|
||
|
||
// TestTidyOfflineLexicalRecall 锁定「无 embedder 时的零依赖回退」:整理在没有
|
||
// embedding 配置时仍能召回并判定(阈值仍是可配的召回门)。
|
||
func TestTidyOfflineLexicalRecall(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
cfg := tidyTestCfg(0.5)
|
||
tr := newTestTidier(t, store, cfg, nil)
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.Pairs != 1 {
|
||
t.Fatalf("词项余弦召回 pairs = %d, want 1", stats.Pairs)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(candidatesByAction(cands, TidyMerge)) != 1 {
|
||
t.Fatalf("离线回退未产出 merge 候选:%+v", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、
|
||
// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 200 / 比对 2000
|
||
// (design §9/§13;后两个是切片 MT-A 抬高的护栏,见 tidy_config.go 的注释)。
|
||
func TestTidyConfigDefaults(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := normalizeTidyConfig(TidyConfig{Enabled: true})
|
||
if cfg.IntervalSec != 86400 || cfg.Interval != 24*time.Hour {
|
||
t.Fatalf("周期默认 = %ds / %s, want 86400s / 24h", cfg.IntervalSec, cfg.Interval)
|
||
}
|
||
if cfg.RecallThreshold != 0.80 {
|
||
t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold)
|
||
}
|
||
if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 200 || cfg.CompareLimit != 2000 {
|
||
t.Fatalf("配额默认 = %d/%d/%d, want 5000/200/2000(scan/candidate/compare)",
|
||
cfg.ScanLimit, cfg.CandidateLimit, cfg.CompareLimit)
|
||
}
|
||
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||
t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour)
|
||
}
|
||
if TidySessionID != "sess-memorytidy" {
|
||
t.Fatalf("隔离会话 = %q, want sess-memorytidy", TidySessionID)
|
||
}
|
||
}
|
||
|
||
// TestTidySchemaCreatedByNewStore 锁定「整理在任意 *sql.DB 上都可用」:
|
||
// NewStore 幂等创建候选表/运行表并前滚 memory_items 的整理列。
|
||
func TestTidySchemaCreatedByNewStore(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
if _, err := NewStore(ctx, store.db, testLogger()); err != nil {
|
||
t.Fatalf("第二次 NewStore(幂等): %v", err)
|
||
}
|
||
for _, table := range []string{"memory_tidy_candidates", "memory_tidy_runs"} {
|
||
var n int
|
||
if err := store.db.QueryRowContext(ctx,
|
||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?", table).Scan(&n); err != nil {
|
||
t.Fatalf("查询表 %s: %v", table, err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("表 %s 不存在", table)
|
||
}
|
||
}
|
||
rows, err := store.db.QueryContext(ctx, "PRAGMA table_info(memory_items)")
|
||
if err != nil {
|
||
t.Fatalf("pragma table_info: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
cols := map[string]bool{}
|
||
for rows.Next() {
|
||
var (
|
||
cid int
|
||
name, typ string
|
||
notNull int
|
||
dflt any
|
||
pk int
|
||
)
|
||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||
t.Fatalf("scan pragma: %v", err)
|
||
}
|
||
cols[name] = true
|
||
}
|
||
for _, want := range []string{"status", "merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at"} {
|
||
if !cols[want] {
|
||
t.Fatalf("memory_items 缺列 %s(现有列:%v)", want, cols)
|
||
}
|
||
}
|
||
}
|
||
|
||
// containsID 报告 ids 里是否含 id。
|