主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都 往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。 按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6 (tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。 - IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落 临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。 - 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的 定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态 补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。 非 defer 三态一笔都不多写。 - T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则, 接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1; 两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。 - T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增 第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判 非法回落 create(reason=t1_reply_invalid)。 - 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。 - 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板 $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键 解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增 (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int + omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。 - preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL 刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。 - 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、 TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、 提取规则正例 11 条 / 反例 11 条。 验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉 编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
239 lines
15 KiB
Go
239 lines
15 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 键。
|
||
//
|
||
// 2026-10-07 再追加 `t1Judge`(T1 灰区模型判定,design/23 §15.1 D3-2):理由仍然
|
||
// 是同一条 —— 它是一处**会花掉模型额度**的开关,必须能一行配置关掉(关掉即回落
|
||
// 纯规则、零模型调用),所以同样落在这里,不新开文件家族、不加 env 键。
|
||
//
|
||
// 2026-10-07 同日追加 `t1PerWindow` / `t1WindowSec`(T1 的**全局限速**,
|
||
// design/23 §15.4 主人裁定「加一层保守的全局速率限流」):T1 默认开,每次「有灰区
|
||
// 候选的审查」就发一次调用,现网每天数百到上千条新记忆 ⇒ 那是一处**没有刹车**的
|
||
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
|
||
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。
|
||
//
|
||
// 2026-10-07 再追加 `intervalSec` / `quietIntervalSec` / `quietStartHour` /
|
||
// `quietEndHour`(**整理频率**·主人两条口径:「**`.env` 只做兜底用** —— 后端自动
|
||
// 创建独立的面板配置文件之后就走面板配置文件,这些键优先从面板配置上取」;
|
||
// 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**,比如白天间隔短
|
||
// 一点,晚上到了静默时期稍微长一点」):
|
||
//
|
||
// - 这四项**逐键**走「面板 > `.env` > 代码默认」三档(唯一实现点是
|
||
// `memory.ApplyTidyScheduleTuning`):面板键在 ⇒ 用面板值;缺位 ⇒ 用 `.env`
|
||
// 的 `MEMORY_TIDY_INTERVAL_SEC` / `MEMORY_TIDY_QUIET_*_HOUR`(quietIntervalSec
|
||
// 没有 env 键,缺位 = 等于 `intervalSec`);两档都没有 ⇒ 代码默认
|
||
// (86400 / 23:00-07:00,见 memory 侧的 normalizeTidyConfig)。
|
||
// - 因此**没有新增任何 `.env` 键**(`.env` 已冻结,design/13 §2.1):上面三个
|
||
// env 键是既有的,本切片只是让面板能覆盖它们。
|
||
// - **坏值不得让整理停摆或忙轮询**:键写了非正数 ⇒ 回落下一档 + WARN
|
||
// (≤0 的周期不是「没配」,而是一个会让整理变成空转的值);整个文件解不开
|
||
// ⇒ 四个键连同其它键一起回落 `.env` 并记 WARN(既有口径,见 LoadMemoryTidyTuning)。
|
||
//
|
||
// 边界与 backthink.go 同源(那是同一族的手续):
|
||
//
|
||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
|
||
// 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
|
||
// `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
|
||
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
|
||
// 面板上的编辑口是另一片,不在本文件。
|
||
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
|
||
//
|
||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
|
||
// 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
|
||
// 调用方记 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` 的形状:记忆模块运行期的旋钮。
|
||
// 文件以「整理」命名,但它是**记忆模块运行期调优的同一处落点**(整理自动档 +
|
||
// 入库闸 + T1 灰区判定),不再为一个开关新开一个文件家族。
|
||
//
|
||
// AutoApply / IngestGate / T1Judge 用**指针**是刻意的:它们的代码默认值都是「开」,而
|
||
// 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"`
|
||
// T1Judge 控制 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区 —— 相似
|
||
// 但互不包含 —— 调一次轻量模型,只许回 `merge`/`modify`/`keep` + 理由、
|
||
// **禁产新内容**)。nil = 没配(用代码默认值:开);false = 一键回到纯规则
|
||
// (**零模型调用**,灰区仍然保守回落新建);true = 显式开(与默认同义)。
|
||
//
|
||
// 2026-10-07 追加,与 IngestGate 同一个文件、同一个理由:它是一处**会花掉
|
||
// 模型额度**的开关,必须能一行配置关掉,而 `.env` 已冻结(零新 env 键)。
|
||
// 指针也是同一个理由:代码默认值是「开」,用 bool 会让「这个键没写」与
|
||
// 「显式写 false」不可区分。
|
||
T1Judge *bool `json:"t1Judge"`
|
||
// T1PerWindow 是 T1 的**全局**窗口额度(次数)—— 全进程共享一个窗口,超限
|
||
// 即跳过 T1 并回落 `create`(`reason=t1_throttled`,design/23 §15.4)。
|
||
// 零/负 = 没配 ⇒ 用代码默认值(memory 侧的 defaultT1PerWindow)。
|
||
//
|
||
// **刻意没有「无上限」语义**(与 autoApplyPerRound 同一口径):≤0 一律回落
|
||
// 默认额 —— 「不限流」不该是敲一个 0 就能得到的状态(design/12 §N C.5 G3
|
||
// 登记过那条静默失败形态)。要完全不花模型额度,请关 `t1Judge`。
|
||
T1PerWindow int `json:"t1PerWindow"`
|
||
// T1WindowSec 是上面那个额度的时间窗(**秒**)。零/负 = 没配 ⇒ 用代码默认值
|
||
// (memory 侧的 defaultT1Window)。
|
||
//
|
||
// 用秒而不是 Go 的时长字面量(`"1h"`)是刻意的:这个文件是给人改的,写
|
||
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
|
||
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
|
||
T1WindowSec int `json:"t1WindowSec"`
|
||
|
||
// IntervalSec 是**非安静时段**的整理周期(秒)。nil = 没配 ⇒ 用 `.env` 的
|
||
// `MEMORY_TIDY_INTERVAL_SEC`;两档都没有 ⇒ 代码默认 86400(每天 1 次)。
|
||
//
|
||
// 用**指针**的理由与 AutoApply 同源,但多一层:数值键的零值(0)在这里是
|
||
// **坏值**而不是「一个合法的 0 秒周期」——`0` 会让整理退化成空转,因此
|
||
// 「键没写」(静默回落)与「写了 0」(WARN + 回落)必须可区分。
|
||
IntervalSec *int `json:"intervalSec"`
|
||
// QuietIntervalSec 是**安静时段**的整理周期(秒)——主人要的「动态分配:
|
||
// 白天间隔短一点,晚上到了静默时期稍微长一点」就落在这个键上。
|
||
//
|
||
// nil = 没配 ⇒ **等于 intervalSec**(缺省 = 不因时段换档,而不是「夜里不跑」);
|
||
// 写非正数 ⇒ 同一档回落 + WARN(理由同 IntervalSec)。
|
||
//
|
||
// 它**没有 env 键**(`.env` 已冻结,design/13 §2.1):安静时段分档是本次新增
|
||
// 的能力,`.env` 那一档只有 `MEMORY_TIDY_INTERVAL_SEC`,因此它的「下一档」就是
|
||
// 上面那个已解析出来的 intervalSec。
|
||
QuietIntervalSec *int `json:"quietIntervalSec"`
|
||
// QuietStartHour / QuietEndHour 是安静时段的两端(**本地钟点**),语义与既有
|
||
// `inQuietHours` 逐字一致:窗口可跨午夜;两端相等且非 0 = 关闭窗口。
|
||
//
|
||
// 用**指针**是必须的(不像 autoApplyPerRound 那样用零值当「没配」):**0 是
|
||
// 合法钟点**(如 `0-7` 的凌晨窗口、`23-0` 到午夜的窗口),用 int 会让「显式
|
||
// 写 0」与「没写」不可区分,于是任何只写一端的文件都会被静默改掉另一端。
|
||
//
|
||
// nil = 没配 ⇒ 用 `.env` 的 `MEMORY_TIDY_QUIET_START_HOUR` / `..._END_HOUR`;
|
||
// 两端都没配 ⇒ 代码默认 23:00-07:00。
|
||
QuietStartHour *int `json:"quietStartHour"`
|
||
QuietEndHour *int `json:"quietEndHour"`
|
||
|
||
// TempTtlSec 是**临时记忆**的存活秒数(第五态 `defer` 写进 `expires_at` 的
|
||
// TTL,design/23 §18.1 / design/03 §9.6)。nil = 没配 ⇒ 代码默认
|
||
// **604800 秒(7 天)**;写非正数 ⇒ 坏值,回落代码默认 + WARN
|
||
// (一个 ≤0 的 TTL 会让每条临时记忆**落库即过期**,与「不过期」恰好相反)。
|
||
//
|
||
// 🔴 **它在 `.env` 里没有出处,也刻意不新增**(`.env` 已冻结,design/13
|
||
// §2.1):所以这一键的实际生效链是「**面板 > 代码默认**」——「面板 > `.env` >
|
||
// 代码默认」里的中间那一档在 TTL 上是**结构性为空**,不是漏配。
|
||
//
|
||
// **指针 + `omitempty`** 的理由与 AutoApply 同源、但更强一层:nil = 没配,
|
||
// 而没配时该键从**播种文件**里**整键省略**(播种文档明禁写 `null` —— 一份
|
||
// 带 null 的配置对主人是噪音)。只有面板/人工显式写了才出现这个键。
|
||
TempTtlSec *int `json:"tempTtlSec,omitempty"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// T1JudgeOff 报告 **T1 灰区模型判定**是否被这份配置显式关掉(口径同 AutoApplyOff:
|
||
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
|
||
func (t MemoryTidyTuning) T1JudgeOff() bool {
|
||
return t.T1Judge != nil && !*t.T1Judge
|
||
}
|
||
|
||
// 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 并回落**下一档**
|
||
// (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
|
||
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
|
||
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
|
||
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
|
||
// 写的意图)。
|
||
//
|
||
// 它不建目录、不读老位置:新家族没有老位置(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
|
||
}
|
||
return decodeMemoryTidy(raw, path)
|
||
}
|
||
|
||
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
|
||
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
|
||
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
|
||
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
|
||
//
|
||
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
|
||
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
|
||
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
|
||
}
|