Files
Cyrene_v2/backend/services/memory/service.go
T
Aska 7295556c59 feat(services): add memory service — model, repository, service layer
- services/memory/model: Memory struct with categories and decay
- services/memory/repository: PostgreSQL CRUD + keyword search
- services/memory/service: dedup, decay, keyword extraction
- tests coming in follow-up commit
2026-08-09 20:50:01 +08:00

208 lines
4.7 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"
"fmt"
"math"
"strings"
"time"
)
// Service handles business logic for the memory system.
type Service struct {
repo *Repository
}
// NewService creates a new memory service.
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
// Add stores a new memory with deduplication.
func (s *Service) Add(ctx context.Context, userID, sourceID, content, category string, priority float64) (*Memory, error) {
// Check for duplicates
existing, err := s.repo.SearchByKeywords(ctx, userID, content, 5)
if err == nil {
for _, e := range existing {
if jaccardSimilarity(content, e.Content) > 0.8 {
// Merge: update priority and keywords
e.Priority = math.Max(e.Priority, priority)
e.Content = mergeContent(e.Content, content)
e.AccessCnt++
e.LastAcc = time.Now()
if err := s.repo.Update(ctx, &e); err != nil {
return nil, fmt.Errorf("merge memory: %w", err)
}
return &e, nil
}
}
}
keywords := extractKeywords(content)
if priority <= 0 {
priority = 0.5
}
m := &Memory{
UserID: userID,
SourceID: sourceID,
Content: content,
Summary: truncate(content, 200),
Keywords: keywords,
Category: category,
Priority: priority,
Decay: 1.0,
}
if _, err := s.repo.Create(ctx, m); err != nil {
return nil, fmt.Errorf("add memory: %w", err)
}
return m, nil
}
// Search performs combined keyword + semantic search.
func (s *Service) Search(ctx context.Context, q SearchQuery) ([]Memory, error) {
if q.Limit <= 0 {
q.Limit = 10
}
memories, err := s.repo.SearchByKeywords(ctx, q.UserID, q.Query, q.Limit)
if err != nil {
return nil, fmt.Errorf("search memory: %w", err)
}
// Record access for found memories
for _, m := range memories {
go s.repo.RecordAccess(context.Background(), m.ID)
}
// Filter by source if specified
if q.SourceID != "" {
filtered := make([]Memory, 0)
for _, m := range memories {
if m.SourceID == q.SourceID {
filtered = append(filtered, m)
}
}
return filtered, nil
}
return memories, nil
}
// ApplyDecay reduces the decay factor based on time elapsed since last access.
func (s *Service) ApplyDecay(ctx context.Context, userID string) (int, error) {
memories, err := s.repo.ListByUser(ctx, userID, 100)
if err != nil {
return 0, fmt.Errorf("list for decay: %w", err)
}
updated := 0
for _, m := range memories {
elapsed := time.Since(m.LastAcc)
days := elapsed.Hours() / 24
newDecay := math.Exp(-0.01 * days)
if newDecay != m.Decay {
m.Decay = newDecay
if err := s.repo.Update(ctx, &m); err == nil {
updated++
}
}
}
// Purge forgotten memories
if _, err := s.repo.PurgeDecayed(ctx, userID); err != nil {
return updated, err
}
return updated, nil
}
// Delete removes a memory (admin use).
func (s *Service) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
// List returns recent memories for a user.
func (s *Service) List(ctx context.Context, userID string, limit int) ([]Memory, error) {
return s.repo.ListByUser(ctx, userID, limit)
}
// GetByID returns a single memory.
func (s *Service) GetByID(ctx context.Context, id string) (*Memory, error) {
return s.repo.GetByID(ctx, id)
}
// --- Internal helpers ---
// jaccardSimilarity computes the Jaccard similarity between two strings.
func jaccardSimilarity(a, b string) float64 {
setA := tokenSet(a)
setB := tokenSet(b)
intersection := 0
for token := range setA {
if setB[token] {
intersection++
}
}
union := len(setA) + len(setB) - intersection
if union == 0 {
return 0
}
return float64(intersection) / float64(union)
}
func tokenSet(s string) map[string]bool {
// Use 2-gram tokens for Chinese and word tokens for English
tokens := make(map[string]bool)
runes := []rune(s)
for i := 0; i < len(runes)-1; i++ {
tokens[string(runes[i:i+2])] = true
}
for _, word := range strings.Fields(s) {
if len(word) > 1 {
tokens[word] = true
}
}
return tokens
}
// extractKeywords extracts simple keywords from text.
func extractKeywords(content string) []string {
// Simple extraction: split by common delimiters, filter short words
words := strings.FieldsFunc(content, func(r rune) bool {
return r == ',' || r == '。' || r == '、' || r == ' ' || r == '\n' || r == '!' || r == '?'
})
seen := make(map[string]bool)
var keywords []string
for _, w := range words {
runes := []rune(w)
if len(runes) >= 2 && len(runes) <= 8 && !seen[w] {
keywords = append(keywords, w)
seen[w] = true
}
if len(keywords) >= 10 {
break
}
}
return keywords
}
func mergeContent(old, new string) string {
if len(new) > len(old) {
return new
}
return old
}
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}