主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
119 lines
4.1 KiB
Go
119 lines
4.1 KiB
Go
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
|
||
}
|