主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能 进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清, 而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。 落法(三条硬规格,全部落地): ① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**), 本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍 余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。 🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)—— 远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步 路径上拍板。 ② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒ `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。 ③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 / 真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行 `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**, 而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行, revert 无从下手,违反 design/23 §8 的红线。 - **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落, 审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。 - **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到 「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。 - 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。 验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿; 我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与 `…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。 子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测 **闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。 ⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒ 「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新 100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态, SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
121 lines
6.3 KiB
Go
121 lines
6.3 KiB
Go
// 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 键**。
|
||
//
|
||
// 2026-10-07 追加 `ingestGate`(同步入库闸,主人「把记忆整理功能接到昔涟和记忆库
|
||
// 中间做审查,由整理功能进行入库」):同一个理由 —— 闸是一处**改记忆落库形态**的
|
||
// 开关,必须能一键回到「只插不审」,而 `.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 / IngestGate 用**指针**是刻意的:它们的代码默认值都是「开」,而
|
||
// JSON 的 `bool` 零值也是 false —— 用 bool 会让「这个键没写」与「显式写
|
||
// false」不可区分,于是任何只写上限的文件都会**静默把开关关掉**。指针让
|
||
// 「没写」= nil = 不覆盖默认。
|
||
//
|
||
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
|
||
type MemoryTidyTuning struct {
|
||
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
|
||
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
|
||
AutoApply *bool `json:"autoApply"`
|
||
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
|
||
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
|
||
AutoApplyPerRound int `json:"autoApplyPerRound"`
|
||
// IngestGate 控制**同步入库闸**(D3 审查接在写入路径上当场拍板,主人
|
||
// 2026-10-07 裁定)。nil = 没配(用代码默认值:开);false = 一键回到
|
||
// 「只插不审」(= 改动之前的异步行为);true = 显式开(与默认同义)。
|
||
IngestGate *bool `json:"ingestGate"`
|
||
}
|
||
|
||
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
|
||
// 「没配」不等于「关掉」,那正是指针存在的理由。
|
||
func (t MemoryTidyTuning) AutoApplyOff() bool {
|
||
return t.AutoApply != nil && !*t.AutoApply
|
||
}
|
||
|
||
// IngestGateOff 报告**同步入库闸**是否被这份配置显式关掉(口径同 AutoApplyOff:
|
||
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
|
||
func (t MemoryTidyTuning) IngestGateOff() bool {
|
||
return t.IngestGate != nil && !*t.IngestGate
|
||
}
|
||
|
||
// 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
|
||
}
|