mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 17:48:47 +00:00
Add Patrol assessment Assistant handoff
This commit is contained in:
parent
812c86692d
commit
73b4cf25c3
7 changed files with 769 additions and 10 deletions
|
|
@ -431,6 +431,11 @@ the canonical monitored-system blocked payload.
|
|||
and the shared dashboard-load bundle inside `frontend-modern/src/stores/aiIntelligence.ts`, so the page orchestration stays on the store-owned bundle instead of enumerating the AI fetches inline
|
||||
and the shared `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` card, so the AI summary page renders the governed policy-posture counts while the resource drawer stays on per-resource policy lines instead of carrying duplicate posture UI loops
|
||||
and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic
|
||||
and that same Patrol investigation-context owner, so the current Patrol
|
||||
assessment summary may open Assistant with bounded model-only assessment,
|
||||
verification, latest-run, supporting-context, active-finding, and resource
|
||||
reference context instead of pasting page-local UI text or raw command
|
||||
payloads into chat
|
||||
and that same Patrol investigation-context owner, so visible Assistant
|
||||
drawer handoffs may include live pending-approval metadata only as safe
|
||||
operator context: approval ID, status, risk, requested/expiry timestamps,
|
||||
|
|
|
|||
|
|
@ -809,7 +809,12 @@ frontend primitive boundary.
|
|||
investigation-record framing must derive that prompt copy through
|
||||
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
|
||||
so shared drawer primitives stay shell-owned rather than becoming a
|
||||
Patrol-specific prompt formatter. The drawer may render a generic
|
||||
Patrol-specific prompt formatter. Patrol assessment-level handoffs must use
|
||||
that same feature helper to attach bounded model-only assessment,
|
||||
verification, latest-run, supporting-context, active-finding, and resource
|
||||
reference context while forcing request-local approval-required mode, so the
|
||||
shared drawer stays a generic shell rather than a Patrol summary prompt
|
||||
builder. The drawer may render a generic
|
||||
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
|
||||
feature-owned helpers must provide the source labels, attention reason,
|
||||
evidence summaries, operator-decision copy, action copy, and safety note.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,16 @@ Patrol-specific presentation helpers.
|
|||
structured `investigationRecord` as investigation data, even when the legacy
|
||||
investigation-detail endpoint has no separate session payload, so it must not
|
||||
render empty-state copy above a durable Patrol record.
|
||||
The primary Patrol assessment summary may open Assistant as a whole-surface
|
||||
review handoff, but that handoff must also flow through
|
||||
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`.
|
||||
The summary action may pass the current assessment title, health score,
|
||||
verification recency, latest run, secondary investigation context, bounded
|
||||
active-finding summaries, and structured resource references as model-only
|
||||
context. It must force request-local approval-required mode, keep raw command
|
||||
and approval payloads out of prompt and drawer copy, and frame Assistant as
|
||||
explanation, prioritization, and safe next-step review rather than a generic
|
||||
reactive chat box.
|
||||
|
||||
## Current State
|
||||
|
||||
|
|
@ -235,6 +245,14 @@ render contract: the header chip, primary summary card, and status bar must
|
|||
all route through the shared `frontend-modern/src/utils/patrolRuntimePresentation.ts`
|
||||
helper plus the backend `runtime_state` payload instead of inferring operator
|
||||
state from the last healthy summary snapshot or run history alone.
|
||||
The primary summary card now also has a Patrol-owned Assistant assessment
|
||||
handoff. `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx`
|
||||
opens Assistant through
|
||||
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`, which
|
||||
packages the current Patrol assessment, verification posture, latest run,
|
||||
secondary investigation context, bounded active-finding summaries, and deduped
|
||||
resource references as model-only context while forcing `autonomousMode:false`
|
||||
and summarizing proposed-fix command-bearing records by count only.
|
||||
That active-runtime label must stay operational rather than verdict-like: the
|
||||
header chip should communicate that Patrol is enabled or available, not imply
|
||||
that infrastructure health is currently good merely because the runtime is on.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
|
|||
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
|
||||
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import {
|
||||
getPatrolAssessmentAction,
|
||||
getPatrolAssessmentShellPresentation,
|
||||
|
|
@ -20,6 +21,8 @@ import {
|
|||
} from '@/utils/patrolRunPresentation';
|
||||
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { buildPatrolAssessmentAssistantHandoff } from './patrolInvestigationContextModel';
|
||||
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
|
||||
function PatrolAssessmentLoadingShell() {
|
||||
|
|
@ -212,6 +215,30 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
|
|||
|
||||
return metrics;
|
||||
});
|
||||
const assessmentAssistantHandoff = createMemo(() =>
|
||||
buildPatrolAssessmentAssistantHandoff({
|
||||
assessment: assessment(),
|
||||
overallHealth: state.intelligenceSummary()?.overall_health,
|
||||
scoreChipLabel: scoreChipLabel(),
|
||||
metricState: metricState(),
|
||||
verification: verification(),
|
||||
recency: recency(),
|
||||
latestRun: latestRun(),
|
||||
investigationContext: {
|
||||
recentChangeCount: state.recentChangeCount(),
|
||||
correlationCount: state.correlationTotal(),
|
||||
governedResourceCount: state.policyPosture()?.total_resources ?? 0,
|
||||
hasContext: state.hasInvestigationContext(),
|
||||
summaryText: state.investigationContextSummary(),
|
||||
},
|
||||
activeFindings: state.activePatrolFindings(),
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDiscussAssessment = () => {
|
||||
const handoff = assessmentAssistantHandoff();
|
||||
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
|
||||
};
|
||||
|
||||
const renderAssessmentIcon = () => {
|
||||
const iconClass = `w-5 h-5 ${assessmentShellPresentation().iconClass}`;
|
||||
|
|
@ -307,18 +334,28 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
|
|||
<p class="mt-1.5 max-w-3xl text-sm leading-6 text-muted">
|
||||
{assessment().description}
|
||||
</p>
|
||||
<Show when={assessmentAction()}>
|
||||
{(action) => (
|
||||
<div class="mt-4">
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Show when={assessmentAction()}>
|
||||
{(action) => (
|
||||
<a
|
||||
href={action().href}
|
||||
class="inline-flex items-center rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-semibold text-base-content shadow-sm transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{action().label}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="patrol-assessment-assistant-button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-semibold text-base-content shadow-sm transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title="Discuss current Patrol assessment"
|
||||
onClick={handleDiscussAssessment}
|
||||
>
|
||||
<MessageSquareIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span>Discuss with Assistant</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -364,7 +401,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
|
|||
{(latest) => (
|
||||
<>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-medium text-base-content">{latest().kindLabel}</span>
|
||||
<span class="font-medium text-base-content">
|
||||
{latest().kindLabel}
|
||||
</span>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${latest().status.badgeClass}`}
|
||||
>
|
||||
|
|
@ -414,7 +453,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
|
|||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={!showRuntimeSummary() && state.intelligenceSummary() && visibleMetrics().length > 0}>
|
||||
<Show
|
||||
when={!showRuntimeSummary() && state.intelligenceSummary() && visibleMetrics().length > 0}
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={visibleMetrics()}>
|
||||
{(metric) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { PatrolIntelligenceSummary } from '../PatrolIntelligenceSummary';
|
||||
import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState';
|
||||
|
||||
describe('PatrolIntelligenceSummary', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens Assistant with model-only context for the current Patrol assessment', () => {
|
||||
const openWithPrompt = vi.spyOn(aiChatStore, 'openWithPrompt').mockImplementation(() => {});
|
||||
|
||||
render(() => <PatrolIntelligenceSummary state={createPatrolState()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('patrol-assessment-assistant-button'));
|
||||
|
||||
expect(openWithPrompt).toHaveBeenCalledTimes(1);
|
||||
const [prompt, context] = openWithPrompt.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(prompt).toContain('Discuss the current Pulse Patrol assessment');
|
||||
expect(context.autonomousMode).toBe(false);
|
||||
expect(context.handoffContext).toContain('[Patrol Assessment Context]');
|
||||
expect(context.handoffContext).toContain('Source: Pulse Patrol current assessment');
|
||||
expect(context.handoffContext).toContain('Supporting Context: 1 recent change');
|
||||
expect(context.handoffContext).toContain('Finding 1: High CPU usage');
|
||||
expect(context.handoffResources).toEqual([
|
||||
{ id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' },
|
||||
]);
|
||||
expect(JSON.stringify(context)).not.toContain('systemctl restart workload.service');
|
||||
});
|
||||
});
|
||||
|
||||
function createPatrolState(): PatrolIntelligenceState {
|
||||
return {
|
||||
activePatrolFindings: () => [
|
||||
{
|
||||
id: 'finding-1',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-100',
|
||||
resourceName: 'web-server',
|
||||
resourceType: 'vm',
|
||||
category: 'performance',
|
||||
severity: 'critical',
|
||||
title: 'High CPU usage',
|
||||
description: 'CPU stayed above 95%.',
|
||||
detectedAt: '2026-05-06T12:00:00Z',
|
||||
lastSeenAt: '2026-05-06T12:10:00Z',
|
||||
status: 'active',
|
||||
investigationStatus: 'completed',
|
||||
investigationOutcome: 'fix_queued',
|
||||
loopState: 'awaiting_approval',
|
||||
timesRaised: 3,
|
||||
regressionCount: 1,
|
||||
investigationRecord: {
|
||||
id: 'record-1',
|
||||
finding_id: 'finding-1',
|
||||
subject: {
|
||||
resource_id: 'vm-100',
|
||||
resource_name: 'web-server',
|
||||
resource_type: 'vm',
|
||||
node: 'pve-1',
|
||||
},
|
||||
trigger: { detected_at: '2026-05-06T12:00:00Z' },
|
||||
status: 'completed',
|
||||
outcome: 'fix_queued',
|
||||
confidence: 'high',
|
||||
conclusion: 'Backup job saturated CPU.',
|
||||
recommended_action: 'Approve a controlled restart after the backup completes.',
|
||||
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
|
||||
proposed_fix: {
|
||||
id: 'fix-1',
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart workload.service'],
|
||||
risk_level: 'medium',
|
||||
destructive: false,
|
||||
},
|
||||
verification: ['CPU returned below 50%'],
|
||||
tools_used: ['ssh.exec'],
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
blockedReason: () => undefined,
|
||||
circuitBreakerStatus: () => undefined,
|
||||
correlationTotal: () => 2,
|
||||
hasInvestigationContext: () => true,
|
||||
initialSurfaceReady: () => true,
|
||||
intelligenceSummary: () => ({
|
||||
overall_health: {
|
||||
grade: 'B',
|
||||
score: 84,
|
||||
factors: [],
|
||||
prediction: 'Patrol surfaced one active critical finding.',
|
||||
},
|
||||
recent_changes_count: 1,
|
||||
policy_posture: {
|
||||
total_resources: 4,
|
||||
sensitivity_counts: {},
|
||||
routing_counts: {},
|
||||
},
|
||||
}),
|
||||
investigationContextSummary: () =>
|
||||
'1 recent change · 2 correlations · 4 policy-covered resources',
|
||||
patrolRunHistory: {
|
||||
value: () => [
|
||||
{
|
||||
id: 'run-1',
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
completed_at: '2026-05-06T12:10:00Z',
|
||||
type: 'full',
|
||||
status: 'issues_found',
|
||||
resources_checked: 12,
|
||||
new_findings: 1,
|
||||
error_count: 0,
|
||||
finding_ids: ['finding-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
patrolStatus: () => ({
|
||||
last_patrol_at: '2026-05-06T12:10:00Z',
|
||||
last_activity_at: '2026-05-06T12:10:00Z',
|
||||
}),
|
||||
policyPosture: () => ({
|
||||
total_resources: 4,
|
||||
sensitivity_counts: {},
|
||||
routing_counts: {},
|
||||
}),
|
||||
recentChangeCount: () => 1,
|
||||
runtimeState: () => 'active',
|
||||
summaryStats: () => ({
|
||||
criticalFindings: 1,
|
||||
warningFindings: 0,
|
||||
totalActive: 1,
|
||||
fixedCount: 0,
|
||||
hasAnyPatrolFindings: true,
|
||||
}),
|
||||
} as unknown as PatrolIntelligenceState;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import type { RemediationPlan } from '@/api/ai';
|
||||
|
||||
import {
|
||||
buildPatrolAssessmentAssistantHandoff,
|
||||
buildPatrolAssistantFindingBriefing,
|
||||
buildPatrolAssistantFindingPrompt,
|
||||
buildPatrolInvestigationContextSummary,
|
||||
|
|
@ -89,6 +90,128 @@ describe('patrolInvestigationContextModel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('builds a model-only Assistant handoff for the current Patrol assessment', () => {
|
||||
const handoff = buildPatrolAssessmentAssistantHandoff({
|
||||
assessment: {
|
||||
title: 'Issues detected',
|
||||
description:
|
||||
'Patrol surfaced one active critical finding and recent coverage is incomplete.',
|
||||
eyebrow: 'Current assessment',
|
||||
},
|
||||
overallHealth: { grade: 'B', score: 84 },
|
||||
scoreChipLabel: 'Health',
|
||||
metricState: {
|
||||
primaryLabel: 'Infrastructure findings',
|
||||
primaryValue: 1,
|
||||
secondaryLabel: 'Runtime issues',
|
||||
secondaryValue: 1,
|
||||
fixedLabel: 'Fixed',
|
||||
fixedValue: 2,
|
||||
},
|
||||
verification: {
|
||||
title: 'Full patrol verified recently',
|
||||
description: 'Latest full run completed with findings.',
|
||||
lastFullRunAt: '2026-05-06T12:00:00Z',
|
||||
activityMixLabel: '1 full patrol · 2 scoped runs',
|
||||
},
|
||||
recency: {
|
||||
label: 'Last patrol',
|
||||
timestamp: '2026-05-06T12:10:00Z',
|
||||
},
|
||||
latestRun: {
|
||||
kindLabel: 'Full patrol',
|
||||
status: { label: 'issues found' },
|
||||
timestamp: '2026-05-06T12:10:00Z',
|
||||
coverageSummary: '12 resources checked',
|
||||
findingsSnapshotAvailable: true,
|
||||
},
|
||||
investigationContext: {
|
||||
recentChangeCount: 1,
|
||||
correlationCount: 2,
|
||||
governedResourceCount: 4,
|
||||
hasContext: true,
|
||||
summaryText: '1 recent change · 2 correlations · 4 policy-covered resources',
|
||||
},
|
||||
activeFindings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
title: 'High CPU usage',
|
||||
description: 'CPU stayed above 95% during backup.',
|
||||
severity: 'critical',
|
||||
status: 'active',
|
||||
resourceId: 'vm-100',
|
||||
resourceName: 'web-server',
|
||||
resourceType: 'vm',
|
||||
detectedAt: '2026-05-06T12:00:00Z',
|
||||
lastSeenAt: '2026-05-06T12:10:00Z',
|
||||
investigationStatus: 'completed',
|
||||
investigationOutcome: 'fix_queued',
|
||||
loopState: 'awaiting_approval',
|
||||
timesRaised: 3,
|
||||
regressionCount: 1,
|
||||
lastRegressionAt: '2026-05-06T12:06:00Z',
|
||||
investigationRecord: {
|
||||
id: 'record-1',
|
||||
finding_id: 'finding-1',
|
||||
subject: {
|
||||
resource_id: 'vm-100',
|
||||
resource_name: 'web-server',
|
||||
resource_type: 'vm',
|
||||
node: 'pve-1',
|
||||
},
|
||||
trigger: { detected_at: '2026-05-06T12:00:00Z' },
|
||||
status: 'completed',
|
||||
outcome: 'fix_queued',
|
||||
confidence: 'high',
|
||||
conclusion: 'Backup job saturated CPU.',
|
||||
recommended_action: 'Approve a controlled restart after the backup completes.',
|
||||
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
|
||||
proposed_fix: {
|
||||
id: 'fix-1',
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart workload.service'],
|
||||
risk_level: 'medium',
|
||||
destructive: false,
|
||||
},
|
||||
verification: ['CPU returned below 50%'],
|
||||
tools_used: ['ssh.exec'],
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'finding-2',
|
||||
title: 'Patrol provider warning',
|
||||
severity: 'warning',
|
||||
status: 'active',
|
||||
resourceId: 'vm-100',
|
||||
resourceName: 'web-server',
|
||||
resourceType: 'vm',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(handoff.prompt).toContain('Discuss the current Pulse Patrol assessment');
|
||||
expect(handoff.context.autonomousMode).toBe(false);
|
||||
expect(handoff.context.handoffContext).toContain('[Patrol Assessment Context]');
|
||||
expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol current assessment');
|
||||
expect(handoff.context.handoffContext).toContain('Health: Health B 84/100');
|
||||
expect(handoff.context.handoffContext).toContain(
|
||||
'Supporting Context: 1 recent change · 2 correlations · 4 policy-covered resources',
|
||||
);
|
||||
expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage');
|
||||
expect(handoff.context.handoffContext).toContain('1 command recorded for approval context');
|
||||
expect(handoff.context.handoffResources).toEqual([
|
||||
{ id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' },
|
||||
]);
|
||||
expect(handoff.context.briefing).toMatchObject({
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol assessment attached',
|
||||
subject: 'Issues detected',
|
||||
safetyNote: 'Diagnostics and remediation require governed approval.',
|
||||
});
|
||||
expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service');
|
||||
});
|
||||
|
||||
it('builds operator-facing Patrol record presentation without exposing raw commands', () => {
|
||||
const presentation = buildPatrolInvestigationRecordPresentation({
|
||||
id: 'record-1',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type {
|
|||
IntelligencePolicyPostureSummary,
|
||||
} from '@/types/aiIntelligence';
|
||||
import type { InvestigationRecord, RemediationPlan } from '@/api/ai';
|
||||
import type { AIChatContextBriefing } from '@/stores/aiChat';
|
||||
import type { AIChatContext, AIChatContextBriefing, AIChatHandoffResource } from '@/stores/aiChat';
|
||||
|
||||
export interface PatrolInvestigationContextSummaryInput {
|
||||
recentChangesCount?: number | null;
|
||||
|
|
@ -83,6 +83,76 @@ export interface PatrolRemediationPlanAssistantInput {
|
|||
plan: RemediationPlan;
|
||||
}
|
||||
|
||||
export interface PatrolAssessmentAssistantFindingInput {
|
||||
id?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
severity?: string | null;
|
||||
status?: string | null;
|
||||
resourceId?: string | null;
|
||||
resourceName?: string | null;
|
||||
resourceType?: string | null;
|
||||
detectedAt?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
investigationStatus?: string | null;
|
||||
investigationOutcome?: string | null;
|
||||
loopState?: string | null;
|
||||
timesRaised?: number | null;
|
||||
regressionCount?: number | null;
|
||||
lastRegressionAt?: string | null;
|
||||
investigationRecord?: InvestigationRecord | null;
|
||||
}
|
||||
|
||||
export interface PatrolAssessmentAssistantHandoffInput {
|
||||
assessment?: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
eyebrow?: string | null;
|
||||
} | null;
|
||||
overallHealth?: {
|
||||
grade?: string | null;
|
||||
score?: number | null;
|
||||
} | null;
|
||||
scoreChipLabel?: string | null;
|
||||
metricState?: {
|
||||
primaryLabel?: string | null;
|
||||
primaryValue?: number | null;
|
||||
secondaryLabel?: string | null;
|
||||
secondaryValue?: number | null;
|
||||
fixedLabel?: string | null;
|
||||
fixedValue?: number | null;
|
||||
} | null;
|
||||
verification?: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
lastFullRunAt?: string | null;
|
||||
activityMixLabel?: string | null;
|
||||
} | null;
|
||||
recency?: {
|
||||
label?: string | null;
|
||||
timestamp?: string | null;
|
||||
} | null;
|
||||
latestRun?: {
|
||||
kindLabel?: string | null;
|
||||
status?: {
|
||||
label?: string | null;
|
||||
} | null;
|
||||
timestamp?: string | null;
|
||||
coverageSummary?: string | null;
|
||||
findingsSnapshotAvailable?: boolean | null;
|
||||
} | null;
|
||||
investigationContext?: PatrolInvestigationContextSummary | null;
|
||||
activeFindings?: PatrolAssessmentAssistantFindingInput[] | null;
|
||||
}
|
||||
|
||||
export interface PatrolAssessmentAssistantHandoff {
|
||||
prompt: string;
|
||||
context: Omit<AIChatContext, 'initialPrompt'>;
|
||||
}
|
||||
|
||||
const MAX_ASSESSMENT_FINDINGS = 5;
|
||||
const MAX_ASSESSMENT_RESOURCES = 8;
|
||||
|
||||
export function buildPatrolInvestigationContextSummary(
|
||||
input: PatrolInvestigationContextSummaryInput,
|
||||
): PatrolInvestigationContextSummary {
|
||||
|
|
@ -183,6 +253,348 @@ export function buildPatrolAssistantFindingPrompt(
|
|||
return prompt;
|
||||
}
|
||||
|
||||
export function buildPatrolAssessmentAssistantHandoff(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): PatrolAssessmentAssistantHandoff {
|
||||
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
|
||||
const description = normalizeText(input.assessment?.description);
|
||||
const handoffContext = buildPatrolAssessmentAssistantModelContext(input);
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
`Discuss the current Pulse Patrol assessment: ${title}.`,
|
||||
description,
|
||||
'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.',
|
||||
'Do not infer, repeat, or execute raw command text from this handoff.',
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('\n\n'),
|
||||
context: {
|
||||
targetType: 'dashboard',
|
||||
targetId: 'pulse-patrol-assessment',
|
||||
autonomousMode: false,
|
||||
handoffContext,
|
||||
handoffResources: buildPatrolAssessmentHandoffResources(input.activeFindings ?? []),
|
||||
briefing: buildPatrolAssessmentAssistantBriefing(input),
|
||||
context: {
|
||||
source: 'pulse-patrol-assessment',
|
||||
activeFindingCount: normalizeNonNegativeCount(input.activeFindings?.length),
|
||||
recentChangeCount: input.investigationContext?.recentChangeCount ?? 0,
|
||||
correlationCount: input.investigationContext?.correlationCount ?? 0,
|
||||
governedResourceCount: input.investigationContext?.governedResourceCount ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPatrolAssessmentAssistantBriefing(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): AIChatContextBriefing {
|
||||
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
|
||||
const description = normalizeText(input.assessment?.description);
|
||||
const health = formatAssessmentHealth(input);
|
||||
const attentionSummary = formatAssessmentAttentionSummary(input);
|
||||
const verification = formatAssessmentVerification(input);
|
||||
const latestRun = formatAssessmentLatestRun(input);
|
||||
const contextSummary = normalizeText(input.investigationContext?.summaryText);
|
||||
const findings = normalizeAssessmentFindings(input.activeFindings);
|
||||
|
||||
return {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol assessment attached',
|
||||
subject: title,
|
||||
statusLabel: [health, attentionSummary].filter(isNonEmptyString).join(' · ') || undefined,
|
||||
detailLines: [description, verification, latestRun, contextSummary]
|
||||
.filter(isNonEmptyString)
|
||||
.slice(0, 4),
|
||||
evidence: findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString).slice(0, 4),
|
||||
actionLabel: 'Discuss Patrol assessment',
|
||||
safetyNote: 'Diagnostics and remediation require governed approval.',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPatrolAssessmentAssistantModelContext(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): string {
|
||||
const findings = normalizeAssessmentFindings(input.activeFindings);
|
||||
const totalFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
|
||||
const omittedFindingCount = Math.max(0, totalFindingCount - findings.length);
|
||||
|
||||
return [
|
||||
'[Patrol Assessment Context]',
|
||||
'Source: Pulse Patrol current assessment',
|
||||
formatContextLine(
|
||||
'Assessment',
|
||||
normalizeText(input.assessment?.title) || 'Pulse Patrol assessment',
|
||||
),
|
||||
formatContextLine('Assessment Description', input.assessment?.description),
|
||||
formatContextLine('Assessment Scope', input.assessment?.eyebrow),
|
||||
formatContextLine('Health', formatAssessmentHealth(input)),
|
||||
formatContextLine('Attention', formatAssessmentAttentionSummary(input)),
|
||||
formatContextLine('Verification', formatAssessmentVerification(input)),
|
||||
formatContextLine('Last Patrol', formatAssessmentRecency(input)),
|
||||
formatContextLine('Latest Run', formatAssessmentLatestRun(input)),
|
||||
formatContextLine('Supporting Context', input.investigationContext?.summaryText),
|
||||
...findings.map((finding, index) => formatAssessmentFindingContextLine(finding, index + 1)),
|
||||
omittedFindingCount > 0
|
||||
? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.`
|
||||
: undefined,
|
||||
'Operator Boundary: This Patrol assessment handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.',
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildPatrolAssessmentHandoffResources(
|
||||
findings: PatrolAssessmentAssistantFindingInput[],
|
||||
): AIChatHandoffResource[] {
|
||||
const resources = new Map<string, AIChatHandoffResource>();
|
||||
|
||||
for (const finding of findings) {
|
||||
const resource = getAssessmentFindingResource(finding);
|
||||
if (!resource.id) continue;
|
||||
|
||||
const key = [resource.type, resource.id].filter(isNonEmptyString).join(':');
|
||||
const existing = resources.get(key);
|
||||
if (existing) {
|
||||
resources.set(key, {
|
||||
...existing,
|
||||
name: existing.name || resource.name,
|
||||
type: existing.type || resource.type,
|
||||
node: existing.node || resource.node,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resources.set(key, resource);
|
||||
|
||||
if (resources.size >= MAX_ASSESSMENT_RESOURCES) break;
|
||||
}
|
||||
|
||||
return Array.from(resources.values());
|
||||
}
|
||||
|
||||
function normalizeAssessmentFindings(
|
||||
findings?: PatrolAssessmentAssistantFindingInput[] | null,
|
||||
): PatrolAssessmentAssistantFindingInput[] {
|
||||
return (findings ?? [])
|
||||
.filter((finding) =>
|
||||
Boolean(
|
||||
normalizeText(finding.id) ||
|
||||
normalizeText(finding.title) ||
|
||||
normalizeText(finding.resourceId) ||
|
||||
normalizeText(finding.investigationRecord?.id),
|
||||
),
|
||||
)
|
||||
.slice(0, MAX_ASSESSMENT_FINDINGS);
|
||||
}
|
||||
|
||||
function formatAssessmentHealth(input: PatrolAssessmentAssistantHandoffInput): string | undefined {
|
||||
const label = normalizeText(input.scoreChipLabel) || 'Health';
|
||||
const grade = normalizeText(input.overallHealth?.grade);
|
||||
const score = input.overallHealth?.score;
|
||||
const scoreLabel =
|
||||
typeof score === 'number' && Number.isFinite(score) ? `${Math.round(score)}/100` : '';
|
||||
return [label, grade, scoreLabel].filter(isNonEmptyString).join(' ') || undefined;
|
||||
}
|
||||
|
||||
function formatAssessmentAttentionSummary(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): string | undefined {
|
||||
const metricState = input.metricState;
|
||||
const parts: string[] = [];
|
||||
const primaryValue = normalizeNonNegativeCount(metricState?.primaryValue);
|
||||
const secondaryValue = normalizeNonNegativeCount(metricState?.secondaryValue);
|
||||
const fixedValue = normalizeNonNegativeCount(metricState?.fixedValue);
|
||||
|
||||
if (primaryValue > 0) {
|
||||
parts.push(
|
||||
formatAssessmentMetricCount(metricState?.primaryLabel || 'Active findings', primaryValue),
|
||||
);
|
||||
}
|
||||
if (
|
||||
secondaryValue > 0 &&
|
||||
normalizeText(metricState?.secondaryLabel) !== normalizeText(metricState?.primaryLabel)
|
||||
) {
|
||||
parts.push(
|
||||
formatAssessmentMetricCount(metricState?.secondaryLabel || 'Warnings', secondaryValue),
|
||||
);
|
||||
}
|
||||
if (fixedValue > 0) {
|
||||
parts.push(formatAssessmentMetricCount(metricState?.fixedLabel || 'Fixed', fixedValue));
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
|
||||
return activeFindingCount > 0
|
||||
? formatAssessmentMetricCount('Active findings', activeFindingCount)
|
||||
: 'No active Patrol findings';
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function formatAssessmentMetricCount(label: string | null | undefined, value: number): string {
|
||||
const normalizedLabel = normalizeText(label) || 'Items';
|
||||
const displayLabel = value === 1 ? singularizeMetricLabel(normalizedLabel) : normalizedLabel;
|
||||
return `${value} ${displayLabel.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function singularizeMetricLabel(label: string): string {
|
||||
if (label.toLowerCase().endsWith('ies')) {
|
||||
return `${label.slice(0, -3)}y`;
|
||||
}
|
||||
if (label.toLowerCase().endsWith('s')) {
|
||||
return label.slice(0, -1);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function formatAssessmentVerification(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): string | undefined {
|
||||
const verification = input.verification;
|
||||
if (!verification) return undefined;
|
||||
|
||||
return [
|
||||
normalizeText(verification.title),
|
||||
normalizeText(verification.description),
|
||||
normalizeText(verification.lastFullRunAt)
|
||||
? `last full run ${normalizeText(verification.lastFullRunAt)}`
|
||||
: undefined,
|
||||
normalizeText(verification.activityMixLabel)
|
||||
? `recent activity mix ${normalizeText(verification.activityMixLabel)}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function formatAssessmentRecency(input: PatrolAssessmentAssistantHandoffInput): string | undefined {
|
||||
const label = normalizeText(input.recency?.label);
|
||||
const timestamp = normalizeText(input.recency?.timestamp);
|
||||
if (!label && !timestamp) return undefined;
|
||||
return [label || 'Last Patrol', timestamp].filter(isNonEmptyString).join(' ');
|
||||
}
|
||||
|
||||
function formatAssessmentLatestRun(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): string | undefined {
|
||||
const latestRun = input.latestRun;
|
||||
if (!latestRun) return undefined;
|
||||
|
||||
return [
|
||||
normalizeText(latestRun.kindLabel),
|
||||
normalizeText(latestRun.status?.label),
|
||||
normalizeText(latestRun.timestamp),
|
||||
normalizeText(latestRun.coverageSummary),
|
||||
latestRun.findingsSnapshotAvailable === false ? 'findings snapshot unavailable' : undefined,
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function formatAssessmentFindingEvidence(
|
||||
finding: PatrolAssessmentAssistantFindingInput,
|
||||
): string | undefined {
|
||||
const title = normalizeText(finding.title) || 'Patrol finding';
|
||||
const resource = getAssessmentFindingResource(finding);
|
||||
const severityStatus = [
|
||||
formatIdentifierLabel(finding.severity),
|
||||
formatIdentifierLabel(finding.status),
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join(' ');
|
||||
const resourceLabel = formatAssessmentResourceLabel(resource);
|
||||
return [title, resourceLabel, severityStatus].filter(isNonEmptyString).join(' · ');
|
||||
}
|
||||
|
||||
function formatAssessmentFindingContextLine(
|
||||
finding: PatrolAssessmentAssistantFindingInput,
|
||||
index: number,
|
||||
): string {
|
||||
const title = truncateContextText(normalizeText(finding.title) || 'Patrol finding', 120);
|
||||
const resource = getAssessmentFindingResource(finding);
|
||||
const record = buildPatrolInvestigationRecordPresentation(finding.investigationRecord);
|
||||
const statusParts = [
|
||||
formatIdentifierLabel(finding.severity),
|
||||
formatIdentifierLabel(finding.status),
|
||||
formatIdentifierLabel(finding.investigationStatus),
|
||||
formatIdentifierLabel(finding.investigationOutcome),
|
||||
formatIdentifierLabel(finding.loopState),
|
||||
].filter(isNonEmptyString);
|
||||
const raisedParts = [
|
||||
normalizeNonNegativeCount(finding.timesRaised) > 1
|
||||
? `raised ${normalizeNonNegativeCount(finding.timesRaised)} times`
|
||||
: undefined,
|
||||
normalizeNonNegativeCount(finding.regressionCount) > 0
|
||||
? `regressed ${normalizeNonNegativeCount(finding.regressionCount)} time${
|
||||
normalizeNonNegativeCount(finding.regressionCount) === 1 ? '' : 's'
|
||||
}`
|
||||
: undefined,
|
||||
normalizeText(finding.lastRegressionAt)
|
||||
? `last regression ${normalizeText(finding.lastRegressionAt)}`
|
||||
: undefined,
|
||||
];
|
||||
const recordParts = [
|
||||
record.statusLabel,
|
||||
record.outcomeLabel,
|
||||
record.confidenceLabel,
|
||||
record.conclusion ? `conclusion ${truncateContextText(record.conclusion, 180)}` : undefined,
|
||||
record.recommendedAction
|
||||
? `recommended ${truncateContextText(record.recommendedAction, 180)}`
|
||||
: undefined,
|
||||
record.proposedFix?.description
|
||||
? `proposed fix ${truncateContextText(record.proposedFix.description, 160)}`
|
||||
: undefined,
|
||||
record.proposedFix?.commandSummary,
|
||||
record.proposedFix?.destructive ? 'destructive proposed fix' : undefined,
|
||||
record.error ? `investigation error ${truncateContextText(record.error, 160)}` : undefined,
|
||||
];
|
||||
|
||||
const parts = [
|
||||
`${title} on ${formatAssessmentResourceLabel(resource) || 'affected resource'}`,
|
||||
normalizeText(finding.id) ? `finding ${normalizeText(finding.id)}` : undefined,
|
||||
statusParts.length > 0 ? statusParts.join(' ') : undefined,
|
||||
normalizeText(finding.description)
|
||||
? `description ${truncateContextText(finding.description, 180)}`
|
||||
: undefined,
|
||||
normalizeText(finding.detectedAt) ? `detected ${normalizeText(finding.detectedAt)}` : undefined,
|
||||
normalizeText(finding.lastSeenAt)
|
||||
? `last seen ${normalizeText(finding.lastSeenAt)}`
|
||||
: undefined,
|
||||
...raisedParts,
|
||||
...recordParts,
|
||||
].filter(isNonEmptyString);
|
||||
|
||||
return `Finding ${index}: ${parts.join('; ')}`;
|
||||
}
|
||||
|
||||
function getAssessmentFindingResource(
|
||||
finding: PatrolAssessmentAssistantFindingInput,
|
||||
): AIChatHandoffResource {
|
||||
const subject = finding.investigationRecord?.subject;
|
||||
return {
|
||||
id: normalizeText(finding.resourceId) || normalizeText(subject?.resource_id),
|
||||
name: normalizeText(finding.resourceName) || normalizeText(subject?.resource_name) || undefined,
|
||||
type: normalizeText(finding.resourceType) || normalizeText(subject?.resource_type) || undefined,
|
||||
node: normalizeText(subject?.node) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAssessmentResourceLabel(resource: AIChatHandoffResource): string | undefined {
|
||||
const name = normalizeText(resource.name);
|
||||
const id = normalizeText(resource.id);
|
||||
const type = normalizeText(resource.type);
|
||||
const node = normalizeText(resource.node);
|
||||
const label = name || id;
|
||||
if (!label) return undefined;
|
||||
|
||||
const qualifiers = [type, id && id !== label ? id : undefined, node ? `node ${node}` : undefined]
|
||||
.filter(isNonEmptyString)
|
||||
.join(' ');
|
||||
return qualifiers ? `${label} (${qualifiers})` : label;
|
||||
}
|
||||
|
||||
export function patrolAssistantFindingHandoffRequiresApprovalMode(
|
||||
input: PatrolAssistantFindingModeInput,
|
||||
): boolean {
|
||||
|
|
@ -665,6 +1077,20 @@ function normalizeText(value?: string | null): string {
|
|||
return value.trim();
|
||||
}
|
||||
|
||||
function truncateContextText(value?: string | null, limit: number = 240): string {
|
||||
const normalized = normalizeText(value).replace(/\s+/g, ' ');
|
||||
if (!normalized || normalized.length <= limit) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, limit - 3)).trim()}...`;
|
||||
}
|
||||
|
||||
function formatContextLine(label: string, value?: string | null): string | undefined {
|
||||
const normalized = truncateContextText(value, 500);
|
||||
if (!normalized) return undefined;
|
||||
return `${label}: ${normalized}`;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue