feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
AskaEth committed 2026-10-07 11:07:00 +08:00
1 parent 353293e807
commit 6d92536276
27 files changed
+2063 -197

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
// memorytidy.go 是记忆整理(design/23)**运行期调优**的可变源:
// `$DATA_DIR/config/memory-tidy.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`,新增配置家族从一开始就只写这里,不落老位置」)。
//
// 为什么需要它(2026-10-07 主人「那么多 link 是干什么的?该删就删,怎么这个记忆
// 整理机制这么保守呢」):轮末自动落地是一件**改记忆**的动作,它必须有一个
// 「一键回到只产建议」的开关与一个每轮上限,而 `.env` 已被主人冻结
// (`design/13 §2.1`:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码
// 默认值,不进 `.env`)。于是这两项落在这里,**零新 env 键**。
//
// 边界与 backthink.go 同源(那是同一族的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
// 或上限读成「无限制」)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidyFileName 是文件名(家族命名 = 服务/模块名 + .json)。
memoryTidyFileName = "memory-tidy.json"
// memoryTidyConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与
// backthink 各自声明一份而不是共用一个常量:某一族换位置时,共用常量会让
// 另一处**静默**跟着搬家。
memoryTidyConfigSubdir = "config"
)
// MemoryTidyConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/memory-tidy.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件,而不必把部署相关的绝对路径
// 塞进接口形状。
const MemoryTidyConfigRelPath = memoryTidyConfigSubdir + "/" + memoryTidyFileName
// ErrInvalidMemoryTidy 是 memory-tidy.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config")
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆整理运行期的两个旋钮。
//
// AutoApply 用**指针**是刻意的:代码默认值是「开」,而 JSON 的 `bool` 零值也是
// false —— 用 bool 会让「这个键没写」与「显式写 false」不可区分,于是任何只写
// 上限的文件都会**静默把自动档关掉**。指针让「没写」= nil = 不覆盖默认。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type MemoryTidyTuning struct {
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
AutoApply *bool `json:"autoApply"`
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
AutoApplyPerRound int `json:"autoApplyPerRound"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
// 「没配」不等于「关掉」,那正是指针存在的理由。
func (t MemoryTidyTuning) AutoApplyOff() bool {
return t.AutoApply != nil && !*t.AutoApply
}
// DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。
func DefaultMemoryTidyPath(dataDir string) string {
return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName)
}
// LoadMemoryTidyTuning 读取 memory-tidy.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
//
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认;
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。
//
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return MemoryTidyTuning{}, nil
}
return MemoryTidyTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return MemoryTidyTuning{}, nil
}
var t MemoryTidyTuning
if err := json.Unmarshal(raw, &t); err != nil {
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
}
return t, nil
}
@@ -0,0 +1,118 @@
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
}