- 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
208 lines
4.7 KiB
Go
208 lines
4.7 KiB
Go
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]) + "..."
|
||
}
|