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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user