| 1 | package llm |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // InsultHistory tracks shown insults to avoid repetition |
| 12 | type InsultHistory struct { |
| 13 | RecentInsults []HistoryEntry `json:"recent_insults"` |
| 14 | InsultFrequency map[string]int `json:"insult_frequency"` // How many times each shown |
| 15 | LastCleanup time.Time `json:"last_cleanup"` |
| 16 | mu sync.RWMutex |
| 17 | maxHistory int |
| 18 | persistPath string |
| 19 | } |
| 20 | |
| 21 | // HistoryEntry represents a single shown insult |
| 22 | type HistoryEntry struct { |
| 23 | Text string `json:"text"` |
| 24 | Timestamp time.Time `json:"timestamp"` |
| 25 | Context string `json:"context"` // Command that triggered it |
| 26 | Score float64 `json:"score"` // Relevance score |
| 27 | } |
| 28 | |
| 29 | // NewInsultHistory creates a new history tracker |
| 30 | func NewInsultHistory(maxHistory int) *InsultHistory { |
| 31 | homeDir, err := os.UserHomeDir() |
| 32 | if err != nil { |
| 33 | homeDir = "." |
| 34 | } |
| 35 | |
| 36 | persistPath := filepath.Join(homeDir, ".parrot", "insult_history.json") |
| 37 | |
| 38 | history := &InsultHistory{ |
| 39 | RecentInsults: make([]HistoryEntry, 0, maxHistory), |
| 40 | InsultFrequency: make(map[string]int), |
| 41 | maxHistory: maxHistory, |
| 42 | persistPath: persistPath, |
| 43 | LastCleanup: time.Now(), |
| 44 | } |
| 45 | |
| 46 | // Try to load existing history |
| 47 | history.Load() |
| 48 | |
| 49 | return history |
| 50 | } |
| 51 | |
| 52 | // RecordInsult adds an insult to the history |
| 53 | func (h *InsultHistory) RecordInsult(text string, context string, score float64) { |
| 54 | h.mu.Lock() |
| 55 | defer h.mu.Unlock() |
| 56 | |
| 57 | entry := HistoryEntry{ |
| 58 | Text: text, |
| 59 | Timestamp: time.Now(), |
| 60 | Context: context, |
| 61 | Score: score, |
| 62 | } |
| 63 | |
| 64 | h.RecentInsults = append(h.RecentInsults, entry) |
| 65 | |
| 66 | // Trim if exceeds max |
| 67 | if len(h.RecentInsults) > h.maxHistory { |
| 68 | h.RecentInsults = h.RecentInsults[1:] |
| 69 | } |
| 70 | |
| 71 | // Update frequency |
| 72 | h.InsultFrequency[text]++ |
| 73 | |
| 74 | // Periodic cleanup (once per day) |
| 75 | if time.Since(h.LastCleanup) > 24*time.Hour { |
| 76 | h.cleanup() |
| 77 | } |
| 78 | |
| 79 | // Persist to disk |
| 80 | h.persist() |
| 81 | } |
| 82 | |
| 83 | // WasRecentlyShown checks if an insult was shown recently |
| 84 | func (h *InsultHistory) WasRecentlyShown(text string, withinLast int) bool { |
| 85 | h.mu.RLock() |
| 86 | defer h.mu.RUnlock() |
| 87 | |
| 88 | if withinLast > len(h.RecentInsults) { |
| 89 | withinLast = len(h.RecentInsults) |
| 90 | } |
| 91 | |
| 92 | startIdx := len(h.RecentInsults) - withinLast |
| 93 | if startIdx < 0 { |
| 94 | startIdx = 0 |
| 95 | } |
| 96 | |
| 97 | for i := startIdx; i < len(h.RecentInsults); i++ { |
| 98 | if h.RecentInsults[i].Text == text { |
| 99 | return true |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | return false |
| 104 | } |
| 105 | |
| 106 | // GetRecencyScore returns a score (0-1) based on how recently insult was shown |
| 107 | // 0 = shown very recently, 1 = never shown or shown long ago |
| 108 | func (h *InsultHistory) GetRecencyScore(text string) float64 { |
| 109 | h.mu.RLock() |
| 110 | defer h.mu.RUnlock() |
| 111 | |
| 112 | // Find the most recent occurrence |
| 113 | for i := len(h.RecentInsults) - 1; i >= 0; i-- { |
| 114 | if h.RecentInsults[i].Text == text { |
| 115 | // Calculate position from end (more recent = lower score) |
| 116 | position := len(h.RecentInsults) - i |
| 117 | // Normalize to 0-1 range |
| 118 | recencyPenalty := float64(position) / float64(h.maxHistory) |
| 119 | // Invert so recent = low score |
| 120 | return 1.0 - recencyPenalty |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | return 1.0 // Not found = full score |
| 125 | } |
| 126 | |
| 127 | // GetFrequencyScore returns a score based on how often insult was used |
| 128 | // Less frequent = higher score |
| 129 | func (h *InsultHistory) GetFrequencyScore(text string) float64 { |
| 130 | h.mu.RLock() |
| 131 | defer h.mu.RUnlock() |
| 132 | |
| 133 | frequency := h.InsultFrequency[text] |
| 134 | if frequency == 0 { |
| 135 | return 1.0 // Never shown |
| 136 | } |
| 137 | |
| 138 | // Find max frequency for normalization |
| 139 | maxFreq := 1 |
| 140 | for _, freq := range h.InsultFrequency { |
| 141 | if freq > maxFreq { |
| 142 | maxFreq = freq |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Normalize and invert (higher frequency = lower score) |
| 147 | return 1.0 - (float64(frequency) / float64(maxFreq) * 0.7) // Max 70% penalty |
| 148 | } |
| 149 | |
| 150 | // GetNoveltyScore combines recency and frequency for overall novelty |
| 151 | func (h *InsultHistory) GetNoveltyScore(text string) float64 { |
| 152 | recency := h.GetRecencyScore(text) |
| 153 | frequency := h.GetFrequencyScore(text) |
| 154 | |
| 155 | // Weight recency more heavily (70% recency, 30% frequency) |
| 156 | return (recency * 0.7) + (frequency * 0.3) |
| 157 | } |
| 158 | |
| 159 | // cleanup removes old entries and resets counters |
| 160 | func (h *InsultHistory) cleanup() { |
| 161 | // Remove entries older than 7 days |
| 162 | cutoff := time.Now().AddDate(0, 0, -7) |
| 163 | validEntries := make([]HistoryEntry, 0) |
| 164 | |
| 165 | for _, entry := range h.RecentInsults { |
| 166 | if entry.Timestamp.After(cutoff) { |
| 167 | validEntries = append(validEntries, entry) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | h.RecentInsults = validEntries |
| 172 | |
| 173 | // Reset frequency counters for removed insults |
| 174 | newFrequency := make(map[string]int) |
| 175 | for _, entry := range h.RecentInsults { |
| 176 | newFrequency[entry.Text]++ |
| 177 | } |
| 178 | h.InsultFrequency = newFrequency |
| 179 | |
| 180 | h.LastCleanup = time.Now() |
| 181 | } |
| 182 | |
| 183 | // persist saves history to disk |
| 184 | func (h *InsultHistory) persist() { |
| 185 | // Create directory if it doesn't exist |
| 186 | dir := filepath.Dir(h.persistPath) |
| 187 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 188 | return // Silently fail if can't create directory |
| 189 | } |
| 190 | |
| 191 | data, err := json.MarshalIndent(h, "", " ") |
| 192 | if err != nil { |
| 193 | return |
| 194 | } |
| 195 | |
| 196 | // Write to temp file first, then rename (atomic) |
| 197 | tempPath := h.persistPath + ".tmp" |
| 198 | if err := os.WriteFile(tempPath, data, 0644); err != nil { |
| 199 | return |
| 200 | } |
| 201 | |
| 202 | os.Rename(tempPath, h.persistPath) |
| 203 | } |
| 204 | |
| 205 | // Load reads history from disk |
| 206 | func (h *InsultHistory) Load() error { |
| 207 | h.mu.Lock() |
| 208 | defer h.mu.Unlock() |
| 209 | |
| 210 | data, err := os.ReadFile(h.persistPath) |
| 211 | if err != nil { |
| 212 | return err // File doesn't exist or can't be read |
| 213 | } |
| 214 | |
| 215 | // Create a temporary struct to unmarshal into |
| 216 | var loaded struct { |
| 217 | RecentInsults []HistoryEntry `json:"recent_insults"` |
| 218 | InsultFrequency map[string]int `json:"insult_frequency"` |
| 219 | LastCleanup time.Time `json:"last_cleanup"` |
| 220 | } |
| 221 | |
| 222 | if err := json.Unmarshal(data, &loaded); err != nil { |
| 223 | return err |
| 224 | } |
| 225 | |
| 226 | h.RecentInsults = loaded.RecentInsults |
| 227 | h.InsultFrequency = loaded.InsultFrequency |
| 228 | h.LastCleanup = loaded.LastCleanup |
| 229 | |
| 230 | return nil |
| 231 | } |
| 232 | |
| 233 | // Clear removes all history |
| 234 | func (h *InsultHistory) Clear() { |
| 235 | h.mu.Lock() |
| 236 | defer h.mu.Unlock() |
| 237 | |
| 238 | h.RecentInsults = make([]HistoryEntry, 0, h.maxHistory) |
| 239 | h.InsultFrequency = make(map[string]int) |
| 240 | h.LastCleanup = time.Now() |
| 241 | |
| 242 | h.persist() |
| 243 | } |
| 244 | |
| 245 | // GetStats returns statistics about insult history |
| 246 | func (h *InsultHistory) GetStats() map[string]interface{} { |
| 247 | h.mu.RLock() |
| 248 | defer h.mu.RUnlock() |
| 249 | |
| 250 | // Find most frequent insult |
| 251 | mostFrequent := "" |
| 252 | maxFreq := 0 |
| 253 | for text, freq := range h.InsultFrequency { |
| 254 | if freq > maxFreq { |
| 255 | maxFreq = freq |
| 256 | mostFrequent = text |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | return map[string]interface{}{ |
| 261 | "total_insults_shown": len(h.RecentInsults), |
| 262 | "unique_insults": len(h.InsultFrequency), |
| 263 | "most_frequent": mostFrequent, |
| 264 | "most_frequent_count": maxFreq, |
| 265 | "last_cleanup": h.LastCleanup, |
| 266 | } |
| 267 | } |
| 268 |