Files
sundynix-agentix/sundynix-mcp-go/internal/memory/store_test.go
T
Blizzard 7ae7f7be67 feat(memory): 记忆召回加 Relevance —— Generative Agents 打分补齐第三项 (P1)
审计三真桩之一。记忆召回此前打分只有 Recency+Importance,缺 Relevance(对当前
任务的语义相关性)——注释写"待接 Milvus",但召回时甚至不知道当前问什么。

关键发现:dispatcher 注入点 fetchMemory(ctx,uid,_) 手上已有当前任务文本(b.query),
只是被 `_` 丢弃了。所以不是"接 Milvus"那么重,把 query 一路传下去 + 缓存嵌入即可。

设计(偏离注释的"接 Milvus"——用户偏好量小,不值当上向量库):
- Profile 加 embedding 列(float32 小端打包存 bytea);Upsert 时对 value 向量化缓存
  (value 没变不重算,失败留空不阻断)。
- memory 包定义 Embedder 小接口,gateway 注入 rag.Engine(复用同一控制面下发的
  embedding 模型),不硬依赖 rag 内部;rag.Engine 加导出 Embed 方法。
- memory_get 工具加可选 query 入参;fetchMemory 停止丢弃 b.query 传下去。
- Get(ctx,uid,query):query 非空且 embedder 就绪 → embed(query) 对每条缓存向量
  内存算余弦 → 三项打分 0.25R+0.35I+0.4Rel;否则回落两项(升级前行为)。
- 优雅降级贯穿:无 query/无 embedder/query 嵌入失败/行无向量 → 静默回落,绝不报错。
  零 Milvus 依赖、零向量库同步问题、保住"没 embedding 也能跑"。

验证:单测(编解码往返/cosine 截0/三项模式相关性翻转顺序/降级返 nil)+ 端到端
(真 PG:写入即向量化、query=咖啡把低重要度的咖啡记忆翻到运动前面)。migration
加列已 live;embedding 复用 RAG 已验证基建。三模块 build/vet/test 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:47:55 +08:00

140 lines
4.8 KiB
Go
Raw 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"
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestBaseModelID 验证雪花 ID 规约:NewID 非空且不重复;BeforeCreate 补 ID、已有则保留。
func TestBaseModelID(t *testing.T) {
a, b := NewID(), NewID()
if a == "" || b == "" {
t.Fatal("NewID 不应为空")
}
if a == b {
t.Errorf("两次 NewID 应不同: %s", a)
}
var m BaseModel
if err := m.BeforeCreate(nil); err != nil {
t.Fatal(err)
}
if m.ID == "" {
t.Error("BeforeCreate 应补 ID")
}
m.ID = "fixed"
_ = m.BeforeCreate(nil)
if m.ID != "fixed" {
t.Error("BeforeCreate 不应覆盖已有 ID")
}
}
// TestProfileStore_Integration 跑真实 Postgres(设 MEMORY_TEST_DSN 才执行):
// 验证迁移后表含雪花规约字段、Upsert (user_id,key) 冲突覆盖而非新增、Get 渲染。
func TestProfileStore_Integration(t *testing.T) {
dsn := os.Getenv("MEMORY_TEST_DSN")
if dsn == "" {
t.Skip("设 MEMORY_TEST_DSN 启用 Postgres 集成测试")
}
raw, err := gorm.Open(postgres.New(postgres.Config{DSN: dsn}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("连接 Postgres: %v", err)
}
_ = raw.Migrator().DropTable("sundynix_user_profile") // 干净起点
s := Open(dsn) // 触发迁移 + AutoMigrate
if s.db == nil {
t.Fatal("Store 不应降级")
}
ctx := context.Background()
// 新规约字段齐全。
for _, col := range []string{"id", "created_at", "updated_at", "deleted_at"} {
if !raw.Migrator().HasColumn(&Profile{}, col) {
t.Errorf("表应含规约字段 %s", col)
}
}
// Upsert 两次同 (user_id,key) → 覆盖,不新增;id 稳定;importance/last_seen 写入。
if err := s.Upsert(ctx, "u1", "城市", "北京", 5); err != nil {
t.Fatal(err)
}
var first Profile
raw.Where("user_id = ? AND key = ?", "u1", "城市").First(&first)
if first.ID == "" || first.CreatedAt.IsZero() {
t.Error("行应有雪花 id 与创建时间")
}
if first.Importance != 5 || first.LastSeenAt.IsZero() {
t.Errorf("应写入 importance 与 last_seen: imp=%v last=%v", first.Importance, first.LastSeenAt)
}
if err := s.Upsert(ctx, "u1", "城市", "上海", 8); err != nil {
t.Fatal(err)
}
var cnt int64
raw.Model(&Profile{}).Where("user_id = ?", "u1").Count(&cnt)
if cnt != 1 {
t.Errorf("同键 upsert 应覆盖,期望 1 行,得 %d", cnt)
}
var after Profile
raw.Where("user_id = ? AND key = ?", "u1", "城市").First(&after)
if after.Value != "上海" || after.ID != first.ID || after.Importance != 8 {
t.Errorf("应覆盖 value/importance 且保留 id: value=%s imp=%v id=%s/%s", after.Value, after.Importance, after.ID, first.ID)
}
// Delete 软删:行打 deleted_at,正常查询不返回,但物理行还在(可审计)。
_ = s.Upsert(ctx, "u1", "临时", "可删", 1)
if err := s.Delete(ctx, "u1", "临时"); err != nil {
t.Fatal(err)
}
var liveCnt, rawCnt int64
raw.Model(&Profile{}).Where("user_id = ? AND key = ?", "u1", "临时").Count(&liveCnt)
raw.Unscoped().Model(&Profile{}).Where("user_id = ? AND key = ?", "u1", "临时").Count(&rawCnt)
if liveCnt != 0 || rawCnt != 1 {
t.Errorf("软删后正常查询应 0、物理行应 1live=%d raw=%d", liveCnt, rawCnt)
}
// Get 渲染多行(按 key 排序),软删的不出现。
_ = s.Upsert(ctx, "u1", "爱好", "围棋", 6)
got, _ := s.Get(ctx, "u1", "")
if got == "" || got != "- 城市:上海\n- 爱好:围棋" {
t.Errorf("Get 渲染不符: %q", got)
}
}
// TestProfileStore_LegacyMigration 验证旧复合主键表(无 id)→ 雪花规约表的迁移保留数据。
func TestProfileStore_LegacyMigration(t *testing.T) {
dsn := os.Getenv("MEMORY_TEST_DSN")
if dsn == "" {
t.Skip("设 MEMORY_TEST_DSN 启用 Postgres 集成测试")
}
raw, err := gorm.Open(postgres.New(postgres.Config{DSN: dsn}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("连接 Postgres: %v", err)
}
// 造一个旧 schema 表(复合主键、无 id/时间戳)并塞一条偏好。
raw.Migrator().DropTable("sundynix_user_profile")
if err := raw.Exec(`CREATE TABLE sundynix_user_profile (user_id varchar(64), "key" varchar(64), value text, PRIMARY KEY(user_id, "key"))`).Error; err != nil {
t.Fatalf("建旧表: %v", err)
}
raw.Exec(`INSERT INTO sundynix_user_profile (user_id, "key", value) VALUES ('legacy', '语言', 'Go')`)
s := Open(dsn) // 应触发 migrateLegacyProfile:备份→重建→回灌
if s.db == nil {
t.Fatal("Store 不应降级")
}
if !raw.Migrator().HasColumn(&Profile{}, "id") {
t.Error("迁移后应有 id 列")
}
var p Profile
if err := raw.Where("user_id = ? AND key = ?", "legacy", "语言").First(&p).Error; err != nil {
t.Fatalf("迁移后旧数据应保留: %v", err)
}
if p.Value != "Go" || p.ID == "" {
t.Errorf("旧偏好应保留且补新 id: value=%s id=%s", p.Value, p.ID)
}
}