Files
Blizzard 9c19bb44f1 feat(dispatcher): 长期偏好记忆抽取(补全记忆闭环)
memorize 的 TODO 落地:写回阶段(异步、离热路径)从本轮对话用 LLM 抽取用户
长期稳定偏好 → 与已有画像去重 → memory_upsert 登记。

- extractMemory:模型/工具不可用或输入过短则跳过;复用 llmCtx 超时;
  抽取 prompt 只取长期偏好、忽略一次性信息。
- 纯逻辑(可单测):parsePrefs(容忍 json 代码围栏)、parseProfile(把 memory_get
  渲染的"- 维度:值"解析回 map,兼容全/半角冒号)、filterNewPrefs(新增/变更才留,
  同批同 key 去重)。
- 单测覆盖三者;LLM 抽取调用沿用已验证的 pool.Chat 模式。

至此记忆闭环:召回(memory_get) + 历史写回 + 偏好自动抽取 全通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:47:49 +08:00

114 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// Pref 是从对话抽取出的一条长期偏好(key/value)。
type Pref struct {
Key string `json:"key"`
Value string `json:"value"`
}
// extractMemory 写回阶段(异步、离开热路径):从本轮对话用 LLM 抽取用户长期偏好,
// 与已有画像去重后经 memory_upsert 登记。模型/工具不可用或输入过短则跳过。
func (o *Orchestrator) extractMemory(ctx context.Context, uid, input, answer string) {
if uid == "" || o.tools == nil || o.pool == nil || !o.pool.Ready() {
return
}
if len([]rune(strings.TrimSpace(input))) < 2 || len([]rune(strings.TrimSpace(answer))) < 20 {
return // 太短,不值得抽取
}
existing := parseProfile(o.fetchMemory(ctx, uid, ""))
cctx, cancel := llmCtx(ctx)
defer cancel()
sys := "你从对话中提取【用户的长期稳定偏好或事实】(如称呼、语言、职业、专业领域、口味、常用工具、固定要求等)," +
"忽略一次性的临时信息与你自己的话。"
user := fmt.Sprintf("用户输入:%s\n助手回答:%s\n请抽取。只输出 JSON 数组 [{\"key\":\"偏好维度\",\"value\":\"值\"}]"+
"没有可抽取的就输出 [],不要任何多余文字。", truncate(input, 800), truncate(answer, 1200))
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
if err != nil {
log.Printf("[eino] (writeback) 偏好抽取失败 user=%s: %v", uid, err)
return
}
fresh := filterNewPrefs(parsePrefs(txt), existing)
for _, p := range fresh {
o.upsertMemory(ctx, uid, p.Key, p.Value)
}
if len(fresh) > 0 {
log.Printf("[eino] (writeback) 已登记 %d 条新偏好 user=%s", len(fresh), uid)
}
}
// upsertMemory 经 mcp-go memory_upsert 工具登记一条偏好。
func (o *Orchestrator) upsertMemory(ctx context.Context, uid, key, value string) {
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_upsert"),
&contract.ToolCall{Tool: "memory_upsert", Args: map[string]any{"user_id": uid, "key": key, "value": value}}); err != nil {
log.Printf("[eino] memory_upsert 失败 %s=%s: %v", key, value, err)
}
}
// parsePrefs 解析 LLM 抽取结果(容忍 ```json 围栏)为 []Pref,过滤空项。
func parsePrefs(txt string) []Pref {
var ps []Pref
if json.Unmarshal([]byte(stripFence(txt)), &ps) != nil {
return nil
}
out := make([]Pref, 0, len(ps))
for _, p := range ps {
p.Key, p.Value = strings.TrimSpace(p.Key), strings.TrimSpace(p.Value)
if p.Key != "" && p.Value != "" {
out = append(out, p)
}
}
return out
}
// parseProfile 把 memory_get 渲染的画像("- 维度:值" 多行)解析回 map,供去重。
func parseProfile(s string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))
if line == "" {
continue
}
for _, sep := range []string{"", ":"} { // 兼容全角/半角冒号
if i := strings.Index(line, sep); i > 0 {
k := strings.TrimSpace(line[:i])
if k != "" {
m[k] = strings.TrimSpace(line[i+len(sep):])
}
break
}
}
}
return m
}
// filterNewPrefs 保留新增或值有变化的偏好(同批同 key 去重;已有且相同则跳过)。
func filterNewPrefs(extracted []Pref, existing map[string]string) []Pref {
out := make([]Pref, 0, len(extracted))
seen := map[string]bool{}
for _, p := range extracted {
if seen[p.Key] {
continue
}
seen[p.Key] = true
if cur, ok := existing[p.Key]; ok && cur == p.Value {
continue
}
out = append(out, p)
}
return out
}