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:
Blizzard
2026-06-22 14:48:11 +08:00
parent 1674252d81
commit 0edfc948ba
9 changed files with 316 additions and 14 deletions
+24
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
sharedbus "github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract"
@@ -75,6 +76,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
return g.memoryUpsert(ctx, call)
case "memory_delete":
return g.memoryDelete(ctx, call)
case "memory_list":
return g.memoryList(ctx, call)
case "history_get":
return g.historyGet(ctx, call)
case "history_append":
@@ -136,6 +139,27 @@ func (g *Gateway) memoryUpsert(ctx context.Context, call *contract.ToolCall) *co
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已记住 %s 的「%s」", uid, key)}
}
// memoryList 返回某用户全部 active 偏好(结构化 JSON,供管理面板查看/编辑)。
func (g *Gateway) memoryList(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
rows, err := g.memory.List(ctx, uid)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_list: " + err.Error()}
}
type item struct {
Key string `json:"key"`
Value string `json:"value"`
Importance float64 `json:"importance"`
LastSeen string `json:"last_seen"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{Key: r.Key, Value: r.Value, Importance: r.Importance, LastSeen: r.LastSeenAt.Format(time.RFC3339)})
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// memoryDelete 软删一条画像偏好(user_id + key)—— consolidate 判定过时/矛盾时调用。
func (g *Gateway) memoryDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
@@ -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)
}
}
+64 -3
View File
@@ -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-NtopN<=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 {