Add Explain contextual button to findings

Adds a contextual "Explain" entry point next to the existing
"Discuss with Assistant" button on every finding card. The new button
opens Assistant with the same handoff context (investigation record,
operational memory, pending approval, proposed fix) but seeds a
different leading sentence: "Explain this Patrol finding... Walk me
through what we know, why it matters for the affected workloads, how
confident the analysis is, and whether the recommended action is the
right next step." This routes the LLM toward an explanatory framing
rather than open-ended discussion, matching the user's vision of
specific contextual entry points instead of a single generic chat
button.

Plumbing changes:
- New PatrolAssistantFindingIntent type ('discuss' | 'explain')
- Optional intent on PatrolAssistantFindingPromptInput and
  PatrolAssistantFindingHandoffInput
- buildPatrolAssistantFindingPrompt switches the leading sentence on
  intent; downstream context attachment is identical so trust signals
  (impact, confidence, previous resolved fix, etc.) flow through both
  paths uniformly.
- FindingsPanel extracts the shared handoff into a small
  openFindingInAssistant(finding, intent) helper used by both
  handleDiscussWithAssistant and the new handleExplainFinding.

Adds two tests: explain-intent prompt has explanation framing while
discuss-intent keeps the existing wording, and the FindingsPanel
source-text test pins the new button + handler wiring. Updates the
patrol-intelligence, frontend-primitives, and api-contracts contracts
to pin the contextual-intent rule and the uniform-context-attachment
invariant.
This commit is contained in:
rcourtman
2026-05-08 19:59:21 +01:00
parent 3c1138ff3c
commit 8fd20f2222
7 changed files with 105 additions and 8 deletions
@@ -966,6 +966,14 @@ the canonical monitored-system blocked payload.
persisted findings created by older binaries must adopt the
freshly-classified impact text on next re-detection rather than
preserving the empty value.
The Patrol Assistant handoff carries an optional
`intent` field of type `PatrolAssistantFindingIntent`
(`'discuss' | 'explain'`) so contextual entry points on the finding
surface can route through the same handoff builder while seeding
different leading sentences. The structured handoff context
(investigation record, operational memory, pending approval,
proposed fix, next-step action) must be attached identically across
intents; only the seed prompt's framing varies.
`unified.UnifiedFinding` also carries a
`previous_resolved_fix_summary` operational-memory field captured at
regression time from
@@ -942,9 +942,13 @@ prompt explain the same operator-facing priority.
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. That feature-owned presentation
helper is the single emitter for investigation-record `impact` and
`rollback` fields: when an investigation record exists but those fields
Patrol-specific prompt formatter. The Patrol-owned helper exposes a
`PatrolAssistantFindingIntent` parameter ('discuss' | 'explain') so
contextual entry points can vary the seeded leading sentence without
duplicating the structured-context attachment; shared drawer
primitives must not branch on intent themselves. That feature-owned
presentation helper is the single emitter for investigation-record
`impact` and `rollback` fields: when an investigation record exists but those fields
are empty, the helper emits explicit `Impact not assessed` and
`Rollback not specified` lines into the model-only Patrol finding
prompt context so the operator-visible gap is surfaced to Assistant
@@ -258,7 +258,17 @@ Patrol-specific presentation helpers.
renders an `Impact:` line between `Description` and `Recommendation`.
The same panel also surfaces `investigation_record.confidence` as a
badge in the collapsed finding row (next to the investigation outcome
badge) so operators can scan trust without expanding every card. The
badge) so operators can scan trust without expanding every card.
Findings carry contextual Assistant entry points keyed on intent: the
default "Discuss with Assistant" button opens an open-ended chat
handoff, and a parallel "Explain" button opens the same handoff with
a `PatrolAssistantFindingIntent='explain'` seed that asks the LLM to
walk through what we know, why it matters, how confident the
analysis is, and whether the recommended action is appropriate. Both
buttons must route through `buildPatrolAssistantFindingHandoff` so
the structured context (investigation record, operational memory,
pending approval, proposed fix, next-step action) is attached
uniformly; only the leading sentence differs by intent. The
badge palette is provided by `getInvestigationConfidenceBadgeClasses`
in `frontend-modern/src/utils/aiFindingPresentation.ts`: high is
reassuringly emphasized, medium is neutral, low is a soft amber.
@@ -498,8 +498,10 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
}
};
const handleDiscussWithAssistant = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
const openFindingInAssistant = async (
finding: UnifiedFinding,
intent: 'discuss' | 'explain',
) => {
await aiIntelligenceStore.loadPendingApprovals();
const subject = getFindingSubjectPresentation(finding).label;
const title = getFindingTitlePresentation(finding).label;
@@ -537,10 +539,21 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
proposedFix,
investigationRecord: finding.investigationRecord,
nextStepAction,
intent,
});
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
};
const handleDiscussWithAssistant = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
await openFindingInAssistant(finding, 'discuss');
};
const handleExplainFinding = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
await openFindingInAssistant(finding, 'explain');
};
const handleOpenPlanInAssistant = (finding: UnifiedFinding, plan: RemediationPlan, e: Event) => {
e.stopPropagation();
const subject = getFindingSubjectPresentation(finding).label;
@@ -1022,6 +1035,22 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
Add Note
</button>
</Show>
<button
type="button"
onClick={(e) => handleExplainFinding(finding, e)}
class="px-2 py-1 rounded border border-border hover:bg-surface-hover flex items-center gap-1"
title="Open Assistant with an explanation prompt"
>
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Explain
</button>
<button
type="button"
onClick={(e) => handleDiscussWithAssistant(finding, e)}
@@ -76,6 +76,17 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).not.toContain('Rollback: `');
});
it('exposes an Explain entry point that seeds Assistant with an explanation prompt', () => {
// The Explain button is a contextual entry point parallel to "Discuss with
// Assistant" but with an intent that produces explanation framing rather
// than open-ended discussion. Both flow through the same handoff builder
// so context attachment stays uniform.
expect(findingsPanelSource).toContain('handleExplainFinding');
expect(findingsPanelSource).toContain("openFindingInAssistant(finding, 'explain')");
expect(findingsPanelSource).toMatch(/>\s*Explain\s*</);
expect(findingsPanelSource).toContain("openFindingInAssistant(finding, 'discuss')");
});
it('surfaces investigation_record.confidence as a badge in the collapsed row', () => {
// The seven-question schema's confidence answer should be visible to
// operators without expanding the card. The badge sits next to the
@@ -876,6 +876,27 @@ describe('patrolInvestigationContextModel', () => {
expect(presentation.impact).toBe('');
});
it('seeds an explain-intent prompt with explanation framing rather than discussion framing', () => {
const discussPrompt = buildPatrolAssistantFindingPrompt({
title: 'Backup job failing',
subject: 'vm-101',
description: 'Datastore quota exhausted',
});
const explainPrompt = buildPatrolAssistantFindingPrompt({
title: 'Backup job failing',
subject: 'vm-101',
description: 'Datastore quota exhausted',
intent: 'explain',
});
expect(discussPrompt).toContain("I'd like to discuss");
expect(explainPrompt).toContain('Explain this Patrol finding');
expect(explainPrompt.toLowerCase()).toContain('walk me through what we know');
// Both prompts include the title and subject so the seed reads naturally.
expect(explainPrompt).toContain('Backup job failing');
expect(explainPrompt).toContain('vm-101');
});
it('surfaces investigation impact and rollback when the backend record carries them', () => {
const presentation = buildPatrolInvestigationRecordPresentation({
id: 'record-2',
@@ -73,6 +73,8 @@ export interface PatrolInvestigationRecordPresentation {
error?: string;
}
export type PatrolAssistantFindingIntent = 'discuss' | 'explain';
export interface PatrolAssistantFindingPromptInput {
title: string;
subject: string;
@@ -83,6 +85,7 @@ export interface PatrolAssistantFindingPromptInput {
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
nextStepAction?: PatrolAssistantNextStepInput | null;
intent?: PatrolAssistantFindingIntent;
}
export interface PatrolAssistantApprovalBriefingInput {
@@ -167,6 +170,7 @@ export interface PatrolAssistantFindingHandoffInput {
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
nextStepAction?: PatrolAssistantNextStepInput | null;
intent?: PatrolAssistantFindingIntent;
}
export interface PatrolAssistantFindingModeInput {
@@ -502,8 +506,17 @@ export function buildPatrolAssistantFindingPrompt(
const actionInstruction = buildPatrolAssistantFindingActionPromptInstruction(input);
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
let prompt = `I'd like to discuss this Patrol finding: "${title}" on ${subject}.`;
if (hasRecord) {
let prompt: string;
if (input.intent === 'explain') {
prompt =
`Explain this Patrol finding: "${title}" on ${subject}. ` +
'Walk me through what we know, why it matters for the affected workloads, ' +
'how confident the analysis is, and whether the recommended action is the right next step. ' +
'Use the structured investigation record and operational memory in the attached context as the primary source.';
} else {
prompt = `I'd like to discuss this Patrol finding: "${title}" on ${subject}.`;
}
if (hasRecord && input.intent !== 'explain') {
prompt +=
'\n\nPulse Patrol has a structured investigation record for this finding. Use that record as the main context before suggesting next actions.';
}
@@ -648,6 +661,7 @@ export function buildPatrolAssistantFindingHandoff(
proposedFix: input.proposedFix,
investigationRecord: input.investigationRecord,
nextStepAction: input.nextStepAction,
intent: input.intent,
}),
context: {
targetType: resource?.type,