| 1 | package llm |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // EditDistanceMatcher finds similar past failures and adapts their insults |
| 12 | // Uses Levenshtein distance for command similarity matching |
| 13 | type EditDistanceMatcher struct { |
| 14 | mu sync.RWMutex |
| 15 | commandHistory []CommandRecord |
| 16 | maxHistory int |
| 17 | similarityThresh float64 |
| 18 | persistencePath string |
| 19 | } |
| 20 | |
| 21 | // CommandRecord stores a past command failure with its successful insult |
| 22 | type CommandRecord struct { |
| 23 | Command string |
| 24 | CommandType string |
| 25 | ErrorPattern string |
| 26 | ProjectType string |
| 27 | Timestamp time.Time |
| 28 | Insult string |
| 29 | Effectiveness float64 // How well this insult worked (from RL) |
| 30 | } |
| 31 | |
| 32 | // SimilarCommand represents a similar past command |
| 33 | type SimilarCommand struct { |
| 34 | Record CommandRecord |
| 35 | Similarity float64 |
| 36 | Distance int |
| 37 | } |
| 38 | |
| 39 | // NewEditDistanceMatcher creates a new matcher |
| 40 | func NewEditDistanceMatcher() *EditDistanceMatcher { |
| 41 | homeDir, _ := os.UserHomeDir() |
| 42 | persistPath := filepath.Join(homeDir, ".parrot", "command_history.json") |
| 43 | |
| 44 | matcher := &EditDistanceMatcher{ |
| 45 | commandHistory: make([]CommandRecord, 0), |
| 46 | maxHistory: 1000, // Keep last 1000 commands |
| 47 | similarityThresh: 0.7, // 70% similarity required |
| 48 | persistencePath: persistPath, |
| 49 | } |
| 50 | |
| 51 | // Try to load existing history |
| 52 | matcher.Load() |
| 53 | |
| 54 | return matcher |
| 55 | } |
| 56 | |
| 57 | // RecordCommand stores a command failure with its insult |
| 58 | func (edm *EditDistanceMatcher) RecordCommand( |
| 59 | ctx *SmartFallbackContext, |
| 60 | insult string, |
| 61 | effectiveness float64, |
| 62 | ) { |
| 63 | edm.mu.Lock() |
| 64 | defer edm.mu.Unlock() |
| 65 | |
| 66 | record := CommandRecord{ |
| 67 | Command: ctx.FullCommand, |
| 68 | CommandType: ctx.CommandType, |
| 69 | ErrorPattern: ctx.ErrorPattern, |
| 70 | ProjectType: ctx.ProjectType, |
| 71 | Timestamp: time.Now(), |
| 72 | Insult: insult, |
| 73 | Effectiveness: effectiveness, |
| 74 | } |
| 75 | |
| 76 | edm.commandHistory = append(edm.commandHistory, record) |
| 77 | |
| 78 | // Prune if too large |
| 79 | if len(edm.commandHistory) > edm.maxHistory { |
| 80 | // Remove oldest entries |
| 81 | edm.commandHistory = edm.commandHistory[len(edm.commandHistory)-edm.maxHistory:] |
| 82 | } |
| 83 | |
| 84 | // Periodically persist |
| 85 | if len(edm.commandHistory)%50 == 0 { |
| 86 | go edm.Save() |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // FindSimilarCommands finds commands similar to the current one |
| 91 | func (edm *EditDistanceMatcher) FindSimilarCommands( |
| 92 | command string, |
| 93 | topK int, |
| 94 | ) []SimilarCommand { |
| 95 | edm.mu.RLock() |
| 96 | defer edm.mu.RUnlock() |
| 97 | |
| 98 | if len(edm.commandHistory) == 0 { |
| 99 | return nil |
| 100 | } |
| 101 | |
| 102 | similarities := make([]SimilarCommand, 0) |
| 103 | |
| 104 | // Calculate similarity to each historical command |
| 105 | for _, record := range edm.commandHistory { |
| 106 | distance := levenshteinDistanceTier6(command, record.Command) |
| 107 | maxLen := maxTier6(len(command), len(record.Command)) |
| 108 | |
| 109 | // Calculate similarity (1.0 = identical, 0.0 = completely different) |
| 110 | similarity := 1.0 - (float64(distance) / float64(maxLen)) |
| 111 | |
| 112 | if similarity >= edm.similarityThresh { |
| 113 | similarities = append(similarities, SimilarCommand{ |
| 114 | Record: record, |
| 115 | Similarity: similarity, |
| 116 | Distance: distance, |
| 117 | }) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // Sort by similarity (descending) |
| 122 | sortSimilarCommands(similarities) |
| 123 | |
| 124 | // Return top K |
| 125 | if len(similarities) > topK { |
| 126 | similarities = similarities[:topK] |
| 127 | } |
| 128 | |
| 129 | return similarities |
| 130 | } |
| 131 | |
| 132 | // GetAdaptedInsult gets an insult adapted from similar command |
| 133 | func (edm *EditDistanceMatcher) GetAdaptedInsult(ctx *SmartFallbackContext) string { |
| 134 | similar := edm.FindSimilarCommands(ctx.FullCommand, 5) |
| 135 | |
| 136 | if len(similar) == 0 { |
| 137 | return "" |
| 138 | } |
| 139 | |
| 140 | // Pick the most effective similar command |
| 141 | bestIdx := 0 |
| 142 | bestScore := similar[0].Similarity * similar[0].Record.Effectiveness |
| 143 | |
| 144 | for i, sim := range similar { |
| 145 | score := sim.Similarity * sim.Record.Effectiveness |
| 146 | if score > bestScore { |
| 147 | bestScore = score |
| 148 | bestIdx = i |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | best := similar[bestIdx] |
| 153 | |
| 154 | // Adapt the insult to current context |
| 155 | adapted := edm.adaptInsult(best.Record.Insult, best.Record.Command, ctx) |
| 156 | |
| 157 | return adapted |
| 158 | } |
| 159 | |
| 160 | // adaptInsult adapts an insult from past command to current context |
| 161 | func (edm *EditDistanceMatcher) adaptInsult( |
| 162 | insult string, |
| 163 | oldCommand string, |
| 164 | ctx *SmartFallbackContext, |
| 165 | ) string { |
| 166 | adapted := insult |
| 167 | |
| 168 | // Extract command differences |
| 169 | oldParts := tokenizeCommand(oldCommand) |
| 170 | newParts := tokenizeCommand(ctx.FullCommand) |
| 171 | |
| 172 | // Find what changed |
| 173 | oldUnique := findUnique(oldParts, newParts) |
| 174 | newUnique := findUnique(newParts, oldParts) |
| 175 | |
| 176 | // Replace old unique parts with new ones |
| 177 | for i := 0; i < minTier6(len(oldUnique), len(newUnique)); i++ { |
| 178 | adapted = replaceWordTier6(adapted, oldUnique[i], newUnique[i]) |
| 179 | } |
| 180 | |
| 181 | // Add context-specific references if missing |
| 182 | if ctx.ProjectType != "" && !containsWordTier6(adapted, ctx.ProjectType) { |
| 183 | // Append project context |
| 184 | adapted = adapted + " In " + ctx.ProjectType + "." |
| 185 | } |
| 186 | |
| 187 | return adapted |
| 188 | } |
| 189 | |
| 190 | |
| 191 | // Save persists command history to disk |
| 192 | func (edm *EditDistanceMatcher) Save() error { |
| 193 | edm.mu.RLock() |
| 194 | defer edm.mu.RUnlock() |
| 195 | |
| 196 | // Ensure directory exists |
| 197 | dir := filepath.Dir(edm.persistencePath) |
| 198 | os.MkdirAll(dir, 0755) |
| 199 | |
| 200 | // Marshal to JSON |
| 201 | data, err := json.MarshalIndent(edm.commandHistory, "", " ") |
| 202 | if err != nil { |
| 203 | return err |
| 204 | } |
| 205 | |
| 206 | // Write atomically |
| 207 | tmpPath := edm.persistencePath + ".tmp" |
| 208 | if err := os.WriteFile(tmpPath, data, 0644); err != nil { |
| 209 | return err |
| 210 | } |
| 211 | |
| 212 | return os.Rename(tmpPath, edm.persistencePath) |
| 213 | } |
| 214 | |
| 215 | // Load restores command history from disk |
| 216 | func (edm *EditDistanceMatcher) Load() error { |
| 217 | edm.mu.Lock() |
| 218 | defer edm.mu.Unlock() |
| 219 | |
| 220 | data, err := os.ReadFile(edm.persistencePath) |
| 221 | if err != nil { |
| 222 | return err // File might not exist yet |
| 223 | } |
| 224 | |
| 225 | history := make([]CommandRecord, 0) |
| 226 | if err := json.Unmarshal(data, &history); err != nil { |
| 227 | return err |
| 228 | } |
| 229 | |
| 230 | edm.commandHistory = history |
| 231 | return nil |
| 232 | } |
| 233 | |
| 234 | // GetStats returns matcher statistics |
| 235 | func (edm *EditDistanceMatcher) GetStats() map[string]interface{} { |
| 236 | edm.mu.RLock() |
| 237 | defer edm.mu.RUnlock() |
| 238 | |
| 239 | avgEffectiveness := 0.0 |
| 240 | if len(edm.commandHistory) > 0 { |
| 241 | for _, record := range edm.commandHistory { |
| 242 | avgEffectiveness += record.Effectiveness |
| 243 | } |
| 244 | avgEffectiveness /= float64(len(edm.commandHistory)) |
| 245 | } |
| 246 | |
| 247 | return map[string]interface{}{ |
| 248 | "total_commands": len(edm.commandHistory), |
| 249 | "avg_effectiveness": avgEffectiveness, |
| 250 | "similarity_threshold": edm.similarityThresh, |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // Helper functions |
| 255 | |
| 256 | func tokenizeCommand(command string) []string { |
| 257 | // Simple tokenization (split on spaces and special chars) |
| 258 | tokens := make([]string, 0) |
| 259 | current := "" |
| 260 | |
| 261 | for _, r := range command { |
| 262 | if r == ' ' || r == '/' || r == '-' || r == '.' { |
| 263 | if len(current) > 0 { |
| 264 | tokens = append(tokens, current) |
| 265 | current = "" |
| 266 | } |
| 267 | } else { |
| 268 | current += string(r) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | if len(current) > 0 { |
| 273 | tokens = append(tokens, current) |
| 274 | } |
| 275 | |
| 276 | return tokens |
| 277 | } |
| 278 | |
| 279 | func findUnique(slice1, slice2 []string) []string { |
| 280 | unique := make([]string, 0) |
| 281 | set2 := make(map[string]bool) |
| 282 | |
| 283 | for _, item := range slice2 { |
| 284 | set2[item] = true |
| 285 | } |
| 286 | |
| 287 | for _, item := range slice1 { |
| 288 | if !set2[item] { |
| 289 | unique = append(unique, item) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | return unique |
| 294 | } |
| 295 | |
| 296 | func sortSimilarCommands(commands []SimilarCommand) { |
| 297 | // Bubble sort by similarity (descending) |
| 298 | n := len(commands) |
| 299 | for i := 0; i < n-1; i++ { |
| 300 | for j := 0; j < n-i-1; j++ { |
| 301 | if commands[j].Similarity < commands[j+1].Similarity { |
| 302 | commands[j], commands[j+1] = commands[j+1], commands[j] |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 |