feat(ai): Add LLM memory system for patrol findings

Implements a comprehensive feedback system that allows the LLM to 'remember'
user decisions about findings, preventing repetitive/annoying alerts.

Backend changes:
- Extended Finding struct with dismissed_reason, user_note, times_raised, suppressed
- Added Dismiss(), Suppress(), SetUserNote(), IsSuppressed() methods to FindingsStore
- Added GetDismissedForContext() to format dismissed findings for LLM context
- Enhanced buildPatrolPrompt() to inject user feedback context
- Added POST /api/ai/patrol/dismiss and /api/ai/patrol/suppress endpoints
- Updated IsActive() to exclude suppressed findings

Frontend changes:
- Added Dismiss dropdown with options: Not an Issue, Expected Behavior, Will Fix Later
- Added Never Alert Again option for permanent suppression
- Expected Behavior prompts for optional note to help LLM understand context
- Added visual badges: recurrence count (×N), dismissed status, suppressed indicator
- Display user notes in expanded finding view

Also fixes:
- Fixed 403 error on Run Patrol (compilation errors from partial refactoring)
- Removed non-LLM patrol checks - patrol now uses LLM analysis only
- Fixed function signature mismatches in alert_triggered.go

The LLM now receives context about previously dismissed findings and is
instructed not to re-raise them unless severity has significantly worsened.
This commit is contained in:
rcourtman 2025-12-10 22:55:34 +00:00
parent c88e2db7b4
commit a3d953172c
8 changed files with 543 additions and 247 deletions

View file

@ -27,6 +27,11 @@ export interface Finding {
acknowledged_at?: string;
snoozed_until?: string; // Finding hidden until this time
alert_id?: string;
// User feedback fields (LLM memory system)
dismissed_reason?: 'not_an_issue' | 'expected_behavior' | 'will_fix_later';
user_note?: string;
times_raised: number;
suppressed: boolean;
}
export interface FindingsSummary {
@ -188,6 +193,36 @@ export async function resolveFinding(findingId: string): Promise<{ success: bool
});
}
/**
* Dismiss a finding with a reason (LLM memory feature)
* The LLM will be told not to re-raise this issue in future patrols.
* @param findingId The ID of the finding to dismiss
* @param reason One of: "not_an_issue", "expected_behavior", "will_fix_later"
* @param note Optional freeform explanation
*/
export async function dismissFinding(
findingId: string,
reason: 'not_an_issue' | 'expected_behavior' | 'will_fix_later',
note?: string
): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/dismiss', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId, reason, note }),
});
}
/**
* Permanently suppress a finding type (LLM memory feature)
* The LLM will never re-raise this type of finding for this resource.
* @param findingId The ID of the finding to suppress
*/
export async function suppressFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/suppress', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Severity color mapping for UI
*/

View file

@ -28,7 +28,7 @@ import History from 'lucide-solid/icons/history';
import Gauge from 'lucide-solid/icons/gauge';
import Send from 'lucide-solid/icons/send';
import Calendar from 'lucide-solid/icons/calendar';
import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, type Finding, type PatrolStatus, type PatrolRunRecord, severityColors, formatTimestamp } from '@/api/patrol';
import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, type Finding, type PatrolStatus, type PatrolRunRecord, severityColors, formatTimestamp } from '@/api/patrol';
import { aiChatStore } from '@/stores/aiChat';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
@ -2528,6 +2528,29 @@ function OverviewTab(props: {
<span class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate flex-1">
{finding.title}
</span>
{/* Recurrence badge - show if raised multiple times */}
<Show when={finding.times_raised > 1}>
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 flex-shrink-0"
title={`This issue has been detected ${finding.times_raised} times`}
>
×{finding.times_raised}
</span>
</Show>
{/* Dismissed status badge */}
<Show when={finding.dismissed_reason}>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
{finding.dismissed_reason === 'not_an_issue' && '✓ Dismissed'}
{finding.dismissed_reason === 'expected_behavior' && '✓ Expected'}
{finding.dismissed_reason === 'will_fix_later' && '⏱ Noted'}
</span>
</Show>
{/* Suppressed badge */}
<Show when={finding.suppressed}>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 flex-shrink-0">
🔇 Suppressed
</span>
</Show>
{/* Resource name pill */}
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 flex-shrink-0 hidden sm:inline">
{finding.resource_name}
@ -2571,6 +2594,15 @@ function OverviewTab(props: {
</p>
</Show>
{/* User note if present */}
<Show when={finding.user_note}>
<div class="mt-2 p-2 rounded bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-700 dark:text-blue-300">
<span class="font-medium">Your note:</span> {finding.user_note}
</p>
</div>
</Show>
{/* Footer with time and actions */}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs text-gray-500 dark:text-gray-500">
@ -2610,6 +2642,94 @@ function OverviewTab(props: {
</svg>
I Fixed It
</button>
{/* Not an Issue dropdown - LLM memory system */}
<div class="relative group">
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
title="Dismiss this finding - AI won't re-raise it"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Dismiss
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown menu */}
<div class="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg transition-colors"
onClick={async (e) => {
e.stopPropagation();
try {
await dismissFinding(finding.id, 'not_an_issue');
showSuccess('Dismissed - AI will not raise this again');
fetchAiData();
} catch (err) {
showError('Failed to dismiss finding');
}
}}
>
<span class="font-medium text-gray-700 dark:text-gray-300">Not an Issue</span>
<p class="text-gray-500 dark:text-gray-500 mt-0.5">This isn't actually a problem</p>
</button>
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onClick={async (e) => {
e.stopPropagation();
const note = prompt('Why is this expected? (optional - helps AI understand your environment)');
try {
await dismissFinding(finding.id, 'expected_behavior', note || undefined);
showSuccess('Dismissed - AI will not raise this again');
fetchAiData();
} catch (err) {
showError('Failed to dismiss finding');
}
}}
>
<span class="font-medium text-gray-700 dark:text-gray-300">Expected Behavior</span>
<p class="text-gray-500 dark:text-gray-500 mt-0.5">This is intentional/by design</p>
</button>
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onClick={async (e) => {
e.stopPropagation();
try {
await dismissFinding(finding.id, 'will_fix_later');
showSuccess('Acknowledged - AI will check again later');
fetchAiData();
} catch (err) {
showError('Failed to dismiss finding');
}
}}
>
<span class="font-medium text-gray-700 dark:text-gray-300">Will Fix Later</span>
<p class="text-gray-500 dark:text-gray-500 mt-0.5">I know about it, will address</p>
</button>
<div class="border-t border-gray-200 dark:border-gray-700">
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-red-50 dark:hover:bg-red-900/30 rounded-b-lg transition-colors text-red-600 dark:text-red-400"
onClick={async (e) => {
e.stopPropagation();
if (confirm('Permanently suppress this type of finding for this resource?\n\nThe AI will never raise this issue again.')) {
try {
await suppressFinding(finding.id);
showSuccess('Suppressed - AI will never raise this again');
fetchAiData();
} catch (err) {
showError('Failed to suppress finding');
}
}
}}
>
<span class="font-medium">Never Alert Again</span>
<p class="text-red-500/80 dark:text-red-400/80 mt-0.5">Permanently suppress for this resource</p>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -238,7 +238,7 @@ func (a *AlertTriggeredAnalyzer) analyzeNodeFromAlert(_ context.Context, alert *
}
// Use patrol service's node analysis
return a.patrolService.analyzeNode(*targetNode, true) // deep=true for triggered analysis
return a.patrolService.analyzeNode(*targetNode)
}
// analyzeGuestFromAlert analyzes a VM/Container triggered by an alert
@ -255,7 +255,7 @@ func (a *AlertTriggeredAnalyzer) analyzeGuestFromAlert(_ context.Context, alert
return a.patrolService.analyzeGuest(
vm.ID, vm.Name, "vm", vm.Node, vm.Status,
vm.CPU, vm.Memory.Usage, vm.Disk.Usage,
nil, vm.Template, true, // deep=true for triggered analysis
nil, vm.Template,
)
}
}
@ -266,7 +266,7 @@ func (a *AlertTriggeredAnalyzer) analyzeGuestFromAlert(_ context.Context, alert
return a.patrolService.analyzeGuest(
ct.ID, ct.Name, "container", ct.Node, ct.Status,
ct.CPU, ct.Memory.Usage, ct.Disk.Usage,
nil, ct.Template, true, // deep=true for triggered analysis
nil, ct.Template,
)
}
}
@ -291,13 +291,13 @@ func (a *AlertTriggeredAnalyzer) analyzeDockerFromAlert(_ context.Context, alert
for _, dh := range state.DockerHosts {
// Check if this is a host alert
if dh.ID == alert.ResourceID || dh.Hostname == alert.ResourceName {
return a.patrolService.analyzeDockerHost(dh, true) // deep=true
return a.patrolService.analyzeDockerHost(dh)
}
// Check containers within this host
for _, container := range dh.Containers {
if container.ID == alert.ResourceID || container.Name == alert.ResourceName {
return a.patrolService.analyzeDockerHost(dh, true) // deep=true
return a.patrolService.analyzeDockerHost(dh)
}
}
}
@ -320,7 +320,7 @@ func (a *AlertTriggeredAnalyzer) analyzeStorageFromAlert(_ context.Context, aler
// Storage is at the top level of StateSnapshot
for _, storage := range state.Storage {
if storage.ID == alert.ResourceID || storage.Name == alert.ResourceName {
return a.patrolService.analyzeStorage(storage, true)
return a.patrolService.analyzeStorage(storage)
}
}

View file

@ -2,6 +2,8 @@
package ai
import (
"fmt"
"strings"
"sync"
"time"
)
@ -54,11 +56,23 @@ type Finding struct {
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` // Finding hidden until this time
// Link to alert if this finding was triggered by or attached to an alert
AlertID string `json:"alert_id,omitempty"`
// User feedback fields - enables LLM "memory" by tracking how users respond
// This helps prevent the LLM from repeatedly raising the same dismissed issues
DismissedReason string `json:"dismissed_reason,omitempty"` // "not_an_issue", "expected_behavior", "will_fix_later"
UserNote string `json:"user_note,omitempty"` // Freeform user explanation, included in LLM context
TimesRaised int `json:"times_raised"` // How many times this finding has been detected
Suppressed bool `json:"suppressed"` // Permanently suppress similar findings for this resource
}
// IsActive returns true if the finding is still active (not resolved and not snoozed)
// IsActive returns true if the finding is still active (not resolved, not snoozed, not suppressed)
func (f *Finding) IsActive() bool {
return f.ResolvedAt == nil && !f.IsSnoozed()
return f.ResolvedAt == nil && !f.IsSnoozed() && !f.Suppressed
}
// IsDismissed returns true if the user has dismissed this finding with a reason
func (f *Finding) IsDismissed() bool {
return f.DismissedReason != ""
}
// IsSnoozed returns true if the finding is currently snoozed
@ -186,7 +200,7 @@ func (s *FindingsStore) ForceSave() error {
}
// Add adds or updates a finding
// If a finding with the same ID exists, it updates LastSeenAt
// If a finding with the same ID exists, it updates LastSeenAt and increments TimesRaised
// Returns true if this is a new finding
func (s *FindingsStore) Add(f *Finding) bool {
s.mu.Lock()
@ -199,6 +213,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
existing.Recommendation = f.Recommendation
existing.Evidence = f.Evidence
existing.Severity = f.Severity
existing.TimesRaised++ // Track recurrence
s.mu.Unlock()
s.scheduleSave()
return false
@ -300,6 +315,83 @@ func (s *FindingsStore) Unsnooze(id string) bool {
return true
}
// Dismiss marks a finding as dismissed with a reason and optional note
// Reasons: "not_an_issue", "expected_behavior", "will_fix_later"
func (s *FindingsStore) Dismiss(id, reason, note string) bool {
s.mu.Lock()
f, exists := s.findings[id]
if !exists {
s.mu.Unlock()
return false
}
f.DismissedReason = reason
if note != "" {
f.UserNote = note
}
// Also mark as acknowledged
now := time.Now()
f.AcknowledgedAt = &now
s.mu.Unlock()
s.scheduleSave()
return true
}
// SetUserNote updates the user note on a finding
func (s *FindingsStore) SetUserNote(id, note string) bool {
s.mu.Lock()
f, exists := s.findings[id]
if !exists {
s.mu.Unlock()
return false
}
f.UserNote = note
s.mu.Unlock()
s.scheduleSave()
return true
}
// Suppress marks a finding type as permanently suppressed for a resource
// Future findings with the same resource+category will be auto-dismissed
func (s *FindingsStore) Suppress(id string) bool {
s.mu.Lock()
f, exists := s.findings[id]
if !exists {
s.mu.Unlock()
return false
}
f.Suppressed = true
f.DismissedReason = "suppressed"
now := time.Now()
f.AcknowledgedAt = &now
s.mu.Unlock()
s.scheduleSave()
return true
}
// IsSuppressed checks if findings of this type for this resource are suppressed
func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory, title string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
ids := s.byResource[resourceID]
for _, id := range ids {
if f, exists := s.findings[id]; exists {
if f.Suppressed && f.Category == category && f.Title == title {
return true
}
}
}
return false
}
// Get returns a finding by ID
func (s *FindingsStore) Get(id string) *Finding {
s.mu.RLock()
@ -425,6 +517,85 @@ func (s *FindingsStore) Cleanup(maxAge time.Duration) int {
return removed
}
// GetDismissedForContext returns findings that the user has dismissed/acknowledged,
// formatted for injection into LLM prompts. This is the core of the "memory" system -
// it tells the LLM what not to re-raise.
func (s *FindingsStore) GetDismissedForContext() string {
s.mu.RLock()
defer s.mu.RUnlock()
var suppressed, dismissed, snoozed []string
for _, f := range s.findings {
// Skip very old findings (more than 30 days)
if time.Since(f.LastSeenAt) > 30*24*time.Hour {
continue
}
// Collect suppressed findings
if f.Suppressed {
note := ""
if f.UserNote != "" {
note = " - User note: " + f.UserNote
}
suppressed = append(suppressed,
fmt.Sprintf("- %s on %s: %s%s", f.Title, f.ResourceName, f.DismissedReason, note))
continue
}
// Collect dismissed/acknowledged findings
if f.DismissedReason != "" {
note := ""
if f.UserNote != "" {
note = " - User note: " + f.UserNote
}
dismissed = append(dismissed,
fmt.Sprintf("- %s on %s (%s)%s", f.Title, f.ResourceName, f.DismissedReason, note))
continue
}
// Collect snoozed findings
if f.IsSnoozed() {
snoozed = append(snoozed,
fmt.Sprintf("- %s on %s (snoozed until %s)",
f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2")))
}
}
if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 {
return ""
}
var result strings.Builder
result.WriteString("\n## Previous Findings - User Feedback\n")
result.WriteString("The following findings have been addressed by the user. Do NOT re-raise these unless the situation has significantly worsened:\n\n")
if len(suppressed) > 0 {
result.WriteString("### Permanently Suppressed (never re-raise):\n")
for _, s := range suppressed {
result.WriteString(s + "\n")
}
result.WriteString("\n")
}
if len(dismissed) > 0 {
result.WriteString("### Dismissed by User:\n")
for _, d := range dismissed {
result.WriteString(d + "\n")
}
result.WriteString("\n")
}
if len(snoozed) > 0 {
result.WriteString("### Temporarily Snoozed:\n")
for _, s := range snoozed {
result.WriteString(s + "\n")
}
}
return result.String()
}
// FindingsSummary provides a quick count of findings by severity
type FindingsSummary struct {
Critical int `json:"critical"`

View file

@ -485,7 +485,7 @@ func (p *PatrolService) patrolLoop(ctx context.Context) {
initialDelay := 30 * time.Second
select {
case <-time.After(initialDelay):
p.runPatrol(ctx, false)
p.runPatrol(ctx)
case <-p.stopCh:
return
case <-ctx.Done():
@ -591,198 +591,18 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
return isNew
}
// Analyze nodes
if cfg.AnalyzeNodes {
for _, node := range state.Nodes {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.nodesChecked++
findings := p.analyzeNode(node)
for _, f := range findings {
trackFinding(f)
}
}
}
// Count resources for statistics (but analysis is done by LLM only)
runStats.nodesChecked = len(state.Nodes)
runStats.guestsChecked = len(state.VMs) + len(state.Containers)
runStats.dockerChecked = len(state.DockerHosts)
runStats.storageChecked = len(state.Storage)
runStats.pbsChecked = len(state.PBSInstances)
runStats.hostsChecked = len(state.Hosts)
runStats.resourceCount = runStats.nodesChecked + runStats.guestsChecked +
runStats.dockerChecked + runStats.storageChecked + runStats.pbsChecked + runStats.hostsChecked
// Analyze VMs and containers
if cfg.AnalyzeGuests {
for _, vm := range state.VMs {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.guestsChecked++
// Calculate usage percentages from Memory/Disk structs
var memUsage, diskUsage float64
if vm.Memory.Total > 0 {
memUsage = float64(vm.Memory.Used) / float64(vm.Memory.Total)
// Cap at 1.0 to prevent impossible percentages from data anomalies
if memUsage > 1.0 {
log.Debug().
Str("guest", vm.Name).
Str("type", "vm").
Int64("memUsed", vm.Memory.Used).
Int64("memTotal", vm.Memory.Total).
Float64("rawRatio", memUsage).
Msg("AI Patrol: Capping memory usage ratio > 1.0")
memUsage = 1.0
}
}
if vm.Disk.Total > 0 {
diskUsage = float64(vm.Disk.Used) / float64(vm.Disk.Total)
// Cap at 1.0 to prevent impossible percentages from data anomalies
if diskUsage > 1.0 {
log.Debug().
Str("guest", vm.Name).
Str("type", "vm").
Int64("diskUsed", vm.Disk.Used).
Int64("diskTotal", vm.Disk.Total).
Float64("rawRatio", diskUsage).
Msg("AI Patrol: Capping disk usage ratio > 1.0")
diskUsage = 1.0
}
}
// Handle LastBackup - pass nil if zero time
var lastBackup *time.Time
if !vm.LastBackup.IsZero() {
t := vm.LastBackup
lastBackup = &t
}
findings := p.analyzeGuest(vm.ID, vm.Name, "vm", vm.Node, vm.Status,
vm.CPU, memUsage, diskUsage, lastBackup, vm.Template)
for _, f := range findings {
trackFinding(f)
}
}
for _, ct := range state.Containers {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.guestsChecked++
// Calculate usage percentages from Memory/Disk structs
var memUsage, diskUsage float64
if ct.Memory.Total > 0 {
memUsage = float64(ct.Memory.Used) / float64(ct.Memory.Total)
// Cap at 1.0 to prevent impossible percentages from data anomalies
if memUsage > 1.0 {
log.Debug().
Str("guest", ct.Name).
Str("type", "container").
Int64("memUsed", ct.Memory.Used).
Int64("memTotal", ct.Memory.Total).
Float64("rawRatio", memUsage).
Msg("AI Patrol: Capping memory usage ratio > 1.0")
memUsage = 1.0
}
}
if ct.Disk.Total > 0 {
diskUsage = float64(ct.Disk.Used) / float64(ct.Disk.Total)
// Cap at 1.0 to prevent impossible percentages from data anomalies
if diskUsage > 1.0 {
log.Debug().
Str("guest", ct.Name).
Str("type", "container").
Int64("diskUsed", ct.Disk.Used).
Int64("diskTotal", ct.Disk.Total).
Float64("rawRatio", diskUsage).
Msg("AI Patrol: Capping disk usage ratio > 1.0")
diskUsage = 1.0
}
}
// Handle LastBackup - pass nil if zero time
var lastBackup *time.Time
if !ct.LastBackup.IsZero() {
t := ct.LastBackup
lastBackup = &t
}
findings := p.analyzeGuest(ct.ID, ct.Name, "container", ct.Node, ct.Status,
ct.CPU, memUsage, diskUsage, lastBackup, ct.Template)
for _, f := range findings {
trackFinding(f)
}
}
}
// Analyze Docker hosts
if cfg.AnalyzeDocker {
for _, dh := range state.DockerHosts {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.dockerChecked++
findings := p.analyzeDockerHost(dh)
for _, f := range findings {
trackFinding(f)
}
}
}
// Analyze storage
if cfg.AnalyzeStorage {
for _, st := range state.Storage {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.storageChecked++
findings := p.analyzeStorage(st)
for _, f := range findings {
trackFinding(f)
}
}
}
// Analyze PBS instances (backup servers)
if cfg.AnalyzePBS {
for _, pbs := range state.PBSInstances {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.pbsChecked++
findings := p.analyzePBSInstance(pbs, state.PBSBackups)
for _, f := range findings {
trackFinding(f)
}
}
}
// Analyze agent hosts (RAID, sensors)
if cfg.AnalyzeHosts {
for _, host := range state.Hosts {
select {
case <-ctx.Done():
return
default:
}
runStats.resourceCount++
runStats.hostsChecked++
findings := p.analyzeHost(host)
for _, f := range findings {
trackFinding(f)
}
}
}
// Run real AI analysis using the LLM
// This asks the AI to analyze the infrastructure and identify issues
// Run AI analysis using the LLM - this is the ONLY analysis method
// The LLM analyzes the infrastructure and identifies issues
aiResult, aiErr := p.runAIAnalysis(ctx, state)
if aiErr != nil {
log.Warn().Err(aiErr).Msg("AI Patrol: LLM analysis failed")
@ -878,9 +698,6 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
p.lastDuration = duration
p.resourcesChecked = runStats.resourceCount
p.errorCount = runStats.errors
if deep {
p.lastDeepAnalysis = completedAt
}
p.mu.Unlock()
// Add to history store (handles persistence automatically)
@ -1410,40 +1227,38 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
}
}
// Check backup jobs for failures (only during deep analysis)
if deep {
for _, job := range pbs.BackupJobs {
if job.Status == "error" || job.Error != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":job:" + job.ID,
ResourceName: fmt.Sprintf("%s/job/%s", pbsName, job.ID),
ResourceType: "pbs_job",
Title: "Backup job failed",
Description: fmt.Sprintf("Backup job '%s' on PBS '%s' is failing", job.ID, pbsName),
Recommendation: "Check PBS task logs for error details",
Evidence: job.Error,
})
}
// Check backup jobs for failures
for _, job := range pbs.BackupJobs {
if job.Status == "error" || job.Error != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":job:" + job.ID,
ResourceName: fmt.Sprintf("%s/job/%s", pbsName, job.ID),
ResourceType: "pbs_job",
Title: "Backup job failed",
Description: fmt.Sprintf("Backup job '%s' on PBS '%s' is failing", job.ID, pbsName),
Recommendation: "Check PBS task logs for error details",
Evidence: job.Error,
})
}
}
for _, job := range pbs.VerifyJobs {
if job.Status == "error" || job.Error != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":verify:" + job.ID,
ResourceName: fmt.Sprintf("%s/verify/%s", pbsName, job.ID),
ResourceType: "pbs_job",
Title: "Verify job failed",
Description: fmt.Sprintf("Verify job '%s' on PBS '%s' is failing", job.ID, pbsName),
Recommendation: "Check PBS task logs - verify failures may indicate backup corruption",
Evidence: job.Error,
})
}
for _, job := range pbs.VerifyJobs {
if job.Status == "error" || job.Error != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":verify:" + job.ID,
ResourceName: fmt.Sprintf("%s/verify/%s", pbsName, job.ID),
ResourceType: "pbs_job",
Title: "Verify job failed",
Description: fmt.Sprintf("Verify job '%s' on PBS '%s' is failing", job.ID, pbsName),
Recommendation: "Check PBS task logs - verify failures may indicate backup corruption",
Evidence: job.Error,
})
}
}
@ -1547,8 +1362,8 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
}
}
// Check high temperature (during deep analysis)
if deep && len(host.Sensors.TemperatureCelsius) > 0 {
// Check high temperature
if len(host.Sensors.TemperatureCelsius) > 0 {
for sensorName, temp := range host.Sensors.TemperatureCelsius {
if temp > 85 {
severity := FindingSeverityWarning
@ -1594,10 +1409,9 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna
return nil, nil // Nothing to analyze
}
// Build the patrol analysis prompt
prompt := p.buildPatrolPrompt(summary)
prompt := p.buildPatrolPrompt(summary, false)
Msg("AI Patrol: Sending infrastructure to LLM for analysis")
log.Debug().Msg("AI Patrol: Sending infrastructure to LLM for analysis")
// Start streaming phase
p.setStreamPhase("analyzing")
@ -1813,20 +1627,37 @@ func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) s
}
// buildPatrolPrompt creates the prompt for AI analysis
// Includes user feedback context to prevent re-raising dismissed findings
func (p *PatrolService) buildPatrolPrompt(summary string, deep bool) string {
// Always run comprehensive analysis (deep flag kept for API backwards compat but ignored)
return fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
// Get user feedback context (dismissed/snoozed findings)
feedbackContext := p.findings.GetDismissedForContext()
basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
%s
Analyze the above and report any findings using the structured format. Focus on:
- Resources showing high utilization
- Patterns that might indicate problems
- Missing backups or stale backup schedules
- Missing backups or stale backup schedules
- Unbalanced resource distribution
- Any anomalies or concerns
If everything looks healthy, say so briefly.`, summary)
// If there's user feedback, append instructions
if feedbackContext != "" {
return basePrompt + "\n\n" + feedbackContext + `
IMPORTANT: Respect the user's feedback above. Do NOT re-raise findings that are:
- Permanently suppressed - the user has explicitly said to never mention these again
- Dismissed as "not_an_issue" or "expected_behavior" - the user knows about these
- Currently snoozed - only re-raise if the severity has significantly worsened
Only report NEW issues or issues where the severity has clearly escalated.`
}
return basePrompt
}
// parseAIFindings extracts structured findings from AI response

View file

@ -1661,7 +1661,6 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
Running: status.Running,
Enabled: h.aiService.IsEnabled(),
LastPatrolAt: status.LastPatrolAt,
LastDeepAnalysis: status.LastDeepAnalysis,
NextPatrolAt: status.NextPatrolAt,
LastDurationMs: status.LastDuration.Milliseconds(),
ResourcesChecked: status.ResourcesChecked,
@ -1973,6 +1972,128 @@ func (h *AISettingsHandler) HandleResolveFinding(w http.ResponseWriter, r *http.
}
}
// HandleDismissFinding dismisses a finding with a reason and optional note (POST /api/ai/patrol/dismiss)
// This is part of the LLM memory system - dismissed findings are included in context to prevent re-raising
// Valid reasons: "not_an_issue", "expected_behavior", "will_fix_later"
func (h *AISettingsHandler) HandleDismissFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
Reason string `json:"reason"` // "not_an_issue", "expected_behavior", "will_fix_later"
Note string `json:"note"` // Optional freeform note
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
// Validate reason
validReasons := map[string]bool{
"not_an_issue": true,
"expected_behavior": true,
"will_fix_later": true,
}
if req.Reason != "" && !validReasons[req.Reason] {
http.Error(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
if !findings.Dismiss(req.FindingID, req.Reason, req.Note) {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Str("reason", req.Reason).
Bool("has_note", req.Note != "").
Msg("AI Patrol: Finding dismissed by user with reason")
response := map[string]interface{}{
"success": true,
"message": "Finding dismissed",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write dismiss response")
}
}
// HandleSuppressFinding permanently suppresses similar findings for a resource (POST /api/ai/patrol/suppress)
// The LLM will be told never to re-raise findings of this type for this resource
func (h *AISettingsHandler) HandleSuppressFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
if !findings.Suppress(req.FindingID) {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Msg("AI Patrol: Finding type permanently suppressed by user")
response := map[string]interface{}{
"success": true,
"message": "Finding type suppressed - similar issues will not be raised again",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write suppress response")
}
}
// HandleGetFindingsHistory returns all findings including resolved for history (GET /api/ai/patrol/history)
func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View file

@ -1101,7 +1101,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleDismissFinding)) // Dismiss with reason (LLM memory)
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, r.aiSettingsHandler.HandleSuppressFinding)) // Permanently suppress (LLM memory)
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory))

View file

@ -32,11 +32,13 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
// Skip CSRF for API token auth (API clients don't have sessions)
if r.Header.Get("X-API-Token") != "" {
log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: API token auth")
return true
}
// Skip CSRF for Basic Auth (doesn't use sessions, not vulnerable to CSRF)
if r.Header.Get("Authorization") != "" {
log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: Basic Auth header present")
return true
}
@ -45,6 +47,7 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
if err != nil {
// No session cookie means no CSRF check needed
// (either no auth configured or using basic auth which doesn't use sessions)
log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: no session cookie")
return true
}
@ -54,6 +57,14 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
csrfToken = r.FormValue("csrf_token")
}
// Log CSRF validation attempt for debugging
log.Debug().
Str("path", r.URL.Path).
Str("method", r.Method).
Str("session", cookie.Value[:8]+"...").
Bool("has_csrf_token", csrfToken != "").
Msg("CSRF validation attempt")
// No CSRF token means request is not eligible for mutation
if csrfToken == "" {
log.Warn().
@ -63,6 +74,7 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
clearCSRFCookie(w)
if newToken := issueNewCSRFCookie(w, r, cookie.Value); newToken != "" {
w.Header().Set("X-CSRF-Token", newToken)
log.Debug().Str("new_token", newToken[:8]+"...").Msg("Issued new CSRF token after missing")
}
return false
}
@ -77,10 +89,15 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
clearCSRFCookie(w)
if newToken := issueNewCSRFCookie(w, r, cookie.Value); newToken != "" {
w.Header().Set("X-CSRF-Token", newToken)
log.Debug().Str("new_token", newToken[:8]+"...").Msg("Issued new CSRF token after invalid")
}
return false
}
log.Debug().
Str("path", r.URL.Path).
Str("session", cookie.Value[:8]+"...").
Msg("CSRF validation successful")
return true
}