feat(memory): P2 读路径打分 + 桌面端记忆面板(看/改/删)
读路径(Generative Agents 公式的 Recency+Importance 两项,Relevance 待 P3): - memory.Get 改为 rankProfiles:Score=0.4·Recency+0.6·Importance,按分降序、截断 top-30。 Recency=0.98^天 指数衰减;未评分行用兜底 importance=5(不被不公平遗忘)。纯函数 + 单测。 - 新增 Store.List(结构化、不截断)+ memory_list 工具。 桌面端记忆面板: - gateway GET /memory(列表) + DELETE /memory?key=(软删),受保护组。 - api listMemory/deleteMemory;MemoryView 右侧从占位 → 真列表:按分排序展示 key/value/重要度/最近时间,可内联编辑(PUT)与删除(软删);左侧登记后自动刷新。 实测:PUT 两条 → GET 返回带 importance/last_seen 的有序列表(较新者靠前)→ DELETE 一条 (软删)→ 再 GET 已消失。rank/recency/兜底 importance 单测过;前端 tsc+构建通过。 至此记忆:召回(打分) + 历史 + 自动对账(P1) + 用户可管控(面板) 闭环。Relevance(Milvus) 留 P3。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRankProfiles(t *testing.T) {
|
||||
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
|
||||
rows := []Profile{
|
||||
{Key: "高重要近期", Value: "a", Importance: 9, LastSeenAt: now.AddDate(0, 0, -1)},
|
||||
{Key: "低重要久远", Value: "b", Importance: 2, LastSeenAt: now.AddDate(0, 0, -60)},
|
||||
{Key: "中等", Value: "c", Importance: 5, LastSeenAt: now.AddDate(0, 0, -10)},
|
||||
}
|
||||
ranked := rankProfiles(rows, now, 0)
|
||||
if ranked[0].Key != "高重要近期" || ranked[2].Key != "低重要久远" {
|
||||
t.Errorf("应按 Score 降序:高重要近期 > 中等 > 低重要久远,得 %s/%s/%s", ranked[0].Key, ranked[1].Key, ranked[2].Key)
|
||||
}
|
||||
// 截断 top-N
|
||||
if got := rankProfiles(rows, now, 2); len(got) != 2 || got[0].Key != "高重要近期" {
|
||||
t.Errorf("top-2 截断错: %d 条 首=%s", len(got), got[0].Key)
|
||||
}
|
||||
// 原切片不被改动(rankProfiles 应 copy)
|
||||
if rows[0].Key != "高重要近期" {
|
||||
t.Error("rankProfiles 不应修改入参顺序")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecencyDecay(t *testing.T) {
|
||||
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
|
||||
if recencyScore(now, time.Time{}) != 1.0 {
|
||||
t.Error("无 last_seen 应视为新鲜=1")
|
||||
}
|
||||
fresh := recencyScore(now, now.AddDate(0, 0, -1))
|
||||
old := recencyScore(now, now.AddDate(0, 0, -30))
|
||||
if !(fresh > old && old > 0) {
|
||||
t.Errorf("越久越低且 >0: fresh=%v old=%v", fresh, old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileScore_DefaultImportance(t *testing.T) {
|
||||
now := time.Now()
|
||||
// importance=0(旧/未评分)应按兜底 5 计,而不是 0(否则被不公平遗忘)。
|
||||
p := Profile{Importance: 0, LastSeenAt: now}
|
||||
got := profileScore(p, now)
|
||||
want := wRecency*1.0 + wImportance*(defaultImportance/10)
|
||||
if got != want {
|
||||
t.Errorf("未评分行应用兜底 importance: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -108,7 +109,17 @@ func migrateLegacyProfile(db *gorm.DB) {
|
||||
log.Printf("[memory] 已回灌 %d 条偏好(新雪花 id)", len(saved))
|
||||
}
|
||||
|
||||
// Get 返回某用户的画像,渲染为可直接注入 prompt 的多行文本(按 key 排序,稳定输出)。
|
||||
// 读路径打分参数(Generative Agents 公式的 Recency + Importance 两项;Relevance 待接 Milvus)。
|
||||
const (
|
||||
memTopN = 30 // 注入上限(截断,控 context)
|
||||
wRecency = 0.4 // 最近性权重
|
||||
wImportance = 0.6 // 重要度权重
|
||||
recencyDecayPerDay = 0.98 // 每天衰减因子(指数)
|
||||
defaultImportance = 5.0 // 旧/未评分行的兜底重要度(避免被不公平遗忘)
|
||||
)
|
||||
|
||||
// Get 返回某用户画像,渲染为可注入 prompt 的多行文本。
|
||||
// 按 Score = wRecency·Recency + wImportance·Importance 降序,截断 top-N(控 context + 自然遗忘)。
|
||||
func (s *Store) Get(ctx context.Context, userID string) (string, error) {
|
||||
if s.db == nil || userID == "" {
|
||||
return "", nil
|
||||
@@ -117,14 +128,64 @@ func (s *Store) Get(ctx context.Context, userID string) (string, error) {
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key })
|
||||
ranked := rankProfiles(rows, time.Now(), memTopN)
|
||||
var b strings.Builder
|
||||
for _, r := range rows {
|
||||
for _, r := range ranked {
|
||||
fmt.Fprintf(&b, "- %s:%s\n", r.Key, r.Value)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// List 返回某用户全部 active 偏好(结构化,供管理面板查看/编辑),按 Score 降序、不截断。
|
||||
func (s *Store) List(ctx context.Context, userID string) ([]Profile, error) {
|
||||
if s.db == nil || userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []Profile
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rankProfiles(rows, time.Now(), 0), nil
|
||||
}
|
||||
|
||||
// rankProfiles 纯函数:按 Score 降序排序(同分 key 升序稳定),截断 top-N(topN<=0 不截断)。
|
||||
func rankProfiles(rows []Profile, now time.Time, topN int) []Profile {
|
||||
out := make([]Profile, len(rows))
|
||||
copy(out, rows)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
si, sj := profileScore(out[i], now), profileScore(out[j], now)
|
||||
if si != sj {
|
||||
return si > sj
|
||||
}
|
||||
return out[i].Key < out[j].Key
|
||||
})
|
||||
if topN > 0 && len(out) > topN {
|
||||
out = out[:topN]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// profileScore 计算一条偏好的 Recency+Importance 综合分(各归一到 [0,1])。
|
||||
func profileScore(p Profile, now time.Time) float64 {
|
||||
imp := p.Importance
|
||||
if imp <= 0 {
|
||||
imp = defaultImportance
|
||||
}
|
||||
return wRecency*recencyScore(now, p.LastSeenAt) + wImportance*(imp/10)
|
||||
}
|
||||
|
||||
// recencyScore 指数衰减的最近性分:last_seen 越久越低;未记时间视为新鲜(1)。
|
||||
func recencyScore(now, last time.Time) float64 {
|
||||
if last.IsZero() {
|
||||
return 1.0
|
||||
}
|
||||
days := now.Sub(last).Hours() / 24
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return math.Pow(recencyDecayPerDay, days)
|
||||
}
|
||||
|
||||
// Upsert 写入/更新一条画像偏好((user_id,key) 冲突即覆盖 value/importance,保留原 id;
|
||||
// 置 last_seen=now 作"印证")。importance<=0 时不覆盖旧值(NOOP 印证场景只 bump 时间)。
|
||||
func (s *Store) Upsert(ctx context.Context, userID, key, value string, importance float64) error {
|
||||
|
||||
Reference in New Issue
Block a user