Files
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

417 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"math"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是记忆整理(design/23 §12)的正/反例测试集:用设计稿引文脱敏构造的
// 夹具,逐条断言「该合的合、不该合的不合」。夹具取自 design/23 §1 的引文
// ([8] 一次性事件、[12]/[15] 假期状态、[1]/[3] 红牛 vs 冰美式、
// [21]/[25] 熬夜习惯的真子集),确保测的是设计稿点名的情形。
const (
fixtureHabitShort = "主人常在深夜与助手聊天,有熬夜习惯"
fixtureHabitLong = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"
fixtureHabitPlain = "主人常在深夜加班"
fixtureHabitVacation = "主人国庆假期期间常在深夜加班,处于休假状态"
fixtureEnergyRedBull = "主人喜欢喝红牛来提神,红牛是提神饮料"
fixtureEnergyIced = "主人喜欢喝冰美式来提神,冰美式是提神饮料"
fixtureOneOffEvent = "2026年9月8日周二晚22:38,主人仍在加班中度过的夜晚。"
fixtureWorkShort = "主人用喵打招呼"
fixtureWorkLong = "主人用喵打招呼,还会配合可爱的表情"
baseTidyTime = "2026-10-01T10:00:00.000000000Z"
)
// stubEmbedder 是按文本查表的确定性 embedder:测试用它精确控制「哪几对被
// 召回」,把**召回**与**判定**分开验(判定绝不看相似度)。未登记文本得到
// 一个与相似族正交的向量,因此不会意外参与召回。
type stubEmbedder struct {
vecs map[string]embedding.Vector
}
func (s stubEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(texts))
for _, text := range texts {
v, ok := s.vecs[text]
if !ok {
v = embedding.Vector{0, 1}
}
out = append(out, v)
}
return out, nil
}
func (s stubEmbedder) Dim() int { return 2 }
// simVector 返回与 {1,0} 余弦恰为 want 的向量(want ∈ (0,1])。
func simVector(want float64) embedding.Vector {
return embedding.Vector{float32(want), float32(math.Sqrt(1 - want*want))}
}
// tidyTestTime 把固定字符串解析成基准时刻。
func tidyTestTime(t *testing.T) time.Time {
t.Helper()
parsed, err := parseTime(baseTidyTime)
if err != nil {
t.Fatalf("parse base time: %v", err)
}
return parsed
}
// tidyTestCfg 是测试用的整理配置:只把召回门显式写死,其余走设计稿默认值。
func tidyTestCfg(threshold float64) TidyConfig {
return TidyConfig{
Enabled: true,
IntervalSec: defaultTidyIntervalSec,
QuietStartHour: defaultTidyQuietStartHour,
QuietEndHour: defaultTidyQuietEndHour,
RecallThreshold: threshold,
ScanLimit: 200,
CandidateLimit: defaultTidyCandidateLimit,
}
}
// newTestTidier 用给定存储、配置与可选 embedder 构建整理器。
func newTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) *Tidier {
t.Helper()
tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: emb, Log: testLogger()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr
}
// seedTidyItem 落库一条测试记忆。
func seedTidyItem(t *testing.T, store *Store, item MemoryItem) MemoryItem {
t.Helper()
got, err := store.Add(context.Background(), item)
if err != nil {
t.Fatalf("seed item %q: %v", item.Content, err)
}
return got
}
// habitItem 是熬夜类记忆的构造器(同一分区:persona.a / persona / 无项目)。
func habitItem(content string, at time.Time, confidence float64) MemoryItem {
return MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: content, Confidence: confidence, CreatedAt: at, SourceConversation: FormatSourceConversation("web", "msg-1"),
}
}
// candidatesByAction 从候选列表里按动作筛选。
func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidate {
out := make([]TidyCandidate, 0)
for _, c := range cands {
if c.Action == action {
out = append(out, c)
}
}
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) {
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))
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Pairs != 1 {
t.Fatalf("stats scanned/pairs = %d/%d, want 2/1 (%+v)", stats.Scanned, stats.Pairs, stats)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
merges := candidatesByAction(cands, TidyMerge)
if len(merges) != 1 {
t.Fatalf("merge candidates = %d, want 1 (%+v)", len(merges), cands)
}
got := merges[0]
if got.TargetID != long.ID {
t.Fatalf("merge target = %q, want 超集 B = %q", got.TargetID, long.ID)
}
if len(got.SourceIDs) != 1 || got.SourceIDs[0] != short.ID {
t.Fatalf("merge source ids = %v, want [%s]", got.SourceIDs, short.ID)
}
if got.MergedContent != fixtureHabitLong {
t.Fatalf("merged content = %q, want 超集原文", got.MergedContent)
}
if got.Evidence.Containment == string(containNone) || got.Evidence.Similarity < 0.80 {
t.Fatalf("evidence = %+v, want 包含判定为子集关系且带召回分数", got.Evidence)
}
if got.MergedContent != fixtureHabitLong || got.ProposedContent != fixtureHabitShort {
t.Fatalf("diff 预览的原文/合并结果不对:proposed=%q merged=%q", got.ProposedContent, got.MergedContent)
}
if got.Status != tidyCandidatePending {
t.Fatalf("status = %q, want %q(人工确认队列)", got.Status, tidyCandidatePending)
}
}
// 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)
base := tidyTestTime(t)
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),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands := allTidyCandidates(t, store, 50)
if len(cands) != 0 {
t.Fatalf("并列事实不该产任何候选(含 link):%+v(主人红线:不要什么内容都合并)", cands)
}
if stats.Kept != 1 {
t.Fatalf("stats.Kept = %d, want 1(抛掉的对必须单独计数,否则口径上是消失)", stats.Kept)
}
// 「什么都不做」必须是**真什么都没做**:两行都还在册。
for _, id := range []string{redBull.ID, iced.ID} {
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("并列事实被改动了:%+v", got)
}
}
}
// TestTidyCrossScopeNeverComparedOrMerged 是 design/23 §12.2 N1 与 §6 硬约束:
// 跨作用域([10] user vs [19] persona,全库相似度最高的一对 0.9319)永不合 ——
// 运行期连比较都不发生(分区隔离),判定层再兜一次否决。
func TestTidyCrossScopeNeverComparedOrMerged(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
content := "主人喜欢被叫小笨蛋"
userItem := seedTidyItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
Content: content, Confidence: 0.95, CreatedAt: base,
})
personaItem := habitItem(content, base.Add(time.Minute), 0.95)
seedTidyItem(t, store, personaItem)
emb := stubEmbedder{vecs: map[string]embedding.Vector{content: {1, 0}}}
// 判定层:跨作用域对被硬门否决(纵深防御,design §5.2-7)。
verdict := judgePair(userItem, personaItem, 0.9319)
if verdict.Veto != VetoScope {
t.Fatalf("judgePair 跨作用域 veto = %q, want %q", verdict.Veto, VetoScope)
}
if verdict.Action != "" {
t.Fatalf("被判否决的对不得带动作,got %q", verdict.Action)
}
// 运行期:分区之间永不比较 ⇒ 零候选(design §6「跨分区永不产生候选」)。
tr := newTestTidier(t, store, tidyTestCfg(0.80), emb)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Pairs != 0 || stats.Candidates != 0 {
t.Fatalf("跨作用域产出了候选:pairs=%d candidates=%d", stats.Pairs, stats.Candidates)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
if len(cands) != 0 {
t.Fatalf("跨作用域候选 = %+v, want 空", cands)
}
}
// TestTidyTemporalPollutionVetoed 是 design/23 §12.2 N2 与 §5.2-1:一次性事件
// 与有期限状态不得被当长期事实合并;含绝对时刻的条目单独走 archive。
func TestTidyTemporalPollutionVetoed(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
plain := seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9))
vacation := seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9))
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9))
// 判定层:[8] 那类一次性事件与长期习惯硬不合。
if v := judgePair(plain, vacation, 0.95); v.Veto != VetoTemporal {
t.Fatalf("时效状态 veto = %q, want %q", v.Veto, VetoTemporal)
}
if v := judgePair(oneOff, plain, 0.95); v.Veto != VetoTemporal {
t.Fatalf("一次性事件 veto = %q, want %q", v.Veto, VetoTemporal)
}
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitPlain: {1, 0},
fixtureHabitVacation: simVector(0.97),
fixtureOneOffEvent: simVector(0.96),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.VetoReasons[string(VetoTemporal)] < 1 {
t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons)
}
cands := allTidyCandidates(t, store, 50)
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
t.Fatalf("时效污染被合并:%+v", merges)
}
archives := candidatesByAction(cands, TidyArchive)
if len(archives) != 1 || archives[0].SourceIDs[0] != oneOff.ID {
t.Fatalf("archive 候选 = %+v, want 恰为一次性事件 %s", archives, oneOff.ID)
}
for _, c := range cands {
if containsID(c.SourceIDs, oneOff.ID) && c.Action != TidyArchive {
t.Fatalf("一次性事件参与了 %s:%+v", c.Action, c)
}
}
}
// TestTidyEmptyAndSingleItemAreNoops 是 design/23 §12 的边界口径:空库与单条
// 记忆都不产候选、不报错,但仍留一条运行痕迹(不静默)。
func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("空库 RunOnce: %v", err)
}
if stats.Scanned != 0 || stats.Candidates != 0 || stats.Pairs != 0 {
t.Fatalf("空库 stats = %+v, want 全 0", stats)
}
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
stats, err = tr.RunOnce(ctx)
if err != nil {
t.Fatalf("单条 RunOnce: %v", err)
}
if stats.Scanned != 1 || stats.Candidates != 0 || stats.Pairs != 0 {
t.Fatalf("单条 stats = %+v, want scanned=1/candidates=0/pairs=0", stats)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 2 {
t.Fatalf("运行留痕 = %d 条, want 2", len(runs))
}
}
// TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮
// 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。
//
// 本用例刻意**关掉轮末自动档**:自动档会把一次性事件当场归档,于是第二轮扫描范围
// 本身就变小了(归档行不再参与比对),duplicates 会被自动档的结果影响 ——
// 那是自动档的语义(另有 tidy_auto_test.go 专门验),不是「候选产出」这条幂等性的
// 一部分。这里要证明的是:规则不变时同一批数据绝不重复产候选。
func TestTidyRerunIsIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
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),
}})
first, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("第一轮 RunOnce: %v", err)
}
if first.Candidates == 0 {
t.Fatalf("第一轮没有产出候选,用例无效:%+v", first)
}
before, err := store.ListTidyCandidates(ctx, "", 100)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
second, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("第二轮 RunOnce: %v", err)
}
if second.Candidates != 0 {
t.Fatalf("第二轮新产候选 = %d, want 0(幂等)", second.Candidates)
}
if second.Duplicates != first.Candidates {
t.Fatalf("第二轮 duplicates = %d, want %d", second.Duplicates, first.Candidates)
}
after, err := store.ListTidyCandidates(ctx, "", 100)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
if len(after) != len(before) {
t.Fatalf("候选行数 %d → %d, want 不变", len(before), len(after))
}
}
func containsID(ids []string, id string) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}