Files
Cyrene_v2/internal/runtime/memory/tidy_config.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

264 lines
13 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 的对可以重评。**改判定规则必须同时升它**。
//
// ⚠️ 2026-10-07「link 不再产候选」**刻意没有**升它(不是漏了):升版的目的是
// 「让历史 reject 的对被重评」,而本次变更不改变任何一对/一条的**判定结论**
// (互不包含的对仍判 keep,只是不再落一条不可应用的候选);升版只会让活库里
// 87 条既存候选凭 idempotency_key 复制出一份重复行,与主人「该删就删」相反。
// 真正需要升版的是「判定结论会变」的改动(阈值语义、包含算法、否决规则)。
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
// defaultTidyAutoApplyPerRound 是**轮末自动落地**的每轮条数上限(2026-10-07
// 主人口径「该删就删,怎么这个记忆整理机制这么保守呢」)。
//
// 它是**代码默认值**,可被 `$DATA_DIR/config/memory-tidy.json` 的
// autoApplyPerRound 覆盖(`.env` 已冻结、design/13 §2.1:新旋钮一律走
// $DATA_DIR/config/** 或代码默认值,不进 .env)。
//
// 上限存在的理由与 design/23 §7 原本「merge/archive 永远人工确认」的顾虑同源:
// 自动落地是**批量改记忆**,一天一轮最多 50 条,坏判据最多伤 50 条且每条都能
// revert —— 而不是一次点下去改几百条、事后无从追溯。
defaultTidyAutoApplyPerRound = 50
// maxTidyAutoApplyScan 是轮末自动档一趟**读取**待定候选的条数上限。自动档要在
// Go 侧按判据筛(判据要读候选证据 JSON,SQL 表达不了),因此它必须有个上限;
// 命中上限时自动档只处理最新的一批,直到**下一轮**再继续(与扫描上限同一口径:
// 最新优先 + 绝不静默,见 tidy_auto.go 的 ScanTruncated 与那条 WARN)。
maxTidyAutoApplyScan = 2000
// tidyAutoMergeMinSimilarity 是自动合并第二档判据的相似度下限:**事实集合等价
// (双向包含 = containBoth)且相似度 ≥ 0.95**。合同等价但相似度低的对说明
// 「关键要素相同、表述差得远」,那不是重复而是两种说法,交人工。
tidyAutoMergeMinSimilarity = 0.95
// defaultTidyRejectBatchLimit 是「按动作批量拒绝候选」的单次条数上限
// (2026-10-07:给活库里 501 条无执行语义的 link 候选一个干净去处)。
//
// 它比批量应用的上限宽得多是**刻意的**:拒绝只写候选表的状态列,**不碰
// memory_items 任何一行**,因此没有「一次改坏几百条记忆」的风险;但仍有上限,
// 且超限如实回报 total/truncated(绝不静默少做)。
defaultTidyRejectBatchLimit = 1000
// maxTidyRejectBatchLimit 是上面那个上限的硬顶(钳位而不是报错)。
maxTidyRejectBatchLimit = 10000
)
// 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
// DisableAutoApply 关闭**轮末自动落地**(见 defaultTidyAutoApplyPerRound)。
//
// 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 ——
// 自动落地是本机制的默认取向(2026-10-07 主人「该删就删」),若写成正向的
// AutoApply,任何没显式配过的地方(含测试与组合根构造的一半配置)都会被读成
// 「关掉」,与默认相反。要一键回到「只产建议」,写
// `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`(不加 env 键:
// `.env` 已冻结)。
DisableAutoApply bool
// AutoApplyPerRound 是轮末自动落地的**每轮**条数上限。非正用默认值
// (defaultTidyAutoApplyPerRound);最终还会被 maxTidyApplyBatchLimit 钳位。
AutoApplyPerRound 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
}
if cfg.AutoApplyPerRound <= 0 {
cfg.AutoApplyPerRound = defaultTidyAutoApplyPerRound
}
return cfg
}
// autoApplyEnabled 报告轮末自动落地是否开启。判据只有 DisableAutoApply 一个
// (见它的零值口径),因此「默认开启」是可证的一条,而不是注释里的承诺。
func (c TidyConfig) autoApplyEnabled() bool { return !c.DisableAutoApply }
// 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
}