Files
AskaEth 066319f9c6 fix(memory): 复审 r2 返工 —— 真实「记住 X」路径钉 long,断言改为能打红
复审 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/ 绿。
2026-10-26 13:36:15 +08:00

184 lines
7.3 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"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
func TestManualExtractor_Extract_ParseRemember_正常(t *testing.T) {
t.Parallel()
ex := NewManualExtractor("persona.a", "role.1")
conversation := []model.Message{
model.TextMessage(model.RoleUser, "记住她喜欢喝冰美式"),
model.TextMessage(model.RoleAssistant, "好的,我记住了"),
model.TextMessage(model.RoleUser, "今天天气不错"), // 无指令
model.TextMessage(model.RoleUser, "请记住周三下午有项目会"),
}
items, err := ex.Extract(context.Background(), conversation)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(items) != 2 {
t.Fatalf("got %d items, want 2", len(items))
}
if items[0].Content != "她喜欢喝冰美式" {
t.Errorf("items[0].Content = %q, want the stripped directive", items[0].Content)
}
if items[0].PersonaID != "persona.a" || items[0].RoleID != "role.1" {
t.Errorf("items[0] persona/role = %q/%q, want persona.a/role.1", items[0].PersonaID, items[0].RoleID)
}
if items[1].Content != "周三下午有项目会" {
t.Errorf("items[1].Content = %q, want the 请记住 content", items[1].Content)
}
}
func TestManualExtractor_Extract_NoRemember_正常(t *testing.T) {
t.Parallel()
ex := NewManualExtractor("persona.a", "role.1")
conversation := []model.Message{
model.TextMessage(model.RoleUser, "今天天气不错"),
model.TextMessage(model.RoleUser, "记得带伞吗"),
model.TextMessage(model.RoleAssistant, "记住是记忆"), // 非用户角色跳过
}
items, err := ex.Extract(context.Background(), conversation)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(items) != 0 {
t.Fatalf("got %d items, want 0", len(items))
}
}
func TestManualExtractor_Extract_Canceled_异常(t *testing.T) {
t.Parallel()
ex := NewManualExtractor("persona.a", "role.1")
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := ex.Extract(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住x")}); !errors.Is(err, context.Canceled) {
t.Fatalf("Extract with canceled ctx err = %v, want context.Canceled", err)
}
}
// 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)
// 第 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)
}
if got.Content != item.Content || got.RetentionTier != RetentionLong {
t.Fatalf("persisted item = (%q, %q), want (%q, %q)",
got.Content, got.RetentionTier, item.Content, RetentionLong)
}
}
}
// 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)
}
}
}
func TestStore_RememberManual_Empty_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
for _, text := range []string{"", " "} {
if _, err := s.RememberManual(ctx, "persona.a", "role.1", text); !errors.Is(err, ErrMissingContent) {
t.Fatalf("RememberManual(%q) err = %v, want ErrMissingContent", text, err)
}
}
}