7ae7f7be67
审计三真桩之一。记忆召回此前打分只有 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>
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
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、物理行应 1:live=%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)
|
||
}
|
||
}
|