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) } } }