From e2957686626daceff40d9aaace0dda92e26a0070 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 18:38:02 +0200 Subject: [PATCH] Consume weighted search answers and suppress dead chips --- ...w_answer_consumption_and_chip_viability.md | 36 +- .../ui/search-zero-learning-primary-entry.md | 3 + .../src/app/core/api/unified-search.client.ts | 195 ++++++++++ .../src/app/core/api/unified-search.models.ts | 65 ++++ .../global-search/global-search.component.ts | 349 ++++++++++++------ .../global-search.component.spec.ts | 109 +++++- ...-search-contextual-suggestions.e2e.spec.ts | 52 +++ ...fied-search-experience-quality.e2e.spec.ts | 91 +++++ .../tests/e2e/unified-search-fixtures.ts | 33 ++ 9 files changed, 812 insertions(+), 121 deletions(-) diff --git a/docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md b/docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md index 933f0d63a..aa4292b48 100644 --- a/docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md +++ b/docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md @@ -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. diff --git a/docs/modules/ui/search-zero-learning-primary-entry.md b/docs/modules/ui/search-zero-learning-primary-entry.md index ce84cc53c..41cc5b86a 100644 --- a/docs/modules/ui/search-zero-learning-primary-entry.md +++ b/docs/modules/ui/search-zero-learning-primary-entry.md @@ -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. diff --git a/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts b/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts index 12e617f72..41bd76269 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts @@ -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 { + 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('/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 { diff --git a/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts index fb8e1ccc0..20154bc87 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts @@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts index 912930635..34506afd8 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts @@ -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()) {
{{ t('ui.search.loading', 'Searching...') }}
- } @else if (query().trim().length >= 1 && cards().length === 0) { + } @else if (query().trim().length >= 1 && visibleCards().length === 0) {
{{ t('ui.search.no_results', 'No results found') }}
} @else if (query().trim().length >= 1) { -
- @for (filter of availableDomainFilters(); track filter) { - - } -
+ @if (scopeWeightingHint(); as scopeHint) { +
{{ scopeHint }}
+ } -
- @for (card of filteredCards(); track card.entityKey; let i = $index) { - - } -
+ @if (cards().length > 0) { +
+ @if (overflowCards().length > 0) { +
{{ t('ui.search.results.primary', 'Best match for this page') }}
+ } +
+ @for (card of cards(); track card.entityKey; let i = $index) { + + } +
+
+ } - + @if (overflowCards().length > 0) { +
+
{{ overflowSectionTitle() }}
+
{{ overflowSectionReason() }}
+
+ @for (card of overflowCards(); track card.entityKey; let i = $index) { + + } +
+
+ } + + @if (!searchResponse()?.contextAnswer) { + + } } @else { @if (recentSearches().length > 0) {
@@ -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(null); + readonly suggestionViability = signal(null); readonly recentSearches = signal([]); - readonly activeDomainFilter = signal('all'); readonly expandedCardKey = signal(null); - readonly pendingDomainFilter = signal(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(() => { 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(() => { 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(() => { - const filters: SearchDomainFilter[] = ['all']; - const domains = new Set(); - 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 { 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): 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 { diff --git a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts index c83e6ea01..fbf7c4958 100644 --- a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts @@ -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, + ).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'); diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts index 1572f34ff..ea87342d5 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts @@ -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, }) => { diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts index 2def45908..94fc1b1a4 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-experience-quality.e2e.spec.ts @@ -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> = []; await page.route('**/search/query**', async (route) => { diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts index 05e099daf..938ddefc8 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts @@ -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, }; }