From 6d925362768e767afc2dca10ab980d51d6010016 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:07:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E6=95=B4=E7=90=86=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=AA=E4=BA=A7=E5=BB=BA=E8=AE=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=BD=AE=E6=9C=AB=E8=87=AA=E5=8A=A8=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=AB=98=E7=BD=AE=E4=BF=A1=20+=20=E5=81=9C=E4=BA=A7?= =?UTF-8?q?=20link=20=E5=80=99=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人原话:「那么多 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),主会话补; ⚠️ 真机端到端未跑(不能重启后端)。 --- cmd/cyrene/wire_memory_tidy.go | 45 +- cmd/cyrene/wire_memory_tidy_test.go | 49 +- internal/config/runtimeconfig/memorytidy.go | 102 ++++ .../config/runtimeconfig/memorytidy_test.go | 118 ++++ internal/runtime/memory/tidy.go | 140 ++--- internal/runtime/memory/tidy_apply.go | 25 +- internal/runtime/memory/tidy_apply_test.go | 53 +- internal/runtime/memory/tidy_auto.go | 331 ++++++++++++ internal/runtime/memory/tidy_auto_test.go | 511 ++++++++++++++++++ internal/runtime/memory/tidy_candidate.go | 30 +- internal/runtime/memory/tidy_config.go | 59 ++ internal/runtime/memory/tidy_containment.go | 5 +- internal/runtime/memory/tidy_dryrun_test.go | 8 +- internal/runtime/memory/tidy_reject.go | 123 +++++ internal/runtime/memory/tidy_reject_test.go | 134 +++++ internal/runtime/memory/tidy_rules.go | 20 +- internal/runtime/memory/tidy_service.go | 13 +- internal/runtime/memory/tidy_service_test.go | 49 +- internal/runtime/memory/tidy_test.go | 76 ++- internal/runtime/memory/tidy_types.go | 119 ++++ internal/runtime/memory/tidy_veto_test.go | 2 +- .../gateway/admin_memory_tidy_apply.go | 4 + .../gateway/admin_memory_tidy_apply_dto.go | 21 +- .../gateway/admin_memory_tidy_apply_test.go | 12 + .../gateway/admin_memory_tidy_reject.go | 90 +++ .../gateway/admin_memory_tidy_reject_test.go | 111 ++++ internal/services/gateway/routes.go | 10 +- 27 files changed, 2063 insertions(+), 197 deletions(-) create mode 100644 internal/config/runtimeconfig/memorytidy.go create mode 100644 internal/config/runtimeconfig/memorytidy_test.go create mode 100644 internal/runtime/memory/tidy_auto.go create mode 100644 internal/runtime/memory/tidy_auto_test.go create mode 100644 internal/runtime/memory/tidy_reject.go create mode 100644 internal/runtime/memory/tidy_reject_test.go create mode 100644 internal/runtime/memory/tidy_types.go create mode 100644 internal/services/gateway/admin_memory_tidy_reject.go create mode 100644 internal/services/gateway/admin_memory_tidy_reject_test.go diff --git a/cmd/cyrene/wire_memory_tidy.go b/cmd/cyrene/wire_memory_tidy.go index 9af32d6..8d88e62 100644 --- a/cmd/cyrene/wire_memory_tidy.go +++ b/cmd/cyrene/wire_memory_tidy.go @@ -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) { diff --git a/cmd/cyrene/wire_memory_tidy_test.go b/cmd/cyrene/wire_memory_tidy_test.go index da1c94d..01966eb 100644 --- a/cmd/cyrene/wire_memory_tidy_test.go +++ b/cmd/cyrene/wire_memory_tidy_test.go @@ -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) + } +} diff --git a/internal/config/runtimeconfig/memorytidy.go b/internal/config/runtimeconfig/memorytidy.go new file mode 100644 index 0000000..249f5a7 --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy.go @@ -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 +} diff --git a/internal/config/runtimeconfig/memorytidy_test.go b/internal/config/runtimeconfig/memorytidy_test.go new file mode 100644 index 0000000..db54510 --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy_test.go @@ -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 +} diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index 8892558..a2ff7b8 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -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) -} diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go index 0e88439..f28860b 100644 --- a/internal/runtime/memory/tidy_apply.go +++ b/internal/runtime/memory/tidy_apply.go @@ -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 } diff --git a/internal/runtime/memory/tidy_apply_test.go b/internal/runtime/memory/tidy_apply_test.go index 2669944..0088d2c 100644 --- a/internal/runtime/memory/tidy_apply_test.go +++ b/internal/runtime/memory/tidy_apply_test.go @@ -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) diff --git a/internal/runtime/memory/tidy_auto.go b/internal/runtime/memory/tidy_auto.go new file mode 100644 index 0000000..0301735 --- /dev/null +++ b/internal/runtime/memory/tidy_auto.go @@ -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, ",") +} diff --git a/internal/runtime/memory/tidy_auto_test.go b/internal/runtime/memory/tidy_auto_test.go new file mode 100644 index 0000000..6957224 --- /dev/null +++ b/internal/runtime/memory/tidy_auto_test.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go index f371ecc..c0a3737 100644 --- a/internal/runtime/memory/tidy_candidate.go +++ b/internal/runtime/memory/tidy_candidate.go @@ -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 预览),而 diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go index b1cb688..d4407d5 100644 --- a/internal/runtime/memory/tidy_config.go +++ b/internal/runtime/memory/tidy_config.go @@ -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 diff --git a/internal/runtime/memory/tidy_containment.go b/internal/runtime/memory/tidy_containment.go index 9a87c78..7737279 100644 --- a/internal/runtime/memory/tidy_containment.go +++ b/internal/runtime/memory/tidy_containment.go @@ -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) diff --git a/internal/runtime/memory/tidy_dryrun_test.go b/internal/runtime/memory/tidy_dryrun_test.go index 991a790..1aee6d0 100644 --- a/internal/runtime/memory/tidy_dryrun_test.go +++ b/internal/runtime/memory/tidy_dryrun_test.go @@ -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) diff --git a/internal/runtime/memory/tidy_reject.go b/internal/runtime/memory/tidy_reject.go new file mode 100644 index 0000000..9b7cd49 --- /dev/null +++ b/internal/runtime/memory/tidy_reject.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_reject_test.go b/internal/runtime/memory/tidy_reject_test.go new file mode 100644 index 0000000..d898c9f --- /dev/null +++ b/internal/runtime/memory/tidy_reject_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_rules.go b/internal/runtime/memory/tidy_rules.go index ae064e7..b959099 100644 --- a/internal/runtime/memory/tidy_rules.go +++ b/internal/runtime/memory/tidy_rules.go @@ -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, } } diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index b68fc6a..c05b07c 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -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 diff --git a/internal/runtime/memory/tidy_service_test.go b/internal/runtime/memory/tidy_service_test.go index 3988155..f27ca3c 100644 --- a/internal/runtime/memory/tidy_service_test.go +++ b/internal/runtime/memory/tidy_service_test.go @@ -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] +} diff --git a/internal/runtime/memory/tidy_test.go b/internal/runtime/memory/tidy_test.go index c3d34b8..58f3767 100644 --- a/internal/runtime/memory/tidy_test.go +++ b/internal/runtime/memory/tidy_test.go @@ -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), diff --git a/internal/runtime/memory/tidy_types.go b/internal/runtime/memory/tidy_types.go new file mode 100644 index 0000000..28737fb --- /dev/null +++ b/internal/runtime/memory/tidy_types.go @@ -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) +} diff --git a/internal/runtime/memory/tidy_veto_test.go b/internal/runtime/memory/tidy_veto_test.go index 1c1e083..0647f27 100644 --- a/internal/runtime/memory/tidy_veto_test.go +++ b/internal/runtime/memory/tidy_veto_test.go @@ -10,7 +10,7 @@ import ( ) // 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、 -// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 / +// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→不产候选 / // 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。 // TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人 // 来源的条目既不合并也不归档,只读不写。 diff --git a/internal/services/gateway/admin_memory_tidy_apply.go b/internal/services/gateway/admin_memory_tidy_apply.go index 156137e..054dd76 100644 --- a/internal/services/gateway/admin_memory_tidy_apply.go +++ b/internal/services/gateway/admin_memory_tidy_apply.go @@ -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 diff --git a/internal/services/gateway/admin_memory_tidy_apply_dto.go b/internal/services/gateway/admin_memory_tidy_apply_dto.go index f925d25..a5cd8e3 100644 --- a/internal/services/gateway/admin_memory_tidy_apply_dto.go +++ b/internal/services/gateway/admin_memory_tidy_apply_dto.go @@ -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"` +} diff --git a/internal/services/gateway/admin_memory_tidy_apply_test.go b/internal/services/gateway/admin_memory_tidy_apply_test.go index 2f5e19b..36b8c4e 100644 --- a/internal/services/gateway/admin_memory_tidy_apply_test.go +++ b/internal/services/gateway/admin_memory_tidy_apply_test.go @@ -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 { diff --git a/internal/services/gateway/admin_memory_tidy_reject.go b/internal/services/gateway/admin_memory_tidy_reject.go new file mode 100644 index 0000000..cb8d4fd --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_reject.go @@ -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=&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, + } +} diff --git a/internal/services/gateway/admin_memory_tidy_reject_test.go b/internal/services/gateway/admin_memory_tidy_reject_test.go new file mode 100644 index 0000000..01ce421 --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_reject_test.go @@ -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) + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index fb50aa4..17e6ac1 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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)))