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

265 lines
9.7 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"
"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, &notNull, &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。