Files
sundynix-agentix/sundynix-mcp-go/internal/memory/store_test.go
T
Blizzard 1674252d81 feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen
把"逐轮盲写抽取"升级为 Mem0 式对账(方案见 memory_industry_analysis.md 落地节):

mcp-go:
- Profile 加 Importance(1~10, poignancy) + LastSeenAt(为 Generative Agents 读路径
  Score=w1·Relevance+w2·Recency+w3·Importance 铺路)。
- Upsert 收 importance + 每次置 last_seen(印证);新增 Delete(软删,BaseModel.DeletedAt
  已具备,失效不物删可审计)+ Touch;memory_upsert 透传 importance、新增 memory_delete 工具。

dispatcher:
- extractMemory → consolidateMemory:一次 LLM 调用同时做 抽取+对账,输出
  [{op:ADD|UPDATE|DELETE|NOOP,key,value,importance}];ADD/UPDATE→upsert、DELETE→软删;
  sanitizeOps 防幻删(DELETE 须命中已有)/夹 importance[1,10]/同key保末个/丢 NOOP。
- 攒批:每 3 轮(per-session 计数)才 consolidate 一次,省成本,对齐 ChatGPT 周期整理。
  从根上解决 exact-key 盲写的记忆腐烂。

验证:parseOps/sanitizeOps/parseProfile 纯逻辑单测;store 集成测试(真 PG)覆盖
importance/last_seen 写入 + 软删(live 0 / 物理 1);dispatcher -race 全过。
(注:完整多轮 LLM consolidate 未做实跑,属构造性验证 + 沿用已证 pool.Chat 模式。)

P2 待做:读路径按 Score(Recency+Importance) 排序/衰减/截断 + 桌面端记忆面板。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:27:16 +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)
}
}