feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「那么多 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:
AskaEth committed 2026-10-07 11:07:00 +08:00
1 parent 353293e807
commit 6d92536276
27 files changed
+2063 -197

No files matched your search

+38 -7
View File
@@ -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) {
+48 -1
View File
@@ -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)
}
}
+102
View File
@@ -0,0 +1,102 @@
// 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 键**。
//
// 边界与 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 用**指针**是刻意的:代码默认值是「开」,而 JSON 的 `bool` 零值也是
// false —— 用 bool 会让「这个键没写」与「显式写 false」不可区分,于是任何只写
// 上限的文件都会**静默把自动档关掉**。指针让「没写」= nil = 不覆盖默认。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type MemoryTidyTuning struct {
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
AutoApply *bool `json:"autoApply"`
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
AutoApplyPerRound int `json:"autoApplyPerRound"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
// 「没配」不等于「关掉」,那正是指针存在的理由。
func (t MemoryTidyTuning) AutoApplyOff() bool {
return t.AutoApply != nil && !*t.AutoApply
}
// 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
}
@@ -0,0 +1,118 @@
package runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
//
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("空白文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() {
t.Fatal("空白文件不该被读成「关掉自动档」")
}
}
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.AutoApply != nil {
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
}
if got.AutoApplyOff() {
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
}
if got.AutoApplyPerRound != 7 {
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
}
}
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !got.AutoApplyOff() {
t.Fatal("显式 false 必须被读成「关掉自动档」")
}
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
t.Fatalf("显式 true 的回读 = %+v", got)
}
}
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
want := filepath.Join("root", "data", "config", "memory-tidy.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
}
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
}
}
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
func writeMemoryTidyFile(t *testing.T, body string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, memoryTidyFileName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
+35 -105
View File
@@ -4,13 +4,8 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是记忆整理(design/23-记忆整理与去重)的整理器与编排:逐分区扫描 →
@@ -19,22 +14,31 @@ import (
// 三条不可走样的口径(design/23 §0/§5.1):
// 1. **相似度只用来召回候选对**,永不直接当合并判据;
// 2. **合并的唯一条件是「事实集合的包含关系」**(A ⊆ B 才并入 B);
// 相似但并列的事实只建链接,绝不合并;
// 相似但并列的事实**什么都不做**(判 keep)—— link 只有提示、没有执行语义,
// 因此 2026-10-07 起不再产出它(详见 tidy_rules.go 的 containNone 分支);
// 3. **规则否决一票否决**(跨作用域/跨角色/跨项目/群聊来源/时效污染/
// 数值不一致/否定冲突/低置信),命中即踢出候选。
//
// 第一版**只产建议**:RunOnce 唯一的写入是候选表与运行表,
// 绝不改写、绝不删除任何一条记忆(design/23 §3 非目标 + §7)。
// 写入面是候选表 + 运行表(§7 的人工在环),外加 2026-10-07 起的**轮末自动落地**:
// 规则层已判为高置信的那一小撮(字面重复 / 事实等价 ∧ ≥0.95 / 一次性事件且不像
// 习惯)由 tidy_auto.go 直接落地,其余仍等人点。**任何路径都不改正文、不删行**。
// TidyAction 是整理建议的动作类别(design/23 §10 的 action 枚举)。
type TidyAction string
const (
// TidyMerge 表示 A 的事实集合是 B 的子集,建议把 A 并入 B(保留 B)。
// 它仍需人工确认(design/23 §7),第一版绝不自动应用。
// **高置信的一部分会由轮末自动档直接落地**(字面重复 / 事实等价 ∧ ≥0.95,
// 见 tidy_auto.go);其余仍等人点(design/23 §7 的人工在环)。
TidyMerge TidyAction = "merge"
// TidyLink 表示两条相似但**互不包含**,只建链接/分组,绝不合并
// (design/23 §1.3 的 [1]/[3]「红牛 vs 冰美式」)。
// TidyLink 是 design/23 §1.3 给「两条相似但**互不包含**」这一判定结论起的动作名
// ([1]/[3]「红牛 vs 冰美式」),语义上代表「只建链接/分组,绝不合并」。
//
// 🔴 2026-10-07 起**规则层不再产出它**:design/23 §14 把「link 关系如何存储与
// 呈现」列为待细化 ⇒ 它没有落点、没有数据模型,产出来只会得到一条永远应用不了
// 的待办(活库曾积压 501 条)。常量保留是**为了读历史行**:库里既有的 link 候选
// 仍要能在面板展示、能被判定为「不可应用」(tidy_apply.go 的 applyable),
// 也能被按动作批量拒绝(RejectTidyCandidatesByAction)。它不参与任何新判定。
TidyLink TidyAction = "link"
// TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并
// (design/23 §5.1 T0-5)。
@@ -79,94 +83,6 @@ var ErrNilStore = errors.New("memory: tidy needs a store")
// 缺失向量会让相似度与条目错位,宁可整体回退到词项余弦。
var ErrTidyEmbedCount = errors.New("memory/tidy: embedder returned a mismatched vector count")
// TidyRun 是一轮整理的留痕(design/23 §10 memory_tidy_runs)。
type TidyRun struct {
ID string
StartedAt time.Time
FinishedAt time.Time
Scanned int
Candidates int
Applied int
Skipped int
Errors int
Vetoed int
VetoReasons map[string]int
RuleVersion string
}
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
type TidyStats struct {
// RunID 是本轮在 memory_tidy_runs 里的留痕 id(切片 TIDY-1):候选按它归属,
// 「按 run 批量应用」因此有确定的抓手。留痕写失败时它仍是本轮已用的 id,
// 调用方不该据此假装留痕存在。
RunID string
Scanned int
Partitions int
Compared int
Pairs int
Candidates int
Duplicates int
Skipped int
Vetoed int
VetoReasons map[string]int
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
ByAction map[string]int
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
Errors int
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
Truncated []string
// DurationMS 是本轮的墙钟耗时(毫秒),与日志里的 duration_ms 同一算法。
DurationMS int64
}
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
type TidyDeps struct {
// Store 是记忆存储。必需 —— 整理只读写它,绝不接触会话历史。
Store *Store
// Embedder 是候选召回的相似度来源。为 nil 时回退到零依赖的
// 字符二元组余弦(离线可用、测试确定性),见 tidy_recall.go。
Embedder embedding.Embedder
// Audit 是审计出口(design/23 §8 的 memory.tidy.* 动作)。可选:
// 组合根把它接到网关的 AuditEvent;为 nil 时只记日志。
Audit func(who, what, result string)
// Now 是运行时间戳的墙钟来源,便于测试注入。
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/tidy"。
Log *slog.Logger
}
// Tidier 是记忆整理器。它并发安全**仅限**「一轮之内不重入」:
// 服务层用一把互斥锁保证同一时刻只有一轮(design/23 §9 单写者)。
type Tidier struct {
cfg TidyConfig
deps TidyDeps
log *slog.Logger
now func() time.Time
}
// NewTidier 由 cfg 与 deps 构建整理器。Store 为 nil 时返回 ErrNilStore。
func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
if deps.Store == nil {
return nil, ErrNilStore
}
cfg = normalizeTidyConfig(cfg)
now := deps.Now
if now == nil {
now = time.Now
}
return &Tidier{
cfg: cfg,
deps: deps,
log: logger.WithSource(baseTidyLog(deps.Log), "memory/tidy"),
now: now,
}, nil
}
// Config 返回生效的整理配置(已套默认值)。
func (t *Tidier) Config() TidyConfig { return t.cfg }
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
@@ -208,13 +124,18 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
recall: t.fallbackRecall,
})
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
// 不再等人点。它排在留痕之前,因此 memory_tidy_runs.applied 这一轮就是真数
// (而不是先写 0、事后靠按 run 批量应用补记)。
auto := t.autoApply(ctx, &stats)
run := TidyRun{
ID: runID,
StartedAt: started,
FinishedAt: t.now().UTC(),
Scanned: stats.Scanned,
Candidates: stats.Candidates,
Applied: 0, // 第一版绝不自动应用(design/23 §7)
Applied: auto.Applied,
Skipped: stats.Skipped + stats.Duplicates,
Errors: stats.Errors,
Vetoed: stats.Vetoed,
@@ -227,6 +148,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
}
t.logRun(stats, run)
t.auditAutoApply(auto)
switch {
case len(failures) == 0:
@@ -253,9 +175,16 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
"skipped", stats.Skipped,
"vetoed", run.Vetoed,
"veto_reasons", tidyReasonsLabel(run.VetoReasons),
"kept", stats.Kept,
"errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil),
"auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged,
"auto_archive", stats.AutoArchived,
"auto_eligible", stats.AutoEligible,
"auto_truncated", stats.AutoTruncated,
}
if len(stats.Truncated) > 0 {
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
@@ -363,6 +292,12 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
stats.VetoReasons[string(verdict.Veto)]++
continue
}
if verdict.Action != TidyMerge {
// 相似但互不包含 ⇒ keep:**不产候选**(2026-10-07,见 tidy_rules.go 的
// containNone 分支)。它必须单独计数,否则「抛掉多少对」会消失。
stats.Kept++
continue
}
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
}
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
@@ -469,8 +404,3 @@ func tidyReasonsLabel(reasons map[string]int) string {
}
return strings.Join(parts, ",")
}
// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。
func baseTidyLog(log *slog.Logger) *slog.Logger {
return discardLogger(log)
}
+23 -2
View File
@@ -27,6 +27,9 @@ import (
// (design/23 §9 的失败隔离同口径);
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
// 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go);
// 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」,
// 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。
//
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
@@ -54,6 +57,11 @@ const (
tidySkipActionNotApplyable = "action_not_applyable"
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
tidySkipSourceNotActive = "source_not_active"
// tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源
// (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御:
// 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」
// 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。
tidySkipSourceRestricted = "restricted_source"
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
tidySkipSourceMissing = "source_missing"
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
@@ -154,7 +162,8 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
AffectedIDs: []string{},
}
if !cand.Action.applyable() {
// link / keep 没有执行语义(design/23 §14):如实说「不可应用」,不写任何行。
// link / keep 没有执行语义(design/23 §14;2026-10-07 起 link 也不再产出,
// 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
now := time.Now().UTC()
@@ -170,7 +179,9 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
}
}
// applyable 报告一个动作是否有执行语义。
// applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false:
// 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务
// 库里既有的历史行与「不许假装做过」的诚实口径。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify:
@@ -223,6 +234,11 @@ func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts T
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(item.SourceConversation) {
// 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在扫描范围外。
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return archiveTidyItem(ctx, tx, item.ID, now)
})
@@ -286,6 +302,11 @@ func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (Memo
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
// 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。
// 规则层已把这类条目排除在扫描外,这里是纵深防御。
if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, nil
}
if absorbed.ID == survivor.ID {
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
}
+35 -18
View File
@@ -240,6 +240,10 @@ func TestTidyApplyMergeUnderConcurrencyHasSingleWinner(t *testing.T) {
// TestTidyApplyArchiveAndRevert 钉住④:archive 是软删(status='archived',内容不变、
// 行数不变),revert 复原为 active。
//
// 本用例刻意**关掉轮末自动档**:它验的是**人工应用/回滚**这条路径,而自动档会在
// RunOnce 收尾时把这条一次性事件当场归档(那会让「apply 得到 applied」变成
// already_decided)。自动档本身在 tidy_auto_test.go 里验。
func TestTidyApplyArchiveAndRevert(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -247,7 +251,9 @@ func TestTidyApplyArchiveAndRevert(t *testing.T) {
base := tidyTestTime(t)
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr := newTestTidier(t, store, cfg, nil)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
@@ -346,31 +352,37 @@ func TestTidyApplyReclassifyAndRevert(t *testing.T) {
// TestTidyApplyLinkIsNotApplyable 钉住⑤:link 没有执行语义(design/23 §14:
// 链接的存储形态未定),应用它只能得到 skipped,且**零改动**。
//
// 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go),因此这里
// 手工落一条历史形状的 link 候选来钉这条执行面口径 —— 库里既有的 link 行仍必须
// 得到「不可应用」的诚实回答(它们的出路是按动作批量拒绝,见 tidy_reject_test.go)。
func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.99),
}})
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
link := TidyCandidate{
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Action: TidyLink, SourceIDs: []string{redBull.ID, iced.ID},
Reason: tidyReasonSimilarNotContained,
Evidence: TidyEvidence{
Similarity: 0.99, RecallMode: tidyRecallEmbedding, Containment: string(containNone),
ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced,
},
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if created, err := store.insertTidyCandidate(ctx, link); err != nil || !created {
t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err)
}
stored, err := store.GetTidyCandidate(ctx, link.ID)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
links := candidatesByAction(cands, TidyLink)
if len(links) != 1 {
t.Fatalf("link 候选 = %d, want 1", len(links))
t.Fatalf("GetTidyCandidate: %v", err)
}
before := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
out, err := store.ApplyTidyCandidate(ctx, links[0].ID, TidyApplyOptions{Actor: "owner:test"})
out, err := store.ApplyTidyCandidate(ctx, stored.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate(link): %v", err)
}
@@ -383,13 +395,16 @@ func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); len(got) != len(before) {
t.Fatalf("link 应用改动了记忆:%d → %d 行", len(before), len(got))
}
if cand, err := store.GetTidyCandidate(ctx, links[0].ID); err != nil || cand.Status != tidyCandidatePending {
if cand, err := store.GetTidyCandidate(ctx, stored.ID); err != nil || cand.Status != tidyCandidatePending {
t.Fatalf("不可应用的候选应保持 pending,实际 %q (err=%v)", cand.Status, err)
}
}
// TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated 钉住⑥:按 run 批量应用
// 逐条给结果、上限生效、超限如实报 Truncated + Total,并把该轮留痕的 applied 累加。
//
// 本用例刻意**关掉轮末自动档**:它验的是批量应用这条**人工**入口的粒度与上限,
// 而自动档会先把这 5 条一次性事件落地(那会让 Total 变成 0)。
func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -402,7 +417,9 @@ func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
ids = append(ids, seedTidyItem(t, store, item).ID)
}
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr := newTestTidier(t, store, cfg, nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
+331
View File
@@ -0,0 +1,331 @@
package memory
import (
"context"
"fmt"
"sort"
"strings"
)
// 本文件是记忆整理**轮末自动落地**的策略与执行面。动因是主人 2026-10-07 的口径:
// 「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」——
// 整理不能永远只产建议,规则层已经判死的高置信动作就该自己落地。
//
// ⚠️ 与 design/23 的既有口径冲突,且**是有意为之**:§7 原写「自动应用默认关闭;
// 即便将来开启,也只允许 apply 低风险动作(如 reclassify),merge 与 archive 永远
// 人工确认」,§13 开放点 5 的处置是「全部人工应用(无自动 apply)」。主人本次的
// 「该删就删」覆盖那两条;design 文档需同步(交付报告已列出建议补的几行)。
//
// 五条护栏,缺一不可:
//
// 1. **可自动的类别只有两类**,判据是纯函数 tidyAutoApplyEligible:
// merge = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95);archive = 理由为
// one_off_event **且不像习惯**(design/23 §5.2-1:一次性事件 ≠ 长期习惯,
// 习惯类绝不能被钟点误判归档)。reclassify 虽小也不在本次授权内,交人工。
// 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*,
// 绝不改正文、绝不删行)、候选状态条件更新(幂等)、失败隔离、可 revert。
// 3. **每轮上限**(cfg.AutoApplyPerRound):超限如实报 Truncated + 真总数,
// 绝不静默少做;下一轮继续。
// 4. **审计**:走 design/23 §8 的 `memory.tidy.apply` 族,result 带 `auto=true`
// 以区分「人点的」与「整理器自己落的」;只带计数,不带任何记忆正文。
// 5. **一键回到只产建议**:`$DATA_DIR/config/memory-tidy.json` 的
// `{"autoApply": false}`(不加 env 键 —— `.env` 已冻结,design/13 §2.1)。
//
// 绝不自动做的事(同样重要,写在这里以免后来者扩大范围):跨分区、跨角色、
// 任何改正文(modify)、任何低置信 merge、任何非 one_off_event 的归档。
// tidyAutoApplyVerb 是自动落地的审计动作名。它与网关侧 HTTP 触发那条同族
// (gateway.tidyApplyAction 逐字同值):动作名是 design/23 §8 的契约,不是实现细节,
// 因此两处必须是同一个字符串(跨包各写一份,改则两处一起改)。
const tidyAutoApplyVerb = "memory.tidy.apply"
// 自动档的**理由标签**:进日志/审计,机器可读(面板与排障按它聚合)。
const (
// tidyAutoReasonLiteralDuplicate 表示两侧正文逐字相同(归一化后)。
tidyAutoReasonLiteralDuplicate = "literal_duplicate"
// tidyAutoReasonEquivalentFacts 表示事实集合等价(双向包含)且相似度 ≥ 0.95。
tidyAutoReasonEquivalentFacts = "fact_equivalent_high_similarity"
// tidyAutoReasonOneOffEvent 表示一次性事件(含绝对日期/时刻且不像习惯)。
tidyAutoReasonOneOffEvent = "one_off_event"
)
// TidyAutoApplyBatch 是轮末自动档的一趟回执(进日志、审计与统计)。
//
// EligibleTotal 是命中自动判据的**真总数**(含因每轮上限没处理的)—— 与扫描/候选
// 上限同一口径:Truncated 为真时,面板看到的落地数**少于**判据命中的数。
type TidyAutoApplyBatch struct {
EligibleTotal int
Limit int
Truncated bool
// ScanTruncated 报告**读取**待定候选时撞上了 maxTidyAutoApplyScan:此时判据
// 只看过了最新的一批,可能还有命中判据的候选没被看到。它与 Truncated(每轮
// 上限截断)是两件事,所以分开报 —— 原因不同,运维动作也不同。
ScanTruncated bool
Applied int
Skipped int
Failed int
Merged int
Archived int
// ByReason 是**成功落地**的条数按自动理由分解(跳过的计不计:它们没落地)。
ByReason map[string]int
}
// normalizedAutoLimit 归一轮末自动档的每轮上限:非正取 defaultTidyAutoApplyPerRound,
// 超过 maxTidyApplyBatchLimit 时钳到它 —— 与批量应用共用同一个硬顶,因为两者都是
// 「一次批量改记忆」的动作,不该有两套上限。
func (o TidyApplyOptions) normalizedAutoLimit() int {
if o.Limit <= 0 {
return defaultTidyAutoApplyPerRound
}
if o.Limit > maxTidyApplyBatchLimit {
return maxTidyApplyBatchLimit
}
return o.Limit
}
// AutoApplyTidyCandidates 跑一趟轮末自动落地:从**全部待定候选**里挑出命中自动
// 判据的那些,按每轮上限逐条落地。
//
// 为什么是「全部待定」而不是「本轮产出的」:整理是周期任务,积压的候选不会自己消失
// (活库曾积压 501 条 link + 56 条 merge 全 pending)。每轮都把判据命中的捞一遍,
// 幂等由候选状态的条件更新保证,于是积压会被逐轮排干,而不会因为「不是这一轮产的」
// 就永远躺着。上限仍然是每轮一次,因此不会出现「一次点击改几百条」。
func (s *Store) AutoApplyTidyCandidates(ctx context.Context, opts TidyApplyOptions) (TidyAutoApplyBatch, error) {
limit := opts.normalizedAutoLimit()
pending, err := s.listTidyCandidates(ctx, tidyCandidatePending, "", maxTidyAutoApplyScan)
if err != nil {
return TidyAutoApplyBatch{}, fmt.Errorf("memory/tidy: auto apply list pending: %w", err)
}
selected, reasons, total := selectAutoEligible(pending, limit)
batch := TidyAutoApplyBatch{
EligibleTotal: total,
Limit: limit,
Truncated: total > len(selected),
ScanTruncated: len(pending) >= maxTidyAutoApplyScan,
ByReason: make(map[string]int),
}
for i, cand := range selected {
if err := ctx.Err(); err != nil {
// 上下文取消时**已落地的保持已落地**,未做的留给下一轮(与批量应用同口径)。
break
}
res, err := s.applyCandidate(ctx, cand, opts)
if err != nil {
res = tidyFailed(TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
AffectedIDs: []string{},
}, err)
}
tallyTidyAutoApply(&batch, res, reasons[i])
}
return batch, nil
}
// selectAutoEligible 挑出自动档要处理的候选(输入顺序即优先序 = 最新优先),最多
// limit 条;返回的 total 是命中判据的**真总数**(不受 limit 影响),reasons 与
// selected 一一对应。total > len(selected) 即「被每轮上限截断」。
func selectAutoEligible(cands []TidyCandidate, limit int) (selected []TidyCandidate, reasons []string, total int) {
selected = make([]TidyCandidate, 0, limit)
reasons = make([]string, 0, limit)
for _, c := range cands {
ok, reason := tidyAutoApplyEligible(c)
if !ok {
continue
}
total++
if len(selected) >= limit {
continue
}
selected = append(selected, c)
reasons = append(reasons, reason)
}
return selected, reasons, total
}
// tidyAutoApplyEligible 是**自动落地的唯一判据**:纯函数、零 IO、确定性,因此可以
// 逐条断言(含反例),也不依赖候选此刻是否还在库。
//
// 读的是候选自带的证据(ContentA/ContentB/Containment/Similarity/Reason),不是
// 此刻的记忆行 —— 因此「判据」与「行是否还在册」是两件事:后者由 apply 路径的
// 分区校验与 `AND status='active'` 条件更新兜住(过时的候选如实 skip)。
//
// 低置信刻意**不在这里判**:候选的**存在本身**就证明它过了 §5.2-6 的低置信否决门
// (tidy_rules.go 的 judgePair 是产候选的唯一路径),因此库里不可能存在低置信的
// merge 候选 —— 在这里再查一遍会是一条永远不生效的死代码。
func tidyAutoApplyEligible(c TidyCandidate) (bool, string) {
switch c.Action {
case TidyMerge:
if len(c.SourceIDs) != 1 || strings.TrimSpace(c.TargetID) == "" {
// 形状不完整(老候选可能有):不自动,交给 apply 路径如实 skip。
return false, ""
}
if literalDuplicate(c.Evidence.ContentA, c.Evidence.ContentB) {
return true, tidyAutoReasonLiteralDuplicate
}
if containmentKind(c.Evidence.Containment) == containBoth &&
c.Evidence.Similarity >= tidyAutoMergeMinSimilarity {
return true, tidyAutoReasonEquivalentFacts
}
return false, ""
case TidyArchive:
if len(c.SourceIDs) != 1 {
return false, ""
}
if c.Reason != tidyReasonOneOffEvent {
// 只认这一个归档理由:理由变了说明规则变了,那属于「拿不准」。
return false, ""
}
if looksLikeHabit(candidateSubjectContent(c)) {
// design/23 §5.2-1:一次性事件 ≠ 长期习惯。习惯类(「常在深夜 23:30
// 后还醒着」)会因为正文里带钟点而被判成一次性事件 —— 归档它是错的,
// 因此这一条守卫是自动档的**红线**,不是优化。
return false, ""
}
return true, tidyAutoReasonOneOffEvent
default:
// reclassify / link / keep 都不自动(link/keep 本来也没有执行语义)。
return false, ""
}
}
// literalDuplicate 报告两条正文是否逐字相同(只去掉首尾空白)。
// 大小写**不**折叠、内部空白**不**压缩:「逐字相同」就该逐字比。
func literalDuplicate(a, b string) bool {
ta, tb := strings.TrimSpace(a), strings.TrimSpace(b)
return ta != "" && ta == tb
}
// candidateSubjectContent 取一条候选「讲的是哪条记忆」的正文:归档候选记在
// ProposedContent(= 原文),其余记在证据的 ContentA(对/条各自的原文)。
func candidateSubjectContent(c TidyCandidate) string {
if strings.TrimSpace(c.ProposedContent) != "" {
return c.ProposedContent
}
return c.Evidence.ContentA
}
// looksLikeHabit 报告一段记忆正文看起来是不是**反复发生的习惯/规律**(而不是
// 一次性事件)。刻意**偏宽**:多一条进人工队列,远小于把主人的作息习惯当成一次性
// 事件归档掉的代价(归档虽可 revert,但主人不会知道要 revert 什么)。
func looksLikeHabit(content string) bool {
normalized := strings.ToLower(strings.TrimSpace(content))
for _, marker := range tidyHabitMarkers() {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
// tidyHabitMarkers 是「反复发生」的信号词。它是函数而不是包级变量
// (AGENTS §10 禁包级全局变量;同 tidySystemSessions 的手法)。
//
// 刻意**不含光杆「常」**:它在中文里到处都是(「非常」「不正常」),且「常在深夜」
// 这类真信号已由「常在」覆盖 —— 判据宁可用长词命中,也不要把绝大多数一次性事件
// 都挡在自动档之外(那会让自动档等于没开)。
func tidyHabitMarkers() []string {
return []string{
"习惯", "经常", "常常", "常年", "常态", "常在", "总是", "反复", "长期",
"一直", "持续", "例行", "惯例", "每逢", "每天", "每日", "每晚", "每周",
"每月", "每次", "作息", "规律", "平时", "平常", "一向", "向来", "通常",
"大多", "有时", "偶尔", "偏向", "偏好", "倾向于", "喜欢",
}
}
// tallyTidyAutoApply 把一条落地结果计入自动档回执(跳过的与失败的都不算「落地」,
// 因此不进 ByReason —— 理由分布要回答的是「自动做成的那些是什么理由」)。
func tallyTidyAutoApply(batch *TidyAutoApplyBatch, res TidyApplyResult, reason string) {
switch res.Status {
case TidyApplyApplied:
batch.Applied++
batch.ByReason[reason]++
switch res.Action {
case TidyMerge:
batch.Merged++
case TidyArchive:
batch.Archived++
}
case TidyApplyFailed:
batch.Failed++
default:
batch.Skipped++
}
}
// autoApply 是 RunOnce 的轮末自动档入口。它在开关关闭时**什么都不做**(连查询都不
// 发),因此「回到只产建议」是一次配置改动而不是一次代码回退。
//
// 失败不让整轮失败(与候选写入失败同口径):记 stats.Errors + WARN —— 这一轮的
// 价值(候选与留痕)已经拿到了,不该因为自动档而回一份错误。
func (t *Tidier) autoApply(ctx context.Context, stats *TidyStats) TidyAutoApplyBatch {
if !t.cfg.autoApplyEnabled() {
return TidyAutoApplyBatch{}
}
batch, err := t.deps.Store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{
Actor: tidyAuditWho,
Limit: t.cfg.AutoApplyPerRound,
})
if err != nil {
stats.Errors++
t.log.Warn("memory tidy auto apply failed", "error", err)
return TidyAutoApplyBatch{}
}
stats.AutoEligible = batch.EligibleTotal
stats.AutoApplied = batch.Applied
stats.AutoMerged = batch.Merged
stats.AutoArchived = batch.Archived
stats.AutoTruncated = batch.Truncated
if batch.Failed > 0 {
stats.Errors += batch.Failed
t.log.Warn("memory tidy auto apply had failures",
"failed", batch.Failed, "applied", batch.Applied, "skipped", batch.Skipped)
}
if batch.Truncated {
t.log.Warn("memory tidy auto apply truncated by per-round limit",
"eligible_total", batch.EligibleTotal, "limit", batch.Limit)
}
if batch.ScanTruncated {
// 待定候选多到读不完:判据只看过了最新的一批。**绝不静默**(与扫描上限同口径)。
t.log.Warn("memory tidy auto apply hit the pending-candidate scan limit",
"scan_limit", maxTidyAutoApplyScan, "pending", maxTidyAutoApplyScan)
}
return batch
}
// auditAutoApply 写一条自动落地的审计。审计只带**计数与理由分布**,不带任何记忆
// 正文(design/23 §8);没接审计出口时退到一条带 source 的日志,绝不静默。
func (t *Tidier) auditAutoApply(batch TidyAutoApplyBatch) {
if !t.cfg.autoApplyEnabled() {
return
}
result := fmt.Sprintf(
"ok auto=true applied=%d merged=%d archived=%d skipped=%d failed=%d eligible_total=%d limit=%d truncated=%t scan_truncated=%t by_reason=%s",
batch.Applied, batch.Merged, batch.Archived, batch.Skipped, batch.Failed,
batch.EligibleTotal, batch.Limit, batch.Truncated, batch.ScanTruncated,
tidyAutoReasonLabel(batch.ByReason))
hook := t.deps.Audit
if hook == nil {
t.log.Info("memory tidy auto apply audit (no sink configured)",
"what", tidyAutoApplyVerb, "result", result)
return
}
hook(tidyAuditWho, tidyAutoApplyVerb, result)
}
// tidyAutoReasonLabel 把自动理由分布渲染成稳定字符串(按理由排序),
// 与 tidyReasonsLabel 同一手法。
func tidyAutoReasonLabel(reasons map[string]int) string {
if len(reasons) == 0 {
return "none"
}
keys := make([]string, 0, len(reasons))
for k := range reasons {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, reasons[k]))
}
return strings.Join(parts, ",")
}
+511
View File
@@ -0,0 +1,511 @@
package memory
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理的**轮末自动落地**(2026-10-07 主人口径「那么多 link 是干什么的?
// 该删就删,怎么这个记忆整理机制这么保守呢」;实现见 tidy_auto.go)。八条判据:
//
// ① 字面重复(两侧逐字相同)⇒ 自动 merge;
// ② 事实集合等价 ∧ 相似度 ≥ 0.95 ⇒ 自动 merge;
// ③ 低置信 ⇒ 不自动(候选在**规则层**就被否决,自动档根本看不到它);
// ④ 一次性事件 ⇒ 自动 archive;
// ⑤ 习惯类被钟点误判成一次性事件 ⇒ **不**自动 archive(红线守卫);
// ⑥ 每轮上限生效、超限如实截断,下一轮继续排干;
// ⑦ 开关关闭 ⇒ 回到只产建议(一条都不落地);
// ⑧ revert 能回滚自动落地的那一条(merge 与 archive 各一次)。
//
// 外加两条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与
// 群聊来源的候选在**执行面**被拒。
const (
// fixtureHabitClock 是「被钟点误判」的典型:正文里带绝对时刻(23:30)⇒ 规则层
// 会把它当一次性事件产 archive 候选,但它明明白白是**作息习惯**(「常在」「习惯」)。
fixtureHabitClock = "主人常在深夜23:30后还醒着,有熬夜习惯"
// fixtureDupText 是字面重复用例的正文(两侧逐字相同)。
fixtureDupText = "主人喜欢在深夜喝冰美式"
// fixtureEquivA / fixtureEquivB 是**事实集合等价但不同措辞**的一对
// (关键要素逐一互相包含 ⇒ containBoth,正文不同 ⇒ 不是字面重复)。
fixtureEquivA = "主人喜欢喝红牛"
fixtureEquivB = "红牛,主人喜欢喝"
)
// newAutoTestTidier 构建整理器并把审计出口捕获下来(自动档的审计是断言面之一)。
func newAutoTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *[]tidyAuditRow) {
t.Helper()
rows := make([]tidyAuditRow, 0)
tr, err := NewTidier(cfg, TidyDeps{
Store: store,
Embedder: emb,
Log: testLogger(),
Audit: func(who, what, result string) {
rows = append(rows, tidyAuditRow{who: who, what: what, result: result})
},
})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr, &rows
}
// autoAuditRows 返回捕获到的 memory.tidy.apply(自动档)审计行。
func autoAuditRows(rows *[]tidyAuditRow) []tidyAuditRow {
out := make([]tidyAuditRow, 0)
for _, r := range *rows {
if r.what == tidyAutoApplyVerb {
out = append(out, r)
}
}
return out
}
// TestTidyAutoMergeLiteralDuplicate 是判据 ①:两侧正文逐字相同 ⇒ 候选由轮末自动档
// 直接落地(软合并),且落地时**只动 status/merged_***,绝不改正文、绝不删行。
func TestTidyAutoMergeLiteralDuplicate(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
first := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9))
second := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9))
tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureDupText: {1, 0},
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoEligible != 1 || stats.AutoApplied != 1 || stats.AutoMerged != 1 {
t.Fatalf("自动档计数 = %+v, want eligible/applied/merged = 1/1/1", stats)
}
if stats.AutoTruncated {
t.Fatalf("没打满上限不该报截断:%+v", stats)
}
// 存活的是更早创建的那条(pickMergeTarget:first_seen 落在存活行上)。
survivor, absorbed := loadTidyItemRow(t, store, first.ID), loadTidyItemRow(t, store, second.ID)
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != first.ID {
t.Fatalf("被吸收行 = %+v, want merged into %s", absorbed, first.ID)
}
if survivor.status != tidyStatusActive || survivor.mergeCount != 2 {
t.Fatalf("存活行 = %+v, want active + merge_count=2", survivor)
}
if absorbed.content != fixtureDupText || survivor.content != fixtureDupText {
t.Fatal("自动合并改了正文(design/23 §8:软动作绝不改 content)")
}
if got := countMemoryItems(t, store); got != 2 {
t.Fatalf("行数 = %d, want 2(软删除:行永不物理删除)", got)
}
// 审计:一条 memory.tidy.apply,带 auto=true 与理由分布;不带任何正文。
auto := autoAuditRows(rows)
if len(auto) != 1 {
t.Fatalf("自动档审计 = %d 条, want 1(%+v)", len(auto), *rows)
}
if !strings.Contains(auto[0].result, "auto=true") ||
!strings.Contains(auto[0].result, tidyAutoReasonLiteralDuplicate) {
t.Fatalf("审计 result = %q, want 含 auto=true 与 %s", auto[0].result, tidyAutoReasonLiteralDuplicate)
}
if strings.Contains(auto[0].result, fixtureDupText) {
t.Fatal("审计里不得出现记忆正文")
}
if auto[0].who != tidyAuditWho {
t.Fatalf("审计 who = %q, want %q(系统任务,不是角色)", auto[0].who, tidyAuditWho)
}
// 该落地写进了这一轮的留痕(memory_tidy_runs.applied 是真数,不是 0)。
runs, err := store.ListTidyRuns(ctx, 1)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].Applied != 1 {
t.Fatalf("运行留痕 = %+v, want applied=1", runs)
}
}
// TestTidyAutoMergeEquivalentFactsHighSimilarity 是判据 ②:事实集合等价(双向包含)
// 且相似度 ≥ 0.95 ⇒ 自动合并。
func TestTidyAutoMergeEquivalentFactsHighSimilarity(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
a := seedTidyItem(t, store, habitItem(fixtureEquivA, base, 0.9))
b := seedTidyItem(t, store, habitItem(fixtureEquivB, base.Add(time.Minute), 0.9))
tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEquivA: {1, 0},
fixtureEquivB: simVector(0.96),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoApplied != 1 || stats.AutoMerged != 1 {
t.Fatalf("自动档计数 = %+v, want applied/merged = 1/1(夹具:等价 ∧ 0.96)", stats)
}
if got := loadTidyItemRow(t, store, b.ID); got.status != ingestStatusMerged || got.mergedInto != a.ID {
t.Fatalf("被吸收行 = %+v, want merged into %s", got, a.ID)
}
}
// TestTidyAutoSkipsSubsetMerge 是判据 ② 的反面,也是「不许自动做的事」之一:
// **真子集**(A ⊆ B 但不等价、也不是字面重复)不自动 —— 它可能丢信息,交人工。
func TestTidyAutoSkipsSubsetMerge(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
emb := stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.99), // 即使相似度极高
}}
tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), emb)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Candidates != 1 {
t.Fatalf("候选 = %d, want 1(真子集仍产 merge 候选,交人工)", stats.Candidates)
}
if stats.AutoEligible != 0 || stats.AutoApplied != 0 {
t.Fatalf("真子集被自动合并了:%+v(不许自动做的事)", stats)
}
for _, id := range []string{short.ID, long.ID} {
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("行 %s 被自动改了:%+v", id, got)
}
}
cands := allTidyCandidates(t, store, 10)
if len(cands) != 1 || cands[0].Status != tidyCandidatePending {
t.Fatalf("候选 = %+v, want 恰 1 条 pending(等人点)", cands)
}
}
// TestTidyAutoLowConfidenceNeverLands 是判据 ③:低置信**不自动**。
//
// 它的形状值得一提:低置信的对在**规则层**就被否决(design §5.2-6 的 VetoLowConfidence),
// 因此连候选都不会有 —— 自动档根本看不到它。这条断言比「自动档里加一层低置信过滤」
// 更强:它证明低置信根本进不了自动档的输入面。
func TestTidyAutoLowConfidenceNeverLands(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
low := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.6))
highText := fixtureDupText + ",提神用"
high := seedTidyItem(t, store, habitItem(highText, base.Add(time.Minute), 0.9))
tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureDupText: {1, 0},
highText: simVector(0.98),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.VetoReasons[string(VetoLowConfidence)] < 1 {
t.Fatalf("低置信否决没有发生:%+v", stats.VetoReasons)
}
if stats.Candidates != 0 || stats.AutoEligible != 0 || stats.AutoApplied != 0 {
t.Fatalf("低置信对产出了候选或落地:%+v", stats)
}
cands := allTidyCandidates(t, store, 10)
if len(cands) != 0 {
t.Fatalf("低置信对不该产候选:%+v", cands)
}
for _, id := range []string{low.ID, high.ID} {
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("低置信对改变了行 %s:%+v", id, got)
}
}
if len(autoAuditRows(rows)) != 1 {
t.Fatalf("自动档审计 = %d 条, want 1(跑了但零落地也要如实留痕):%+v",
len(autoAuditRows(rows)), *rows)
}
if !strings.Contains(autoAuditRows(rows)[0].result, "applied=0") {
t.Fatalf("自动档审计 = %q, want applied=0", autoAuditRows(rows)[0].result)
}
}
// TestTidyAutoArchiveOneOffEvent 是判据 ④:一次性事件(含绝对日期/时刻)⇒ 自动软删
// (status='archived',行与正文都还在)。
func TestTidyAutoArchiveOneOffEvent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoApplied != 1 || stats.AutoArchived != 1 {
t.Fatalf("自动档计数 = %+v, want applied/archived = 1/1", stats)
}
got := loadTidyItemRow(t, store, oneOff.ID)
if got.status != tidyStatusArchived || got.content != fixtureOneOffEvent {
t.Fatalf("归档结果 = %+v, want archived 且正文不变", got)
}
if count := countMemoryItems(t, store); count != 1 {
t.Fatalf("行数 = %d, want 1(软删除)", count)
}
auto := autoAuditRows(rows)
if len(auto) != 1 || !strings.Contains(auto[0].result, tidyAutoReasonOneOffEvent) {
t.Fatalf("自动档审计 = %+v, want 含 %s", auto, tidyAutoReasonOneOffEvent)
}
}
// TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff 是判据 ⑤,也是本次改动最要紧的
// 那条守卫(design/23 §5.2-1:一次性事件 ≠ 长期习惯):正文里带钟点会让规则层把它
// 当成一次性事件,但只要正文同时是**习惯类**,自动档就必须住手 —— 归档主人的作息
// 习惯是错的。
//
// ⚠️ 变异用例:去掉 tidy_auto.go 里 `looksLikeHabit` 那道守卫 ⇒ 本用例 RED。
func TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
habit := seedTidyItem(t, store, habitItem(fixtureHabitClock, base, 0.9))
tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if got := loadTidyItemRow(t, store, habit.ID); got.status != tidyStatusActive {
t.Fatalf("作息习惯被自动归档了:%+v(design §5.2-1 红线)", got)
}
if stats.AutoApplied != 0 {
t.Fatalf("习惯类不该被自动落地:%+v", stats)
}
// 候选仍要产出(交人工看),只是不自动。
cands := allTidyCandidates(t, store, 10)
if len(cands) != 1 || cands[0].Action != TidyArchive || cands[0].Reason != tidyReasonOneOffEvent {
t.Fatalf("候选 = %+v, want 恰 1 条 archive/one_off_event 待人工", cands)
}
if cands[0].Status != tidyCandidatePending {
t.Fatalf("习惯类的归档候选不该被自动应用:%+v", cands[0])
}
}
// TestTidyAutoPerRoundLimitTruncatesAndDrains 是判据 ⑥:每轮上限生效、超限如实报
// Truncated + 真总数,且**下一轮继续排干**(不吞掉剩下的)。
//
// ⚠️ 变异用例:把 AutoApplyPerRound 的上限判断去掉(一次全做)⇒ 本用例 RED。
func TestTidyAutoPerRoundLimitTruncatesAndDrains(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
const oneOffs = 5
ids := make([]string, 0, oneOffs)
for i := 0; i < oneOffs; i++ {
item := habitItem(fixtureOneOffEvent, base.Add(time.Duration(i)*time.Minute), 0.9)
ids = append(ids, seedTidyItem(t, store, item).ID)
}
cfg := tidyTestCfg(0.80)
cfg.AutoApplyPerRound = 2
tr, _ := newAutoTestTidier(t, store, cfg, nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("第一轮 RunOnce: %v", err)
}
if stats.AutoEligible != oneOffs || stats.AutoApplied != 2 || !stats.AutoTruncated {
t.Fatalf("第一轮自动档 = %+v, want eligible=5 applied=2 truncated=true", stats)
}
if got := countArchived(t, store, ids); got != 2 {
t.Fatalf("实际归档 = %d, want 2(上限必须真的拦住)", got)
}
stats, err = tr.RunOnce(ctx)
if err != nil {
t.Fatalf("第二轮 RunOnce: %v", err)
}
if stats.AutoApplied != 2 || stats.AutoEligible != oneOffs-2 {
t.Fatalf("第二轮自动档 = %+v, want applied=2 eligible=3(剩下的下一轮继续)", stats)
}
if got := countArchived(t, store, ids); got != 4 {
t.Fatalf("两轮后归档 = %d, want 4", got)
}
}
// TestTidyAutoDisabledKeepsSuggestOnly 是判据 ⑦:关掉开关 ⇒ **一键回到只产建议**
// (一条都不落地,连自动档的审计都不写)。
func TestTidyAutoDisabledKeepsSuggestOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
dupA := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9))
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr, rows := newAutoTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureDupText: {1, 0},
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoEligible != 0 || stats.AutoApplied != 0 {
t.Fatalf("开关关闭却仍在自动落地:%+v", stats)
}
for _, id := range []string{oneOff.ID, dupA.ID} {
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("开关关闭时行 %s 被改了:%+v", id, got)
}
}
if len(autoAuditRows(rows)) != 0 {
t.Fatalf("开关关闭时不该写自动档审计:%+v", *rows)
}
// 建议照旧产出(只产建议 = 候选在、记忆不动)。
cands := allTidyCandidates(t, store, 10)
if len(cands) == 0 {
t.Fatal("关闭自动档不该连候选都不产(那是关闭整理,不是关闭自动档)")
}
}
// TestTidyAutoRevertRestoresBothActions 是判据 ⑧:自动落地的 merge 与 archive 都能
// 回滚(候选退回 pending、记忆行复原、行数不变)。
func TestTidyAutoRevertRestoresBothActions(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
first := seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9))
second := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9))
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9))
rowsBefore := countMemoryItems(t, store)
tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureDupText: {1, 0},
}})
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if got := loadTidyItemRow(t, store, second.ID); got.status != ingestStatusMerged {
t.Fatalf("前置条件不成立(该条没被自动合并):%+v", got)
}
if got := loadTidyItemRow(t, store, oneOff.ID); got.status != tidyStatusArchived {
t.Fatalf("前置条件不成立(该条没被自动归档):%+v", got)
}
for _, c := range allTidyCandidates(t, store, 10) {
if c.Status != tidyCandidateApplied {
t.Fatalf("自动落地的候选应当是 applied:%+v", c)
}
res, err := store.RevertTidyCandidate(ctx, c.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate(%s): %v", c.ID, err)
}
if res.Status != TidyApplyApplied {
t.Fatalf("回滚结果 = %+v, want applied", res)
}
if cand, err := store.GetTidyCandidate(ctx, c.ID); err != nil || cand.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", cand.Status, err)
}
}
restored := loadTidyItemRow(t, store, second.ID)
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.mergeCount != 1 {
t.Fatalf("软合并未复原:%+v", restored)
}
if got := loadTidyItemRow(t, store, oneOff.ID); got.status != tidyStatusActive {
t.Fatalf("软删除未复原:%+v", got)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("回滚前后行数 %d → %d,want 不变", rowsBefore, got)
}
survivor := loadTidyItemRow(t, store, first.ID)
if survivor.mergeCount != 1 {
t.Fatalf("存活行 merge_count = %d, want 1(回滚要摘掉那次合并)", survivor.mergeCount)
}
}
// TestTidyAutoSkipsRestrictedSourceAndForeignPartition 钉住两条「不许自动做的事」:
// 受限来源(群聊/陌生人,design §5.2-9/§6)与跨分区候选,在**执行面**被拒 ——
// 候选行是落库数据,可能来自更早的规则版本或手工改动,因此纵深防御必须有。
func TestTidyAutoSkipsRestrictedSourceAndForeignPartition(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
group := seedTidyItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: fixtureOneOffEvent, Confidence: 0.9, CreatedAt: base,
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
})
owner := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9))
// ① 受限来源的归档候选(手工落库,模拟历史行 / 被别处塞进来的行)。
restricted := TidyCandidate{
ID: mustNewTidyID(), PersonaID: group.PersonaID, Scope: group.Scope, Kind: group.Kind,
Action: TidyArchive, SourceIDs: []string{group.ID}, ProposedContent: group.Content,
Reason: tidyReasonOneOffEvent,
Evidence: TidyEvidence{Containment: string(containNone), ContentA: group.Content},
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
}
// ② 跨分区的合并候选:候选说 user 区,行在 persona 区。
foreign := TidyCandidate{
ID: mustNewTidyID(), PersonaID: owner.PersonaID, Scope: ScopeUser, Kind: owner.Kind,
Action: TidyMerge, SourceIDs: []string{owner.ID}, TargetID: group.ID,
ProposedContent: owner.Content, MergedContent: group.Content,
Reason: tidyReasonFactSubset,
Evidence: TidyEvidence{
Similarity: 1, Containment: string(containBoth),
ContentA: owner.Content, ContentB: owner.Content,
},
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
}
for _, c := range []TidyCandidate{restricted, foreign} {
if created, err := store.insertTidyCandidate(ctx, c); err != nil || !created {
t.Fatalf("落候选 %s = (%t, %v), want (true, nil)", c.ID, created, err)
}
}
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("AutoApplyTidyCandidates: %v", err)
}
if batch.EligibleTotal != 2 {
t.Fatalf("命中判据 = %d, want 2(两条都看似高置信)", batch.EligibleTotal)
}
if batch.Applied != 0 || batch.Skipped != 2 {
t.Fatalf("回执 = %+v, want applied=0 skipped=2(执行面的硬门拦住)", batch)
}
if got := loadTidyItemRow(t, store, group.ID); got.status != tidyStatusActive {
t.Fatalf("群聊来源被归档了:%+v(design §5.2-9 红线)", got)
}
if got := loadTidyItemRow(t, store, owner.ID); got.status != tidyStatusActive {
t.Fatalf("跨分区候选改动了行:%+v", got)
}
}
// countArchived 数给定 id 里已归档的行数。
func countArchived(t *testing.T, store *Store, ids []string) int {
t.Helper()
n := 0
for _, id := range ids {
if loadTidyItemRow(t, store, id).status == tidyStatusArchived {
n++
}
}
return n
}
+22 -8
View File
@@ -71,6 +71,22 @@ type tidyVerdict struct {
Reason string
}
// 候选与判定结论的理由标签(落 memory_tidy_candidates.reason;`tidyAutoApplyEligible`
// 会把其中两条读回来当自动落地的判据)。抽成常量是因为它们各有两处以上使用:
// 「三次复制必须抽取」(AGENTS §10)。
const (
// tidyReasonSimilarNotContained 是「相似但互不包含」的判定结论。2026-10-07 起
// 这种对**不再产候选**(判 keep,见 tidy_rules.go),它只留在判定结果与日志里。
tidyReasonSimilarNotContained = "similar_but_not_contained"
// tidyReasonFactSubset 是包含式合并(A ⊆ B,把 A 并入 B)的理由。
tidyReasonFactSubset = "fact_subset"
// tidyReasonOneOffEvent 是一次性事件的归档理由,**也是自动档唯一认的归档理由**:
// 理由不同(例如将来新增的归档类别)就不在新口径的授权范围内,交人工。
tidyReasonOneOffEvent = "one_off_event"
// tidyReasonKindMismatch 是 kind 误分类的理由(只产候选,不自动改)。
tidyReasonKindMismatch = "kind_mismatch"
)
// archiveCandidate 为一次性事件产一条归档建议(design/23 §5.1 T0-5)。
func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate {
return TidyCandidate{
@@ -82,7 +98,7 @@ func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate {
Action: TidyArchive,
SourceIDs: []string{it.ID},
ProposedContent: it.Content,
Reason: "one_off_event",
Reason: tidyReasonOneOffEvent,
Evidence: TidyEvidence{
Containment: "none",
ContentA: it.Content,
@@ -121,12 +137,10 @@ func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate
}
if v.Action != TidyMerge || v.Target == nil {
// 相似但互不包含:只建链接/分组,绝不合并(design §1.3 的 A-MEM 口径)。
base.Action = TidyLink
base.SourceIDs = []string{pr.a.ID, pr.b.ID}
base.Kind = pr.a.Kind
base.Reason = "similar_but_not_contained"
return []TidyCandidate{base}
// 非 merge 的判定结论**不产候选**:link 没有执行语义(design/23 §14 的待
// 细化项,见 tidy_rules.go 的 containNone 分支),keep 按 §5.2-10 就是
// 「什么都不做」。返回空切片而不是一条应用不了的候选 —— 后者只会污染待办。
return nil
}
absorbed, target := pr.a, *v.Target
@@ -152,7 +166,7 @@ func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate
re.TargetID = ""
re.ProposedContent = ""
re.MergedContent = ""
re.Reason = "kind_mismatch"
re.Reason = tidyReasonKindMismatch
re.Evidence.ProposedKind = string(target.Kind)
// KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」:
// merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而
+59
View File
@@ -24,6 +24,12 @@ const (
// TidyRuleVersion 是 T0 规则集的版本号,进候选的幂等键(design/23 §9):
// 规则不变时重复运行绝不重复产候选;升版后同一数据重新产候选,
// 因此被 reject 的对可以重评。**改判定规则必须同时升它**。
//
// ⚠️ 2026-10-07「link 不再产候选」**刻意没有**升它(不是漏了):升版的目的是
// 「让历史 reject 的对被重评」,而本次变更不改变任何一对/一条的**判定结论**
// (互不包含的对仍判 keep,只是不再落一条不可应用的候选);升版只会让活库里
// 87 条既存候选凭 idempotency_key 复制出一份重复行,与主人「该删就删」相反。
// 真正需要升版的是「判定结论会变」的改动(阈值语义、包含算法、否决规则)。
TidyRuleVersion = "t0.1"
// defaultTidyIntervalSec 是整理周期秒数,默认 86400 = 每天 1 次
@@ -81,6 +87,40 @@ const (
// 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 选取安全默认值,
@@ -109,6 +149,18 @@ type TidyConfig struct {
// 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 侧旋钮)映射成
@@ -150,9 +202,16 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
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
+3 -2
View File
@@ -198,7 +198,7 @@ type containment struct {
// containsFacts 报告 a 的关键要素是否**逐一**出现在 b 中(a ⊆ b):数值、
// 否定标记、CJK 内容字、拉丁词/emoji 符号四类都要满足。这就是 design/23
// §5.1 T0-3 的「保守近似」:A ⊆ B 仅当 A 的关键要素(专有名词、数值、否定词)
// 逐一出现在 B 中,不满足即视为互不包含 → 只建链接。
// 逐一出现在 B 中,不满足即视为互不包含 → 判 keep、不产候选(见 tidy_rules.go)。
func containsFacts(a, b tidyFacts) (bool, []string) {
var missing []string
for n := range a.nums {
@@ -231,7 +231,8 @@ func containsFacts(a, b tidyFacts) (bool, []string) {
}
// judgeContainment 判定两条内容的事实集合包含关系。互不包含
// (含「相似但并列」)返回 containNone —— 调用方据此只建链接,绝不合并。
// (含「相似但并列」)返回 containNone —— 调用方据此判 keep、**不产候选**
// (2026-10-07 起 link 候选已不再产出,见 tidy_rules.go 的 containNone 分支)。
func judgeContainment(aContent, bContent string) containment {
a := extractTidyFacts(aContent)
b := extractTidyFacts(bContent)
+4 -4
View File
@@ -186,6 +186,9 @@ func TestTidyDryRun_ZeroSideEffects(t *testing.T) {
// TestTidyDryRun_MatchesRealRunCandidates 是判据 ②:试算走的必须是**同一套规则**
// —— 同一批数据上,试算候选与真跑一轮落库的候选逐字段一致。
//
// 2026-10-07 起真跑还会跑轮末自动档,因此落库候选要按**全部状态**读(自动落地的
// 会变成 applied):本用例比的是「规则产出了什么候选」,不是「待办队列里还剩几条」。
func TestTidyDryRun_MatchesRealRunCandidates(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -203,10 +206,7 @@ func TestTidyDryRun_MatchesRealRunCandidates(t *testing.T) {
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
persisted, err := store.ListTidyCandidates(ctx, "", 100)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
persisted := allTidyCandidates(t, store, 100)
want := sortedSignatures(out.Candidates)
got := sortedSignatures(persisted)
+123
View File
@@ -0,0 +1,123 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// 本文件是整理候选的**批量决定**路径(2026-10-07 主人「那么多 link 是干什么的?
// 该删就删」)。
//
// 它解决的问题很具体:库里的 `link` 候选**没有执行语义**(design/23 §14 把「link
// 关系如何存储与呈现」列为待细化 ⇒ 无落点、无数据模型,详见 tidy_rules.go 的
// containNone 分支),因此它们永远不会被应用,只会一直 pending、把面板的待办列表
// 淹没(活库实测 501 条)。规则层已改成不产它们,但**存量**必须有一条干净的出路
// —— 一条条点不现实,所以这里给一个幂等的按动作批量决定口。
//
// 为什么叫「拒绝(rejected)」而不是「删除」:候选是**建议**,不是记忆。拒绝只写
// 候选表的状态列(design/23 §10 的 status 闭集第三档,此前只实现了 pending/applied),
// **不碰 memory_items 任何一行**,且幂等(`WHERE status='pending'` 条件更新)——
// 重复调用第二次就是 0 条,不是错误。
// tidyCandidateRejected 是候选被**明确拒绝**的状态(design/23 §10 的 status 闭集:
// pending / applied / rejected)。落它的意义是「这条建议已经被看过了、决定不做」,
// 于是它不再出现在默认(pending)的待办理列表里,也不再被轮末自动档捞出来。
const tidyCandidateRejected = "rejected"
// ErrTidyUnknownAction 在按动作批量决定时收到了闭集之外的动作名时返回。
// 它与「查不到」不同:那是调用方拼错了动作,必须回 400 而不是静默改 0 条。
var ErrTidyUnknownAction = errors.New("memory: unknown tidy action")
// 按动作批量拒绝候选的两条 SQL。第一条是**幂等闸**:只动 pending 的行,因此重复
// 调用天然收敛;LIMIT 放在子查询里(UPDATE ... LIMIT 需要 SQLite 的编译开关)。
const rejectTidyCandidatesByActionSQL = `UPDATE memory_tidy_candidates
SET status = ?, decided_at = ?, decided_by = ?
WHERE id IN (
SELECT id FROM memory_tidy_candidates
WHERE status = ? AND action = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?
)`
const countTidyCandidatesByActionSQL = `SELECT COUNT(*) FROM memory_tidy_candidates
WHERE status = ? AND action = ?`
// TidyRejectBatch 是一次按动作批量决定的回执。Total 是**筛选真总数**(该动作下
// 此刻的 pending 条数),Rejected 才是这次真正改动的条数 —— 两者之差由 Truncated
// 表达(超限绝不静默少做,与扫描/候选/应用上限同一口径)。
type TidyRejectBatch struct {
Action TidyAction `json:"action"`
Limit int `json:"limit"`
Total int `json:"total"`
Rejected int `json:"rejected"`
Truncated bool `json:"truncated"`
}
// valid 报告一个动作是否在 design/23 §10 的动作闭集内。
func (a TidyAction) valid() bool {
switch a {
case TidyMerge, TidyLink, TidyArchive, TidyReclassify, TidyKeep:
return true
default:
return false
}
}
// normalizedRejectLimit 归一化「按动作批量拒绝」的上限:非正取
// defaultTidyRejectBatchLimit,超过 maxTidyRejectBatchLimit 时钳到它
// (钳位而不是报错,与既有查询面同一约定)。
func (o TidyApplyOptions) normalizedRejectLimit() int {
if o.Limit <= 0 {
return defaultTidyRejectBatchLimit
}
if o.Limit > maxTidyRejectBatchLimit {
return maxTidyRejectBatchLimit
}
return o.Limit
}
// RejectTidyCandidatesByAction 把某个动作下**全部待定**候选标记为 rejected
// (一次最多 limit 条,重复调用幂等)。它是给存量候选(尤其是无执行语义的 link)
// 用的清洁口,也是「reject 记入审计、用于后续调参与防重复提议」这条设计口径的落点。
func (s *Store) RejectTidyCandidatesByAction(ctx context.Context, action TidyAction, opts TidyApplyOptions) (TidyRejectBatch, error) {
if !action.valid() {
return TidyRejectBatch{}, fmt.Errorf("%w: %q", ErrTidyUnknownAction, string(action))
}
batch := TidyRejectBatch{
Action: action,
Limit: opts.normalizedRejectLimit(),
}
total, err := s.countTidyCandidatesByAction(ctx, action, tidyCandidatePending)
if err != nil {
return batch, err
}
batch.Total = total
res, err := s.db.ExecContext(ctx, rejectTidyCandidatesByActionSQL,
tidyCandidateRejected, formatTime(time.Now().UTC()), opts.Actor,
tidyCandidatePending, string(action), batch.Limit)
if err != nil {
return batch, fmt.Errorf("memory/tidy: reject candidates of action %s: %w", action, err)
}
n, err := res.RowsAffected()
if err != nil {
return batch, fmt.Errorf("memory/tidy: reject candidates rows: %w", err)
}
batch.Rejected = int(n)
batch.Truncated = batch.Total > batch.Rejected
return batch, nil
}
// countTidyCandidatesByAction 数某动作在某状态下的候选条数(真总数,不受 LIMIT 影响)。
func (s *Store) countTidyCandidatesByAction(ctx context.Context, action TidyAction, status string) (int, error) {
if strings.TrimSpace(status) == "" {
status = tidyCandidatePending
}
var total int
if err := s.db.QueryRowContext(ctx, countTidyCandidatesByActionSQL, status, string(action)).
Scan(&total); err != nil {
return 0, fmt.Errorf("memory/tidy: count candidates of action %s: %w", action, err)
}
return total, nil
}
+134
View File
@@ -0,0 +1,134 @@
package memory
import (
"context"
"errors"
"testing"
"time"
)
// 本文件锁定整理候选的**按动作批量拒绝**(tidy_reject.go):给没有执行语义的候选
// (典型是 link,活库曾积压 501 条)一条干净的出路。四条判据:
//
// ① 只动指定动作的 pending 行,**不碰其他动作、不碰已决定的行**;
// ② 幂等:重复调用第二次是 0 条,不是错误;
// ③ 上限生效、超限如实报 total/truncated;
// ④ 动作名不在闭集内 ⇒ 分类错误(调用方据此回 400),绝不静默改 0 条。
// insertLinkCandidate 落一条 link 候选(历史形状:无执行语义)。
func insertLinkCandidate(t *testing.T, store *Store, ids []string) TidyCandidate {
t.Helper()
c := TidyCandidate{
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Action: TidyLink, SourceIDs: ids, Reason: tidyReasonSimilarNotContained,
Evidence: TidyEvidence{
Similarity: 0.9, Containment: string(containNone),
ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced,
},
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: tidyTestTime(t),
}
if created, err := store.insertTidyCandidate(context.Background(), c); err != nil || !created {
t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err)
}
return c
}
// TestTidyRejectByActionIsIdempotentAndScoped 是判据 ①②:只决定指定动作的 pending 行,
// 重复调用第二次 0 条;其他动作的候选与记忆行**一行不动**。
func TestTidyRejectByActionIsIdempotentAndScoped(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
items := make([]string, 0, 4)
for i := 0; i < 4; i++ {
items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID)
}
// 两条 link 候选的 source_ids 必须不同:幂等键含 source_ids,否则第二条会被
// INSERT OR IGNORE 静默挡下(那是幂等的正确行为,但会让夹具失效)。
links := []TidyCandidate{
insertLinkCandidate(t, store, items[0:2]),
insertLinkCandidate(t, store, items[2:4]),
}
// 一条**别的动作**的候选:它必须原封不动。
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9))
other := TidyCandidate{
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Action: TidyArchive, SourceIDs: []string{"mem-nonexistent"}, Reason: tidyReasonOneOffEvent,
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
}
if created, err := store.insertTidyCandidate(ctx, other); err != nil || !created {
t.Fatalf("落 archive 候选 = (%t, %v)", created, err)
}
itemsBefore := countMemoryItems(t, store)
batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 10})
if err != nil {
t.Fatalf("RejectTidyCandidatesByAction: %v", err)
}
if batch.Total != 2 || batch.Rejected != 2 || batch.Truncated {
t.Fatalf("回执 = %+v, want total=2 rejected=2 truncated=false", batch)
}
for _, c := range links {
got, err := store.GetTidyCandidate(ctx, c.ID)
if err != nil {
t.Fatalf("GetTidyCandidate: %v", err)
}
if got.Status != tidyCandidateRejected {
t.Fatalf("link 候选状态 = %q, want %q", got.Status, tidyCandidateRejected)
}
}
if got, err := store.GetTidyCandidate(ctx, other.ID); err != nil || got.Status != tidyCandidatePending {
t.Fatalf("别的动作的候选被动了:%q (err=%v)", got.Status, err)
}
// 拒绝是**决定**不是删除:记忆一行不动、正文不动。
if got := countMemoryItems(t, store); got != itemsBefore {
t.Fatalf("memory_items 行数 %d → %d,拒绝不该碰记忆", itemsBefore, got)
}
pending, err := store.ListTidyCandidates(ctx, tidyCandidatePending, 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
if len(pending) != 1 || pending[0].ID != other.ID {
t.Fatalf("待办队列 = %+v, want 只剩那条 archive(噪声被清掉)", pending)
}
// 幂等:再来一次,0 条且不是错误。
again, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("第二次 RejectTidyCandidatesByAction: %v", err)
}
if again.Rejected != 0 || again.Total != 0 || again.Truncated {
t.Fatalf("第二次回执 = %+v, want 全 0(幂等)", again)
}
}
// TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction 是判据 ③④:
// 上限生效且超限如实报 truncated;动作名不在闭集内返回分类错误。
func TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
items := make([]string, 0, 4)
for i := 0; i < 4; i++ {
items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID)
}
// 三条 link 候选各用不同的 source_ids(幂等键含 source_ids,见上一个用例)。
for i := 0; i < 3; i++ {
insertLinkCandidate(t, store, items[i:i+2])
}
batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 2})
if err != nil {
t.Fatalf("RejectTidyCandidatesByAction: %v", err)
}
if batch.Limit != 2 || batch.Total != 3 || batch.Rejected != 2 || !batch.Truncated {
t.Fatalf("回执 = %+v, want limit=2 total=3 rejected=2 truncated=true", batch)
}
if _, err := store.RejectTidyCandidatesByAction(ctx, TidyAction("delete-everything"),
TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyUnknownAction) {
t.Fatalf("未知动作的 err = %v, want ErrTidyUnknownAction", err)
}
}
+15 -5
View File
@@ -10,7 +10,8 @@ import "strings"
// 2. 时效污染(一次性事件、有期限状态)—— §5.2-1;
// 3. 数值不一致 —— §5.2-2;
// 4. 低置信 —— §5.2-6;
// 5. 包含判定:互不包含 → 只建链接(§1.3);
// 5. 包含判定:互不包含 → 判 keep、**不产候选**(2026-10-07 口径:link 无执行
// 语义,见本文件 containNone 分支的说明);
// 6. 包含成立但否定不一致 → 冲突,走覆盖而非重复(§5.2-3)。
//
// 命中任一条否决规则即踢出候选(design §5.1 T0-4)。
@@ -43,9 +44,18 @@ func judgePair(a, b MemoryItem, score float64) tidyVerdict {
c := judgeContainment(a.Content, b.Content)
if c.Kind == containNone {
// 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):
// 只建链接/分组,绝不合并。
return tidyVerdict{Action: TidyLink, Containment: c, Reason: "similar_but_not_contained"}
// 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):判 **keep**,
// 一条候选都不产。
//
// 2026-10-07 主人口径(「那么多 link 是干什么的?该删就删」)——**为什么
// 不实现 link**:design/23 §14 把「`link` 关系如何存储与呈现(是否建
// `memory_links` 表;是否只做面板展示而不进检索)」明确列为**待细化** ⇒
// 它既没有落点(§10 的 memory_items 新列里没有链接列、§11 的端点表里没有
// link 端点),也没有数据模型;而执行面只认三种可应用动作
// (tidy_apply.go 的 applyable())。⇒ 产一条 link 候选的**唯一后果**是
// 一条永远应用不了、只能 skip 的待办(活库曾积压 501 条,把面板淹没)。
// 「以设计为准」的结论是:**不产它**,直到 §14 那条被细化出存储形态。
return tidyVerdict{Action: TidyKeep, Containment: c, Reason: tidyReasonSimilarNotContained}
}
if !sameNegations(a.Content, b.Content) {
// 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」):
@@ -57,7 +67,7 @@ func judgePair(a, b MemoryItem, score float64) tidyVerdict {
Action: TidyMerge,
Target: &target,
Containment: c,
Reason: "fact_subset",
Reason: tidyReasonFactSubset,
}
}
+9 -4
View File
@@ -136,6 +136,8 @@ func (s *TidyService) Start(ctx context.Context) {
"scan_limit", s.cfg.ScanLimit,
"compare_limit", s.cfg.CompareLimit,
"candidate_limit", s.cfg.CandidateLimit,
"auto_apply", s.cfg.autoApplyEnabled(),
"auto_apply_per_round", s.cfg.AutoApplyPerRound,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID,
"last_run_at", s.lastRunLabel())
@@ -218,8 +220,10 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
// 1. **尊重配置开关**:关闭时如实返回 ErrTidyDisabled,不退化成空结果;
// 2. **触发标签是 manual**:审计里能区分「人点的」与「心跳/补跑的」。
//
// 它写候选与运行留痕(与试算 DryRun 的零副作用相反),但**绝不自动应用** ——
// 应用是另一条显式入口(候选 apply / 按 run 批量 apply),保持 §7 的人工在环。
// 它写候选与运行留痕(与试算 DryRun 的零副作用相反),并会跑**轮末自动档**:
// 规则层判为高置信的候选(字面重复 / 事实等价 ∧ ≥0.95 / 一次性事件且不像习惯)
// 由 tidy_auto.go 直接落地,其余仍等人点(候选 apply / 按 run 批量 apply)。
// 关掉自动档只需 `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`。
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
if !s.cfg.Enabled {
return TidyStats{}, ErrTidyDisabled
@@ -347,9 +351,10 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats,
func (s *TidyService) audit(stats TidyStats, trigger string) {
hook := s.tidier.deps.Audit
result := fmt.Sprintf(
"trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d duration_ms=%d by_action=%s rejected_reasons=%s",
"trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d kept=%d duration_ms=%d by_action=%s rejected_reasons=%s auto_applied=%d auto_merge=%d auto_archive=%d auto_eligible=%d auto_truncated=%t",
trigger, stats.RunID, stats.Scanned, stats.Candidates, stats.Duplicates, stats.Vetoed,
stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons))
stats.Kept, stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons),
stats.AutoApplied, stats.AutoMerged, stats.AutoArchived, stats.AutoEligible, stats.AutoTruncated)
if hook == nil {
s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result)
return
+40 -9
View File
@@ -148,14 +148,13 @@ func TestTidyServiceManualRunAndAudit(t *testing.T) {
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
type auditRow struct{ who, what, result string }
var rows []auditRow
var rows []tidyAuditRow
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, time.Now, func(who, what, result string) {
rows = append(rows, auditRow{who, what, result})
rows = append(rows, tidyAuditRow{who, what, result})
})
stats, err := svc.RunOnce(ctx)
@@ -165,21 +164,34 @@ func TestTidyServiceManualRunAndAudit(t *testing.T) {
if stats.Candidates != 1 {
t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates)
}
if len(rows) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(rows))
// 两条审计:① 领域侧记的那条 memory.tidy.run(design §8);② 2026-10-07 起
// 轮末自动档自己写的一条 memory.tidy.apply(result 带 auto=true)。本夹具的
// merge 候选是「真子集」(a_subset_b),不命中自动判据,因此第二条是**零落地**
// 的诚实回执 —— 这正是「自动档跑了但什么都没做」该有的形状。
// 两条的先后不构成契约(领域侧先写自动档那条),因此按 what 取,不按下标取。
if len(rows) != 2 {
t.Fatalf("审计行数 = %d, want 2(run + auto apply):%+v", len(rows), rows)
}
if rows[0].what != "memory.tidy.run" || rows[0].who != tidyAuditWho {
t.Fatalf("审计 who/what = %q/%q", rows[0].who, rows[0].what)
round := auditRowOf(t, rows, "memory.tidy.run")
auto := auditRowOf(t, rows, tidyAutoApplyVerb)
if round.who != tidyAuditWho {
t.Fatalf("审计 who = %q, want %q", round.who, tidyAuditWho)
}
if strings.Contains(rows[0].result, fixtureHabitShort) {
if strings.Contains(round.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文")
}
if !strings.Contains(auto.result, "auto=true") || !strings.Contains(auto.result, "applied=0") {
t.Fatalf("自动档审计 = %+v, want memory.tidy.apply + auto=true + applied=0", auto)
}
if strings.Contains(auto.result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文(自动档那条也不行)")
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 {
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(不自动应用)", runs, TidyRuleVersion)
t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(真子集候选不在自动档授权内)", runs, TidyRuleVersion)
}
}
@@ -216,3 +228,22 @@ func TestTidyRunRecordedVetoDistribution(t *testing.T) {
t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned)
}
}
// tidyAuditRow 是一条捕获到的审计行(本文件的用例只需这三个字段)。
type tidyAuditRow struct{ who, what, result string }
// auditRowOf 按动作名取一条审计行;缺了或重复了就当场失败(审计行必须**恰有一条**,
// 「多了」与「少了」都是缺陷)。
func auditRowOf(t *testing.T, rows []tidyAuditRow, what string) tidyAuditRow {
t.Helper()
var found []tidyAuditRow
for _, r := range rows {
if r.what == what {
found = append(found, r)
}
}
if len(found) != 1 {
t.Fatalf("审计 %s = %d 条, want 恰 1 条(全部:%+v)", what, len(found), rows)
}
return found[0]
}
+51 -25
View File
@@ -114,6 +114,26 @@ func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidat
return out
}
// allTidyCandidates 读一份**不分状态**的候选集合(pending + applied + rejected)。
//
// 2026-10-07 起 RunOnce 会跑**轮末自动档**:命中判据的候选(字面重复 / 事实等价 ∧
// ≥0.95 / 一次性事件且不像习惯)当场就被落地成 applied。因此「这一轮的规则产出了
// 什么候选」这类断言必须看全部状态 —— 只看待办队列会把「已经自动做完了」读成
// 「什么都没产出」。
func allTidyCandidates(t *testing.T, store *Store, limit int) []TidyCandidate {
t.Helper()
ctx := context.Background()
out := make([]TidyCandidate, 0)
for _, status := range []string{tidyCandidatePending, tidyCandidateApplied, tidyCandidateRejected} {
cands, err := store.ListTidyCandidates(ctx, status, limit)
if err != nil {
t.Fatalf("ListTidyCandidates(%s): %v", status, err)
}
out = append(out, cands...)
}
return out
}
// TestTidyContainedFactProducesMergeCandidate 是 design/23 §12.1 P1 的口径:
// A 的事实集合是 B 的子集 ⇒ 产 merge 候选,存活条目是超集 B,被并入的是 A。
func TestTidyContainedFactProducesMergeCandidate(t *testing.T) {
@@ -165,10 +185,16 @@ func TestTidyContainedFactProducesMergeCandidate(t *testing.T) {
}
}
// TestTidyParallelFactsOnlyLink 是主人红线「不要什么内容都合并」与
// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)只建链接,
// 绝不合并 —— 即使召回分数很高。
func TestTidyParallelFactsOnlyLink(t *testing.T) {
// TestTidyParallelFactsProduceNoCandidate 是主人红线「不要什么内容都合并」与
// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)**既不合并,也不产
// link 候选**(2026-10-07 主人口径「那么多 link 是干什么的?该删就删」)。
//
// 为什么连 link 候选都不产:design/23 §14 把「link 关系如何存储与呈现」列为**待细化**
// ⇒ 没有落点、没有数据模型;执行面只认 merge/archive/reclassify(tidy_apply.go 的
// applyable())⇒ 一条 link 候选的应用结果永远是 skipped/action_not_applyable。
// 老的用例名是 TestTidyParallelFactsOnlyLink(曾经断言「恰产一条 link」),那正是
// 活库积压 501 条无执行语义候选的来源。
func TestTidyParallelFactsProduceNoCandidate(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
@@ -180,27 +206,23 @@ func TestTidyParallelFactsOnlyLink(t *testing.T) {
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.99),
}})
if _, err := tr.RunOnce(ctx); err != nil {
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
cands := allTidyCandidates(t, store, 50)
if len(cands) != 0 {
t.Fatalf("并列事实不该产任何候选(含 link):%+v(主人红线:不要什么内容都合并)", cands)
}
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
t.Fatalf("并列事实被合并了:%+v(主人红线:不要什么内容都合并)", merges)
if stats.Kept != 1 {
t.Fatalf("stats.Kept = %d, want 1(抛掉的对必须单独计数,否则口径上是消失)", stats.Kept)
}
links := candidatesByAction(cands, TidyLink)
if len(links) != 1 {
t.Fatalf("link candidates = %d, want 1 (%+v)", len(links), cands)
}
ids := links[0].SourceIDs
if len(ids) != 2 || !containsID(ids, redBull.ID) || !containsID(ids, iced.ID) {
t.Fatalf("link source ids = %v, want 两条并列事实", ids)
}
if links[0].MergedContent != "" || links[0].TargetID != "" {
t.Fatalf("link 候选不得带合并结果:%+v", links[0])
// 「什么都不做」必须是**真什么都没做**:两行都还在册。
for _, id := range []string{redBull.ID, iced.ID} {
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("并列事实被改动了:%+v", got)
}
}
}
@@ -281,10 +303,7 @@ func TestTidyTemporalPollutionVetoed(t *testing.T) {
t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
cands := allTidyCandidates(t, store, 50)
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
t.Fatalf("时效污染被合并:%+v", merges)
}
@@ -335,6 +354,11 @@ func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) {
// TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮
// 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。
//
// 本用例刻意**关掉轮末自动档**:自动档会把一次性事件当场归档,于是第二轮扫描范围
// 本身就变小了(归档行不再参与比对),duplicates 会被自动档的结果影响 ——
// 那是自动档的语义(另有 tidy_auto_test.go 专门验),不是「候选产出」这条幂等性的
// 一部分。这里要证明的是:规则不变时同一批数据绝不重复产候选。
func TestTidyRerunIsIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -344,7 +368,9 @@ func TestTidyRerunIsIdempotent(t *testing.T) {
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
fixtureOneOffEvent: simVector(0.9),
+119
View File
@@ -0,0 +1,119 @@
package memory
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是记忆整理的**类型面**:一轮运行的留痕与观测口径、注入依赖、整理器本体与
// 构造函数。它从 tidy.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与
// 「模块按职责拆源文件」那条规范 —— todo 与口径逐字未变。
// TidyRun 是一轮整理的留痕(design/23 §10 memory_tidy_runs)。
type TidyRun struct {
ID string
StartedAt time.Time
FinishedAt time.Time
Scanned int
Candidates int
Applied int
Skipped int
Errors int
Vetoed int
VetoReasons map[string]int
RuleVersion string
}
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
type TidyStats struct {
// RunID 是本轮在 memory_tidy_runs 里的留痕 id(切片 TIDY-1):候选按它归属,
// 「按 run 批量应用」因此有确定的抓手。留痕写失败时它仍是本轮已用的 id,
// 调用方不该据此假装留痕存在。
RunID string
Scanned int
Partitions int
Compared int
Pairs int
Candidates int
Duplicates int
Skipped int
Vetoed int
VetoReasons map[string]int
// Kept 是本轮判定为「相似但互不包含」的对数(design §1.3 的 [1]/[3])。
// 2026-10-07 起这种对不产候选,因此必须**单独计数** —— 否则「这一轮抛掉了
// 多少对」在口径上彻底消失,面板会把「没有建议」与「有 300 对不值得动」
// 读成同一件事。
Kept int
// AutoEligible / AutoApplied / AutoMerged / AutoArchived / AutoTruncated 是轮末
// 自动档(2026-10-07 主人口径「该删就删」)的观测口径:Eligible 是命中自动判据
// 的**真总数**(含因每轮上限没处理的),Applied 是这一轮真的落地的条数。
AutoEligible int
AutoApplied int
AutoMerged int
AutoArchived int
AutoTruncated bool
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
ByAction map[string]int
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
Errors int
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
Truncated []string
// DurationMS 是本轮的墙钟耗时(毫秒),与日志里的 duration_ms 同一算法。
DurationMS int64
}
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
type TidyDeps struct {
// Store 是记忆存储。必需 —— 整理只读写它,绝不接触会话历史。
Store *Store
// Embedder 是候选召回的相似度来源。为 nil 时回退到零依赖的
// 字符二元组余弦(离线可用、测试确定性),见 tidy_recall.go。
Embedder embedding.Embedder
// Audit 是审计出口(design/23 §8 的 memory.tidy.* 动作)。可选:
// 组合根把它接到网关的 AuditEvent;为 nil 时只记日志。
Audit func(who, what, result string)
// Now 是运行时间戳的墙钟来源,便于测试注入。
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/tidy"。
Log *slog.Logger
}
// Tidier 是记忆整理器。它并发安全**仅限**「一轮之内不重入」:
// 服务层用一把互斥锁保证同一时刻只有一轮(design/23 §9 单写者)。
type Tidier struct {
cfg TidyConfig
deps TidyDeps
log *slog.Logger
now func() time.Time
}
// NewTidier 由 cfg 与 deps 构建整理器。Store 为 nil 时返回 ErrNilStore。
func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
if deps.Store == nil {
return nil, ErrNilStore
}
cfg = normalizeTidyConfig(cfg)
now := deps.Now
if now == nil {
now = time.Now
}
return &Tidier{
cfg: cfg,
deps: deps,
log: logger.WithSource(baseTidyLog(deps.Log), "memory/tidy"),
now: now,
}, nil
}
// Config 返回生效的整理配置(已套默认值)。
func (t *Tidier) Config() TidyConfig { return t.cfg }
// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。
func baseTidyLog(log *slog.Logger) *slog.Logger {
return discardLogger(log)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 /
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→不产候选 /
// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。
// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人
// 来源的条目既不合并也不归档,只读不写。
@@ -65,6 +65,7 @@ type tidyApplyStore interface {
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
RejectTidyCandidatesByAction(ctx context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error)
}
const (
@@ -77,6 +78,9 @@ const (
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
// 回滚,并把候选退回 pending,因此不复用。
tidyRevertAction = "memory.tidy.revert"
// tidyRejectAction 是候选**批量拒绝**的审计动作名(design/23 §8 的 `memory.tidy.*`
// 族)。它与「拒绝」在设计里的定位一致:`reject` 记入审计,用于后续调参与防重复提议。
tidyRejectAction = "memory.tidy.reject"
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
tidyRunTimeout = 5 * time.Minute
@@ -70,8 +70,11 @@ type AdminTidyApplyBatchResponse struct {
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
// `candidatesByAction{merge|archive}` / `durationMs`。
//
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
// ⚠️ 它是**建议侧**计数。2026-10-07 起真跑还会跑**轮末自动档**(规则层判为高置信的
// 候选直接落地,见 memory 包的 tidy_auto.go),但那份计数**不在本结构里** —— 它走
// 审计(`memory.tidy.apply` + `auto=true`)与日志,以及 `GET …/candidates?status=applied`
// 里 `decidedBy=system/memorytidy` 的行。要把自动档计数搬上本回执,需同时补
// `Docs/api-client.md` 的 B24 段(属文档侧改动)。
type AdminTidyRunResponse struct {
RunID string `json:"runId"`
Trigger string `json:"trigger"`
@@ -92,6 +95,18 @@ type AdminTidyRunResponse struct {
// 挡下(同一批数据重复跑)的数量。
CandidatesCreated int `json:"candidatesCreated"`
CandidatesDuplicate int `json:"candidatesDuplicate"`
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
CandidatesByAction map[string]int `json:"candidatesByAction"`
}
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
// `total` 是该动作下**此刻**的待定真总数,`rejected` 才是本次真正决定掉的条数 ——
// 两者不等时 `truncated=true`(超限绝不静默少做)。重复调用同一动作是幂等的:
// 第二次 `total`/`rejected` 都会如实变小,而不是报错。
type AdminTidyRejectResponse struct {
Action string `json:"action"`
Limit int `json:"limit"`
Total int `json:"total"`
Rejected int `json:"rejected"`
Truncated bool `json:"truncated"`
}
@@ -48,6 +48,11 @@ type fakeTidyApply struct {
revertErr error
revertID string
revertOpts memory.TidyApplyOptions
rejectResult memory.TidyRejectBatch
rejectErr error
rejectAction memory.TidyAction
rejectOpts memory.TidyApplyOptions
}
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
@@ -74,6 +79,11 @@ func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts m
return f.revertResult, f.revertErr
}
func (f *fakeTidyApply) RejectTidyCandidatesByAction(_ context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error) {
f.rejectAction, f.rejectOpts = action, opts
return f.rejectResult, f.rejectErr
}
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
type fakeTidyRunner struct {
@@ -453,6 +463,8 @@ func TestAdminTidyApplyRBAC(t *testing.T) {
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/apply"},
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/revert"},
// 按动作批量拒绝(2026-10-07):门与同族同档(owner|admin)。
{http.MethodPost, "/api/v1/admin/memories/candidates/reject?action=link"},
}
for _, p := range paths {
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
@@ -0,0 +1,90 @@
package gateway
import (
"errors"
"net/http"
"strconv"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载整理候选的**批量拒绝**(按动作)管理面:
//
// POST /api/v1/admin/memories/candidates/reject?action=<merge|link|archive|reclassify>&limit=
//
// 动因(2026-10-07 主人:「那么多 link 是干什么的?该删就删」):活库里积压了 501 条
// `link` 候选,而 link **没有执行语义**(design/23 §14 把「link 关系如何存储与呈现」
// 列为待细化 ⇒ 无落点、无数据模型,详见 memory 包的 tidy_rules.go 与 tidy_apply.go 的
// applyable())—— 它们永远不会被应用,只会一直占着待办列表。规则层已改成不产它们,
// 存量则需要一条**批量**的出路:一条条点不现实(这正是主人这次投诉的形状)。
//
// 口径:
//
// 1. **它是「决定」不是「删除」**:只写候选表的状态列(`rejected`),**不碰
// memory_items 任何一行**。记忆的删除/归档语义仍只有两条路(archive 候选 + §8 的
// 软删除口径),候选拒绝与它无关。
// 2. **幂等**:只动 `status='pending'` 的行,重复调用第二次就是 0 条,不是错误。
// 3. **门同族**:走 `writeAuth`(owner|admin),与 `…/candidates/apply`、
// `…/candidates/{id}/revert` 同档。
// 4. **动作名必须合法**:不在 design/23 §10 的动作闭集里 ⇒ 400 `bad_request`,
// 而不是静默改 0 条(拼错动作与「没有这类候选」必须能分辨)。
// 5. **写审计**:动作名 `memory.tidy.reject`(design/23 §8 的动作名族),只带计数,
// 不带候选正文。
func (s *Server) adminTidyCandidatesReject(w http.ResponseWriter, r *http.Request) {
store := s.tidyCandidates()
if store == nil {
s.auditClaims(r, tidyRejectAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
return
}
action := memory.TidyAction(r.URL.Query().Get("action"))
if action == "" {
s.auditClaims(r, tidyRejectAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
return
}
limit, ok := parseTidyApplyLimit(r)
if !ok {
s.auditClaims(r, tidyRejectAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
batch, err := store.RejectTidyCandidatesByAction(r.Context(), action, memory.TidyApplyOptions{
Actor: s.tidyActor(r),
Limit: limit,
})
if err != nil {
s.auditClaims(r, tidyRejectAction, "error")
if errors.Is(err, memory.ErrTidyUnknownAction) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown action %q", string(action)))
return
}
s.log.Error("reject tidy candidates", "err", err, "action", string(action))
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_reject_failed", "批量拒绝整理候选失败"))
return
}
s.auditClaims(r, tidyRejectAction, tidyRejectAuditResult(batch))
writeJSON(w, http.StatusOK, adminTidyRejectBatch(batch))
}
// tidyRejectAuditResult 渲染批量拒绝的审计 result(以 ok 开头,与同族一致):
// 带真总数与超限标记,因此审计能回答「这次到底决定掉多少、有没有被上限截住」。
func tidyRejectAuditResult(batch memory.TidyRejectBatch) string {
return "ok action=" + string(batch.Action) +
" limit=" + strconv.Itoa(batch.Limit) +
" total=" + strconv.Itoa(batch.Total) +
" rejected=" + strconv.Itoa(batch.Rejected) +
" truncated=" + strconv.FormatBool(batch.Truncated)
}
// adminTidyRejectBatch 投影批量拒绝回执。
func adminTidyRejectBatch(batch memory.TidyRejectBatch) AdminTidyRejectResponse {
return AdminTidyRejectResponse{
Action: string(batch.Action),
Limit: batch.Limit,
Total: batch.Total,
Rejected: batch.Rejected,
Truncated: batch.Truncated,
}
}
@@ -0,0 +1,111 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定整理候选**按动作批量拒绝**的管理面(admin_memory_tidy_reject.go;
// 2026-10-07 主人「那么多 link 是干什么的?该删就删」)。四条判据:
//
// ① 200 + 参数如实透传(action/limit)+ 回执含 total/rejected/truncated;
// ② 落审计 memory.tidy.reject(只带计数,不带候选正文);
// ③ 动作缺失 / 不在闭集内 ⇒ 400(拼错动作不能与「没有这类候选」混为一谈);
// ④ 未接线 ⇒ 500 tidy_unavailable,绝不回一份「已处理 0 条」的假成功。
// TestAdminTidyCandidatesRejectPassesThroughAndAudits 钉住 ①②。
func TestAdminTidyCandidatesRejectPassesThroughAndAudits(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{rejectResult: memory.TidyRejectBatch{
Action: memory.TidyLink, Limit: 1000, Total: 501, Rejected: 501,
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=1000", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("reject status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.rejectAction != memory.TidyLink || fake.rejectOpts.Limit != 1000 {
t.Fatalf("透传 action/limit = %q/%d, want link/1000", fake.rejectAction, fake.rejectOpts.Limit)
}
if fake.rejectOpts.Actor == "" {
t.Fatal("决定者标签必须落 decided_by(审计要能回答「谁批的」)")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v (body=%s)", err, body)
}
for key, want := range map[string]string{
"action": `"link"`, "limit": `1000`, "total": `501`, "rejected": `501`, "truncated": `false`,
} {
if got := string(payload[key]); got != want {
t.Fatalf("%s = %s, want %s", key, got, want)
}
}
if len(audit.entries) != 1 || audit.entries[0].What != tidyRejectAction {
t.Fatalf("审计 = %+v, want 一条 %s", audit.entries, tidyRejectAction)
}
res := audit.entries[0].Result
if !strings.HasPrefix(res, "ok") || !strings.Contains(res, "action=link") ||
!strings.Contains(res, "rejected=501") || !strings.Contains(res, "total=501") {
t.Fatalf("审计 result = %q, want ok + action=link + total/rejected 计数", res)
}
}
// TestAdminTidyCandidatesRejectRejectsBadAction 钉住 ③:动作缺失或不在闭集内 ⇒ 400。
func TestAdminTidyCandidatesRejectRejectsBadAction(t *testing.T) {
t.Parallel()
audit := &recordingAudit{}
fake := &fakeTidyApply{rejectErr: memory.ErrTidyUnknownAction}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/reject", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("缺 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=delete-everything", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("未知 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
if len(audit.entries) != 3 {
t.Fatalf("三次失败都该留审计(result=error):%+v", audit.entries)
}
for _, e := range audit.entries {
if e.What != tidyRejectAction || e.Result != "error" {
t.Fatalf("审计 = %+v, want %s/error", e, tidyRejectAction)
}
}
}
// TestAdminTidyCandidatesRejectNotWired 钉住 ④:存储没实现候选视图 ⇒ 500,绝不假装
// 「已拒绝 0 条」。
func TestAdminTidyCandidatesRejectNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link", nil, tok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
}
}
+7 -3
View File
@@ -244,16 +244,20 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
// 记忆整理的**应用路径 + 手动真跑**(切片 TIDY-1,design/23 §7/§8/§11;见
// admin_memory_tidy_apply.go)。五条端点补齐「整理真的能用」:此前只有
// admin_memory_tidy_apply.go)。六条端点补齐「整理真的能用」:此前只有
// `POST …/tidy/dry-run`(只读试算),候选落库后**没有任何执行面**,于是 77 条
// 全 pending、两天一条没应用。门与同族的 `GET /admin/memories` /
// `POST …/memories/{id}/revert` 同档(writeAuth = owner|admin):候选正文含隐私。
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert`;`…/tidy/run` 的审计
// 由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.reject`;
// `…/tidy/run` 的审计由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
mux.Handle("GET /api/v1/admin/memories/candidates", writeAuth(http.HandlerFunc(s.adminTidyCandidatesList)))
mux.Handle("POST /api/v1/admin/memories/candidates/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidatesApplyBatch)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidateApply)))
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/revert", writeAuth(http.HandlerFunc(s.adminTidyCandidateRevert)))
// 批量拒绝(按动作,2026-10-07):给**没有执行语义**的候选一条干净出路
// (活库积压 501 条 link,永远应用不了,只占待办)。它只改候选状态、不碰记忆,
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))