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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
1 parent
353293e807
commit
6d92536276
27 files changed
+2063
-197
No files matched your search
@@ -3,28 +3,33 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 三件事只在这里定:
|
||||
// 四件事只在这里定:
|
||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计。
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 读「自动落地开关 + 每轮上限」。它是 `.env` 冻结(design/13 §2.1)之后新旋钮
|
||||
// 的唯一去处,**零新 env 键**;读不到/解不开一律回落代码默认值并记 WARN
|
||||
// (坏配置绝不能让自动档变成「无限制」)。
|
||||
//
|
||||
// 刻意**不做**的两件事(design §4.1 F、§3 非目标):
|
||||
// 刻意**不做**的一件事(design §4.1 F):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
// 不是主人的会话);
|
||||
// - 不给它任何写记忆的路径(第一版只产候选,"立即整理"入口是
|
||||
// TidyService.RunOnce)。
|
||||
// 不是主人的会话)。
|
||||
func (a *app) wireMemoryTidy() error {
|
||||
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy), memory.TidyDeps{
|
||||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryTidy,
|
||||
@@ -37,6 +42,32 @@ func (a *app) wireMemoryTidy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限。
|
||||
//
|
||||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落代码默认值」。
|
||||
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/tidy").Warn(
|
||||
"memory tidy tuning unreadable, falling back to code defaults",
|
||||
"path", path, "error", err)
|
||||
return
|
||||
}
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
logger.WithSource(a.log, "memory/tidy").Info(
|
||||
"memory tidy tuning loaded",
|
||||
"path", path, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound)
|
||||
}
|
||||
|
||||
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||||
func (a *app) auditMemoryTidy(who, what, result string) {
|
||||
|
||||
@@ -52,7 +52,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
"a.embedder = embedder",
|
||||
},
|
||||
"wire_memory_tidy.go": {
|
||||
"memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy)",
|
||||
"memory.NewTidyService(cfg, memory.TidyDeps{",
|
||||
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
|
||||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||
"runtimeconfig.LoadMemoryTidyTuning",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||
// 的结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryTidy",
|
||||
@@ -206,3 +210,46 @@ func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
|
||||
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
|
||||
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
|
||||
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
empty.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
|
||||
}
|
||||
|
||||
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
off.applyMemoryTidyTuning(&cfg)
|
||||
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
|
||||
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
|
||||
}
|
||||
|
||||
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
broken.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user