Consume weighted search answers and suppress dead chips
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user