fix(ai): Make LLM finding IDs stable across patrol runs

The main issue was that finding IDs included the title, which the LLM
generates differently each time. 'High CPU on minipc' vs 'Node minipc
experiencing high CPU load' got different IDs, making dismissals useless.

Changes:
1. LLM findings now get IDs based on resource+category only, not title
2. Add() now checks if finding is suppressed before adding as new
3. Add() now checks dismissed findings and only reactivates on severity escalation
4. IsSuppressed() now matches by resource+category only, not title
5. Added isSuppressedInternal() for use when lock is already held

Now when you dismiss 'performance issues on minipc', any future patrol finding
about performance on minipc will be recognized as the same issue and stay dismissed.
This commit is contained in:
rcourtman 2025-12-11 00:03:17 +00:00
parent b1199b3cbf
commit 7ef96919d3
2 changed files with 48 additions and 4 deletions

View file

@ -201,17 +201,47 @@ func (s *FindingsStore) ForceSave() error {
// Add adds or updates a finding
// If a finding with the same ID exists, it updates LastSeenAt and increments TimesRaised
// If the finding is suppressed or dismissed, it may be skipped
// Returns true if this is a new finding
func (s *FindingsStore) Add(f *Finding) bool {
s.mu.Lock()
existing, exists := s.findings[f.ID]
if exists {
// Check if it's permanently suppressed - don't update at all
if existing.Suppressed {
s.mu.Unlock()
return false
}
// Check if dismissed - only update if severity has escalated
if existing.DismissedReason != "" {
severityOrder := map[FindingSeverity]int{
FindingSeverityInfo: 0,
FindingSeverityWatch: 1,
FindingSeverityWarning: 2,
FindingSeverityCritical: 3,
}
// If new severity is same or lower, don't reactivate
if severityOrder[f.Severity] <= severityOrder[existing.Severity] {
existing.LastSeenAt = time.Now()
existing.TimesRaised++
s.mu.Unlock()
s.scheduleSave()
return false
}
// Severity escalated - clear dismissal and reactivate
existing.DismissedReason = ""
existing.UserNote = "" // Clear note since situation changed
existing.AcknowledgedAt = nil
}
// Update existing finding
existing.LastSeenAt = time.Now()
existing.Description = f.Description
existing.Recommendation = f.Recommendation
existing.Evidence = f.Evidence
existing.Title = f.Title // Update title in case LLM phrased it better
existing.Severity = f.Severity
existing.TimesRaised++ // Track recurrence
s.mu.Unlock()
@ -219,6 +249,12 @@ func (s *FindingsStore) Add(f *Finding) bool {
return false
}
// New finding - check if resource+category is suppressed
if s.isSuppressedInternal(f.ResourceID, f.Category) {
s.mu.Unlock()
return false
}
// New finding
if f.DetectedAt.IsZero() {
f.DetectedAt = time.Now()
@ -377,14 +413,20 @@ func (s *FindingsStore) Suppress(id string) bool {
}
// IsSuppressed checks if findings of this type for this resource are suppressed
func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory, title string) bool {
// Checks by resource+category only (not title, since LLM titles vary)
func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.isSuppressedInternal(resourceID, category)
}
// isSuppressedInternal checks suppression without locking (caller must hold lock)
func (s *FindingsStore) isSuppressedInternal(resourceID string, category FindingCategory) bool {
ids := s.byResource[resourceID]
for _, id := range ids {
if f, exists := s.findings[id]; exists {
if f.Suppressed && f.Category == category && f.Title == title {
// Match by resource+category only
if f.Suppressed && f.Category == category {
return true
}
}

View file

@ -1772,8 +1772,10 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding {
cat = FindingCategoryPerformance
}
// Generate stable ID from content
id := generateFindingID(resource, string(cat), title)
// Generate stable ID from resource and category ONLY (not title)
// This ensures the same issue on the same resource gets the same ID even if
// the LLM phrases it differently each time
id := generateFindingID(resource, string(cat), "llm-finding")
return &Finding{
ID: id,