Suppress empty metric values on state-alert Assistant handoffs

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.
This commit is contained in:
rcourtman 2026-05-10 23:01:21 +01:00
parent 4dff26f728
commit 68e2100955
3 changed files with 112 additions and 17 deletions

View file

@ -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%');
});
});

View file

@ -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)),

View file

@ -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<string> = 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);
}