Files
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

250 lines
9.5 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"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理的**运行隔离与心跳口径**(design/23 §4/§4.1/§9):
// 独立会话标识、低峰调度、可开关、手动可触发、审计留痕。
// fixedTidyClock 返回固定时刻的时钟,好让调度断言不依赖真实时间。
func fixedTidyClock(at time.Time) func() time.Time {
return func() time.Time { return at }
}
// newTidyServiceForTest 构建带固定时钟与可选审计出口的整理服务。
func newTidyServiceForTest(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder,
clock func() time.Time, audit func(who, what, result string)) *TidyService {
t.Helper()
svc, err := NewTidyService(cfg, TidyDeps{
Store: store, Embedder: emb, Audit: audit, Log: testLogger(), Now: clock,
})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
return svc
}
// TestTidyServiceNilStoreRejected:没有记忆存储就没有整理(依赖面收窄到最小)。
func TestTidyServiceNilStoreRejected(t *testing.T) {
t.Parallel()
if _, err := NewTidyService(TidyConfig{Enabled: true}, TidyDeps{Log: testLogger()}); err == nil {
t.Fatal("缺 Store 应当报错")
}
}
// TestTidySessionIDIsNotAConversationLane 锁定 design/23 §4.1 B:整理跑在
// 专属隔离会话,绝不是主人的对话车道(cmd/cyrene 的 isConversationLane
// 对 "web"/"web:*" 才为 true)。
func TestTidySessionIDIsNotAConversationLane(t *testing.T) {
t.Parallel()
if TidySessionID == "web" || strings.HasPrefix(TidySessionID, "web:") {
t.Fatalf("整理会话 %q 落在主人对话车道上", TidySessionID)
}
svc := newTidyServiceForTest(t, newTestStore(t), tidyTestCfg(0.80), nil, time.Now, nil)
if svc.SessionID() != TidySessionID {
t.Fatalf("SessionID() = %q, want %q", svc.SessionID(), TidySessionID)
}
}
// TestTidyServiceNextDelay 锁定低峰调度:下一个低峰窗口起点、周期下限、
// 窗口关闭时退回纯周期,且等待恒有下界(不空转)。
func TestTidyServiceNextDelay(t *testing.T) {
t.Parallel()
store := newTestStore(t)
// 15:00 起跑,低峰 23:00-07:00 ⇒ 等 8 小时。
at := time.Date(2026, 10, 5, 15, 0, 0, 0, time.Local)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(at), nil)
if got, want := svc.nextDelay(), 8*time.Hour; got != want {
t.Fatalf("nextDelay = %s, want %s", got, want)
}
// 23:30 起跑 ⇒ 下一个窗口起点是明天 23:00(约 23.5 小时),且不早于
// 「上次运行 + 周期(24h)」。
late := time.Date(2026, 10, 5, 23, 30, 0, 0, time.Local)
svc = newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(late), nil)
svc.lastRun = time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local)
if got := svc.nextDelay(); got < 23*time.Hour || got > 24*time.Hour {
t.Fatalf("周期下限未生效:nextDelay = %s, want ≈ 23.5h 且 ≤ 24h", got)
}
// 窗口关闭(两端相等且非 0)⇒ 纯周期路径。
cfg := tidyTestCfg(0.80)
cfg.QuietStartHour, cfg.QuietEndHour = 9, 9
svc = newTidyServiceForTest(t, store, cfg, nil, fixedTidyClock(at), nil)
if got, want := svc.nextDelay(), 24*time.Hour; got != want {
t.Fatalf("窗口关闭时 nextDelay = %s, want %s", got, want)
}
}
// TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately 锁定补跑判据的
// 一条边界(切片 TIDY-1):**无历史**且启动在安静窗**之外**时不做启动即跑 ——
// 新装的库不该在任意时刻烧一次全库向量化扫描,等窗口起点那一轮即可。
// 时钟是注入的固定值,因此这条断言与用例真的跑在几点无关(旧实现用
// time.Now,在 23:00–07:00 之间跑就会抖动)。
func TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
clock := &schedClock{at: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 0) // 绝不真的等到窗口起点
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 1)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("窗口外无历史时不该启动即跑:%+v", runs)
}
}
// TestTidyServiceDisabledStartIsNoop:关闭是显式选择 —— 不跑、不报错。
func TestTidyServiceDisabledStartIsNoop(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, nil, time.Now, nil)
if svc.Enabled() {
t.Fatal("Enabled() 应为 false")
}
svc.Start(ctx)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("关闭时仍跑了整理:%+v", runs)
}
}
// TestTidyServiceManualRunAndAudit:「立即整理」入口可手动触发一轮,
// 并写一条 memory.tidy.run 审计(design/23 §8;审计只带计数、不带正文)。
func TestTidyServiceManualRunAndAudit(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))
var rows []tidyAuditRow
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, time.Now, func(who, what, result string) {
rows = append(rows, tidyAuditRow{who, what, result})
})
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Candidates != 1 {
t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates)
}
// 两条审计:① 领域侧记的那条 memory.tidy.run(design §8);② 2026-10-07 起
// 轮末自动档自己写的一条 memory.tidy.apply(result 带 auto=true)。本夹具的
// merge 候选是「真子集」(a_subset_b),不命中自动判据,因此第二条是**零落地**
// 的诚实回执 —— 这正是「自动档跑了但什么都没做」该有的形状。
// 两条的先后不构成契约(领域侧先写自动档那条),因此按 what 取,不按下标取。
if len(rows) != 2 {
t.Fatalf("审计行数 = %d, want 2(run + auto apply):%+v", len(rows), rows)
}
round := auditRowOf(t, rows, "memory.tidy.run")
auto := auditRowOf(t, rows, tidyAutoApplyVerb)
if round.who != tidyAuditWho {
t.Fatalf("审计 who = %q, want %q", round.who, tidyAuditWho)
}
if strings.Contains(round.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文")
}
if !strings.Contains(auto.result, "auto=true") || !strings.Contains(auto.result, "applied=0") {
t.Fatalf("自动档审计 = %+v, want memory.tidy.apply + auto=true + applied=0", auto)
}
if strings.Contains(auto.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文(自动档那条也不行)")
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 {
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(真子集候选不在自动档授权内)", runs, TidyRuleVersion)
}
}
// TestTidyRunRecordedVetoDistribution:否决原因分布进运行表(可查询,不只在日志里)。
func TestTidyRunRecordedVetoDistribution(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9))
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitPlain: {1, 0},
fixtureHabitVacation: simVector(0.97),
}}, time.Now, nil)
if _, err := svc.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
runs, err := store.ListTidyRuns(ctx, 1)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 {
t.Fatalf("运行留痕 = %d 条, want 1", len(runs))
}
if runs[0].VetoReasons[string(VetoTemporal)] != 1 {
t.Fatalf("运行表的否决分布 = %+v, want temporal=1", runs[0].VetoReasons)
}
if runs[0].ID == "" {
t.Fatal("运行留痕缺 id(日志里的 run_id 也必须指向同一条记录)")
}
if runs[0].Scanned != 2 {
t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned)
}
}
// tidyAuditRow 是一条捕获到的审计行(本文件的用例只需这三个字段)。
type tidyAuditRow struct{ who, what, result string }
// auditRowOf 按动作名取一条审计行;缺了或重复了就当场失败(审计行必须**恰有一条**,
// 「多了」与「少了」都是缺陷)。
func auditRowOf(t *testing.T, rows []tidyAuditRow, what string) tidyAuditRow {
t.Helper()
var found []tidyAuditRow
for _, r := range rows {
if r.what == what {
found = append(found, r)
}
}
if len(found) != 1 {
t.Fatalf("审计 %s = %d 条, want 恰 1 条(全部:%+v)", what, len(found), rows)
}
return found[0]
}