Files
Cyrene_v2/internal/config/runtimeconfig/memorytidy_test.go
T
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

119 lines
4.1 KiB
Go
Raw 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 runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
//
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("空白文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() {
t.Fatal("空白文件不该被读成「关掉自动档」")
}
}
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.AutoApply != nil {
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
}
if got.AutoApplyOff() {
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
}
if got.AutoApplyPerRound != 7 {
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
}
}
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !got.AutoApplyOff() {
t.Fatal("显式 false 必须被读成「关掉自动档」")
}
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
t.Fatalf("显式 true 的回读 = %+v", got)
}
}
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
want := filepath.Join("root", "data", "config", "memory-tidy.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
}
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
}
}
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
func writeMemoryTidyFile(t *testing.T, body string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, memoryTidyFileName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}