From 066319f9c6a70ba2905e2da0099f870d6443d9fe Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:36:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=20=E2=80=94=E2=80=94=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E3=80=8C=E8=AE=B0=E4=BD=8F=20X=E3=80=8D=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E9=92=89=20long=EF=BC=8C=E6=96=AD=E8=A8=80=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=83=BD=E6=89=93=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2 = p2-2items 全在「RememberManual 钉 long」那半,两条指向同一件事: **我以为修好的人工路径实际没修**。逐条: P2-1 上一版断言是**空断言**:夹具正文「记住她喜欢喝咖啡」含「记住」⇒ extract_retention.go 的护栏① 直接放行 ⇒ 默认落点本来就是 long,因此删掉 store.go 的 pin 仍然绿。 → extract_test.go 的 TestStore_RememberManual_正常 改为一对语料: 「我正在测通道」(N7,规则真会判 temp)+「记住她喜欢喝咖啡」(正文保留契约), 并同时断言档位、`ExpiresAt` 零值、以及**落库列** `(retention_tier, expires_at)`。 P2-2 pin 钉在零生产调用方的方法上:真实路径是 ManualExtractor.Extract(无可用 LLM 出口时的生产回退)→ persistMemories → AddScoped → Add,而 rememberDirective **先剥掉「记住」前缀** ⇒ 护栏① 永远看不到它。改前实测:记住今天下雨了→temp/n6、 记住我正在测通道→temp/n7、记住现在几点了→temp/n4(均 +7 天)。 → extract.go 的 ManualExtractor.Extract 在**产出侧**显式置 `RetentionTier: RetentionLong`(并在注释里写清「不能靠护栏①」的原因)。 store.go 的 RememberManual pin 保留(两条独立的契约点)。 → 新增 TestManualExtractorItemsStayLongTerm:四条正文过 Extract → 复刻 persistMemories 的溯源覆盖 → AddScoped 落库 → 断言**落库列** `(long, 空串)`。 **变异证据(删掉两处 pin 后实测)**: --- FAIL: TestManualExtractorItemsStayLongTerm extract_test.go:167: 「记住今天下雨了」全链落库 = (tier="temp", expires_at="2026-11-02T05:34:20.844624200Z"), want (long, 空串) --- FAIL: TestStore_RememberManual_正常 extract_test.go:99: RememberManual("我正在测通道") 的档位 = "temp", want "long" 恢复 pin 后两条均 PASS。 回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/... ./internal/db/... ./cmd/cyrene/ 全绿;go test -race ./internal/runtime/memory/ 绿。 --- internal/runtime/memory/extract.go | 23 +++-- internal/runtime/memory/extract_test.go | 115 +++++++++++++++++++----- 2 files changed, 108 insertions(+), 30 deletions(-) diff --git a/internal/runtime/memory/extract.go b/internal/runtime/memory/extract.go index 81d7f7f..61f49e3 100644 --- a/internal/runtime/memory/extract.go +++ b/internal/runtime/memory/extract.go @@ -50,6 +50,14 @@ func NewManualExtractor(personaID, roleID string) *ManualExtractor { // Extract 为每个要求代理记住点什么的用户 // 轮次返回一条记忆条目(kind 为 relationship、作用域为 persona)。领起的 // 「记住」/「请记住」指令会从落库内容中剥离;没有该指令的轮次跳过。 +// +// 🔴 **档位显式置 long**(迁移 0026 / 主人 2026-10-26 对「人工动作默认 long」的裁定): +// 这是**人明确要求记住**的路径,留存规则不许把它降成临时层(否则「记住我现在在 +// 测通道」会被判 temp,7 天后被静默删掉)。注意这里**不能靠护栏①**( +// extract_retention.go 的「显式记住一票否决」)兜底:`rememberDirective` 在上一步 +// 已经把「记住」前缀剥掉了,落库正文里没有那两个词,护栏① 永远看不到它 —— +// 因此必须在**产出侧**把档位写死(retention.go 的 resolveRetention 第一条分支 +// 就是「调用方显式档位不许被规则推翻」)。 func (e *ManualExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) { if err := ctx.Err(); err != nil { return nil, err @@ -65,13 +73,14 @@ func (e *ManualExtractor) Extract(ctx context.Context, conversation []model.Mess continue } items = append(items, MemoryItem{ - PersonaID: e.personaID, - RoleID: e.roleID, - Scope: ScopePersona, - Kind: KindRelationship, - Content: content, - Confidence: 1.0, - DecayWeight: 1.0, + PersonaID: e.personaID, + RoleID: e.roleID, + Scope: ScopePersona, + Kind: KindRelationship, + Content: content, + Confidence: 1.0, + DecayWeight: 1.0, + RetentionTier: RetentionLong, }) } return items, nil diff --git a/internal/runtime/memory/extract_test.go b/internal/runtime/memory/extract_test.go index 47d482d..9823aa9 100644 --- a/internal/runtime/memory/extract_test.go +++ b/internal/runtime/memory/extract_test.go @@ -68,36 +68,105 @@ func TestManualExtractor_Extract_Canceled_异常(t *testing.T) { } } +// TestStore_RememberManual_正常 钉住手动单条目路径(design §1「记住 X」)的两条契约: +// 正文按原样落库(不剥离指令),以及**档位恒为 long**。 +// +// 🔴 语料里**必须**有一条「留存规则真的会判 temp」的正文(这里是 `我正在测通道`, +// N7 进行中状态)。上一版的夹具是「记住她喜欢喝咖啡」——它含「记住」⇒ 护栏① 直接 +// 放行 ⇒ 默认落点本来就是 long,于是删掉 store.go 的 pin 断言**仍然是绿的**(复审 +// p2-1 实测)。现在这两条一起钉:删掉 pin ⇒ 第一条正文会被判 temp + 写有效期 ⇒ 红。 func TestStore_RememberManual_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) - item, err := s.RememberManual(ctx, "persona.a", "role.1", "记住她喜欢喝咖啡") - if err != nil { - t.Fatalf("RememberManual: %v", err) - } - // 落库内容保留指令原文;这是手动单条目 - // 路径,与会剥离指令的提取器不同。 - if item.Content != "记住她喜欢喝咖啡" { - t.Fatalf("RememberManual content = %q, want the passed text", item.Content) - } - if item.PersonaID != "persona.a" || item.Scope != ScopePersona || item.Kind != KindRelationship { - t.Fatalf("item persona/scope/kind = %q/%q/%q, want persona.a/persona/relationship", - item.PersonaID, item.Scope, item.Kind) - } - // 人工动作(有人明确说「记住 X」)的档位恒为 long —— 留存规则不许把它降成临时层 - // (否则一条「记住 晚安」会在 7 天后被静默删掉)。见 store.go 的 RememberManual 注释。 - if item.RetentionTier != RetentionLong { - t.Fatalf("RememberManual 的档位 = %q, want %q(人工动作默认长期)", item.RetentionTier, RetentionLong) - } + // 第 1 条:正文会被留存规则判 temp(N7)——它才是真正压住 pin 的那一条。 + // 第 2 条:带指令前缀的正文仍按原样落库(手动路径不剥离指令)。 + for _, text := range []string{"我正在测通道", "记住她喜欢喝咖啡"} { + item, err := s.RememberManual(ctx, "persona.a", "role.1", text) + if err != nil { + t.Fatalf("RememberManual(%q): %v", text, err) + } + if item.Content != text { + t.Fatalf("RememberManual content = %q, want the passed text %q", item.Content, text) + } + if item.PersonaID != "persona.a" || item.Scope != ScopePersona || item.Kind != KindRelationship { + t.Fatalf("item persona/scope/kind = %q/%q/%q, want persona.a/persona/relationship", + item.PersonaID, item.Scope, item.Kind) + } + // 人工动作(有人明确要求记住)的档位恒为 long —— 见 store.go 的 RememberManual 注释。 + if item.RetentionTier != RetentionLong { + t.Fatalf("RememberManual(%q) 的档位 = %q, want %q(人工动作默认长期)", + text, item.RetentionTier, RetentionLong) + } + // 长期档 ⇒ Add 不得给出有效期(两个来源都查:返回值 + 落库列)。 + if !item.ExpiresAt.IsZero() { + t.Fatalf("RememberManual(%q) 的 ExpiresAt = %v, want 零值(长期层不过期)", text, item.ExpiresAt) + } + if tier, expires := readRetentionColumns(t, s, item.ID); tier != RetentionLong || expires != "" { + t.Fatalf("RememberManual(%q) 落库 = (tier=%q, expires_at=%q), want (long, 空串)", + text, tier, expires) + } - got, err := s.Get(ctx, item.ID) - if err != nil { - t.Fatalf("Get: %v", err) + got, err := s.Get(ctx, item.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Content != item.Content || got.RetentionTier != RetentionLong { + t.Fatalf("persisted item = (%q, %q), want (%q, %q)", + got.Content, got.RetentionTier, item.Content, RetentionLong) + } } - if got.Content != item.Content { - t.Fatalf("persisted content = %q, want %q", got.Content, item.Content) +} + +// TestManualExtractorItemsStayLongTerm 是 p2-2 的回归:真实的「记住 X」路径不是 +// `Store.RememberManual`(零生产调用方),而是 `ManualExtractor.Extract` → +// `persistMemories` → `AddScoped` → `Add`(cmd/cyrene/extractor.go 在无可用 LLM +// 出口时的生产回退)。 +// +// 🔴 为什么必须在**产出侧**钉档位:`rememberDirective` 先把「记住」前缀剥掉,落库 +// 正文里没有那两个词 ⇒ extract_retention.go 的护栏①(显式「记住」一票放行) +// 永远看不到它。改前实测:`记住今天下雨了`→temp/n6/+7d、`记住我正在测通道`→temp/n7/+7d、 +// `记住现在几点了`→temp/n4/+7d。删掉 extract.go 的 pin ⇒ 本用例必红。 +func TestManualExtractorItemsStayLongTerm(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + // 角色由 AddScoped 按回合盖章(persistMemories 的口径),因此这里空角色构建。 + ex := NewManualExtractor("", "") + + // 前三条逐条都是「留存规则真会判 temp」的正文(n6 / n7 / n4),第四条是长期对照。 + texts := []string{ + "记住今天下雨了", + "记住我正在测通道", + "记住现在几点了", + "记住我喜欢喝冰美式", + } + msgs := make([]model.Message, 0, len(texts)) + for _, text := range texts { + msgs = append(msgs, model.TextMessage(model.RoleUser, text)) + } + items, err := ex.Extract(ctx, msgs) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if len(items) != len(texts) { + t.Fatalf("Extract 产出 %d 条, want %d", len(items), len(texts)) + } + for i, item := range items { + // 复刻 persistMemories 的那一笔:只覆盖溯源,然后经 AddScoped 落库。 + item.SourceConversation = FormatSourceConversation("web", "msg-1") + got, err := store.AddScoped(ctx, "persona.a", item) + if err != nil { + t.Fatalf("AddScoped(%q): %v", texts[i], err) + } + // 断言落在**落库列**上(全链口径):删掉 extract.go 的 pin 时,这三条会被 + // 留存规则判 temp 并写下 7 天有效期 ⇒ 这里的 (long, 空串) 当场变红。 + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionLong || expires != "" { + t.Fatalf("「%s」全链落库 = (tier=%q, expires_at=%q), want (long, 空串)", + texts[i], tier, expires) + } } }