1674252d81
把"逐轮盲写抽取"升级为 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>
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)
|
||
}
|
||
}
|