Files
Cyrene_v2/internal/runtime/memory/tidy_config.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。
根因两条(都在这片里修掉):
① **以前根本没有执行面** —— 只有只读试算,候选永远 pending
② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完

- 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并**
  (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计
  (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道)
- 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run),
  「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死**
- 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时)
- 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column)
- 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false)
⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行)
⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
2026-10-06 22:39:35 +08:00

205 lines
8.8 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 memory
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
// 只有一处定义、可被复核。
//
// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在
// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
// subagent。
const (
// TidySessionID 是记忆整理运行的专属隔离会话标识(design/23 §4.1 B)。
// 它与主会话 history.MainSessionID("web")不同,因此整理不改主人的心情、
// 不进主会话泳道(cmd/cyrene 的 isConversationLane 对它恒为 false),
// 默认也不进 flowPub 的图源白名单(design/23 §4.1 F 的可选小开关默认关)。
TidySessionID = "sess-memorytidy"
// TidyRuleVersion 是 T0 规则集的版本号,进候选的幂等键(design/23 §9):
// 规则不变时重复运行绝不重复产候选;升版后同一数据重新产候选,
// 因此被 reject 的对可以重评。**改判定规则必须同时升它**。
TidyRuleVersion = "t0.1"
// defaultTidyIntervalSec 是整理周期秒数,默认 86400 = 每天 1 次
// (design/23 §13 开放点 8 的建议值)。
defaultTidyIntervalSec = 86400
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)
// —— 整理只在低峰跑(design/23 §4)。
defaultTidyQuietStartHour = 23
defaultTidyQuietEndHour = 7
// defaultTidyRecallThreshold 是候选召回的余弦**召回门**,0.80
// (design/23 §5.1 T0-2 与 §13 开放点 2 的建议值)。它只决定「哪些对值得
// 看一眼」:能否合并由包含判定 + 规则否决决定,绝不退化成阈值判断
// (design/23 §1.2:「必合」0.8330 与「必不合」0.7100 只差 0.12)。
defaultTidyRecallThreshold = 0.80
// defaultTidyScanLimit 是每分区每轮的扫描上限(design/23 §9)。
defaultTidyScanLimit = 5000
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
// 见 tidy_recall.go。
maxTidyCompareItems = 2000
// defaultTidyCandidateLimit 是每分区每轮的候选上限(design/23 §9)。
// 2026-10-06 由 20 抬到 200:首轮真跑四个分区同时打满旧值 ⇒ 候选数由
// 上限而不是规则决定(77 是下限)。
defaultTidyCandidateLimit = 200
// tidyLowConfidenceThreshold 是置信度否决门(design/23 §5.2-6):
// confidence 不能当合并门([2]0.7 与 [5]0.8 是同一事实),但必须当否决门。
tidyLowConfidenceThreshold = 0.7
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
// 一次性交给远端 embedding API。
tidyEmbedBatch = 64
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
minTidyContentRunes = 3
// defaultTidyApplyBatchLimit 是「批量应用候选」的单次条数上限(切片 TIDY-1)。
// 它有上限是**刻意**的:一次点下去改几百条记忆是不可回头的观感,因此宁可
// 让主人分批点,且回执如实带上 Total/Truncated(超限绝不静默少应用)。
// 它是**代码默认值**(`.env` 已冻结、design/13 §2.1:新旋钮一律走
// $DATA_DIR/config/** 或代码默认值),不新增任何 env 键。
defaultTidyApplyBatchLimit = 100
// maxTidyApplyBatchLimit 是批量应用上限的硬顶:请求给更大的值就钳到这里
// (与既有查询面「钳位而不是报错」同一约定)。
maxTidyApplyBatchLimit = 500
)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
// 因此半填的 Config 仍可用。
type TidyConfig struct {
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。
Enabled bool
// Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。
Interval time.Duration
// IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。
// 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次,
// 因此实际间隔取两者中较长者。
IntervalSec int
// QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
QuietStartHour int
QuietEndHour int
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
// 非正用默认值。
RecallThreshold float64
// ScanLimit 是每分区每轮扫描条数上限。非正用默认值。
ScanLimit int
// CandidateLimit 是每分区每轮候选条数上限。非正用默认值。
CandidateLimit int
// CompareLimit 是每分区每轮真正参与两两比对的条目上限(性能护栏)。
// 非正用默认值(maxTidyCompareItems)。
CompareLimit int
}
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
return TidyConfig{
Enabled: cfg.Enabled,
IntervalSec: cfg.IntervalSec,
QuietStartHour: cfg.QuietStartHour,
QuietEndHour: cfg.QuietEndHour,
RecallThreshold: cfg.RecallThreshold,
ScanLimit: cfg.ScanLimit,
CandidateLimit: cfg.CandidateLimit,
CompareLimit: cfg.CompareLimit,
}
}
// normalize 用设计稿默认值填充 TidyConfig 的零值字段。
func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = defaultTidyIntervalSec
}
if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
}
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
cfg.QuietStartHour = defaultTidyQuietStartHour
cfg.QuietEndHour = defaultTidyQuietEndHour
}
if cfg.RecallThreshold <= 0 {
cfg.RecallThreshold = defaultTidyRecallThreshold
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultTidyScanLimit
}
if cfg.CandidateLimit <= 0 {
cfg.CandidateLimit = defaultTidyCandidateLimit
}
if cfg.CompareLimit <= 0 {
cfg.CompareLimit = maxTidyCompareItems
}
return cfg
}
// quietWindowDisabled 报告低峰窗口是否被显式关闭(两端相等且非 0)。
func (c TidyConfig) quietWindowDisabled() bool {
return c.QuietStartHour != 0 && c.QuietStartHour == c.QuietEndHour
}
// inQuietHours 报告 at 是否落在低峰窗口内。窗口可跨午夜(23:00-07:00);
// 与 backthink.inQuietHours 同语义。
func (c TidyConfig) inQuietHours(at time.Time) bool {
if c.quietWindowDisabled() {
return true
}
h := at.Hour()
if c.QuietStartHour <= c.QuietEndHour {
return h >= c.QuietStartHour && h < c.QuietEndHour
}
return h >= c.QuietStartHour || h < c.QuietEndHour
}
// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点:
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
// 改走纯周期路径。
func (c TidyConfig) nextQuietStart(at time.Time) time.Time {
if c.quietWindowDisabled() {
return time.Time{}
}
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
if !start.After(at) {
start = start.AddDate(0, 0, 1)
}
return start
}
// currentQuietStart 返回 at **所在**的低峰窗口起点(不晚于 at):
// 落在窗口里(含跨午夜的半夜那一段)时是今天/昨天的 QuietStartHour 整点。
// 窗口关闭时返回零值时间。
//
// 它服务**补跑判据**(切片 TIDY-1):判断「这个窗口是不是已经跑过」只需要
// 「窗口起点 ≤ 上次 run」这一个比较,不必去构造「窗口内哪天」这种概念 ——
// 跨午夜的窗口(23:00–07:00)用日期算会算错。
func (c TidyConfig) currentQuietStart(at time.Time) time.Time {
if c.quietWindowDisabled() {
return time.Time{}
}
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
if start.After(at) {
start = start.AddDate(0, 0, -1)
}
return start
}