Consume weighted search answers and suppress dead chips

This commit is contained in:
master
2026-03-07 18:38:02 +02:00
parent 86a4928109
commit e295768662
9 changed files with 812 additions and 121 deletions

View File

@@ -19,7 +19,7 @@
## Delivery Tracker
### FE-ZL-001 - Consume primary and overflow search sections
Status: TODO
Status: DONE
Dependency: none
Owners: Developer (Frontend)
Task description:
@@ -27,12 +27,12 @@ Task description:
- Present current-scope-biased cards first and clearly label overflow results without asking the user to manage scope manually.
Completion criteria:
- [ ] The frontend consumes additive overflow/coverage fields without breaking legacy fallback behavior.
- [ ] Primary cards render before overflow cards when both exist.
- [ ] Overflow results are visually subordinate and only shown when present.
- [x] The frontend consumes additive overflow/coverage fields without breaking legacy fallback behavior.
- [x] Primary cards render before overflow cards when both exist.
- [x] Overflow results are visually subordinate and only shown when present.
### FE-ZL-002 - Use backend answer framing instead of frontend heuristics
Status: TODO
Status: DONE
Dependency: FE-ZL-001
Owners: Developer (Frontend)
Task description:
@@ -40,12 +40,12 @@ Task description:
- Preserve a deterministic fallback when the backend omits the new answer frame.
Completion criteria:
- [ ] Search answer panels prefer backend answer data.
- [ ] Frontend fallback logic remains available for older or degraded responses.
- [ ] `Did you mean` remains directly under the input/result header.
- [x] Search answer panels prefer backend answer data.
- [x] Frontend fallback logic remains available for older or degraded responses.
- [x] `Did you mean` remains directly under the input/result header.
### FE-ZL-003 - Suppress dead contextual chips using backend coverage
Status: TODO
Status: DONE
Dependency: FE-ZL-001
Owners: Developer (Frontend)
Task description:
@@ -53,30 +53,34 @@ Task description:
- Preserve useful fallback chips only when the backend confirms a non-empty relevant corpus or candidate set.
Completion criteria:
- [ ] Contextual chips are hidden when coverage says their domain/query family is empty.
- [ ] Clicking any rendered contextual chip yields visible results or a backend-issued clarify frame.
- [ ] Search history still records only successful result-bearing searches.
- [x] Contextual chips are hidden when coverage says their domain/query family is empty.
- [x] Clicking any rendered contextual chip yields visible results or a backend-issued clarify frame.
- [x] Search history still records only successful result-bearing searches.
### FE-ZL-004 - Targeted frontend verification
Status: TODO
Status: DONE
Dependency: FE-ZL-003
Owners: Test Automation
Task description:
- Add unit and Playwright coverage for overflow sections, backend answer consumption, and chip suppression.
Completion criteria:
- [ ] Angular tests cover overflow rendering and backend answer consumption.
- [ ] Playwright covers chip suppression and mixed primary/overflow result presentation.
- [ ] Execution log records commands and outcomes.
- [x] Angular tests cover overflow rendering and backend answer consumption.
- [x] Playwright covers chip suppression and mixed primary/overflow result presentation.
- [x] Execution log records commands and outcomes.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created to consume backend zero-learning search contracts in the Web primary entry. | Project Manager |
| 2026-03-07 | Implemented additive FE contract consumption: backend `contextAnswer` now drives the answer-first panel when present, overflow results render as a secondary section, and domain-filter teaching controls are no longer shown. | Developer |
| 2026-03-07 | Added suggestion viability preflight for empty-state contextual chips/common questions so dead suggestions are suppressed before render while legacy fallback behavior remains intact if the endpoint is unavailable. | Developer |
| 2026-03-07 | Verification: `npm test -- --include src/tests/global_search/global-search.component.spec.ts` passed (`24/24`). `npx playwright test tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts tests/e2e/unified-search-experience-quality.e2e.spec.ts --config playwright.config.ts` passed (`13/13`). | Test Automation |
## Decisions & Risks
- Decision: chip viability is enforced from backend coverage signals, not static page metadata alone.
- Decision: backend answer framing is preferred, with frontend heuristics retained only as backward-compatible fallback.
- Decision: overflow results replace manual result-domain refinements in the primary search experience. The user sees a weighted answer first and any cross-scope evidence second, instead of being asked to filter the result set manually.
- Risk: mixed deployments may return older unified-search payloads during rollout.
- Mitigation: keep all new frontend contract usage additive and optional.

View File

@@ -29,6 +29,7 @@
- Place `Did you mean` directly below the input because it is an input correction, not a result refinement.
- Remove explicit `Find / Explain / Act` controls.
- Remove the explicit scope toggle chip.
- Remove manual result-domain refinements from the primary flow; the server should rank the current page first and render any cross-scope overflow as a secondary section instead.
- Remove the recovery panel.
- If top results are close in score, compose one short summary across them.
- If one result is clearly dominant, present that answer first and then cards.
@@ -58,10 +59,12 @@
### Suggestion viability
- Suggestions must be validated against the current corpus before rendering.
- Knowledge/domain emptiness should be detectable so the UI can suppress invalid chips.
- Empty-state contextual chips and page-owned common-question chips should preflight through the backend viability endpoint before they render.
- Live Playwright coverage must assert that every surfaced suggestion returns visible results.
## Phase map
- Phase 1: FE primary-entry consolidation and removal of explicit search controls.
- Phase 2: AdvisoryAI implicit scope weighting, answer blending, and suggestion viability.
- Phase 3: FE consumption of overflow results and executable suggestion contracts.
- Implemented on 2026-03-07: backend `contextAnswer` is now preferred over frontend heuristics, overflow renders as a secondary result section, and suggestion viability preflight suppresses dead chips before they are shown.
- Phase 4: Live Playwright reliability matrix with corpus preflight and chip-success guarantees.

View File

@@ -10,13 +10,17 @@ import type {
EntityCard,
UnifiedEntityType,
UnifiedSearchAmbientContext,
UnifiedSearchCoverage,
UnifiedSearchDiagnostics,
UnifiedSearchDomain,
UnifiedSearchFilter,
UnifiedSearchOverflow,
UnifiedSearchResponse,
SynthesisResult,
ContextAnswer,
SearchSuggestion,
SearchRefinement,
SearchSuggestionViabilityResponse,
SearchFeedbackRequest,
SearchQualityAlert,
SearchQualityMetrics,
@@ -58,11 +62,79 @@ interface UnifiedSearchRequestDto {
interface SearchSuggestionDto {
text: string;
reason: string;
domain?: string;
candidateCount?: number;
}
interface SearchRefinementDto {
text: string;
source: string;
domain?: string;
candidateCount?: number;
}
interface ContextAnswerCitationDto {
entityKey: string;
title: string;
domain: string;
route?: string;
}
interface ContextAnswerQuestionDto {
query: string;
kind?: string;
}
interface ContextAnswerDto {
status: string;
code: string;
summary: string;
reason: string;
evidence: string;
citations?: ContextAnswerCitationDto[];
questions?: ContextAnswerQuestionDto[];
}
interface UnifiedSearchOverflowDto {
currentScopeDomain: string;
reason: string;
cards: EntityCardDto[];
}
interface UnifiedSearchDomainCoverageDto {
domain: string;
candidateCount: number;
visibleCardCount: number;
topScore: number;
isCurrentScope: boolean;
hasVisibleResults: boolean;
}
interface UnifiedSearchCoverageDto {
currentScopeDomain?: string;
currentScopeWeighted: boolean;
domains: UnifiedSearchDomainCoverageDto[];
}
interface SearchSuggestionViabilityRequestDto {
queries: string[];
filters?: UnifiedSearchRequestDto['filters'];
ambient?: UnifiedSearchRequestDto['ambient'];
}
interface SearchSuggestionViabilityResultDto {
query: string;
viable: boolean;
status: string;
code: string;
cardCount: number;
leadingDomain?: string;
reason: string;
}
interface SearchSuggestionViabilityResponseDto {
suggestions: SearchSuggestionViabilityResultDto[];
coverage?: UnifiedSearchCoverageDto | null;
}
interface UnifiedSearchResponseDto {
@@ -72,6 +144,9 @@ interface UnifiedSearchResponseDto {
synthesis: SynthesisResultDto | null;
suggestions?: SearchSuggestionDto[];
refinements?: SearchRefinementDto[];
contextAnswer?: ContextAnswerDto | null;
overflow?: UnifiedSearchOverflowDto | null;
coverage?: UnifiedSearchCoverageDto | null;
diagnostics: UnifiedSearchDiagnosticsDto;
}
@@ -131,6 +206,9 @@ export class UnifiedSearchClient {
topK: limit,
cards: [],
synthesis: null,
contextAnswer: null,
overflow: null,
coverage: null,
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
@@ -159,6 +237,49 @@ export class UnifiedSearchClient {
);
}
evaluateSuggestions(
queries: readonly string[],
filter?: UnifiedSearchFilter,
ambient?: UnifiedSearchAmbientContext,
): Observable<SearchSuggestionViabilityResponse | null> {
const normalizedQueries = Array.from(new Set(
queries
.map((query) => query.trim())
.filter((query) => query.length > 0),
)).slice(0, 12);
if (normalizedQueries.length === 0) {
return of({
suggestions: [],
coverage: null,
});
}
const request: SearchSuggestionViabilityRequestDto = {
queries: normalizedQueries,
filters: this.normalizeFilter(filter),
ambient: this.normalizeAmbient(ambient),
};
return this.http
.post<SearchSuggestionViabilityResponseDto>('/api/v1/search/suggestions/evaluate', request)
.pipe(
map((response) => ({
suggestions: (response.suggestions ?? []).map((suggestion) => ({
query: suggestion.query ?? '',
viable: suggestion.viable === true,
status: (suggestion.status as 'grounded' | 'clarify' | 'insufficient') ?? 'insufficient',
code: suggestion.code ?? 'no_grounded_evidence',
cardCount: suggestion.cardCount ?? 0,
leadingDomain: suggestion.leadingDomain,
reason: suggestion.reason ?? '',
})),
coverage: this.mapCoverage(response.coverage),
})),
catchError(() => of(null)),
);
}
private fallbackToLegacy(
query: string,
filter?: UnifiedSearchFilter,
@@ -245,6 +366,9 @@ export class UnifiedSearchClient {
topK: Math.max(1, Math.min(100, limit)),
cards,
synthesis: null,
contextAnswer: null,
overflow: null,
coverage: null,
diagnostics: {
ftsMatches: response.totalCount ?? cards.length,
vectorMatches: 0,
@@ -373,6 +497,8 @@ export class UnifiedSearchClient {
? response.suggestions.map((s) => ({
text: s.text ?? '',
reason: s.reason ?? '',
domain: s.domain,
candidateCount: s.candidateCount,
}))
: undefined;
@@ -381,9 +507,56 @@ export class UnifiedSearchClient {
? response.refinements.map((r) => ({
text: r.text ?? '',
source: r.source ?? '',
domain: r.domain,
candidateCount: r.candidateCount,
}))
: undefined;
const contextAnswer: ContextAnswer | null = response.contextAnswer
? {
status: (response.contextAnswer.status as ContextAnswer['status']) ?? 'insufficient',
code: response.contextAnswer.code ?? 'no_grounded_evidence',
summary: response.contextAnswer.summary ?? '',
reason: response.contextAnswer.reason ?? '',
evidence: response.contextAnswer.evidence ?? '',
citations: response.contextAnswer.citations?.map((citation) => ({
entityKey: citation.entityKey ?? '',
title: citation.title ?? '',
domain: citation.domain ?? '',
route: citation.route,
})),
questions: response.contextAnswer.questions?.map((question) => ({
query: question.query ?? '',
kind: question.kind,
})),
}
: null;
const overflow: UnifiedSearchOverflow | null = response.overflow
? {
currentScopeDomain: response.overflow.currentScopeDomain ?? '',
reason: response.overflow.reason ?? '',
cards: (response.overflow.cards ?? []).map((card) => ({
entityKey: card.entityKey ?? '',
entityType: (card.entityType as EntityCard['entityType']) ?? 'docs',
domain: (card.domain as EntityCard['domain']) ?? 'knowledge',
title: card.title?.trim() || '(untitled)',
snippet: this.normalizeSnippet(card.snippet),
score: Number.isFinite(card.score) ? card.score : 0,
severity: card.severity,
actions: (card.actions ?? []).map((action) => ({
label: action.label ?? 'Open',
actionType: (action.actionType as EntityCard['actions'][0]['actionType']) ?? 'navigate',
route: action.route,
command: action.command,
isPrimary: action.isPrimary ?? false,
})),
metadata: card.metadata,
sources: card.sources ?? [],
})),
}
: null;
return {
query: response.query?.trim() || queryFallback,
topK: response.topK ?? 10,
@@ -391,10 +564,32 @@ export class UnifiedSearchClient {
synthesis,
suggestions,
refinements,
contextAnswer,
overflow,
coverage: this.mapCoverage(response.coverage),
diagnostics,
};
}
private mapCoverage(response?: UnifiedSearchCoverageDto | null): UnifiedSearchCoverage | null {
if (!response) {
return null;
}
return {
currentScopeDomain: response.currentScopeDomain,
currentScopeWeighted: response.currentScopeWeighted === true,
domains: (response.domains ?? []).map((domain) => ({
domain: domain.domain ?? '',
candidateCount: domain.candidateCount ?? 0,
visibleCardCount: domain.visibleCardCount ?? 0,
topScore: domain.topScore ?? 0,
isCurrentScope: domain.isCurrentScope === true,
hasVisibleResults: domain.hasVisibleResults === true,
})),
};
}
private normalizeFilter(
filter?: UnifiedSearchFilter,
): UnifiedSearchRequestDto['filters'] | undefined {

View File

@@ -54,11 +54,73 @@ export interface SynthesisResult {
export interface SearchSuggestion {
text: string;
reason: string;
domain?: string;
candidateCount?: number;
}
export interface SearchRefinement {
text: string;
source: string;
domain?: string;
candidateCount?: number;
}
export interface ContextAnswerCitation {
entityKey: string;
title: string;
domain: string;
route?: string;
}
export interface ContextAnswerQuestion {
query: string;
kind?: string;
}
export interface ContextAnswer {
status: 'grounded' | 'clarify' | 'insufficient';
code: string;
summary: string;
reason: string;
evidence: string;
citations?: ContextAnswerCitation[];
questions?: ContextAnswerQuestion[];
}
export interface UnifiedSearchOverflow {
currentScopeDomain: string;
reason: string;
cards: EntityCard[];
}
export interface UnifiedSearchDomainCoverage {
domain: string;
candidateCount: number;
visibleCardCount: number;
topScore: number;
isCurrentScope: boolean;
hasVisibleResults: boolean;
}
export interface UnifiedSearchCoverage {
currentScopeDomain?: string;
currentScopeWeighted: boolean;
domains: UnifiedSearchDomainCoverage[];
}
export interface SearchSuggestionViabilityResult {
query: string;
viable: boolean;
status: 'grounded' | 'clarify' | 'insufficient';
code: string;
cardCount: number;
leadingDomain?: string;
reason: string;
}
export interface SearchSuggestionViabilityResponse {
suggestions: SearchSuggestionViabilityResult[];
coverage?: UnifiedSearchCoverage | null;
}
export interface UnifiedSearchAmbientAction {
@@ -87,6 +149,9 @@ export interface UnifiedSearchResponse {
synthesis: SynthesisResult | null;
suggestions?: SearchSuggestion[];
refinements?: SearchRefinement[];
contextAnswer?: ContextAnswer | null;
overflow?: UnifiedSearchOverflow | null;
coverage?: UnifiedSearchCoverage | null;
diagnostics: UnifiedSearchDiagnostics;
}

View File

@@ -23,6 +23,7 @@ import { UnifiedSearchClient } from '../../core/api/unified-search.client';
import type {
EntityCard,
EntityCardAction,
SearchSuggestionViabilityResponse,
UnifiedSearchResponse,
UnifiedSearchDomain,
} from '../../core/api/unified-search.models';
@@ -34,7 +35,6 @@ import { SearchChatContextService } from '../../core/services/search-chat-contex
import { I18nService } from '../../core/i18n';
import { normalizeSearchActionRoute } from './search-route-matrix';
type SearchDomainFilter = 'all' | UnifiedSearchDomain;
type SearchSuggestionView = {
query: string;
reason: string;
@@ -207,44 +207,65 @@ type SearchAnswerView = {
@if (isLoading()) {
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
} @else if (query().trim().length >= 1 && cards().length === 0) {
} @else if (query().trim().length >= 1 && visibleCards().length === 0) {
<div class="search__empty">{{ t('ui.search.no_results', 'No results found') }}</div>
} @else if (query().trim().length >= 1) {
<div class="search__filters">
@for (filter of availableDomainFilters(); track filter) {
<button
type="button"
class="search__filter"
data-role="domain-filter"
[class.search__filter--active]="activeDomainFilter() === filter"
(click)="setDomainFilter(filter)"
>
{{ getDomainFilterLabel(filter) }}
</button>
}
</div>
@if (scopeWeightingHint(); as scopeHint) {
<div class="search__scope-hint">{{ scopeHint }}</div>
}
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
@for (card of filteredCards(); track card.entityKey; let i = $index) {
<app-entity-card
[card]="card"
[selected]="selectedIndex() === i"
[expandedInput]="expandedCardKey() === card.entityKey"
(actionExecuted)="onCardAction(card, $event)"
(askAi)="onAskAiFromCard($event)"
(toggleExpand)="onTogglePreview($event)"
(feedbackSubmitted)="onResultFeedback(card, $event)"
(click)="onCardSelect(card)"
(mouseenter)="selectedIndex.set(i)"
/>
}
</div>
@if (cards().length > 0) {
<div class="search__cards-section">
@if (overflowCards().length > 0) {
<div class="search__group-label">{{ t('ui.search.results.primary', 'Best match for this page') }}</div>
}
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
@for (card of cards(); track card.entityKey; let i = $index) {
<app-entity-card
[card]="card"
[selected]="selectedIndex() === i"
[expandedInput]="expandedCardKey() === card.entityKey"
(actionExecuted)="onCardAction(card, $event)"
(askAi)="onAskAiFromCard($event)"
(toggleExpand)="onTogglePreview($event)"
(feedbackSubmitted)="onResultFeedback(card, $event)"
(click)="onCardSelect(card)"
(mouseenter)="selectedIndex.set(i)"
/>
}
</div>
</div>
}
<app-synthesis-panel
[synthesis]="synthesis()"
(askAi)="onAskAiFromSynthesis()"
(synthesisFeedbackSubmitted)="onSynthesisFeedback($event)"
/>
@if (overflowCards().length > 0) {
<div class="search__cards-section search__cards-section--overflow" data-overflow-results>
<div class="search__group-label">{{ overflowSectionTitle() }}</div>
<div class="search__overflow-reason">{{ overflowSectionReason() }}</div>
<div class="search__cards search__cards--overflow" role="list" [attr.aria-live]="'polite'">
@for (card of overflowCards(); track card.entityKey; let i = $index) {
<app-entity-card
[card]="card"
[selected]="selectedIndex() === cards().length + i"
[expandedInput]="expandedCardKey() === card.entityKey"
(actionExecuted)="onCardAction(card, $event)"
(askAi)="onAskAiFromCard($event)"
(toggleExpand)="onTogglePreview($event)"
(feedbackSubmitted)="onResultFeedback(card, $event)"
(click)="onCardSelect(card)"
(mouseenter)="selectedIndex.set(cards().length + i)"
/>
}
</div>
</div>
}
@if (!searchResponse()?.contextAnswer) {
<app-synthesis-panel
[synthesis]="synthesis()"
(askAi)="onAskAiFromSynthesis()"
(synthesisFeedbackSubmitted)="onSynthesisFeedback($event)"
/>
}
} @else {
@if (recentSearches().length > 0) {
<div class="search__group">
@@ -538,10 +559,35 @@ type SearchAnswerView = {
border-bottom: 1px solid var(--color-border-primary);
}
.search__scope-hint {
padding: 0.5rem 0.75rem 0;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.search__cards-section {
padding: 0.25rem 0 0.5rem;
}
.search__cards-section--overflow {
border-top: 1px solid var(--color-border-primary);
}
.search__cards {
padding: 0.25rem 0;
}
.search__cards--overflow {
padding-top: 0;
}
.search__overflow-reason {
padding: 0 0.75rem 0.35rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
line-height: 1.4;
}
.search__group {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-primary);
@@ -1071,10 +1117,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly selectedIndex = signal(0);
readonly placeholderIndex = signal(0);
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
readonly suggestionViability = signal<SearchSuggestionViabilityResponse | null>(null);
readonly recentSearches = signal<string[]>([]);
readonly activeDomainFilter = signal<SearchDomainFilter>('all');
readonly expandedCardKey = signal<string | null>(null);
readonly pendingDomainFilter = signal<SearchDomainFilter | null>(null);
readonly showResults = computed(() => this.isFocused());
readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown');
@@ -1211,6 +1256,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => {
return [...this.ambientContext.getSearchSuggestions()]
.sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left))
.filter((suggestion) => this.isSuggestionQueryViable(this.i18n.tryT(suggestion.key) ?? suggestion.fallback))
.map((suggestion) => ({
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
reason: this.resolveSuggestionReason(suggestion),
@@ -1221,6 +1267,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
return [...this.ambientContext.getCommonQuestions('find')]
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
.filter((question) => this.isSuggestionQueryViable(this.i18n.tryT(question.key) ?? question.fallback))
.map((question) => ({
query: this.i18n.tryT(question.key) ?? question.fallback,
kind: question.kind ?? 'page',
@@ -1252,6 +1299,30 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
const nextSearches = this.contextualSuggestions()
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
.slice(0, 2);
const backendAnswer = response.contextAnswer;
if (backendAnswer) {
return {
status: backendAnswer.status,
eyebrow,
title: this.answerTitleForStatus(backendAnswer.status),
summary: backendAnswer.summary || this.answerFallbackSummaryForStatus(backendAnswer.status, query, pageLabel),
evidence: backendAnswer.evidence || backendAnswer.reason,
citations: (backendAnswer.citations ?? [])
.filter((citation) => citation.title.trim().length > 0)
.slice(0, 3)
.map((citation) => ({
key: citation.entityKey,
title: citation.title,
})),
questionLabel: backendAnswer.status === 'clarify'
? this.t('ui.search.answer.questions.clarify', 'Clarify with one of these')
: this.t('ui.search.answer.questions.follow_up', 'Ask next'),
questions: this.buildBackendAnswerQuestions(backendAnswer),
nextSearches,
};
}
const hasGroundedEvidence = response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
if (hasGroundedEvidence) {
@@ -1323,29 +1394,42 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
readonly cards = computed(() => this.searchResponse()?.cards ?? []);
readonly overflowCards = computed(() => this.searchResponse()?.overflow?.cards ?? []);
readonly visibleCards = computed(() => [...this.cards(), ...this.overflowCards()]);
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
readonly filteredCards = computed(() => {
const filter = this.activeDomainFilter();
if (filter === 'all') {
return this.cards();
readonly filteredCards = computed(() => this.visibleCards());
readonly scopeWeightingHint = computed(() => {
const coverage = this.searchResponse()?.coverage;
if (!coverage?.currentScopeWeighted || !coverage.currentScopeDomain) {
return null;
}
return this.cards().filter((card) => card.domain === filter);
const domainLabel = DOMAIN_LABELS[coverage.currentScopeDomain as UnifiedSearchDomain]
?? coverage.currentScopeDomain;
return this.t(
'ui.search.scope_weighting',
'Weighted toward {domain} because of the current page.',
{ domain: domainLabel },
);
});
readonly availableDomainFilters = computed<SearchDomainFilter[]>(() => {
const filters: SearchDomainFilter[] = ['all'];
const domains = new Set<string>();
for (const card of this.cards()) {
domains.add(card.domain);
}
for (const domain of domains) {
filters.push(domain as SearchDomainFilter);
}
return filters;
readonly overflowSectionTitle = computed(() => {
const scopeDomain = this.searchResponse()?.overflow?.currentScopeDomain
|| this.searchResponse()?.coverage?.currentScopeDomain;
const scopeLabel = scopeDomain
? (DOMAIN_LABELS[scopeDomain as UnifiedSearchDomain] ?? scopeDomain)
: this.t('ui.search.scope.default', 'this page');
return this.t(
'ui.search.results.overflow',
'Also relevant outside {scope}',
{ scope: scopeLabel },
);
});
readonly overflowSectionReason = computed(() =>
this.searchResponse()?.overflow?.reason
?? this.t(
'ui.search.results.overflow.reason',
'These results are outside the current page weighting but remain strongly relevant.',
));
ngOnInit(): void {
this.placeholderRotationHandle = setInterval(() => {
@@ -1363,7 +1447,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntil(this.destroy$),
)
.subscribe(() => this.consumeChatToSearchContext());
.subscribe(() => {
this.consumeChatToSearchContext();
if (this.isFocused()) {
this.refreshSuggestionViability();
}
});
this.consumeChatToSearchContext();
@@ -1373,9 +1462,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
switchMap((term) => {
if (term.length < 1) {
this.searchResponse.set(null);
this.suggestionViability.set(null);
this.isLoading.set(false);
this.selectedIndex.set(0);
this.activeDomainFilter.set('all');
return of(null);
}
@@ -1409,16 +1498,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.searchResponse.set(response);
this.selectedIndex.set(0);
const pendingDomainFilter = this.pendingDomainFilter();
if (
pendingDomainFilter &&
response.cards.some((card) => card.domain === pendingDomainFilter)
) {
this.activeDomainFilter.set(pendingDomainFilter);
} else {
this.activeDomainFilter.set('all');
}
this.pendingDomainFilter.set(null);
this.expandedCardKey.set(null);
this.isLoading.set(false);
if (this.hasSearchEvidence(response)) {
@@ -1454,6 +1533,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.consumeChatToSearchContext();
this.loadRecentSearches();
this.loadServerHistory();
this.refreshSuggestionViability();
}
onBlur(): void {
@@ -1482,27 +1562,16 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
onQueryChange(value: string): void {
this.query.set(value);
this.selectedIndex.set(0);
this.activeDomainFilter.set('all');
this.escapeCount = 0;
if (value.trim().length === 0) {
this.refreshSuggestionViability();
}
this.searchTerms$.next(value.trim());
}
onKeydown(event: KeyboardEvent): void {
const count = this.getNavigableItemCount();
// Number keys 1-6 for domain filter shortcuts
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
const filterIndex = parseInt(event.key, 10);
if (filterIndex >= 1 && filterIndex <= 6) {
const filters = this.availableDomainFilters();
if (filterIndex < filters.length) {
event.preventDefault();
this.setDomainFilter(filters[filterIndex]);
return;
}
}
}
switch (event.key) {
case 'ArrowDown':
if (count === 0) return;
@@ -1712,11 +1781,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
void this.router.navigateByUrl(route);
}
setDomainFilter(filter: SearchDomainFilter): void {
this.activeDomainFilter.set(filter);
this.selectedIndex.set(0);
}
t(key: string, fallback: string, params?: Record<string, string | number>): string {
const translated = this.i18n.tryT(key, params);
if (translated !== null) {
@@ -1733,17 +1797,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
}
getDomainFilterLabel(filter: SearchDomainFilter): string {
if (filter === 'all') {
const allLabel = this.t('ui.search.filter.all', 'All');
return `${allLabel} (${this.cards().length})`;
}
const count = this.cards().filter((c) => c.domain === filter).length;
const label = DOMAIN_LABELS[filter as UnifiedSearchDomain] ?? filter;
return `${label} (${count})`;
}
private executeAction(action: EntityCardAction): void {
const normalizedRoute = action.route ? this.normalizeActionRoute(action.route) : undefined;
@@ -1820,8 +1873,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.query.set('');
this.selectedIndex.set(0);
this.searchResponse.set(null);
this.activeDomainFilter.set('all');
this.pendingDomainFilter.set(null);
this.expandedCardKey.set(null);
this.isFocused.set(false);
this.escapeCount = 0;
@@ -1936,7 +1987,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.searchResponse.set(null);
this.expandedCardKey.set(null);
this.isFocused.set(true);
this.pendingDomainFilter.set(context.domain ?? null);
this.recordAmbientAction(context.action ?? 'chat_to_search', {
source: 'advisory_ai_chat',
queryHint: query,
@@ -1999,6 +2049,93 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
}
private buildBackendAnswerQuestions(answer: NonNullable<UnifiedSearchResponse['contextAnswer']>): SearchQuestionView[] {
const backendQuestions = (answer.questions ?? [])
.filter((question) => question.query.trim().length > 0)
.map((question) => ({
query: question.query,
kind: question.kind === 'clarify' ? 'clarify' : 'page',
} satisfies SearchQuestionView));
if (backendQuestions.length > 0) {
return backendQuestions.slice(0, 3);
}
return answer.status === 'clarify'
? this.clarifyingQuestions().slice(0, 3)
: this.commonQuestions().slice(0, 3);
}
private answerTitleForStatus(status: SearchAnswerView['status']): string {
switch (status) {
case 'clarify':
return this.t('ui.search.answer.title.clarify', 'Tighten the question');
case 'insufficient':
return this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet');
default:
return this.t('ui.search.answer.title.find', 'What we found');
}
}
private answerFallbackSummaryForStatus(
status: SearchAnswerView['status'],
query: string,
pageLabel: string,
): string {
switch (status) {
case 'clarify':
return this.t(
'ui.search.answer.summary.clarify',
'I could not form a grounded answer for "{query}" in {page}. Narrow the entity, symptom, or target.',
{ query, page: pageLabel },
);
case 'insufficient':
return this.t(
'ui.search.answer.summary.insufficient',
'Search did not find enough evidence to answer "{query}" from the current context. Try a stronger entity, blocker, or time-bound question.',
{ query },
);
default:
return this.t(
'ui.search.answer.summary.grounded.default',
'Relevant evidence was found for this query.',
);
}
}
private isSuggestionQueryViable(query: string): boolean {
const normalized = query.trim().toLowerCase();
const viability = this.suggestionViability();
if (!normalized || !viability) {
return true;
}
const match = viability.suggestions.find((suggestion) => suggestion.query.trim().toLowerCase() === normalized);
return match ? match.viable : true;
}
private refreshSuggestionViability(): void {
if (!this.isFocused()) {
return;
}
const queries = Array.from(new Set([
...this.ambientContext.getSearchSuggestions().map((suggestion) => this.i18n.tryT(suggestion.key) ?? suggestion.fallback),
...this.ambientContext.getCommonQuestions('find').map((question) => this.i18n.tryT(question.key) ?? question.fallback),
].map((query) => query.trim()).filter((query) => query.length > 0))).slice(0, 12);
if (queries.length === 0) {
this.suggestionViability.set(null);
return;
}
this.searchClient.evaluateSuggestions(queries, undefined, this.buildAmbientSnapshot())
.pipe(takeUntil(this.destroy$))
.subscribe((response) => {
this.suggestionViability.set(response);
});
}
private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string {
const synthesisSummary = response.synthesis?.summary?.trim();
if (synthesisSummary) {
@@ -2063,7 +2200,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
this.searchChatContext.setSearchToChat({
query: query || pageLabel,
entityCards: this.cards(),
entityCards: this.visibleCards(),
synthesis: this.synthesis(),
suggestedPrompt,
});
@@ -2116,6 +2253,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
entityKey: options.entityKey,
route: options.route,
});
if (this.isFocused()) {
this.refreshSuggestionViability();
}
}
private buildAskAiPromptForCard(card: EntityCard): string {
@@ -2155,7 +2296,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
this.searchChatContext.setSearchToChat({
query: query || pageLabel,
entityCards: this.cards(),
entityCards: this.visibleCards(),
synthesis: this.synthesis(),
suggestedPrompt,
});
@@ -2166,7 +2307,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
private hasSearchEvidence(response: UnifiedSearchResponse): boolean {
return response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
return response.cards.length > 0
|| (response.overflow?.cards.length ?? 0) > 0
|| (response.synthesis?.sourceCount ?? 0) > 0;
}
private persistRecentSearches(entries: string[]): void {

View File

@@ -30,6 +30,7 @@ describe('GlobalSearchComponent', () => {
searchClient = jasmine.createSpyObj('UnifiedSearchClient', [
'search',
'evaluateSuggestions',
'recordAnalytics',
'getHistory',
'clearHistory',
@@ -49,6 +50,7 @@ describe('GlobalSearchComponent', () => {
mode: 'fts-only',
},
}));
searchClient.evaluateSuggestions.and.returnValue(of(null));
searchClient.getHistory.and.returnValue(of([]));
ambientContext = jasmine.createSpyObj('AmbientContextService', [
@@ -463,6 +465,26 @@ describe('GlobalSearchComponent', () => {
usedVector: false,
mode: 'fts-only',
},
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'Current-scope findings matched first, with one related policy hit held as overflow.',
reason: 'The current page weighted findings above cross-domain matches.',
evidence: 'Grounded in 2 sources across Findings and Policy.',
citations: [
{
entityKey: 'findings:sample',
title: 'findings sample',
domain: 'findings',
},
],
questions: [
{
query: 'What evidence blocks this release?',
kind: 'follow_up',
},
],
},
}));
component.onFocus();
@@ -473,11 +495,94 @@ describe('GlobalSearchComponent', () => {
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
expect(answerPanel).not.toBeNull();
expect(answerPanel?.textContent).toContain('What we found');
expect(answerPanel?.textContent).toContain('One critical finding matched the current page context.');
expect(answerPanel?.textContent).toContain('Grounded in 2 source(s) across Findings, Policy.');
expect(answerPanel?.textContent).toContain('Current-scope findings matched first, with one related policy hit held as overflow.');
expect(answerPanel?.textContent).toContain('Grounded in 2 sources across Findings and Policy.');
expect(answerPanel?.textContent).toContain('findings sample');
});
it('renders overflow results as a secondary section instead of domain filter refinements', async () => {
searchClient.search.and.returnValue(of({
query: 'database connectivity',
topK: 10,
cards: [createCard('knowledge', '/ops/operations/doctor?check=check.core.db.connectivity')],
overflow: {
currentScopeDomain: 'knowledge',
reason: 'Related policy evidence is relevant but secondary to the Doctor page context.',
cards: [createCard('policy', '/policy/DENY-CRITICAL-PROD')],
},
coverage: {
currentScopeDomain: 'knowledge',
currentScopeWeighted: true,
domains: [],
},
synthesis: null,
diagnostics: {
ftsMatches: 2,
vectorMatches: 0,
entityCardCount: 2,
durationMs: 7,
usedVector: false,
mode: 'fts-only',
},
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'Doctor evidence matched first, with policy overflow held separately.',
reason: 'The current route weights knowledge first.',
evidence: 'Grounded in 2 sources across Knowledge and Policy.',
citations: [],
questions: [],
},
}));
component.onFocus();
component.onQueryChange('database connectivity');
await waitForDebounce();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-role="domain-filter"]')).toBeNull();
const overflowSection = fixture.nativeElement.querySelector('[data-overflow-results]') as HTMLElement | null;
expect(overflowSection).not.toBeNull();
expect(overflowSection?.textContent).toContain('Also relevant outside Knowledge');
expect(overflowSection?.textContent).toContain('Related policy evidence is relevant but secondary to the Doctor page context.');
});
it('suppresses contextual chips marked non-viable by backend suggestion evaluation', () => {
searchClient.evaluateSuggestions.and.returnValue(of({
suggestions: [
{
query: 'How do I deploy?',
viable: false,
status: 'insufficient',
code: 'no_grounded_evidence',
cardCount: 0,
reason: 'No grounded evidence matched the suggestion in the active corpus.',
},
{
query: 'Show critical findings',
viable: true,
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 3,
leadingDomain: 'findings',
reason: 'Findings evidence is available for this suggestion.',
},
],
coverage: null,
}));
component.onFocus();
fixture.detectChanges();
const suggestionButtons = Array.from(
fixture.nativeElement.querySelectorAll('.search__suggestions .search__chip') as NodeListOf<HTMLButtonElement>,
).map((node) => node.textContent?.trim());
expect(searchClient.evaluateSuggestions).toHaveBeenCalled();
expect(suggestionButtons).not.toContain('How do I deploy?');
expect(suggestionButtons).toContain('Show critical findings');
});
it('renders a clarify answer panel when no grounded evidence is found', async () => {
component.onFocus();
component.onQueryChange('mystery issue');

View File

@@ -162,6 +162,58 @@ test.describe('Unified Search - Contextual Suggestions', () => {
await expect(page.locator('app-entity-card').first()).toContainText(/cve-2024-21626/i);
});
test('suppresses non-viable contextual chips before they are shown', async ({ page }) => {
await page.route('**/search/suggestions/evaluate**', async (route) => {
const body = route.request().postDataJSON() as { queries?: string[] };
const queries = body.queries ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: queries.map((query) => ({
query,
viable: !/how do i deploy/i.test(query),
status: /how do i deploy/i.test(query) ? 'insufficient' : 'grounded',
code: /how do i deploy/i.test(query) ? 'no_grounded_evidence' : 'retrieved_scope_weighted_evidence',
cardCount: /how do i deploy/i.test(query) ? 0 : 1,
leadingDomain: /critical findings/i.test(query) ? 'findings' : 'vex',
reason: /how do i deploy/i.test(query)
? 'No grounded evidence matched the suggestion in the active corpus.'
: 'Evidence is available for this suggestion.',
})),
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: 4,
visibleCardCount: 1,
topScore: 0.96,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
}),
});
});
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
const searchInput = page.locator('app-global-search input[type="text"]');
await searchInput.focus();
await waitForResults(page);
await expect(page.locator('.search__suggestions .search__chip', {
hasText: /^How do I deploy\?$/i,
})).toHaveCount(0);
await expect(page.locator('.search__suggestions .search__chip', {
hasText: /critical findings/i,
}).first()).toBeVisible();
});
test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({
page,
}) => {

View File

@@ -54,6 +54,74 @@ const policyBlockerResponse = buildResponse(
},
);
const weightedOverflowResponse = buildResponse(
'critical findings',
[
findingCard({
cveId: 'CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
snippet: 'Reachable critical vulnerability remains the strongest match for this page.',
severity: 'critical',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'Current-page findings matched first, with one policy blocker held as related overflow.',
reason: 'The search weighted the active findings page first.',
evidence: 'Grounded in 2 sources across Findings and Policy.',
citations: [
{
entityKey: 'cve:CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
domain: 'findings',
},
],
questions: [
{
query: 'What evidence blocks this release?',
kind: 'follow_up',
},
],
},
overflow: {
currentScopeDomain: 'findings',
reason: 'Policy results remain relevant but are weaker than the current findings context.',
cards: [
policyCard({
ruleId: 'POL-118',
title: 'POL-118 release blocker',
snippet: 'Production rollout is blocked while this CVE remains unresolved.',
}),
],
},
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: 3,
visibleCardCount: 1,
topScore: 0.96,
isCurrentScope: true,
hasVisibleResults: true,
},
{
domain: 'policy',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.74,
isCurrentScope: false,
hasVisibleResults: true,
},
],
},
},
);
test.describe('Unified Search - Experience Quality UX', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
@@ -192,6 +260,29 @@ test.describe('Unified Search - Experience Quality UX', () => {
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
});
test('uses backend answer framing and shows overflow as secondary results without manual filters', async ({ page }) => {
await mockSearchResponses(page, (query) =>
query.includes('critical findings')
? weightedOverflowResponse
: emptyResponse(query));
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
/current-page findings matched first/i,
);
await expect(page.locator('.search__scope-hint')).toContainText(/weighted toward findings/i);
await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant outside findings/i);
await expect(page.locator('[data-overflow-results]')).toContainText(/policy results remain relevant/i);
await expect(page.locator('[data-role="domain-filter"]')).toHaveCount(0);
await expect(page.locator('app-synthesis-panel')).toHaveCount(0);
});
test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await page.route('**/search/query**', async (route) => {

View File

@@ -287,6 +287,35 @@ export function buildResponse(
sourceCount: number;
domainsCovered: string[];
},
options?: {
suggestions?: Array<{ text: string; reason: string; domain?: string; candidateCount?: number }>;
contextAnswer?: {
status: 'grounded' | 'clarify' | 'insufficient';
code: string;
summary: string;
reason: string;
evidence: string;
citations?: Array<{ entityKey: string; title: string; domain: string; route?: string }>;
questions?: Array<{ query: string; kind?: string }>;
};
overflow?: {
currentScopeDomain: string;
reason: string;
cards: CardFixture[];
};
coverage?: {
currentScopeDomain?: string;
currentScopeWeighted: boolean;
domains: Array<{
domain: string;
candidateCount: number;
visibleCardCount: number;
topScore: number;
isCurrentScope: boolean;
hasVisibleResults: boolean;
}>;
};
},
) {
return {
query,
@@ -307,6 +336,10 @@ export function buildResponse(
usedVector: true,
mode: 'hybrid',
},
suggestions: options?.suggestions,
contextAnswer: options?.contextAnswer,
overflow: options?.overflow,
coverage: options?.coverage,
};
}