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)
|
||||
}
|
||||
Reference in new issue
Block a user