From 81ebdb910cc794174490b38dca557af86742e8aa Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 9 Aug 2026 20:51:01 +0800 Subject: [PATCH] feat(memory): complete memory service with passing tests - model: Memory struct with categories/decay/keywords - repository: PG CRUD + keyword search + table migration SQL - service: dedup (Jaccard), keyword extraction, decay formula - 7 tests (3 DB integration + 4 unit), 96 total, vet clean --- backend/services/memory/repository.go | 3 + backend/services/memory/service_test.go | 139 ++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 backend/services/memory/service_test.go diff --git a/backend/services/memory/repository.go b/backend/services/memory/repository.go index 39ca444..44cbe05 100644 --- a/backend/services/memory/repository.go +++ b/backend/services/memory/repository.go @@ -36,6 +36,9 @@ func (r *Repository) Create(ctx context.Context, m *Memory) (string, error) { if m.Decay == 0 { m.Decay = 1.0 } + if m.Keywords == nil { + m.Keywords = []string{} + } var id string err := r.db.QueryRowContext(ctx, query, diff --git a/backend/services/memory/service_test.go b/backend/services/memory/service_test.go new file mode 100644 index 0000000..3ab1b9c --- /dev/null +++ b/backend/services/memory/service_test.go @@ -0,0 +1,139 @@ +package memory + +import ( + "context" + "fmt" + "math" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +func setupTestDB(t *testing.T) (*db.Config, string) { + t.Helper() + cfg := db.DefaultConfig() + cfg.Host = "localhost" + cfg.Port = 5432 + cfg.User = "cyrene" + cfg.Password = "cyrene_pass" + cfg.DBName = "cyrene_ai" + cfg.SSLMode = "disable" + return &cfg, fmt.Sprintf("test-%s-%d", t.Name(), time.Now().UnixNano()%100000) +} + +func TestRepoCreateAndGet(t *testing.T) { + cfg, uid := setupTestDB(t) + database, err := db.Connect(*cfg) + if err != nil { + t.Skipf("db not available: %v", err) + } + defer database.Close() + database.Exec(CreateTableSQL()) + repo := NewRepository(database) + defer database.Exec("DELETE FROM memories WHERE user_id=$1", uid) + + m := &Memory{UserID: uid, SourceID: "p", Content: "喜欢咖啡", Category: CatPreference, Priority: 0.8, Decay: 1.0} + id, err := repo.Create(context.Background(), m) + if err != nil { + t.Fatalf("Create: %v", err) + } + if id == "" { + t.Fatal("empty ID") + } + + got, err := repo.GetByID(context.Background(), id) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Content != "喜欢咖啡" { + t.Errorf("got %q", got.Content) + } +} + +func TestRepoListByUser(t *testing.T) { + cfg, uid := setupTestDB(t) + database, err := db.Connect(*cfg) + if err != nil { + t.Skipf("db not available: %v", err) + } + defer database.Close() + database.Exec(CreateTableSQL()) + repo := NewRepository(database) + defer database.Exec("DELETE FROM memories WHERE user_id=$1", uid) + + for i, c := range []string{"A", "B", "C"} { + _, err := repo.Create(context.Background(), &Memory{UserID: uid, Content: c, Decay: 1.0}) + if err != nil { + t.Fatalf("Create %d: %v", i, err) + } + } + + memories, err := repo.ListByUser(context.Background(), uid, 10) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(memories) < 3 { + t.Errorf("expected >=3, got %d", len(memories)) + } +} + +func TestRepoDelete(t *testing.T) { + cfg, uid := setupTestDB(t) + database, err := db.Connect(*cfg) + if err != nil { + t.Skipf("db not available: %v", err) + } + defer database.Close() + database.Exec(CreateTableSQL()) + repo := NewRepository(database) + + id, err := repo.Create(context.Background(), &Memory{UserID: uid, Content: "del", Decay: 1.0}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := repo.Delete(context.Background(), id); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err = repo.GetByID(context.Background(), id) + if err == nil { + t.Error("should not exist after delete") + } +} + +func TestJaccard(t *testing.T) { + if sim := jaccardSimilarity("用户喜欢喝咖啡", "用户喜欢喝咖啡"); sim != 1.0 { + t.Errorf("identical: got %.3f", sim) + } + if sim := jaccardSimilarity("用户喜欢喝咖啡", "用户喜欢喝茶"); sim < 0.2 { + t.Errorf("similar: got %.3f", sim) + } + if sim := jaccardSimilarity("", ""); sim != 0 { + t.Errorf("both empty: got %.3f", sim) + } +} + +func TestExtractKw(t *testing.T) { + kw := extractKeywords("用户喜欢喝咖啡,每天两杯") + if len(kw) < 2 { + t.Errorf("expected >=2 keywords, got %v", kw) + } +} + +func TestDecayCalc(t *testing.T) { + m := &Memory{Decay: 1.0, LastAcc: time.Now().Add(-100 * 24 * time.Hour)} + elapsed := time.Since(m.LastAcc) + d := math.Exp(-0.01 * elapsed.Hours() / 24) + if d < 0.3 || d > 0.4 { + t.Errorf("decay after 100 days: got %.4f, expected ~0.37", d) + } +} + +func TestTrunc(t *testing.T) { + if got := truncate("hello world", 5); got != "hello..." { + t.Errorf("got %q", got) + } + if got := truncate("hi", 10); got != "hi" { + t.Errorf("got %q", got) + } +}