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
+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:群聊/陌生人
|
||||
// 来源的条目既不合并也不归档,只读不写。
|
||||
|
||||
Reference in new issue
Block a user