Render clarify search prompts as guidance only

This commit is contained in:
master
2026-03-08 11:50:34 +02:00
parent e01a499df9
commit abbfe64bd7
10 changed files with 224 additions and 124 deletions

View File

@@ -94,11 +94,11 @@ export const DEFAULT_COMMON_QUESTIONS: readonly SearchQuestionChip[] = withQuest
export const DEFAULT_CLARIFYING_QUESTIONS: readonly SearchQuestionChip[] = withQuestionKind([
{
key: 'ui.search.question.default.clarify_target',
fallback: 'Which workload, release, or policy should I narrow this to?',
fallback: 'Add the workload, release, or policy you want to inspect.',
},
{
key: 'ui.search.question.default.clarify_scope',
fallback: 'What exact blocker or symptom should I narrow this to?',
fallback: 'Add the exact blocker or symptom you want to inspect.',
},
], 'clarify');
@@ -153,8 +153,8 @@ const FINDINGS_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.findings.remediation', fallback: 'What is the safest remediation path?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.findings.clarify_target', fallback: 'Which CVE, workload, or package should I narrow this to?' },
{ key: 'ui.search.question.findings.clarify_scope', fallback: 'What part of this finding matters most right now?' },
{ key: 'ui.search.question.findings.clarify_target', fallback: 'Add the CVE, workload, or package you want to inspect.' },
{ key: 'ui.search.question.findings.clarify_scope', fallback: 'Add the part of the finding that matters most right now.' },
], 'clarify'),
};
@@ -165,8 +165,8 @@ const VEX_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.vex.conflicting_evidence', fallback: 'What evidence conflicts with this VEX?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.vex.clarify_statement', fallback: 'Which statement, component, or product range should I narrow this to?' },
{ key: 'ui.search.question.vex.clarify_need', fallback: 'Do you want exploitability meaning, coverage, or conflict evidence?' },
{ key: 'ui.search.question.vex.clarify_statement', fallback: 'Add the statement, component, or product range you want to inspect.' },
{ key: 'ui.search.question.vex.clarify_need', fallback: 'Add whether you need exploitability meaning, coverage, or conflict evidence.' },
], 'clarify'),
};
@@ -177,8 +177,8 @@ const POLICY_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.policy.exception', fallback: 'What is the safest exception path?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.policy.clarify_target', fallback: 'Which rule, environment, or control should I narrow this to?' },
{ key: 'ui.search.question.policy.clarify_need', fallback: 'Do you want recent failures, exceptions, or promotion impact?' },
{ key: 'ui.search.question.policy.clarify_target', fallback: 'Add the rule, environment, or control you want to inspect.' },
{ key: 'ui.search.question.policy.clarify_need', fallback: 'Add whether you need recent failures, exceptions, or promotion impact.' },
], 'clarify'),
};
@@ -189,8 +189,8 @@ const DOCTOR_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.doctor.changed', fallback: 'What changed before this health issue?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.doctor.clarify_check', fallback: 'Which check or symptom should I narrow this to?' },
{ key: 'ui.search.question.doctor.clarify_need', fallback: 'Do you want diagnosis, remediation, or verification steps?' },
{ key: 'ui.search.question.doctor.clarify_check', fallback: 'Add the check or symptom you want to inspect.' },
{ key: 'ui.search.question.doctor.clarify_need', fallback: 'Add whether you need diagnosis, remediation, or verification steps.' },
], 'clarify'),
};
@@ -201,8 +201,8 @@ const GRAPH_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.graph.next_hop', fallback: 'What should I inspect next on this path?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.graph.clarify_node', fallback: 'Which node, package, or edge should I narrow this to?' },
{ key: 'ui.search.question.graph.clarify_need', fallback: 'Do you want reachability, impact, or next-step guidance?' },
{ key: 'ui.search.question.graph.clarify_node', fallback: 'Add the node, package, or edge you want to inspect.' },
{ key: 'ui.search.question.graph.clarify_need', fallback: 'Add whether you need reachability, impact, or next-step guidance.' },
], 'clarify'),
};
@@ -213,8 +213,8 @@ const OPS_MEMORY_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.ops_memory.repeat', fallback: 'What repeated failures are related to this?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.ops_memory.clarify_job', fallback: 'Which job, incident, or recurring failure should I narrow this to?' },
{ key: 'ui.search.question.ops_memory.clarify_need', fallback: 'Do you want precedent, likely cause, or recommended recovery?' },
{ key: 'ui.search.question.ops_memory.clarify_job', fallback: 'Add the job, incident, or recurring failure you want to inspect.' },
{ key: 'ui.search.question.ops_memory.clarify_need', fallback: 'Add whether you need precedent, likely cause, or recommended recovery.' },
], 'clarify'),
};
@@ -225,8 +225,8 @@ const TIMELINE_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.timeline.next_event', fallback: 'What else happened around this event?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.timeline.clarify_window', fallback: 'Which deployment, incident, or time window should I narrow this to?' },
{ key: 'ui.search.question.timeline.clarify_need', fallback: 'Do you want causes, impacts, or follow-up events?' },
{ key: 'ui.search.question.timeline.clarify_window', fallback: 'Add the deployment, incident, or time window you want to inspect.' },
{ key: 'ui.search.question.timeline.clarify_need', fallback: 'Add whether you need causes, impacts, or follow-up events.' },
], 'clarify'),
};
@@ -237,8 +237,8 @@ const RELEASES_SELF_SERVE: SearchSelfServeDefinition = {
{ key: 'ui.search.question.releases.next_step', fallback: 'What is the safest next step to ship?' },
], 'page'),
clarifyingQuestions: withQuestionKind([
{ key: 'ui.search.question.releases.clarify_target', fallback: 'Which environment or release should I narrow this to?' },
{ key: 'ui.search.question.releases.clarify_need', fallback: 'Do you want blockers, approvals, or policy impact?' },
{ key: 'ui.search.question.releases.clarify_target', fallback: 'Add the environment or release you want to inspect.' },
{ key: 'ui.search.question.releases.clarify_need', fallback: 'Add whether you need blockers, approvals, or policy impact.' },
], 'clarify'),
};

View File

@@ -78,6 +78,8 @@ type SearchAnswerView = {
}>;
questionLabel: string;
questions: SearchQuestionView[];
guidanceLabel: string | null;
guidance: SearchQuestionView[];
nextSearches: SearchSuggestionView[];
};
type SuccessfulSearchHistoryEntry = {
@@ -202,6 +204,22 @@ type SuccessfulSearchHistoryEntry = {
</div>
}
@if (answer.guidance.length > 0) {
<div class="search__answer-guidance">
<div class="search__group-label">{{ answer.guidanceLabel }}</div>
<div class="search__guidance-list">
@for (question of answer.guidance; track question.query) {
<div
class="search__guidance-item"
data-answer-guidance="clarify"
>
{{ question.query }}
</div>
}
</div>
</div>
}
@if (answer.nextSearches.length > 0) {
<div class="search__answer-next">
<div class="search__group-label">{{ t('ui.search.answer.next_searches', 'Related searches') }}</div>
@@ -681,6 +699,26 @@ type SuccessfulSearchHistoryEntry = {
border-style: dashed;
}
.search__answer-guidance {
margin-top: 0.75rem;
}
.search__guidance-list {
display: grid;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0;
}
.search__guidance-item {
padding: 0.5rem 0.625rem;
border: 1px dashed var(--color-border-secondary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.75rem;
line-height: 1.35;
}
.search__answer {
margin: 0.625rem 0.75rem 0.5rem;
padding: 0.75rem;
@@ -1174,7 +1212,15 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
questionLabel: backendAnswer.status === 'clarify'
? this.t('ui.search.answer.questions.clarify', 'Try one of these')
: this.t('ui.search.answer.questions.follow_up', 'Related questions'),
questions: this.buildBackendAnswerQuestions(backendAnswer),
questions: backendAnswer.status === 'clarify'
? []
: this.buildBackendAnswerQuestions(backendAnswer),
guidanceLabel: backendAnswer.status === 'clarify'
? this.t('ui.search.answer.guidance.clarify', 'Narrow the search by adding')
: null,
guidance: backendAnswer.status === 'clarify'
? this.buildClarifyGuidance(backendAnswer)
: [],
nextSearches,
};
}
@@ -1191,6 +1237,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
citations: this.buildAnswerCitations(response),
questionLabel: this.t('ui.search.answer.questions.follow_up', 'Related questions'),
questions: this.commonQuestions().slice(0, 3),
guidanceLabel: null,
guidance: [],
nextSearches,
};
}
@@ -1211,8 +1259,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
'No direct grounded answer was found from the current page context.',
),
citations: [],
questionLabel: this.t('ui.search.answer.questions.clarify', 'Try one of these'),
questions: clarifyingQuestions,
questionLabel: '',
questions: [],
guidanceLabel: this.t('ui.search.answer.guidance.clarify', 'Narrow the search by adding'),
guidance: clarifyingQuestions,
nextSearches,
};
}
@@ -1233,6 +1283,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
citations: [],
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these'),
questions: this.commonQuestions().slice(0, 2),
guidanceLabel: null,
guidance: [],
nextSearches,
};
});
@@ -1974,6 +2026,21 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
: this.commonQuestions().slice(0, 3);
}
private buildClarifyGuidance(answer: NonNullable<UnifiedSearchResponse['contextAnswer']>): SearchQuestionView[] {
const backendGuidance = (answer.questions ?? [])
.filter((question) => question.query.trim().length > 0)
.map((question) => ({
query: question.query,
kind: 'clarify' as const,
}));
if (backendGuidance.length > 0) {
return backendGuidance.slice(0, 3);
}
return this.clarifyingQuestions().slice(0, 3);
}
private answerTitleForStatus(status: SearchAnswerView['status']): string {
switch (status) {
case 'clarify':

View File

@@ -199,8 +199,8 @@ describe('AmbientContextService', () => {
events.next(new NavigationEnd(1, '/ops/policy', '/ops/policy'));
const questions = service.getClarifyingQuestions().map((item) => item.fallback);
expect(questions).toContain('Which rule, environment, or control should I narrow this to?');
expect(questions).toContain('Do you want recent failures, exceptions, or promotion impact?');
expect(questions).toContain('Add the rule, environment, or control you want to inspect.');
expect(questions).toContain('Add whether you need recent failures, exceptions, or promotion impact.');
});
it('returns timeline self-serve questions after navigating to the timeline route', () => {
@@ -215,8 +215,8 @@ describe('AmbientContextService', () => {
expect(commonQuestions).toContain('What changed before this incident?');
expect(commonQuestions).toContain('Which release introduced this risk?');
expect(clarifyingQuestions).toContain('Which deployment, incident, or time window should I narrow this to?');
expect(clarifyingQuestions).toContain('Do you want causes, impacts, or follow-up events?');
expect(clarifyingQuestions).toContain('Add the deployment, incident, or time window you want to inspect.');
expect(clarifyingQuestions).toContain('Add whether you need causes, impacts, or follow-up events.');
});
it('returns release-control self-serve questions for release routes without forcing a search domain', () => {
@@ -233,7 +233,7 @@ describe('AmbientContextService', () => {
expect(panel?.titleFallback).toBe('Release control');
expect(commonQuestions).toContain('What blocked this promotion?');
expect(commonQuestions).toContain('Which approvals are missing?');
expect(clarifyingQuestions).toContain('Which environment or release should I narrow this to?');
expect(clarifyingQuestions).toContain('Do you want blockers, approvals, or policy impact?');
expect(clarifyingQuestions).toContain('Add the environment or release you want to inspect.');
expect(clarifyingQuestions).toContain('Add whether you need blockers, approvals, or policy impact.');
});
});

View File

@@ -158,12 +158,12 @@ describe('GlobalSearchComponent', () => {
ambientContext.getClarifyingQuestions.and.returnValue([
{
key: 'ui.search.question.findings.clarify_target',
fallback: 'Which CVE, workload, or package should I narrow this to?',
fallback: 'Add the CVE, workload, or package you want to inspect.',
kind: 'clarify',
},
{
key: 'ui.search.question.findings.clarify_scope',
fallback: 'What part of this finding matters most right now?',
fallback: 'Add the part of the finding that matters most right now.',
kind: 'clarify',
},
]);
@@ -807,11 +807,15 @@ describe('GlobalSearchComponent', () => {
const answerQuestions = Array.from(
fixture.nativeElement.querySelectorAll('[data-answer-question]') as NodeListOf<HTMLButtonElement>,
).map((node) => node.textContent?.trim());
const guidanceItems = Array.from(
fixture.nativeElement.querySelectorAll('[data-answer-guidance="clarify"]') as NodeListOf<HTMLElement>,
).map((node) => node.textContent?.trim());
expect(answerPanel).not.toBeNull();
expect(answerPanel?.textContent).toContain('Tighten the question');
expect(answerQuestions).toContain('Which CVE, workload, or package should I narrow this to?');
expect(answerQuestions).toContain('What part of this finding matters most right now?');
expect(answerQuestions).toEqual([]);
expect(guidanceItems).toContain('Add the CVE, workload, or package you want to inspect.');
expect(guidanceItems).toContain('Add the part of the finding that matters most right now.');
});
it('does not hard-filter search requests to the current route scope', async () => {

View File

@@ -342,6 +342,26 @@ test.describe('Unified Search - Experience Quality UX', () => {
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
});
test('renders release clarify guidance as non-executable hints on /releases/versions', async ({ page }) => {
await mockSearchResponses(page, () => emptyResponse('mystery release blocker'));
await page.goto('/releases/versions');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearch(page, 'mystery release blocker');
await waitForResults(page);
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
await expect(page.locator('[data-answer-guidance="clarify"]').filter({
hasText: /add the environment or release you want to inspect\./i,
})).toHaveCount(1);
await expect(page.locator('[data-answer-guidance="clarify"]').filter({
hasText: /add whether you need blockers, approvals, or policy impact\./i,
})).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Add the environment or release you want to inspect.' })).toHaveCount(0);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('mystery release blocker');
});
test('uses backend answer framing and shows overflow as secondary results without manual filters', async ({ page }) => {
await mockSearchResponses(page, (query) =>
query.includes('critical findings')

View File

@@ -230,48 +230,6 @@ const doctorGroundedResponse = buildResponse(
},
);
const timelineGroundedResponse = buildResponse(
'Which deployment, incident, or time window should I narrow this to?',
[
timelineCard({
eventId: 'incident-db-spike',
title: 'Database latency spike before promotion',
snippet: 'A deployment preceded the incident and introduced the latency spike that now blocks the rollout.',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'The timeline shows a deployment immediately before the incident that introduced the blocking latency spike.',
reason: 'Timeline evidence became grounded once the time window was narrowed.',
evidence: 'Grounded in 1 source across Timeline.',
citations: [
{
entityKey: 'timeline:incident-db-spike',
title: 'Database latency spike before promotion',
domain: 'timeline',
},
],
},
coverage: {
currentScopeDomain: 'timeline',
currentScopeWeighted: true,
domains: [
{
domain: 'timeline',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.9,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
},
);
const releasesGroundedResponse = buildResponse(
'What blocked this promotion?',
[
@@ -437,7 +395,7 @@ test.describe('Unified Search - Priority Route Self-Serve Journeys', () => {
}).first()).toBeVisible();
});
test('recovers a timeline search from clarify to grounded with the route-owned narrowing question', async ({ page }) => {
test('shows timeline narrowing guidance instead of a runnable clarify prompt', async ({ page }) => {
await mockPriorityRouteSearch(page);
await openRoute(page, '/ops/timeline');
@@ -445,14 +403,12 @@ test.describe('Unified Search - Priority Route Self-Serve Journeys', () => {
await waitForResults(page);
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
await page.getByRole('button', { name: 'Which deployment, incident, or time window should I narrow this to?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(
'Which deployment, incident, or time window should I narrow this to?',
);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/deployment immediately before the incident/i);
await expect(page.locator('[data-answer-guidance="clarify"]')).toContainText([
'Add the deployment, incident, or time window you want to inspect.',
'Add whether you need causes, impacts, or follow-up events.',
]);
await expect(page.getByRole('button', { name: 'Add the deployment, incident, or time window you want to inspect.' })).toHaveCount(0);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('spike');
});
test('reruns a policy next-search inside the current policy route context', async ({ page }) => {
@@ -557,10 +513,6 @@ function resolvePriorityRouteResponse(currentRoute: string, query: string): unkn
}
if (currentRoute.includes('/ops/timeline')) {
if (query.includes('which deployment, incident, or time window should i narrow this to')) {
return timelineGroundedResponse;
}
if (query.includes('spike')) {
return emptyResponse('spike');
}

View File

@@ -46,25 +46,6 @@ const groundedFindingResponse = {
},
};
const narrowedFindingResponse = buildResponse(
'Which CVE, workload, or package should I narrow this to?',
[
findingCard({
cveId: 'CVE-2023-38545',
title: 'CVE-2023-38545 in edge-router',
snippet: 'A narrowed finding result is now grounded and ready for review.',
severity: 'high',
}),
],
{
summary: 'Narrowing the question exposed a grounded finding answer.',
template: 'finding_overview',
confidence: 'high',
sourceCount: 1,
domainsCovered: ['Findings'],
},
);
test.describe('Unified Search - Self-Serve Answer Panel', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
@@ -103,12 +84,8 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
await expect(answerPanel.locator('[data-answer-citation]')).toContainText(['CVE-2024-21626 in api-gateway']);
});
test('uses clarify questions to rerun search and recover a grounded answer', async ({ page }) => {
test('shows clarify guidance without rendering dead clarify prompts as buttons', async ({ page }) => {
await mockSearchResponses(page, (query) => {
if (query.includes('which cve, workload, or package should i narrow this to?')) {
return narrowedFindingResponse;
}
if (query.includes('mystery issue')) {
return emptyResponse('mystery issue');
}
@@ -122,17 +99,18 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
await typeInSearch(page, 'mystery issue');
await waitForResults(page);
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
await page.getByRole('button', { name: 'Which CVE, workload, or package should I narrow this to?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(
'Which CVE, workload, or package should I narrow this to?',
);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
'Narrowing the question exposed a grounded finding answer.',
);
await expect(
page.locator('[data-answer-guidance="clarify"]').filter({
hasText: /Add the CVE, workload, or package you want to inspect\./i,
}),
).toHaveCount(1);
await expect(
page.locator('[data-answer-guidance="clarify"]').filter({
hasText: /Add the part of the finding that matters most right now\./i,
}),
).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Add the CVE, workload, or package you want to inspect.' })).toHaveCount(0);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('mystery issue');
});
test('opens AdvisoryAI from the answer panel with grounded answer context', async ({ page }) => {