From 68e21009554b04d3ff841edbe6972f7f221bb4d5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 23:01:21 +0100 Subject: [PATCH] Suppress empty metric values on state-alert Assistant handoffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pulse Assistant briefing and prompt for a powered-off alert rendered "Current value 0.0%; threshold 0.0%" because the backend sends value=0 and threshold=0 for state alerts (which have no metric semantics). That line is misleading to the operator and gives the LLM no useful signal. Adds isMetricAlertType / isStateAlertType helpers to frontend-modern/src/utils/alerts.ts naming the state-alert set (powered-off, unreachable, offline, host-offline, connectivity, docker-container-state, docker-container-health, docker-host-offline). State alerts represent binary or enumerated conditions, not metric threshold crossings. The alert handoff builder routes through that helper: - Briefing detailLines omit the value/threshold line when the alert is a state alert. - Prompt omits the **Current Value:** and **Threshold:** lines. - Prompt now includes **Message:** so the actual signal is surfaced (was previously dropped from the prompt). - Prompt step 2 swaps "Check related metrics" for "Check what changed recently for this resource (state events, recent commands, related alerts)" — the right question for a binary-state alert. Two new tests cover the state-alert and metric-alert branches. --- .../alertAssistantHandoffModel.test.ts | 47 ++++++++++++++++ .../Alerts/alertAssistantHandoffModel.ts | 56 +++++++++++++------ frontend-modern/src/utils/alerts.ts | 26 +++++++++ 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts index 1157e982e..a4ab6e3d0 100644 --- a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts +++ b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts @@ -87,4 +87,51 @@ describe('alertAssistantHandoffModel', () => { expect(handoff.context.targetType).toBe('app-container'); }); + + it('suppresses current-value and threshold for state alerts (powered-off, etc.)', () => { + // State alerts are binary/enumerated conditions. The backend sends + // value=0 and threshold=0 for those; rendering "current 0.0% / threshold + // 0.0%" is meaningless. The briefing and prompt must omit those lines + // and use the alert message instead to convey what's wrong. + const handoff = buildAlertAssistantHandoff({ + alert: makeAlert({ + type: 'powered-off', + value: 0, + threshold: 0, + message: "VM 'docker' is powered off", + }), + now: new Date('2026-05-07T10:05:00.000Z'), + }); + + expect(handoff.prompt).not.toContain('**Current Value:**'); + expect(handoff.prompt).not.toContain('**Threshold:**'); + expect(handoff.prompt).not.toContain('0.0%'); + expect(handoff.prompt).toContain('**Alert Type:** powered-off'); + expect(handoff.prompt).toContain("**Message:** VM 'docker' is powered off"); + + const briefing = handoff.context.briefing as { detailLines: string[] }; + for (const line of briefing.detailLines) { + expect(line).not.toContain('Current value'); + expect(line).not.toContain('threshold'); + } + // Message line is still present so the operator and the LLM both have + // the actual signal. + expect(briefing.detailLines).toContain("Message: VM 'docker' is powered off"); + }); + + it('keeps current-value and threshold for metric alerts (cpu, memory, etc.)', () => { + const handoff = buildAlertAssistantHandoff({ + alert: makeAlert({ + type: 'cpu', + value: 92.5, + threshold: 80, + }), + now: new Date('2026-05-07T10:05:00.000Z'), + }); + + expect(handoff.prompt).toContain('**Current Value:** 92.5%'); + expect(handoff.prompt).toContain('**Threshold:** 80.0%'); + const briefing = handoff.context.briefing as { detailLines: string[] }; + expect(briefing.detailLines).toContain('Current value 92.5%; threshold 80.0%'); + }); }); diff --git a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts index a51a69ac1..5a4bf65d0 100644 --- a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts +++ b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts @@ -2,6 +2,7 @@ import type { Alert } from '@/types/api'; import type { AIChatContext } from '@/stores/aiChat'; import { getCanonicalAlertId } from '@/features/alerts/identity'; import { formatAlertValue } from '@/utils/alertFormatters'; +import { isMetricAlertType } from '@/utils/alerts'; import { resolveAlertTargetType } from '@/utils/alertTargetTypes'; interface BuildAlertAssistantHandoffInput { @@ -24,8 +25,15 @@ export function buildAlertAssistantHandoff({ }: BuildAlertAssistantHandoffInput): AlertAssistantHandoff { const alertIdentifier = getCanonicalAlertId(alert); const durationText = formatAlertDuration(alert.startTime, now); - const currentValue = formatAlertValue(alert.value, alert.type); - const thresholdValue = formatAlertValue(alert.threshold, alert.type); + // State alerts (powered-off, unreachable, container-state, etc.) are + // binary or enumerated conditions, not threshold crossings. Backend + // sends value=0 and threshold=0 for those; rendering "current 0.0% / + // threshold 0.0%" in operator-facing copy is misleading default-zero + // noise. Suppress those fields and rely on alert.type + alert.message + // to convey what's wrong. + const hasMetricValues = isMetricAlertType(alert.type); + const currentValue = hasMetricValues ? formatAlertValue(alert.value, alert.type) : ''; + const thresholdValue = hasMetricValues ? formatAlertValue(alert.threshold, alert.type) : ''; const nodeLabel = alert.node ? alert.nodeDisplayName || alert.node : ''; const levelLabel = formatAlertLevel(alert.level); const targetType = resolveAlertTargetType({ @@ -47,20 +55,32 @@ export function buildAlertAssistantHandoff({ levelLabel, }); - const prompt = `Investigate this ${alert.level.toUpperCase()} alert: - -**Resource:** ${alert.resourceName} -**Alert Type:** ${alert.type} -**Current Value:** ${currentValue} -**Threshold:** ${thresholdValue} -**Duration:** ${durationText} -${nodeLabel ? `**Node:** ${nodeLabel}` : ''} - -Please: -1. Identify the root cause -2. Check related metrics -3. Suggest specific remediation steps -4. Ask for operator approval before running any diagnostic command or change`; + const promptLines = [ + `Investigate this ${alert.level.toUpperCase()} alert:`, + ``, + `**Resource:** ${alert.resourceName}`, + `**Alert Type:** ${alert.type}`, + ]; + if (hasMetricValues) { + promptLines.push(`**Current Value:** ${currentValue}`); + promptLines.push(`**Threshold:** ${thresholdValue}`); + } + promptLines.push(`**Duration:** ${durationText}`); + if (nodeLabel) promptLines.push(`**Node:** ${nodeLabel}`); + if (alert.message) promptLines.push(`**Message:** ${alert.message}`); + promptLines.push(``); + promptLines.push(`Please:`); + promptLines.push(`1. Identify the root cause`); + if (hasMetricValues) { + promptLines.push(`2. Check related metrics`); + promptLines.push(`3. Suggest specific remediation steps`); + promptLines.push(`4. Ask for operator approval before running any diagnostic command or change`); + } else { + promptLines.push(`2. Check what changed recently for this resource (state events, recent commands, related alerts)`); + promptLines.push(`3. Suggest specific remediation steps`); + promptLines.push(`4. Ask for operator approval before running any diagnostic command or change`); + } + const prompt = promptLines.join('\n'); return { prompt, @@ -83,7 +103,9 @@ Please: subject: `${levelLabel} ${alert.type} on ${alert.resourceName}`, statusLabel: `${levelLabel} alert · Active ${durationText}`, detailLines: [ - `Current value ${currentValue}; threshold ${thresholdValue}`, + hasMetricValues + ? `Current value ${currentValue}; threshold ${thresholdValue}` + : undefined, nodeLabel ? `Node: ${nodeLabel}` : undefined, alert.message ? `Message: ${alert.message}` : undefined, ].filter((line): line is string => Boolean(line)), diff --git a/frontend-modern/src/utils/alerts.ts b/frontend-modern/src/utils/alerts.ts index 069a3f5e3..b30b8ced0 100644 --- a/frontend-modern/src/utils/alerts.ts +++ b/frontend-modern/src/utils/alerts.ts @@ -108,3 +108,29 @@ export const getAlertStyles = ( hasAcknowledgedOnlyAlert: !hasUnacknowledgedAlert && acknowledgedCount > 0, }; }; + +// Alert types representing binary or enumerated state conditions rather +// than a metric crossing a threshold. For these, "current value vs +// threshold" is meaningless (both come through as 0 from the backend) and +// surfacing those fields in operator-facing copy is misleading. The +// Assistant briefing and prompt builders omit the value/threshold lines +// when the alert type is one of these. +const STATE_ALERT_TYPES: ReadonlySet = new Set([ + 'powered-off', + 'unreachable', + 'offline', + 'host-offline', + 'connectivity', + 'docker-host-offline', + 'docker-container-state', + 'docker-container-health', +]); + +export function isStateAlertType(alertType: string | undefined): boolean { + if (!alertType) return false; + return STATE_ALERT_TYPES.has(alertType); +} + +export function isMetricAlertType(alertType: string | undefined): boolean { + return !isStateAlertType(alertType); +}