// Package memory 的 AI 记忆选择器. // // 对应早期实现的 AI 选择逻辑: // 用模型从记忆列表中选出与当前查询最相关的 ≤5 条记忆. // // 升华改进(ELEVATED): 与 RelevanceScorer 的区别-- // - RelevanceScorer: 单条打分(0.0~1.0),适合排序 // - MemorySelector: 批量选择,一次 AI 调用完成,支持额外过滤参数 // // 替代方案:<复用 RelevanceScorer 逐条打分再排序> - 否决: // AI 打分成本高,逐条打分 token 消耗是批量选择的数倍. package memory import ( "context" "encoding/json" "fmt" "path/filepath" "sort" "strings" "time" ) // ModelQueryFunc 是模型查询函数类型. // // 打破 memory→engine 的循环依赖:memory 包只依赖此函数签名, // engine 层传入闭包实现,不需要 memory import flyto 包. // // systemPrompt: 系统提示词 // userPrompt: 用户消息 // 返回: 模型的纯文本回复,error type ModelQueryFunc func(ctx context.Context, systemPrompt, userPrompt string) (string, error) // SelectOpts 是记忆选择器的选项. type SelectOpts struct { Limit int // 最多返回几条,<=0 则用 defaultRelevanceLimit(5) RecentTools []string // 最近用到的工具名(避免重推这些工具的 ref docs) AlreadySurfaced map[string]bool // 本 session 已展示过的文件路径(去重) } // MemorySelector 是批量记忆选择器接口. // // 升华改进(ELEVATED): 与 RelevanceScorer 的区别-- // // - RelevanceScorer: 单条打分(0.0~1.0),适合排序 // MemorySelector: 批量选择,一次 AI 调用完成,支持额外过滤参数 // // 替代方案:<复用 RelevanceScorer 逐条打分再排序> - 否决: // AI 打分成本高,逐条打分 token 消耗是批量选择的数倍. // // 错误契约 (2026-04-14 反转): // - ctx 取消: 返回 (nil, nil), 正常中止不算错误. // - 其他错误 (模型请求失败 / JSON 解析失败 / ...): 返回 (nil, err), // 由调用方 (Store) 决定 fallback 策略. // // 不在 selector 内部 fallback 的原因: selector 无 back-reference 到 // 调用方 Store 的配置 (scorer / typeRegistry / ...), 内部 fallback 只能用 // nil 参数, 会静默忽略用户的 WithScorer 等配置. fallback 必须由持有配置 // 的层 (Store.FindRelevant) 做, 才能把 s.scorer 正确传给 SelectRelevant. // // Shape: synchronous callback. Store.FindRelevant calls Select // synchronously during prompt assembly; the selector implementation (AI // or heuristic) ranks MemoryHeader candidates for the query. // // 形态: 同步回调. Store.FindRelevant 在拼 prompt 时同步调 Select; selector // 实现 (AI 或启发式) 对 MemoryHeader 候选按 query 排序. type MemorySelector interface { Select(ctx context.Context, query string, headers []MemoryHeader, opts SelectOpts) ([]MemoryHeader, error) } // SELECT_MEMORIES_SYSTEM_PROMPT 是记忆选择的系统提示词. // // 不翻译为中文的原因: // 模型对英文指令的理解更精准(大多数模型的预训练语料以英文为主), // 且 Flyto Agent 本身是多语言工具,保留英文可以服务于多语言用户场景. const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Flyto as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. Return a list of filenames for the memories that will clearly be useful to Flyto as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description. - If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning. - If there are no memories in the list that would clearly be useful, feel free to return an empty list. - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (Flyto is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.` // AIMemorySelector 是基于模型调用的记忆选择器. type AIMemorySelector struct { queryFn ModelQueryFunc } // NewAIMemorySelector 创建 AI 记忆选择器. func NewAIMemorySelector(fn ModelQueryFunc) *AIMemorySelector { return &AIMemorySelector{queryFn: fn} } // formatMemoryManifest 将记忆头信息格式化为清单字符串. // // 精妙之处(CLEVER): 格式与早期实现 完全一致("- [type] filename (ISO时间): description"), // 确保模型能够准确理解记忆条目的含义和格式,便于做出正确的选择决策. // 无 description 时省略 ": description" 部分,保持格式紧凑. func formatMemoryManifest(headers []MemoryHeader) string { if len(headers) == 0 { return "" } var sb strings.Builder for _, h := range headers { name := filepath.Base(h.Path) timeStr := h.ModTime.UTC().Format(time.RFC3339) // 精妙之处(CLEVER): 与早期实现 `type ? '[type] ' : ''` 保持一致-- // 空 type 省略括号,避免输出 "- [] filename" 这种格式噪音. tag := "" if h.Frontmatter.Type != "" { tag = fmt.Sprintf("[%s] ", h.Frontmatter.Type) } if h.Frontmatter.Description != "" { fmt.Fprintf(&sb, "- %s%s (%s): %s\n", tag, name, timeStr, h.Frontmatter.Description) } else { fmt.Fprintf(&sb, "- %s%s (%s)\n", tag, name, timeStr) } } return sb.String() } // selectedMemoriesResponse 是模型返回的 JSON 解析结构. type selectedMemoriesResponse struct { SelectedMemories []string `json:"selected_memories"` } // Select 实现 MemorySelector 接口. // // 流程: // 1. 过滤 opts.AlreadySurfaced(先于 AI 调用,节省 token) // 2. 过滤后若 headers 为空,直接返回 nil, nil // 3. 构建 validFilenames set(只接受这些文件名作为 AI 回复) // 4. 调用 formatMemoryManifest 构建清单 // 5. 若 opts.RecentTools 非空,追加 "\n\nRecently used tools: tool1, tool2" // 6. 构建 user prompt: "Query: {query}\n\nAvailable memories:\n{manifest}{toolsSection}" // 7. 调用 queryFn(ctx, SELECT_MEMORIES_SYSTEM_PROMPT, userPrompt) // 8. 解析 JSON 回复 {"selected_memories": ["file1.md", "file2.md"]} // 9. 过滤不在 validFilenames 中的条目 // // 10. 按原 headers 顺序重建结果(保持 mtime 倒序) // 11. 截断到 opts.Limit // // AI 调用失败时 (契约于 2026-04-14 反转): // - ctx 已取消:返回 (nil, nil), 正常中止不算错误. // - 其他错误 (queryFn 返回 error / JSON 解析失败): 返回 (nil, err). // 调用方 (Store.FindRelevant) 负责 fallback 到 SelectRelevant 并传 s.scorer. // 之前版本在此内部调 SelectRelevant(..., nil), 静默忽略 Store 的 WithScorer // 配置, 形成潜伏 bug - 已修复. func (s *AIMemorySelector) Select(ctx context.Context, query string, headers []MemoryHeader, opts SelectOpts) ([]MemoryHeader, error) { // 第一步:过滤已展示的记忆(节省 token) if opts.AlreadySurfaced != nil && len(opts.AlreadySurfaced) > 0 { filtered := make([]MemoryHeader, 0, len(headers)) for _, h := range headers { if !opts.AlreadySurfaced[h.Path] { filtered = append(filtered, h) } } headers = filtered } // 第二步:空检查 if len(headers) == 0 { return nil, nil } // 第三步:构建 validFilenames set validFiles := make(map[string]struct{}, len(headers)) for _, h := range headers { validFiles[filepath.Base(h.Path)] = struct{}{} } // 第四步:构建 manifest manifest := formatMemoryManifest(headers) // 第五步:RecentTools section toolsSection := "" if len(opts.RecentTools) > 0 { toolsSection = "\n\nRecently used tools: " + strings.Join(opts.RecentTools, ", ") } // 第六步:构建 user prompt userPrompt := fmt.Sprintf("Query: %s\n\nAvailable memories:\n%s%s\n\nRespond ONLY with valid JSON in this exact format: {\"selected_memories\": [\"filename1.md\", \"filename2.md\"]}", query, manifest, toolsSection) // 第七步:调用模型(先检查 ctx 是否已取消,避免无效调用) if ctx.Err() != nil { return nil, nil } respText, err := s.queryFn(ctx, SELECT_MEMORIES_SYSTEM_PROMPT, userPrompt) if err != nil { // ctx 取消不是错误--正常中止流程 if ctx.Err() != nil { return nil, nil } // 非 ctx 错误: 上抛, 由 Store 层决定 fallback (需要 s.scorer). return nil, fmt.Errorf("ai selector: query model: %w", err) } // 第八步:解析 JSON var resp selectedMemoriesResponse if err := json.Unmarshal([]byte(respText), &resp); err != nil { // JSON 解析失败: 上抛, 由 Store 层决定 fallback (需要 s.scorer). return nil, fmt.Errorf("ai selector: parse json: %w", err) } // 第九步:过滤非法文件名 selected := make([]MemoryHeader, 0, len(resp.SelectedMemories)) for _, fname := range resp.SelectedMemories { if _, ok := validFiles[fname]; ok { // 找到对应 header for _, h := range headers { if filepath.Base(h.Path) == fname { selected = append(selected, h) break } } } // 不在 headers 中就忽略(第 9 步的过滤) } // 第十步:按原 headers 顺序重建(保持 mtime 倒序) sort.Slice(selected, func(i, j int) bool { return selected[i].ModTime.After(selected[j].ModTime) }) // 第十一步:截断到 limit limit := opts.Limit if limit <= 0 { limit = defaultRelevanceLimit } if len(selected) > limit { selected = selected[:limit] } return selected, nil }