feat: 第四轮功能增强 - LLM 思维记忆优化、DevTools 记忆UI、9个新工具、5分钟自我思考

- 优化 LLM 思维方式和记忆方法(类别/重要性/关键词/相似度合并/衰减)
- DevTools 记忆查询 UI 重新设计(类别筛选/排序/星标/搜索)
- 新增 9 个 LLM 工具:calculator, datetime, file_ops, http_request, json_ops, text, random, crypto, markdown
- 管理员主对话 5 分钟自我思考增强(工具调用/记忆提取/记忆维护)
This commit is contained in:
2026-05-18 12:13:49 +08:00
parent 07781eda0e
commit b6ec36886c
20 changed files with 4654 additions and 320 deletions
+174 -51
View File
@@ -12,8 +12,8 @@ import (
// Extractor 记忆提取器 —— 从对话中提取结构化记忆
type Extractor struct {
store *Store
llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
store *Store
llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
}
// NewExtractor 创建记忆提取器
@@ -38,12 +38,21 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
for _, mem := range memories {
mem.UserID = userID
mem.SessionID = sessionID
mem.Source = "conversation"
// 去重检查:查询用户已有的相关记忆
existing, err := e.findSimilar(ctx, userID, &mem)
if err == nil && existing != nil {
// 相似度 > 80%,更新现有记忆
e.mergeMemory(ctx, existing, &mem)
continue
}
if err := e.store.Save(ctx, &mem); err != nil {
log.Printf("[memory] 记忆保存失败: %v", err)
continue
}
log.Printf("[memory] 新记忆已保存 [%s]: %s", mem.Category, mem.Summary)
log.Printf("[memory] 新记忆已保存 [%s|%d★]: %s", mem.Category, mem.Importance, mem.Summary)
}
}
@@ -59,12 +68,17 @@ func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse
// MemoryExtractionResult LLM提取结果的结构
type MemoryExtractionResult struct {
Memories []struct {
Content string `json:"content"`
Summary string `json:"summary"`
Category string `json:"category"`
Priority int `json:"priority"`
} `json:"memories"`
Memories []ExtractedMemory `json:"memories"`
}
// ExtractedMemory LLM提取的原始记忆条目
type ExtractedMemory struct {
Content string `json:"content"`
Summary string `json:"summary"`
Category string `json:"category"`
Priority int `json:"priority"`
Importance int `json:"importance"` // 重要程度 1-10
Keywords []string `json:"keywords"` // 关键词标签
}
// extractWithLLM 使用LLM提取记忆
@@ -74,20 +88,40 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
用户消息: %s
昔涟回复: %s
请以JSON格式返回提取的记忆。每条记忆需要包含:
- content: 完整的记忆内容(一句话描述)
请以JSON格式返回提取的记忆。每条记忆需要包含以下字段
- content: 完整的记忆内容(一句话描述,客观准确
- summary: 简短摘要(10字以内)
- category: 分类 (preference/fact/event/relationship/habit/other)
- category: 记忆分类,必须是以下之一:
* user_preference: 用户偏好(食物、颜色、习惯、爱好)
* personal_info: 个人信息(姓名、年龄、职业、住址)
* conversation: 对话摘要(值得记住的对话主题)
* knowledge: 知识性信息(用户分享的知识或观点)
* event: 事件记录(发生了什么事)
* task: 任务/计划(用户的计划、待办事项)
* relationship: 关系信息(用户与他人的关系)
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
- importance: 重要程度 1-10(评估这条信息对了解用户有多重要)
* 1-3: 琐碎信息,可能很快过时
* 4-6: 一般有用,值得记住
* 7-8: 重要信息,长期有用
* 9-10: 核心信息,对理解用户至关重要
- keywords: 关键词标签数组(3-5个词,用于检索和匹配)
重要性评估指南:
- 用户明确表达的偏好(喜欢/讨厌)→ importance 7-8
- 用户的基本个人信息(姓名/生日)→ importance 9-10
- 日常闲聊主题 → importance 2-3
- 用户提到的计划/任务 → importance 5-7
- 用户的情感状态 → importance 5-6
只提取有意义的信息,不要提取无意义的闲聊。如果没有值得记住的内容,返回空数组。
输出格式:
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1}]}
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1, "importance": 6, "keywords": ["词1", "词2"]}]}
`, userMessage, assistantResponse)
resp, err := e.llmChat(ctx, []model.LLMMessage{
{Role: "system", Content: "你是一个记忆提取助手。你只输出JSON格式的结果,不输出其他内容。"},
{Role: "system", Content: "你是一个记忆提取助手。你只输出JSON格式的结果,不输出其他内容。你的任务是评估对话中关于用户的信息,提取值得记住的内容,并为其打分。"},
{Role: "user", Content: prompt},
})
if err != nil {
@@ -98,26 +132,41 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
result := MemoryExtractionResult{}
content := extractJSON(resp.Content)
if err := json.Unmarshal([]byte(content), &result); err != nil {
// 尝试作为数组解析
var arrResult []struct {
Content string `json:"content"`
Summary string `json:"summary"`
Category string `json:"category"`
Priority int `json:"priority"`
}
// 尝试作为数组解析(兼容旧格式)
var arrResult []ExtractedMemory
if err2 := json.Unmarshal([]byte(content), &arrResult); err2 != nil {
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:min(len(content), 100)])
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)])
}
result.Memories = arrResult
}
var entries []model.MemoryEntry
for _, m := range result.Memories {
cat := model.MemoryCategory(m.Category)
if cat == "" {
cat = model.CategoryKnowledge
}
pri := model.MemoryPriority(m.Priority)
if pri < 0 || pri > 3 {
pri = model.MemoryNormal
}
imp := m.Importance
if imp < 1 {
imp = 5
}
if imp > 10 {
imp = 10
}
entries = append(entries, model.MemoryEntry{
Content: m.Content,
Summary: m.Summary,
Category: model.MemoryCategory(m.Category),
Priority: model.MemoryPriority(m.Priority),
Content: m.Content,
Summary: m.Summary,
Category: cat,
Priority: pri,
Importance: imp,
Keywords: m.Keywords,
})
}
@@ -128,35 +177,45 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
func (e *Extractor) extractWithRules(userMessage, _ string) []model.MemoryEntry {
var entries []model.MemoryEntry
// 规则1: 检测用户偏好表达
prefPatterns := map[string]string{
"喜欢": "preference",
"爱": "preference",
"最喜欢": "preference",
"讨厌": "preference",
"不喜欢": "preference",
"经常": "habit",
"每天都": "habit",
"一直": "habit",
"我叫": "fact",
"我是": "fact",
"我家": "fact",
"住在": "fact",
"生日": "fact",
// 规则: 检测用户偏好表达 - 使用新的分类体系
prefPatterns := map[string]struct {
category model.MemoryCategory
importance int
}{
"喜欢": {model.CategoryUserPreference, 7},
"": {model.CategoryUserPreference, 8},
"最喜欢": {model.CategoryUserPreference, 9},
"讨厌": {model.CategoryUserPreference, 8},
"不喜欢": {model.CategoryUserPreference, 7},
"经常": {model.CategoryUserPreference, 6},
"每天都": {model.CategoryUserPreference, 6},
"一直": {model.CategoryUserPreference, 5},
"我叫": {model.CategoryPersonalInfo, 9},
"我是": {model.CategoryPersonalInfo, 8},
"我家": {model.CategoryPersonalInfo, 7},
"住在": {model.CategoryPersonalInfo, 8},
"生日": {model.CategoryPersonalInfo, 10},
"计划": {model.CategoryTask, 6},
"打算": {model.CategoryTask, 6},
"去了": {model.CategoryEvent, 4},
"发生": {model.CategoryEvent, 4},
}
for pattern, category := range prefPatterns {
for pattern, info := range prefPatterns {
if idx := strings.Index(userMessage, pattern); idx != -1 {
// 提取包含关键词的句子片段
start := max(0, idx-5)
end := min(len([]rune(userMessage)), idx+len([]rune(pattern))+15)
content := strings.TrimSpace(string([]rune(userMessage)[start:end]))
start := maxint(0, idx-5)
runes := []rune(userMessage)
end := minint(len(runes), idx+len([]rune(pattern))+15)
content := strings.TrimSpace(string(runes[start:end]))
entries = append(entries, model.MemoryEntry{
Content: content,
Summary: truncateString(content, 20),
Category: model.MemoryCategory(category),
Priority: model.MemoryNormal,
Content: content,
Summary: truncateString(content, 20),
Category: info.category,
Priority: model.MemoryNormal,
Importance: info.importance,
Keywords: []string{pattern},
})
break // 每条消息最多提取一条规则记忆
}
@@ -165,6 +224,70 @@ func (e *Extractor) extractWithRules(userMessage, _ string) []model.MemoryEntry
return entries
}
// findSimilar 查找与给定记忆相似的已有记忆
func (e *Extractor) findSimilar(ctx context.Context, userID string, newMem *model.MemoryEntry) (*model.MemoryEntry, error) {
existing, err := e.store.Query(ctx, model.MemoryQuery{
UserID: userID,
Limit: 100,
})
if err != nil {
return nil, err
}
for i := range existing {
score := existing[i].SimilarityScore(newMem)
if score >= deDupThreshold {
return &existing[i], nil
}
}
return nil, nil
}
// mergeMemory 合并新记忆到已有记忆
func (e *Extractor) mergeMemory(ctx context.Context, existing *model.MemoryEntry, newMem *model.MemoryEntry) {
// 更新内容(如果新内容更有价值)
if newMem.Importance > existing.Importance || len(newMem.Content) > len(existing.Content) {
existing.Content = newMem.Content
existing.Summary = newMem.Summary
}
// 合并关键词
keywordSet := make(map[string]bool)
for _, k := range existing.Keywords {
keywordSet[k] = true
}
for _, k := range newMem.Keywords {
keywordSet[k] = true
}
mergedKeywords := make([]string, 0, len(keywordSet))
for k := range keywordSet {
mergedKeywords = append(mergedKeywords, k)
}
existing.Keywords = mergedKeywords
// 取最高重要性
if newMem.Importance > existing.Importance {
existing.Importance = newMem.Importance
}
// 取最高优先级
if newMem.Priority > existing.Priority {
existing.Priority = newMem.Priority
}
// 增加访问计数(因为又被"想起"了)
existing.AccessCount++
if err := e.store.Update(ctx, existing); err != nil {
log.Printf("[memory] 合并记忆更新失败: %v", err)
return
}
log.Printf("[memory] 合并记忆 [%s|%d★]: %s (相似度 > %.0f%%)",
existing.Category, existing.Importance, existing.Summary, deDupThreshold*100)
}
// extractJSON 从LLM回复中提取JSON内容
func extractJSON(text string) string {
text = strings.TrimSpace(text)
@@ -191,14 +314,14 @@ func truncateString(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}
func min(a, b int) int {
func minint(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
func maxint(a, b int) int {
if a > b {
return a
}
+93 -6
View File
@@ -55,7 +55,7 @@ func NewRetriever(store *Store, embedder Embedder) *Retriever {
}
// Retrieve 检索与查询相关的记忆
// 策略: 向量相似度 + 关键词匹配混合
// 策略: 向量相似度 + 关键词匹配混合 → 按重要性降序返回
func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
var allEntries []MemoryEntry
seen := make(map[string]bool)
@@ -63,7 +63,7 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
// 1. 向量相似度检索
embedding, err := r.embedder.Embed(ctx, query)
if err == nil {
vecEntries, err := r.store.SearchByVector(ctx, userID, embedding, 5)
vecEntries, err := r.store.SearchByVector(ctx, userID, embedding, 8)
if err == nil {
for _, e := range vecEntries {
if !seen[e.ID] {
@@ -74,7 +74,7 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
}
}
// 2. 关键词匹配检索(核心/重要记忆优先
// 2. 关键词匹配检索(包含关键词标签匹配
keywordEntries, err := r.keywordSearch(ctx, userID, query)
if err == nil {
for _, e := range keywordEntries {
@@ -90,13 +90,19 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
recentEntries, err := r.store.Query(ctx, model.MemoryQuery{
UserID: userID,
Priority: model.MemoryImportant,
Limit: 3,
Limit: 5,
})
if err == nil {
allEntries = recentEntries
}
}
// 4. 去重合并:对高度相似的记忆只保留Importance更高的
allEntries = r.deduplicate(allEntries)
// 5. 按重要性降序排列
sortByImportance(allEntries)
// 限制返回数量
if len(allEntries) > 10 {
allEntries = allEntries[:10]
@@ -105,7 +111,19 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
return allEntries, nil
}
// keywordSearch 关键词匹配检索
// RetrieveByCategory 按分类检索记忆
func (r *Retriever) RetrieveByCategory(ctx context.Context, userID string, category model.MemoryCategory, limit int) ([]MemoryEntry, error) {
if limit <= 0 {
limit = 20
}
return r.store.Query(ctx, model.MemoryQuery{
UserID: userID,
Category: category,
Limit: limit,
})
}
// keywordSearch 关键词匹配检索(包含关键词标签匹配)
func (r *Retriever) keywordSearch(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
// 查询最近的核心和重要记忆
entries, err := r.store.Query(ctx, model.MemoryQuery{
@@ -117,15 +135,27 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
return nil, err
}
// 简单的关键词匹配过滤
// 关键词匹配过滤
var matched []MemoryEntry
queryLower := strings.ToLower(query)
for _, entry := range entries {
contentLower := strings.ToLower(entry.Content)
summaryLower := strings.ToLower(entry.Summary)
// 内容/摘要匹配
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
matched = append(matched, entry)
continue
}
// 关键词标签匹配
for _, kw := range entry.Keywords {
if strings.Contains(queryLower, strings.ToLower(kw)) ||
strings.Contains(strings.ToLower(kw), queryLower) {
matched = append(matched, entry)
break
}
}
}
@@ -141,6 +171,14 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
summaryLower := strings.ToLower(entry.Summary)
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
matched = append(matched, entry)
continue
}
for _, kw := range entry.Keywords {
if strings.Contains(queryLower, strings.ToLower(kw)) ||
strings.Contains(strings.ToLower(kw), queryLower) {
matched = append(matched, entry)
break
}
}
}
}
@@ -148,5 +186,54 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
return matched, nil
}
// deduplicate 去重合并:对高度相似的记忆只保留 Importance 更高的
func (r *Retriever) deduplicate(entries []MemoryEntry) []MemoryEntry {
if len(entries) < 2 {
return entries
}
result := make([]MemoryEntry, 0, len(entries))
discarded := make(map[int]bool)
for i := 0; i < len(entries); i++ {
if discarded[i] {
continue
}
for j := i + 1; j < len(entries); j++ {
if discarded[j] {
continue
}
score := entries[i].SimilarityScore(&entries[j])
if score >= deDupThreshold {
// 保留更重要的那条
if entries[j].Importance > entries[i].Importance ||
(entries[j].Importance == entries[i].Importance && entries[j].Priority > entries[i].Priority) {
discarded[i] = true
break
} else {
discarded[j] = true
}
}
}
if !discarded[i] {
result = append(result, entries[i])
}
}
return result
}
// sortByImportance 按 Importance 降序, Priority 降序排列
func sortByImportance(entries []MemoryEntry) {
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[j].Importance > entries[i].Importance ||
(entries[j].Importance == entries[i].Importance && entries[j].Priority > entries[i].Priority) {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
}
// Ensure fmt is used
var _ = fmt.Sprintf
+227 -34
View File
@@ -12,6 +12,15 @@ import (
_ "github.com/lib/pq"
)
// deDupThreshold 去重相似度阈值
const deDupThreshold = 0.75
// decayThresholdDays 记忆衰减阈值(天)
const decayThresholdDays = 30
// decayLowImportanceMax 衰减时低重要性记忆的最大保留值
const decayLowImportanceMax = 1
const reconnectInterval = 30 * time.Second
// Store 记忆持久化存储(PostgreSQL + pgvector
@@ -146,25 +155,32 @@ func (s *Store) migrate() error {
user_id VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
summary TEXT DEFAULT '',
category VARCHAR(32) DEFAULT 'other',
category VARCHAR(32) DEFAULT 'knowledge',
priority INT DEFAULT 1,
importance INT DEFAULT 5,
keywords TEXT DEFAULT '[]',
session_id VARCHAR(64) DEFAULT '',
source TEXT DEFAULT '',
source TEXT DEFAULT 'conversation',
embedding vector(1536),
access_count INT DEFAULT 0,
last_access TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`,
`CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority)`,
`CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance)`,
`CREATE INDEX IF NOT EXISTS idx_memories_user_priority ON memories(user_id, priority DESC)`,
`CREATE INDEX IF NOT EXISTS idx_memories_user_importance ON memories(user_id, importance DESC)`,
`CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source)`,
`CREATE INDEX IF NOT EXISTS idx_memories_category_importance ON memories(category, importance DESC)`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("执行迁移 '%s' 失败: %w", q[:50], err)
return fmt.Errorf("执行迁移 '%s' 失败: %w", q[:min(50, len(q))], err)
}
}
return nil
@@ -177,8 +193,16 @@ func (s *Store) Save(ctx context.Context, entry *model.MemoryEntry) error {
return errDBNotReady
}
query := `INSERT INTO memories (user_id, content, summary, category, priority, session_id, source, embedding, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
// 设置默认值
if entry.Source == "" {
entry.Source = "conversation"
}
if entry.Importance == 0 {
entry.Importance = 5
}
query := `INSERT INTO memories (user_id, content, summary, category, priority, importance, keywords, session_id, source, embedding, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at`
var embedding interface{}
@@ -193,6 +217,7 @@ func (s *Store) Save(ctx context.Context, entry *model.MemoryEntry) error {
return db.QueryRowContext(ctx, query,
entry.UserID, entry.Content, entry.Summary,
string(entry.Category), int(entry.Priority),
entry.Importance, entry.KeywordsJSON(),
entry.SessionID, entry.Source, embedding, entry.ExpiresAt,
).Scan(&entry.ID, &entry.CreatedAt)
}
@@ -204,16 +229,17 @@ func (s *Store) GetByID(ctx context.Context, id string) (*model.MemoryEntry, err
return nil, errDBNotReady
}
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
access_count, last_access, created_at, expires_at
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at
FROM memories WHERE id = $1`
entry := &model.MemoryEntry{}
var category string
var category, keywordsRaw string
err := db.QueryRowContext(ctx, query, id).Scan(
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
&category, &entry.Priority, &entry.SessionID, &entry.Source,
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -222,6 +248,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*model.MemoryEntry, err
return nil, fmt.Errorf("查询记忆失败: %w", err)
}
entry.Category = model.MemoryCategory(category)
entry.Keywords = model.ParseKeywords(keywordsRaw)
// 更新访问计数
go s.incrementAccess(context.Background(), id)
@@ -240,8 +267,8 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
q.Limit = 10
}
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
access_count, last_access, created_at, expires_at
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at
FROM memories WHERE user_id = $1`
args := []interface{}{q.UserID}
argIdx := 2
@@ -258,7 +285,13 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
argIdx++
}
query += fmt.Sprintf(" ORDER BY priority DESC, created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
if q.MinImportance > 0 {
query += fmt.Sprintf(" AND importance >= $%d", argIdx)
args = append(args, q.MinImportance)
argIdx++
}
query += fmt.Sprintf(" ORDER BY priority DESC, importance DESC, created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, q.Limit, q.Offset)
rows, err := db.QueryContext(ctx, query, args...)
@@ -267,22 +300,7 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
}
defer rows.Close()
var entries []model.MemoryEntry
for rows.Next() {
var entry model.MemoryEntry
var category string
if err := rows.Scan(
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
&category, &entry.Priority, &entry.SessionID, &entry.Source,
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("扫描记忆行失败: %w", err)
}
entry.Category = model.MemoryCategory(category)
entries = append(entries, entry)
}
return entries, rows.Err()
return scanMemoryRows(rows)
}
// Delete 删除记忆
@@ -321,8 +339,8 @@ func (s *Store) SearchByVector(ctx context.Context, userID string, embedding []f
}
vecStr := fmt.Sprintf("[%s]", joinFloats(embedding))
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
access_count, last_access, created_at, expires_at,
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at,
1 - (embedding <=> $1) AS similarity
FROM memories
WHERE user_id = $2 AND embedding IS NOT NULL
@@ -338,23 +356,177 @@ func (s *Store) SearchByVector(ctx context.Context, userID string, embedding []f
var entries []model.MemoryEntry
for rows.Next() {
var entry model.MemoryEntry
var category string
var category, keywordsRaw string
var similarity float64
if err := rows.Scan(
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
&category, &entry.Priority, &entry.SessionID, &entry.Source,
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
&similarity,
); err != nil {
return nil, fmt.Errorf("扫描向量搜索结果失败: %w", err)
}
entry.Category = model.MemoryCategory(category)
entry.Keywords = model.ParseKeywords(keywordsRaw)
entries = append(entries, entry)
}
return entries, rows.Err()
}
// Update 更新记忆
func (s *Store) Update(ctx context.Context, entry *model.MemoryEntry) error {
db := s.getDB()
if db == nil {
return errDBNotReady
}
query := `UPDATE memories SET content = $1, summary = $2, category = $3, priority = $4,
importance = $5, keywords = $6, source = $7, updated_at = NOW()
WHERE id = $8`
_, err := db.ExecContext(ctx, query,
entry.Content, entry.Summary, string(entry.Category), int(entry.Priority),
entry.Importance, entry.KeywordsJSON(), entry.Source, entry.ID,
)
return err
}
// GetMemoriesByCategory 按分类获取记忆
func (s *Store) GetMemoriesByCategory(ctx context.Context, userID string, category model.MemoryCategory) ([]model.MemoryEntry, error) {
if !s.IsReady() {
return nil, errDBNotReady
}
return s.Query(ctx, model.MemoryQuery{
UserID: userID,
Category: category,
Limit: 50,
})
}
// ConsolidateMemories 记忆整理:合并相似记忆
func (s *Store) ConsolidateMemories(ctx context.Context, userID string) error {
db := s.getDB()
if db == nil {
return errDBNotReady
}
// 获取用户所有记忆
allMems, err := s.Query(ctx, model.MemoryQuery{
UserID: userID,
Limit: 500,
})
if err != nil {
return fmt.Errorf("查询记忆失败: %w", err)
}
if len(allMems) < 2 {
return nil
}
merged := 0
for i := 0; i < len(allMems); i++ {
if allMems[i].ID == "" {
continue
}
for j := i + 1; j < len(allMems); j++ {
if allMems[j].ID == "" {
continue
}
score := allMems[i].SimilarityScore(&allMems[j])
if score >= deDupThreshold {
keep, discard := &allMems[i], &allMems[j]
if discard.Importance > keep.Importance || discard.Priority > keep.Priority {
keep, discard = discard, keep
}
// 合并关键词
keywordSet := make(map[string]bool)
for _, k := range keep.Keywords {
keywordSet[k] = true
}
for _, k := range discard.Keywords {
keywordSet[k] = true
}
mergedKeywords := make([]string, 0, len(keywordSet))
for k := range keywordSet {
mergedKeywords = append(mergedKeywords, k)
}
keep.Keywords = mergedKeywords
if keep.Importance < 10 {
keep.Importance++
}
keep.Source = "consolidated"
if err := s.Update(ctx, keep); err != nil {
log.Printf("[memory] 合并更新记忆 %s 失败: %v", keep.ID, err)
continue
}
if err := s.Delete(ctx, discard.ID); err != nil {
log.Printf("[memory] 合并删除记忆 %s 失败: %v", discard.ID, err)
continue
}
discard.ID = ""
merged++
log.Printf("[memory] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
keep.ID[:min(8, len(keep.ID))], discard.ID[:min(8, len(discard.ID))], score*100)
}
}
}
if merged > 0 {
log.Printf("[memory] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
}
return nil
}
// DecayMemories 记忆衰减:降低长期未访问的低重要性记忆
func (s *Store) DecayMemories(ctx context.Context, userID string) error {
db := s.getDB()
if db == nil {
return errDBNotReady
}
result1, err := db.ExecContext(ctx, `
UPDATE memories SET priority = GREATEST(priority - 1, 0), updated_at = NOW()
WHERE user_id = $1
AND access_count < 3
AND last_access < NOW() - INTERVAL '30 days'
AND importance < 3
AND priority > 0
AND category NOT IN ('personal_info', 'user_preference')
`, userID)
if err != nil {
return fmt.Errorf("衰减低活跃记忆失败: %w", err)
}
decayed1, _ := result1.RowsAffected()
result2, err := db.ExecContext(ctx, `
DELETE FROM memories
WHERE user_id = $1
AND priority = 0
AND access_count = 0
AND last_access < NOW() - INTERVAL '14 days'
`, userID)
if err != nil {
return fmt.Errorf("清理临时记忆失败: %w", err)
}
deleted2, _ := result2.RowsAffected()
total := decayed1 + deleted2
if total > 0 {
log.Printf("[memory] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
userID, decayed1, deleted2)
}
return nil
}
func (s *Store) incrementAccess(ctx context.Context, id string) {
db := s.getDB()
if db == nil {
@@ -375,6 +547,27 @@ func (s *Store) Close() error {
return nil
}
// scanMemoryRows 扫描记忆行(通用方法)
func scanMemoryRows(rows *sql.Rows) ([]model.MemoryEntry, error) {
var entries []model.MemoryEntry
for rows.Next() {
var entry model.MemoryEntry
var category, keywordsRaw string
if err := rows.Scan(
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("扫描记忆行失败: %w", err)
}
entry.Category = model.MemoryCategory(category)
entry.Keywords = model.ParseKeywords(keywordsRaw)
entries = append(entries, entry)
}
return entries, rows.Err()
}
// joinFloats 将 float64 切片转为逗号分隔字符串
func joinFloats(vec []float64) string {
if len(vec) == 0 {