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

351 lines
13 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"
"errors"
"fmt"
"sort"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理「试算」(契约 v0.75;切片 MT1)的四条判据:
//
// ① **零副作用**:试算前后 memory_items / memory_tidy_candidates /
// memory_tidy_runs **逐行不变**(这是 dry-run 的命门);
// ② **真复用同一套规则**:同一批数据上,试算候选与真跑一轮落库的候选
// 逐字段一致(不是另一套阈值实现);
// ③ **尊重既有配置上限**:每分区候选上限(CandidateLimit)在试算里同样生效;
// ④ **诚实错误**:语义召回不可用 / 整理被配置关闭时如实报错,绝不回空候选
// 假装「没有建议」。
// failingEmbedder 模拟「语义召回不可用」(例如 llama.cpp :11434 不可达)。
type failingEmbedder struct{ err error }
func (f failingEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
return nil, f.err
}
func (f failingEmbedder) Dim() int { return 8 }
// 三张相关表的**显式列清单**(不用 SELECT *):逐行快照按完整行内容比对,
// 任何一列被改动都会让快照不等。
var (
memoryItemsColumns = []string{
"id", "persona_id", "role_id", "scope", "kind", "content", "tags", "confidence",
"source_conversation", "created_at", "updated_at", "decay_weight", "status",
"merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at",
}
tidyCandidateColumns = []string{
"id", "persona_id", "scope", "project_id", "kind", "action", "source_ids", "target_id",
"proposed_content", "merged_content", "reason", "evidence", "rule_version",
"score_min", "score_max", "status", "idempotency_key", "run_id", "created_at",
"decided_at", "decided_by",
}
tidyRunColumns = []string{
"id", "started_at", "finished_at", "scanned", "candidates", "applied", "skipped",
"errors", "vetoed", "veto_reasons", "rule_version",
}
)
// tidyTableSnapshot 是三张表在某一时刻的逐行内容。
type tidyTableSnapshot struct {
items []string
candidates []string
runs []string
}
// dumpTidyTable 把一张表按主键序渲染成字符串行。
func dumpTidyTable(t *testing.T, store *Store, table string, columns []string) []string {
t.Helper()
query := fmt.Sprintf("SELECT %s FROM %s ORDER BY id", strings.Join(columns, ", "), table)
rows, err := store.db.QueryContext(context.Background(), query)
if err != nil {
t.Fatalf("dump %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
out := make([]string, 0)
for rows.Next() {
vals := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
t.Fatalf("scan %s: %v", table, err)
}
parts := make([]string, 0, len(columns))
for i, col := range columns {
parts = append(parts, col+"="+formatCell(vals[i]))
}
out = append(out, strings.Join(parts, "|"))
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate %s: %v", table, err)
}
return out
}
// formatCell 把 scan 出来的值渲染成稳定文本(TEXT 列以 []byte 出现)。
func formatCell(v any) string {
switch got := v.(type) {
case nil:
return "<nil>"
case []byte:
return string(got)
default:
return fmt.Sprintf("%v", got)
}
}
// snapshotTidyTables 拍一张三表快照。
func snapshotTidyTables(t *testing.T, store *Store) tidyTableSnapshot {
t.Helper()
return tidyTableSnapshot{
items: dumpTidyTable(t, store, "memory_items", memoryItemsColumns),
candidates: dumpTidyTable(t, store, "memory_tidy_candidates", tidyCandidateColumns),
runs: dumpTidyTable(t, store, "memory_tidy_runs", tidyRunColumns),
}
}
// assertRowsUnchanged 逐行比对快照:任何一行被新增/删除/改列都会失败。
func assertRowsUnchanged(t *testing.T, what string, before, after []string) {
t.Helper()
if len(before) != len(after) {
t.Fatalf("%s 行数 %d → %d,试算有副作用", what, len(before), len(after))
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("%s 第 %d 行被改动:\n before=%s\n after =%s", what, i, before[i], after[i])
}
}
}
// candidateSignature 把一条候选渲染成与 id/落库时间无关的稳定签名,用于比对
// 「试算候选」与「真跑一轮的候选」是否逐字段一致。
func candidateSignature(c TidyCandidate) string {
return fmt.Sprintf("%s|sources=%s|target=%s|kind=%s|reason=%s|proposed=%q|merged=%q|rule=%s|min=%.6f|max=%.6f",
c.Action, strings.Join(c.SourceIDs, ","), c.TargetID, c.Kind, c.Reason,
c.ProposedContent, c.MergedContent, c.RuleVersion, c.ScoreMin, c.ScoreMax)
}
// sortedSignatures 返回按签名排序的候选集合(消除落库读回的排序差异)。
func sortedSignatures(cands []TidyCandidate) []string {
out := make([]string, 0, len(cands))
for _, c := range cands {
out = append(out, candidateSignature(c))
}
sort.Strings(out)
return out
}
// seedDryRunFixture 落三条记忆:一对必合(短 ⊆ 长)+ 一条一次性事件(走 archive)。
func seedDryRunFixture(t *testing.T, store *Store) {
t.Helper()
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))
}
// dryRunFixtureEmbedder 让上述三条恰好产出一 merge + 一 archive。
func dryRunFixtureEmbedder() stubEmbedder {
return stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
fixtureOneOffEvent: simVector(0.9),
}}
}
// TestTidyDryRun_ZeroSideEffects 是判据 ①:试算跑完之后,memory_items /
// memory_tidy_candidates / memory_tidy_runs **逐行**与跑之前完全一致。
func TestTidyDryRun_ZeroSideEffects(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if len(out.Candidates) == 0 {
t.Fatalf("夹具应当产出候选(否则零副作用断言是空转):%+v", out.Stats)
}
assertRowsUnchanged(t, "memory_items", before.items, snapshotTidyTables(t, store).items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
}
// TestTidyDryRun_MatchesRealRunCandidates 是判据 ②:试算走的必须是**同一套规则**
// —— 同一批数据上,试算候选与真跑一轮落库的候选逐字段一致。
//
// 2026-10-07 起真跑还会跑轮末自动档,因此落库候选要按**全部状态**读(自动落地的
// 会变成 applied):本用例比的是「规则产出了什么候选」,不是「待办队列里还剩几条」。
func TestTidyDryRun_MatchesRealRunCandidates(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if len(out.Candidates) == 0 {
t.Fatal("夹具应当产出候选")
}
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
persisted := allTidyCandidates(t, store, 100)
want := sortedSignatures(out.Candidates)
got := sortedSignatures(persisted)
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("试算候选与真跑一轮的候选不一致:\n dry=%v\n real=%v", want, got)
}
}
// TestTidyDryRun_RespectsCandidateLimit 是判据 ③:每分区候选上限(既有配置旋钮)
// 在试算里同样生效,且超出的候选被如实登记(Skipped / Truncated),绝不静默。
func TestTidyDryRun_RespectsCandidateLimit(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
cfg := tidyTestCfg(0.80)
cfg.CandidateLimit = 1
tr := newTestTidier(t, store, cfg, dryRunFixtureEmbedder())
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if len(out.Candidates) != cfg.CandidateLimit {
t.Fatalf("候选数 = %d, want %d(必须尊重既有每分区上限)", len(out.Candidates), cfg.CandidateLimit)
}
if out.Stats.Skipped != 1 {
t.Fatalf("超限候选未被如实登记:skipped=%d, want 1", out.Stats.Skipped)
}
if len(out.Stats.Truncated) == 0 {
t.Fatalf("候选上限命中必须留下截断痕迹:%+v", out.Stats)
}
}
// TestTidyDryRun_EmbeddingUnavailableIsHonest 是判据 ④:语义召回不可用时**如实
// 报错**(ErrTidyRecallUnavailable),绝不静默降级成「没有候选」;而生产轮在同样
// 条件下依旧按既有口径回退到词项余弦(试算的严格性只属于试算)。
func TestTidyDryRun_EmbeddingUnavailableIsHonest(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
tr := newTestTidier(t, store, tidyTestCfg(0.80),
failingEmbedder{err: errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")})
before := snapshotTidyTables(t, store)
out, err := tr.DryRun(ctx)
if !errors.Is(err, ErrTidyRecallUnavailable) {
t.Fatalf("DryRun err = %v, want ErrTidyRecallUnavailable", err)
}
if len(out.Candidates) != 0 {
t.Fatalf("召回不可用时不得产出候选:%+v", out.Candidates)
}
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
// 生产轮(RunOnce)不受试算的严格性影响:它照旧降级并跑完一轮。
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("生产轮应当回退到词项余弦并可跑完:%v", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 {
t.Fatalf("生产轮留痕 = %d 条, want 1(试算本身不写留痕)", len(runs))
}
}
// TestTidyDryRun_RecallModeMatchesEmbedder 钉住试算报出的召回来源与依赖一致。
func TestTidyDryRun_RecallModeMatchesEmbedder(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
withEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
out, err := withEmbedder.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun(embedder): %v", err)
}
if out.RecallMode != tidyRecallEmbedding {
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallEmbedding)
}
withoutEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), nil)
out, err = withoutEmbedder.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun(lexical): %v", err)
}
if out.RecallMode != tidyRecallLexical {
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallLexical)
}
}
// TestTidyServiceDryRun_DisabledIsHonest 钉住「配置关闭 ⇒ 诚实错误」:关闭是显式
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
func TestTidyServiceDryRun_DisabledIsHonest(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, dryRunFixtureEmbedder(), time.Now, nil)
if _, err := svc.DryRun(ctx); !errors.Is(err, ErrTidyDisabled) {
t.Fatalf("DryRun err = %v, want ErrTidyDisabled", err)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 0 {
t.Fatalf("关闭时不得留下任何留痕:%+v", runs)
}
}
// TestTidyServiceDryRun_EnabledWritesNothing 是服务层的零副作用回归:开启状态下
// 经服务入口试算同样一行都不写(连运行留痕都不写)。
func TestTidyServiceDryRun_EnabledWritesNothing(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
out, err := svc.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if len(out.Candidates) == 0 {
t.Fatal("夹具应当产出候选")
}
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "memory_items", before.items, after.items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
}