|
1 | 1 | package memory |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "encoding/json" |
5 | 6 | "fmt" |
6 | 7 | "os" |
7 | 8 | "path/filepath" |
8 | 9 | "sort" |
| 10 | + "strings" |
9 | 11 | "time" |
10 | 12 | ) |
11 | 13 |
|
@@ -178,10 +180,73 @@ func truncateForIndex(summary string) string { |
178 | 180 | // ── Default ranker ──────────────────────────────────────────────────── |
179 | 181 |
|
180 | 182 | // defaultRanker returns all episodes sorted by recency (no LLM). |
181 | | -// The MemoryManager wraps this with a real SimpleCall when available. |
182 | 183 | func defaultRanker(query string, episodes []EpisodeMeta) ([]EpisodeMeta, error) { |
183 | | - // Already sorted newest-first by ReadIndex |
184 | 184 | out := make([]EpisodeMeta, len(episodes)) |
185 | 185 | copy(out, episodes) |
186 | 186 | return out, nil |
187 | 187 | } |
| 188 | + |
| 189 | +// NewLLMRanker creates a RankStrategy that uses an LLM client to rank |
| 190 | +// episodes by semantic relevance to the query. Falls back to recency |
| 191 | +// ordering if the LLM call fails or returns unparseable output. |
| 192 | +func NewLLMRanker(llm LLMClient) RankStrategy { |
| 193 | + return func(query string, episodes []EpisodeMeta) ([]EpisodeMeta, error) { |
| 194 | + if len(episodes) == 0 { |
| 195 | + return episodes, nil |
| 196 | + } |
| 197 | + |
| 198 | + // Build a compact prompt listing episodes |
| 199 | + var b strings.Builder |
| 200 | + fmt.Fprintf(&b, "Rank these memory summaries by relevance to: %s\n\n", query) |
| 201 | + for i, ep := range episodes { |
| 202 | + // Truncate summary for the prompt (already truncated in index, but be safe) |
| 203 | + summary := ep.Summary |
| 204 | + if len(summary) > 200 { |
| 205 | + summary = summary[:197] + "..." |
| 206 | + } |
| 207 | + fmt.Fprintf(&b, "[%d] %s\n", i, summary) |
| 208 | + } |
| 209 | + b.WriteString("\nReturn only the indices of the most relevant entries, ordered by relevance (most relevant first).\n") |
| 210 | + b.WriteString("Format: a single line of comma-separated numbers, e.g. \"3,0,1\"\n") |
| 211 | + b.WriteString("If none are relevant, return \"none\".") |
| 212 | + |
| 213 | + resp, err := llm.SimpleCall(context.Background(), |
| 214 | + "You are a relevance ranking system. Given a query and a list of items, return the indices of the most relevant items ordered by relevance. Return only a comma-separated list of numbers or the word 'none'.", |
| 215 | + b.String(), |
| 216 | + ) |
| 217 | + if err != nil { |
| 218 | + return defaultRanker(query, episodes) |
| 219 | + } |
| 220 | + |
| 221 | + resp = strings.TrimSpace(resp) |
| 222 | + if resp == "" || resp == "none" { |
| 223 | + return nil, nil |
| 224 | + } |
| 225 | + |
| 226 | + // Parse "3,0,1" or "3, 0, 1" into indices |
| 227 | + parts := strings.Split(resp, ",") |
| 228 | + seen := make(map[int]bool) |
| 229 | + var ranked []EpisodeMeta |
| 230 | + for _, p := range parts { |
| 231 | + p = strings.TrimSpace(p) |
| 232 | + if p == "" { |
| 233 | + continue |
| 234 | + } |
| 235 | + idx := 0 |
| 236 | + for _, c := range p { |
| 237 | + if c >= '0' && c <= '9' { |
| 238 | + idx = idx*10 + int(c-'0') |
| 239 | + } |
| 240 | + } |
| 241 | + if idx >= 0 && idx < len(episodes) && !seen[idx] { |
| 242 | + ranked = append(ranked, episodes[idx]) |
| 243 | + seen[idx] = true |
| 244 | + } |
| 245 | + } |
| 246 | + |
| 247 | + if len(ranked) == 0 { |
| 248 | + return defaultRanker(query, episodes) |
| 249 | + } |
| 250 | + return ranked, nil |
| 251 | + } |
| 252 | +} |
0 commit comments