feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
1 parent
353293e807
commit
6d92536276
27 files changed
+2063
-197
No files matched your search
@@ -3,28 +3,33 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 三件事只在这里定:
|
||||
// 四件事只在这里定:
|
||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计。
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 读「自动落地开关 + 每轮上限」。它是 `.env` 冻结(design/13 §2.1)之后新旋钮
|
||||
// 的唯一去处,**零新 env 键**;读不到/解不开一律回落代码默认值并记 WARN
|
||||
// (坏配置绝不能让自动档变成「无限制」)。
|
||||
//
|
||||
// 刻意**不做**的两件事(design §4.1 F、§3 非目标):
|
||||
// 刻意**不做**的一件事(design §4.1 F):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
// 不是主人的会话);
|
||||
// - 不给它任何写记忆的路径(第一版只产候选,"立即整理"入口是
|
||||
// TidyService.RunOnce)。
|
||||
// 不是主人的会话)。
|
||||
func (a *app) wireMemoryTidy() error {
|
||||
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy), memory.TidyDeps{
|
||||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryTidy,
|
||||
@@ -37,6 +42,32 @@ func (a *app) wireMemoryTidy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限。
|
||||
//
|
||||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落代码默认值」。
|
||||
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/tidy").Warn(
|
||||
"memory tidy tuning unreadable, falling back to code defaults",
|
||||
"path", path, "error", err)
|
||||
return
|
||||
}
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
logger.WithSource(a.log, "memory/tidy").Info(
|
||||
"memory tidy tuning loaded",
|
||||
"path", path, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound)
|
||||
}
|
||||
|
||||
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||||
func (a *app) auditMemoryTidy(who, what, result string) {
|
||||
|
||||
@@ -52,7 +52,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
"a.embedder = embedder",
|
||||
},
|
||||
"wire_memory_tidy.go": {
|
||||
"memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy)",
|
||||
"memory.NewTidyService(cfg, memory.TidyDeps{",
|
||||
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
|
||||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||
"runtimeconfig.LoadMemoryTidyTuning",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||
// 的结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryTidy",
|
||||
@@ -206,3 +210,46 @@ func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
|
||||
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
|
||||
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
|
||||
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
empty.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
|
||||
}
|
||||
|
||||
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
off.applyMemoryTidyTuning(&cfg)
|
||||
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
|
||||
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
|
||||
}
|
||||
|
||||
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
|
||||
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||
broken.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// memorytidy.go 是记忆整理(design/23)**运行期调优**的可变源:
|
||||
// `$DATA_DIR/config/memory-tidy.json`(design/13 §2.1「运行期生成的配置进
|
||||
// `$DATA_DIR/config/`,新增配置家族从一开始就只写这里,不落老位置」)。
|
||||
//
|
||||
// 为什么需要它(2026-10-07 主人「那么多 link 是干什么的?该删就删,怎么这个记忆
|
||||
// 整理机制这么保守呢」):轮末自动落地是一件**改记忆**的动作,它必须有一个
|
||||
// 「一键回到只产建议」的开关与一个每轮上限,而 `.env` 已被主人冻结
|
||||
// (`design/13 §2.1`:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码
|
||||
// 默认值,不进 `.env`)。于是这两项落在这里,**零新 env 键**。
|
||||
//
|
||||
// 边界与 backthink.go 同源(那是同一族的手续):
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||||
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
|
||||
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
|
||||
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
|
||||
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
|
||||
//
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
|
||||
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
|
||||
// 或上限读成「无限制」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// memoryTidyFileName 是文件名(家族命名 = 服务/模块名 + .json)。
|
||||
memoryTidyFileName = "memory-tidy.json"
|
||||
// memoryTidyConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与
|
||||
// backthink 各自声明一份而不是共用一个常量:某一族换位置时,共用常量会让
|
||||
// 另一处**静默**跟着搬家。
|
||||
memoryTidyConfigSubdir = "config"
|
||||
)
|
||||
|
||||
// MemoryTidyConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/memory-tidy.json`)。
|
||||
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件,而不必把部署相关的绝对路径
|
||||
// 塞进接口形状。
|
||||
const MemoryTidyConfigRelPath = memoryTidyConfigSubdir + "/" + memoryTidyFileName
|
||||
|
||||
// ErrInvalidMemoryTidy 是 memory-tidy.json 结构不合法时的分类错误(errors.Is 可判类)。
|
||||
var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config")
|
||||
|
||||
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆整理运行期的两个旋钮。
|
||||
//
|
||||
// AutoApply 用**指针**是刻意的:代码默认值是「开」,而 JSON 的 `bool` 零值也是
|
||||
// false —— 用 bool 会让「这个键没写」与「显式写 false」不可区分,于是任何只写
|
||||
// 上限的文件都会**静默把自动档关掉**。指针让「没写」= nil = 不覆盖默认。
|
||||
//
|
||||
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
|
||||
type MemoryTidyTuning struct {
|
||||
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
|
||||
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
|
||||
AutoApply *bool `json:"autoApply"`
|
||||
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
|
||||
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
|
||||
AutoApplyPerRound int `json:"autoApplyPerRound"`
|
||||
}
|
||||
|
||||
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
|
||||
// 「没配」不等于「关掉」,那正是指针存在的理由。
|
||||
func (t MemoryTidyTuning) AutoApplyOff() bool {
|
||||
return t.AutoApply != nil && !*t.AutoApply
|
||||
}
|
||||
|
||||
// DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。
|
||||
func DefaultMemoryTidyPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName)
|
||||
}
|
||||
|
||||
// LoadMemoryTidyTuning 读取 memory-tidy.json(**只读**)。
|
||||
//
|
||||
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
|
||||
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
|
||||
//
|
||||
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认;
|
||||
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。
|
||||
//
|
||||
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
|
||||
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return MemoryTidyTuning{}, nil
|
||||
}
|
||||
return MemoryTidyTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return MemoryTidyTuning{}, nil
|
||||
}
|
||||
var t MemoryTidyTuning
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
|
||||
//
|
||||
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
|
||||
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
|
||||
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
|
||||
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
|
||||
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
|
||||
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
|
||||
|
||||
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("缺失文件的 err = %v, want nil", err)
|
||||
}
|
||||
if got.AutoApplyOff() || got.AutoApplyPerRound != 0 {
|
||||
t.Fatalf("缺失文件应当给零值:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, " \n\t")
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("空白文件的 err = %v, want nil", err)
|
||||
}
|
||||
if got.AutoApplyOff() {
|
||||
t.Fatal("空白文件不该被读成「关掉自动档」")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||||
}
|
||||
if got.AutoApply != nil {
|
||||
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
|
||||
}
|
||||
if got.AutoApplyOff() {
|
||||
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
|
||||
}
|
||||
if got.AutoApplyPerRound != 7 {
|
||||
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||||
}
|
||||
if !got.AutoApplyOff() {
|
||||
t.Fatal("显式 false 必须被读成「关掉自动档」")
|
||||
}
|
||||
|
||||
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
|
||||
got, err = LoadMemoryTidyTuning(on)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
|
||||
}
|
||||
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
|
||||
t.Fatalf("显式 true 的回读 = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
|
||||
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
|
||||
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
|
||||
want := filepath.Join("root", "data", "config", "memory-tidy.json")
|
||||
if got != want {
|
||||
t.Fatalf("落点 = %q, want %q", got, want)
|
||||
}
|
||||
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
|
||||
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
|
||||
}
|
||||
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
|
||||
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
|
||||
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
|
||||
}
|
||||
}
|
||||
|
||||
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
|
||||
func writeMemoryTidyFile(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, memoryTidyFileName)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
+35
-105
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ",")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 预览),而
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、
|
||||
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 /
|
||||
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→不产候选 /
|
||||
// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。
|
||||
// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人
|
||||
// 来源的条目既不合并也不归档,只读不写。
|
||||
|
||||
@@ -65,6 +65,7 @@ type tidyApplyStore interface {
|
||||
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||||
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
|
||||
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||||
RejectTidyCandidatesByAction(ctx context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -77,6 +78,9 @@ const (
|
||||
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
|
||||
// 回滚,并把候选退回 pending,因此不复用。
|
||||
tidyRevertAction = "memory.tidy.revert"
|
||||
// tidyRejectAction 是候选**批量拒绝**的审计动作名(design/23 §8 的 `memory.tidy.*`
|
||||
// 族)。它与「拒绝」在设计里的定位一致:`reject` 记入审计,用于后续调参与防重复提议。
|
||||
tidyRejectAction = "memory.tidy.reject"
|
||||
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
|
||||
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
|
||||
tidyRunTimeout = 5 * time.Minute
|
||||
|
||||
@@ -70,8 +70,11 @@ type AdminTidyApplyBatchResponse struct {
|
||||
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
|
||||
// `candidatesByAction{merge|archive}` / `durationMs`。
|
||||
//
|
||||
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
|
||||
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
|
||||
// ⚠️ 它是**建议侧**计数。2026-10-07 起真跑还会跑**轮末自动档**(规则层判为高置信的
|
||||
// 候选直接落地,见 memory 包的 tidy_auto.go),但那份计数**不在本结构里** —— 它走
|
||||
// 审计(`memory.tidy.apply` + `auto=true`)与日志,以及 `GET …/candidates?status=applied`
|
||||
// 里 `decidedBy=system/memorytidy` 的行。要把自动档计数搬上本回执,需同时补
|
||||
// `Docs/api-client.md` 的 B24 段(属文档侧改动)。
|
||||
type AdminTidyRunResponse struct {
|
||||
RunID string `json:"runId"`
|
||||
Trigger string `json:"trigger"`
|
||||
@@ -92,6 +95,18 @@ type AdminTidyRunResponse struct {
|
||||
// 挡下(同一批数据重复跑)的数量。
|
||||
CandidatesCreated int `json:"candidatesCreated"`
|
||||
CandidatesDuplicate int `json:"candidatesDuplicate"`
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
|
||||
CandidatesByAction map[string]int `json:"candidatesByAction"`
|
||||
}
|
||||
|
||||
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
|
||||
// `total` 是该动作下**此刻**的待定真总数,`rejected` 才是本次真正决定掉的条数 ——
|
||||
// 两者不等时 `truncated=true`(超限绝不静默少做)。重复调用同一动作是幂等的:
|
||||
// 第二次 `total`/`rejected` 都会如实变小,而不是报错。
|
||||
type AdminTidyRejectResponse struct {
|
||||
Action string `json:"action"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Rejected int `json:"rejected"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
@@ -48,6 +48,11 @@ type fakeTidyApply struct {
|
||||
revertErr error
|
||||
revertID string
|
||||
revertOpts memory.TidyApplyOptions
|
||||
|
||||
rejectResult memory.TidyRejectBatch
|
||||
rejectErr error
|
||||
rejectAction memory.TidyAction
|
||||
rejectOpts memory.TidyApplyOptions
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
|
||||
@@ -74,6 +79,11 @@ func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts m
|
||||
return f.revertResult, f.revertErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) RejectTidyCandidatesByAction(_ context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error) {
|
||||
f.rejectAction, f.rejectOpts = action, opts
|
||||
return f.rejectResult, f.rejectErr
|
||||
}
|
||||
|
||||
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
|
||||
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
|
||||
type fakeTidyRunner struct {
|
||||
@@ -453,6 +463,8 @@ func TestAdminTidyApplyRBAC(t *testing.T) {
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/apply"},
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/apply"},
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/revert"},
|
||||
// 按动作批量拒绝(2026-10-07):门与同族同档(owner|admin)。
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/reject?action=link"},
|
||||
}
|
||||
for _, p := range paths {
|
||||
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件承载整理候选的**批量拒绝**(按动作)管理面:
|
||||
//
|
||||
// POST /api/v1/admin/memories/candidates/reject?action=<merge|link|archive|reclassify>&limit=
|
||||
//
|
||||
// 动因(2026-10-07 主人:「那么多 link 是干什么的?该删就删」):活库里积压了 501 条
|
||||
// `link` 候选,而 link **没有执行语义**(design/23 §14 把「link 关系如何存储与呈现」
|
||||
// 列为待细化 ⇒ 无落点、无数据模型,详见 memory 包的 tidy_rules.go 与 tidy_apply.go 的
|
||||
// applyable())—— 它们永远不会被应用,只会一直占着待办列表。规则层已改成不产它们,
|
||||
// 存量则需要一条**批量**的出路:一条条点不现实(这正是主人这次投诉的形状)。
|
||||
//
|
||||
// 口径:
|
||||
//
|
||||
// 1. **它是「决定」不是「删除」**:只写候选表的状态列(`rejected`),**不碰
|
||||
// memory_items 任何一行**。记忆的删除/归档语义仍只有两条路(archive 候选 + §8 的
|
||||
// 软删除口径),候选拒绝与它无关。
|
||||
// 2. **幂等**:只动 `status='pending'` 的行,重复调用第二次就是 0 条,不是错误。
|
||||
// 3. **门同族**:走 `writeAuth`(owner|admin),与 `…/candidates/apply`、
|
||||
// `…/candidates/{id}/revert` 同档。
|
||||
// 4. **动作名必须合法**:不在 design/23 §10 的动作闭集里 ⇒ 400 `bad_request`,
|
||||
// 而不是静默改 0 条(拼错动作与「没有这类候选」必须能分辨)。
|
||||
// 5. **写审计**:动作名 `memory.tidy.reject`(design/23 §8 的动作名族),只带计数,
|
||||
// 不带候选正文。
|
||||
func (s *Server) adminTidyCandidatesReject(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.tidyCandidates()
|
||||
if store == nil {
|
||||
s.auditClaims(r, tidyRejectAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||||
return
|
||||
}
|
||||
action := memory.TidyAction(r.URL.Query().Get("action"))
|
||||
if action == "" {
|
||||
s.auditClaims(r, tidyRejectAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
|
||||
return
|
||||
}
|
||||
limit, ok := parseTidyApplyLimit(r)
|
||||
if !ok {
|
||||
s.auditClaims(r, tidyRejectAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||||
return
|
||||
}
|
||||
batch, err := store.RejectTidyCandidatesByAction(r.Context(), action, memory.TidyApplyOptions{
|
||||
Actor: s.tidyActor(r),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
s.auditClaims(r, tidyRejectAction, "error")
|
||||
if errors.Is(err, memory.ErrTidyUnknownAction) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown action %q", string(action)))
|
||||
return
|
||||
}
|
||||
s.log.Error("reject tidy candidates", "err", err, "action", string(action))
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_reject_failed", "批量拒绝整理候选失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, tidyRejectAction, tidyRejectAuditResult(batch))
|
||||
writeJSON(w, http.StatusOK, adminTidyRejectBatch(batch))
|
||||
}
|
||||
|
||||
// tidyRejectAuditResult 渲染批量拒绝的审计 result(以 ok 开头,与同族一致):
|
||||
// 带真总数与超限标记,因此审计能回答「这次到底决定掉多少、有没有被上限截住」。
|
||||
func tidyRejectAuditResult(batch memory.TidyRejectBatch) string {
|
||||
return "ok action=" + string(batch.Action) +
|
||||
" limit=" + strconv.Itoa(batch.Limit) +
|
||||
" total=" + strconv.Itoa(batch.Total) +
|
||||
" rejected=" + strconv.Itoa(batch.Rejected) +
|
||||
" truncated=" + strconv.FormatBool(batch.Truncated)
|
||||
}
|
||||
|
||||
// adminTidyRejectBatch 投影批量拒绝回执。
|
||||
func adminTidyRejectBatch(batch memory.TidyRejectBatch) AdminTidyRejectResponse {
|
||||
return AdminTidyRejectResponse{
|
||||
Action: string(batch.Action),
|
||||
Limit: batch.Limit,
|
||||
Total: batch.Total,
|
||||
Rejected: batch.Rejected,
|
||||
Truncated: batch.Truncated,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定整理候选**按动作批量拒绝**的管理面(admin_memory_tidy_reject.go;
|
||||
// 2026-10-07 主人「那么多 link 是干什么的?该删就删」)。四条判据:
|
||||
//
|
||||
// ① 200 + 参数如实透传(action/limit)+ 回执含 total/rejected/truncated;
|
||||
// ② 落审计 memory.tidy.reject(只带计数,不带候选正文);
|
||||
// ③ 动作缺失 / 不在闭集内 ⇒ 400(拼错动作不能与「没有这类候选」混为一谈);
|
||||
// ④ 未接线 ⇒ 500 tidy_unavailable,绝不回一份「已处理 0 条」的假成功。
|
||||
|
||||
// TestAdminTidyCandidatesRejectPassesThroughAndAudits 钉住 ①②。
|
||||
func TestAdminTidyCandidatesRejectPassesThroughAndAudits(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{rejectResult: memory.TidyRejectBatch{
|
||||
Action: memory.TidyLink, Limit: 1000, Total: 501, Rejected: 501,
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=1000", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("reject status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.rejectAction != memory.TidyLink || fake.rejectOpts.Limit != 1000 {
|
||||
t.Fatalf("透传 action/limit = %q/%d, want link/1000", fake.rejectAction, fake.rejectOpts.Limit)
|
||||
}
|
||||
if fake.rejectOpts.Actor == "" {
|
||||
t.Fatal("决定者标签必须落 decided_by(审计要能回答「谁批的」)")
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v (body=%s)", err, body)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"action": `"link"`, "limit": `1000`, "total": `501`, "rejected": `501`, "truncated": `false`,
|
||||
} {
|
||||
if got := string(payload[key]); got != want {
|
||||
t.Fatalf("%s = %s, want %s", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyRejectAction {
|
||||
t.Fatalf("审计 = %+v, want 一条 %s", audit.entries, tidyRejectAction)
|
||||
}
|
||||
res := audit.entries[0].Result
|
||||
if !strings.HasPrefix(res, "ok") || !strings.Contains(res, "action=link") ||
|
||||
!strings.Contains(res, "rejected=501") || !strings.Contains(res, "total=501") {
|
||||
t.Fatalf("审计 result = %q, want ok + action=link + total/rejected 计数", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesRejectRejectsBadAction 钉住 ③:动作缺失或不在闭集内 ⇒ 400。
|
||||
func TestAdminTidyCandidatesRejectRejectsBadAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{rejectErr: memory.ErrTidyUnknownAction}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/reject", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("缺 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/candidates/reject?action=delete-everything", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("未知 action ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link&limit=abc", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
if len(audit.entries) != 3 {
|
||||
t.Fatalf("三次失败都该留审计(result=error):%+v", audit.entries)
|
||||
}
|
||||
for _, e := range audit.entries {
|
||||
if e.What != tidyRejectAction || e.Result != "error" {
|
||||
t.Fatalf("审计 = %+v, want %s/error", e, tidyRejectAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesRejectNotWired 钉住 ④:存储没实现候选视图 ⇒ 500,绝不假装
|
||||
// 「已拒绝 0 条」。
|
||||
func TestAdminTidyCandidatesRejectNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/candidates/reject?action=link", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user