Consume weighted search answers and suppress dead chips
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-ZL-001 - Consume primary and overflow search sections
|
### FE-ZL-001 - Consume primary and overflow search sections
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: Developer (Frontend)
|
Owners: Developer (Frontend)
|
||||||
Task description:
|
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.
|
- Present current-scope-biased cards first and clearly label overflow results without asking the user to manage scope manually.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] The frontend consumes additive overflow/coverage fields without breaking legacy fallback behavior.
|
- [x] The frontend consumes additive overflow/coverage fields without breaking legacy fallback behavior.
|
||||||
- [ ] Primary cards render before overflow cards when both exist.
|
- [x] Primary cards render before overflow cards when both exist.
|
||||||
- [ ] Overflow results are visually subordinate and only shown when present.
|
- [x] Overflow results are visually subordinate and only shown when present.
|
||||||
|
|
||||||
### FE-ZL-002 - Use backend answer framing instead of frontend heuristics
|
### FE-ZL-002 - Use backend answer framing instead of frontend heuristics
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-ZL-001
|
Dependency: FE-ZL-001
|
||||||
Owners: Developer (Frontend)
|
Owners: Developer (Frontend)
|
||||||
Task description:
|
Task description:
|
||||||
@@ -40,12 +40,12 @@ Task description:
|
|||||||
- Preserve a deterministic fallback when the backend omits the new answer frame.
|
- Preserve a deterministic fallback when the backend omits the new answer frame.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Search answer panels prefer backend answer data.
|
- [x] Search answer panels prefer backend answer data.
|
||||||
- [ ] Frontend fallback logic remains available for older or degraded responses.
|
- [x] Frontend fallback logic remains available for older or degraded responses.
|
||||||
- [ ] `Did you mean` remains directly under the input/result header.
|
- [x] `Did you mean` remains directly under the input/result header.
|
||||||
|
|
||||||
### FE-ZL-003 - Suppress dead contextual chips using backend coverage
|
### FE-ZL-003 - Suppress dead contextual chips using backend coverage
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-ZL-001
|
Dependency: FE-ZL-001
|
||||||
Owners: Developer (Frontend)
|
Owners: Developer (Frontend)
|
||||||
Task description:
|
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.
|
- Preserve useful fallback chips only when the backend confirms a non-empty relevant corpus or candidate set.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Contextual chips are hidden when coverage says their domain/query family is empty.
|
- [x] 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.
|
- [x] Clicking any rendered contextual chip yields visible results or a backend-issued clarify frame.
|
||||||
- [ ] Search history still records only successful result-bearing searches.
|
- [x] Search history still records only successful result-bearing searches.
|
||||||
|
|
||||||
### FE-ZL-004 - Targeted frontend verification
|
### FE-ZL-004 - Targeted frontend verification
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-ZL-003
|
Dependency: FE-ZL-003
|
||||||
Owners: Test Automation
|
Owners: Test Automation
|
||||||
Task description:
|
Task description:
|
||||||
- Add unit and Playwright coverage for overflow sections, backend answer consumption, and chip suppression.
|
- Add unit and Playwright coverage for overflow sections, backend answer consumption, and chip suppression.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Angular tests cover overflow rendering and backend answer consumption.
|
- [x] Angular tests cover overflow rendering and backend answer consumption.
|
||||||
- [ ] Playwright covers chip suppression and mixed primary/overflow result presentation.
|
- [x] Playwright covers chip suppression and mixed primary/overflow result presentation.
|
||||||
- [ ] Execution log records commands and outcomes.
|
- [x] Execution log records commands and outcomes.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| 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 | 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
|
## Decisions & Risks
|
||||||
- Decision: chip viability is enforced from backend coverage signals, not static page metadata alone.
|
- 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: 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.
|
- Risk: mixed deployments may return older unified-search payloads during rollout.
|
||||||
- Mitigation: keep all new frontend contract usage additive and optional.
|
- 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.
|
- 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 explicit `Find / Explain / Act` controls.
|
||||||
- Remove the explicit scope toggle chip.
|
- 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.
|
- Remove the recovery panel.
|
||||||
- If top results are close in score, compose one short summary across them.
|
- 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.
|
- If one result is clearly dominant, present that answer first and then cards.
|
||||||
@@ -58,10 +59,12 @@
|
|||||||
### Suggestion viability
|
### Suggestion viability
|
||||||
- Suggestions must be validated against the current corpus before rendering.
|
- Suggestions must be validated against the current corpus before rendering.
|
||||||
- Knowledge/domain emptiness should be detectable so the UI can suppress invalid chips.
|
- 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.
|
- Live Playwright coverage must assert that every surfaced suggestion returns visible results.
|
||||||
|
|
||||||
## Phase map
|
## Phase map
|
||||||
- Phase 1: FE primary-entry consolidation and removal of explicit search controls.
|
- Phase 1: FE primary-entry consolidation and removal of explicit search controls.
|
||||||
- Phase 2: AdvisoryAI implicit scope weighting, answer blending, and suggestion viability.
|
- Phase 2: AdvisoryAI implicit scope weighting, answer blending, and suggestion viability.
|
||||||
- Phase 3: FE consumption of overflow results and executable suggestion contracts.
|
- 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.
|
- Phase 4: Live Playwright reliability matrix with corpus preflight and chip-success guarantees.
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import type {
|
|||||||
EntityCard,
|
EntityCard,
|
||||||
UnifiedEntityType,
|
UnifiedEntityType,
|
||||||
UnifiedSearchAmbientContext,
|
UnifiedSearchAmbientContext,
|
||||||
|
UnifiedSearchCoverage,
|
||||||
UnifiedSearchDiagnostics,
|
UnifiedSearchDiagnostics,
|
||||||
UnifiedSearchDomain,
|
UnifiedSearchDomain,
|
||||||
UnifiedSearchFilter,
|
UnifiedSearchFilter,
|
||||||
|
UnifiedSearchOverflow,
|
||||||
UnifiedSearchResponse,
|
UnifiedSearchResponse,
|
||||||
SynthesisResult,
|
SynthesisResult,
|
||||||
|
ContextAnswer,
|
||||||
SearchSuggestion,
|
SearchSuggestion,
|
||||||
SearchRefinement,
|
SearchRefinement,
|
||||||
|
SearchSuggestionViabilityResponse,
|
||||||
SearchFeedbackRequest,
|
SearchFeedbackRequest,
|
||||||
SearchQualityAlert,
|
SearchQualityAlert,
|
||||||
SearchQualityMetrics,
|
SearchQualityMetrics,
|
||||||
@@ -58,11 +62,79 @@ interface UnifiedSearchRequestDto {
|
|||||||
interface SearchSuggestionDto {
|
interface SearchSuggestionDto {
|
||||||
text: string;
|
text: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
domain?: string;
|
||||||
|
candidateCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchRefinementDto {
|
interface SearchRefinementDto {
|
||||||
text: string;
|
text: string;
|
||||||
source: 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 {
|
interface UnifiedSearchResponseDto {
|
||||||
@@ -72,6 +144,9 @@ interface UnifiedSearchResponseDto {
|
|||||||
synthesis: SynthesisResultDto | null;
|
synthesis: SynthesisResultDto | null;
|
||||||
suggestions?: SearchSuggestionDto[];
|
suggestions?: SearchSuggestionDto[];
|
||||||
refinements?: SearchRefinementDto[];
|
refinements?: SearchRefinementDto[];
|
||||||
|
contextAnswer?: ContextAnswerDto | null;
|
||||||
|
overflow?: UnifiedSearchOverflowDto | null;
|
||||||
|
coverage?: UnifiedSearchCoverageDto | null;
|
||||||
diagnostics: UnifiedSearchDiagnosticsDto;
|
diagnostics: UnifiedSearchDiagnosticsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +206,9 @@ export class UnifiedSearchClient {
|
|||||||
topK: limit,
|
topK: limit,
|
||||||
cards: [],
|
cards: [],
|
||||||
synthesis: null,
|
synthesis: null,
|
||||||
|
contextAnswer: null,
|
||||||
|
overflow: null,
|
||||||
|
coverage: null,
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
ftsMatches: 0,
|
ftsMatches: 0,
|
||||||
vectorMatches: 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(
|
private fallbackToLegacy(
|
||||||
query: string,
|
query: string,
|
||||||
filter?: UnifiedSearchFilter,
|
filter?: UnifiedSearchFilter,
|
||||||
@@ -245,6 +366,9 @@ export class UnifiedSearchClient {
|
|||||||
topK: Math.max(1, Math.min(100, limit)),
|
topK: Math.max(1, Math.min(100, limit)),
|
||||||
cards,
|
cards,
|
||||||
synthesis: null,
|
synthesis: null,
|
||||||
|
contextAnswer: null,
|
||||||
|
overflow: null,
|
||||||
|
coverage: null,
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
ftsMatches: response.totalCount ?? cards.length,
|
ftsMatches: response.totalCount ?? cards.length,
|
||||||
vectorMatches: 0,
|
vectorMatches: 0,
|
||||||
@@ -373,6 +497,8 @@ export class UnifiedSearchClient {
|
|||||||
? response.suggestions.map((s) => ({
|
? response.suggestions.map((s) => ({
|
||||||
text: s.text ?? '',
|
text: s.text ?? '',
|
||||||
reason: s.reason ?? '',
|
reason: s.reason ?? '',
|
||||||
|
domain: s.domain,
|
||||||
|
candidateCount: s.candidateCount,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -381,9 +507,56 @@ export class UnifiedSearchClient {
|
|||||||
? response.refinements.map((r) => ({
|
? response.refinements.map((r) => ({
|
||||||
text: r.text ?? '',
|
text: r.text ?? '',
|
||||||
source: r.source ?? '',
|
source: r.source ?? '',
|
||||||
|
domain: r.domain,
|
||||||
|
candidateCount: r.candidateCount,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
query: response.query?.trim() || queryFallback,
|
query: response.query?.trim() || queryFallback,
|
||||||
topK: response.topK ?? 10,
|
topK: response.topK ?? 10,
|
||||||
@@ -391,10 +564,32 @@ export class UnifiedSearchClient {
|
|||||||
synthesis,
|
synthesis,
|
||||||
suggestions,
|
suggestions,
|
||||||
refinements,
|
refinements,
|
||||||
|
contextAnswer,
|
||||||
|
overflow,
|
||||||
|
coverage: this.mapCoverage(response.coverage),
|
||||||
diagnostics,
|
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(
|
private normalizeFilter(
|
||||||
filter?: UnifiedSearchFilter,
|
filter?: UnifiedSearchFilter,
|
||||||
): UnifiedSearchRequestDto['filters'] | undefined {
|
): UnifiedSearchRequestDto['filters'] | undefined {
|
||||||
|
|||||||
@@ -54,11 +54,73 @@ export interface SynthesisResult {
|
|||||||
export interface SearchSuggestion {
|
export interface SearchSuggestion {
|
||||||
text: string;
|
text: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
domain?: string;
|
||||||
|
candidateCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchRefinement {
|
export interface SearchRefinement {
|
||||||
text: string;
|
text: string;
|
||||||
source: 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 {
|
export interface UnifiedSearchAmbientAction {
|
||||||
@@ -87,6 +149,9 @@ export interface UnifiedSearchResponse {
|
|||||||
synthesis: SynthesisResult | null;
|
synthesis: SynthesisResult | null;
|
||||||
suggestions?: SearchSuggestion[];
|
suggestions?: SearchSuggestion[];
|
||||||
refinements?: SearchRefinement[];
|
refinements?: SearchRefinement[];
|
||||||
|
contextAnswer?: ContextAnswer | null;
|
||||||
|
overflow?: UnifiedSearchOverflow | null;
|
||||||
|
coverage?: UnifiedSearchCoverage | null;
|
||||||
diagnostics: UnifiedSearchDiagnostics;
|
diagnostics: UnifiedSearchDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { UnifiedSearchClient } from '../../core/api/unified-search.client';
|
|||||||
import type {
|
import type {
|
||||||
EntityCard,
|
EntityCard,
|
||||||
EntityCardAction,
|
EntityCardAction,
|
||||||
|
SearchSuggestionViabilityResponse,
|
||||||
UnifiedSearchResponse,
|
UnifiedSearchResponse,
|
||||||
UnifiedSearchDomain,
|
UnifiedSearchDomain,
|
||||||
} from '../../core/api/unified-search.models';
|
} from '../../core/api/unified-search.models';
|
||||||
@@ -34,7 +35,6 @@ import { SearchChatContextService } from '../../core/services/search-chat-contex
|
|||||||
import { I18nService } from '../../core/i18n';
|
import { I18nService } from '../../core/i18n';
|
||||||
import { normalizeSearchActionRoute } from './search-route-matrix';
|
import { normalizeSearchActionRoute } from './search-route-matrix';
|
||||||
|
|
||||||
type SearchDomainFilter = 'all' | UnifiedSearchDomain;
|
|
||||||
type SearchSuggestionView = {
|
type SearchSuggestionView = {
|
||||||
query: string;
|
query: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -207,44 +207,65 @@ type SearchAnswerView = {
|
|||||||
|
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
|
<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>
|
<div class="search__empty">{{ t('ui.search.no_results', 'No results found') }}</div>
|
||||||
} @else if (query().trim().length >= 1) {
|
} @else if (query().trim().length >= 1) {
|
||||||
<div class="search__filters">
|
@if (scopeWeightingHint(); as scopeHint) {
|
||||||
@for (filter of availableDomainFilters(); track filter) {
|
<div class="search__scope-hint">{{ scopeHint }}</div>
|
||||||
<button
|
}
|
||||||
type="button"
|
|
||||||
class="search__filter"
|
|
||||||
data-role="domain-filter"
|
|
||||||
[class.search__filter--active]="activeDomainFilter() === filter"
|
|
||||||
(click)="setDomainFilter(filter)"
|
|
||||||
>
|
|
||||||
{{ getDomainFilterLabel(filter) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
|
@if (cards().length > 0) {
|
||||||
@for (card of filteredCards(); track card.entityKey; let i = $index) {
|
<div class="search__cards-section">
|
||||||
<app-entity-card
|
@if (overflowCards().length > 0) {
|
||||||
[card]="card"
|
<div class="search__group-label">{{ t('ui.search.results.primary', 'Best match for this page') }}</div>
|
||||||
[selected]="selectedIndex() === i"
|
}
|
||||||
[expandedInput]="expandedCardKey() === card.entityKey"
|
<div class="search__cards" role="list" [attr.aria-live]="'polite'">
|
||||||
(actionExecuted)="onCardAction(card, $event)"
|
@for (card of cards(); track card.entityKey; let i = $index) {
|
||||||
(askAi)="onAskAiFromCard($event)"
|
<app-entity-card
|
||||||
(toggleExpand)="onTogglePreview($event)"
|
[card]="card"
|
||||||
(feedbackSubmitted)="onResultFeedback(card, $event)"
|
[selected]="selectedIndex() === i"
|
||||||
(click)="onCardSelect(card)"
|
[expandedInput]="expandedCardKey() === card.entityKey"
|
||||||
(mouseenter)="selectedIndex.set(i)"
|
(actionExecuted)="onCardAction(card, $event)"
|
||||||
/>
|
(askAi)="onAskAiFromCard($event)"
|
||||||
}
|
(toggleExpand)="onTogglePreview($event)"
|
||||||
</div>
|
(feedbackSubmitted)="onResultFeedback(card, $event)"
|
||||||
|
(click)="onCardSelect(card)"
|
||||||
|
(mouseenter)="selectedIndex.set(i)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<app-synthesis-panel
|
@if (overflowCards().length > 0) {
|
||||||
[synthesis]="synthesis()"
|
<div class="search__cards-section search__cards-section--overflow" data-overflow-results>
|
||||||
(askAi)="onAskAiFromSynthesis()"
|
<div class="search__group-label">{{ overflowSectionTitle() }}</div>
|
||||||
(synthesisFeedbackSubmitted)="onSynthesisFeedback($event)"
|
<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 {
|
} @else {
|
||||||
@if (recentSearches().length > 0) {
|
@if (recentSearches().length > 0) {
|
||||||
<div class="search__group">
|
<div class="search__group">
|
||||||
@@ -538,10 +559,35 @@ type SearchAnswerView = {
|
|||||||
border-bottom: 1px solid var(--color-border-primary);
|
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 {
|
.search__cards {
|
||||||
padding: 0.25rem 0;
|
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 {
|
.search__group {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
@@ -1071,10 +1117,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
readonly selectedIndex = signal(0);
|
readonly selectedIndex = signal(0);
|
||||||
readonly placeholderIndex = signal(0);
|
readonly placeholderIndex = signal(0);
|
||||||
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
|
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
|
||||||
|
readonly suggestionViability = signal<SearchSuggestionViabilityResponse | null>(null);
|
||||||
readonly recentSearches = signal<string[]>([]);
|
readonly recentSearches = signal<string[]>([]);
|
||||||
readonly activeDomainFilter = signal<SearchDomainFilter>('all');
|
|
||||||
readonly expandedCardKey = signal<string | null>(null);
|
readonly expandedCardKey = signal<string | null>(null);
|
||||||
readonly pendingDomainFilter = signal<SearchDomainFilter | null>(null);
|
|
||||||
|
|
||||||
readonly showResults = computed(() => this.isFocused());
|
readonly showResults = computed(() => this.isFocused());
|
||||||
readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown');
|
readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown');
|
||||||
@@ -1211,6 +1256,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => {
|
readonly contextualSuggestions = computed<SearchSuggestionView[]>(() => {
|
||||||
return [...this.ambientContext.getSearchSuggestions()]
|
return [...this.ambientContext.getSearchSuggestions()]
|
||||||
.sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left))
|
.sort((left, right) => this.scoreSuggestion(right) - this.scoreSuggestion(left))
|
||||||
|
.filter((suggestion) => this.isSuggestionQueryViable(this.i18n.tryT(suggestion.key) ?? suggestion.fallback))
|
||||||
.map((suggestion) => ({
|
.map((suggestion) => ({
|
||||||
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
|
query: this.i18n.tryT(suggestion.key) ?? suggestion.fallback,
|
||||||
reason: this.resolveSuggestionReason(suggestion),
|
reason: this.resolveSuggestionReason(suggestion),
|
||||||
@@ -1221,6 +1267,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
|
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
|
||||||
return [...this.ambientContext.getCommonQuestions('find')]
|
return [...this.ambientContext.getCommonQuestions('find')]
|
||||||
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
|
.sort((left, right) => this.scoreQuestion(right) - this.scoreQuestion(left))
|
||||||
|
.filter((question) => this.isSuggestionQueryViable(this.i18n.tryT(question.key) ?? question.fallback))
|
||||||
.map((question) => ({
|
.map((question) => ({
|
||||||
query: this.i18n.tryT(question.key) ?? question.fallback,
|
query: this.i18n.tryT(question.key) ?? question.fallback,
|
||||||
kind: question.kind ?? 'page',
|
kind: question.kind ?? 'page',
|
||||||
@@ -1252,6 +1299,30 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
const nextSearches = this.contextualSuggestions()
|
const nextSearches = this.contextualSuggestions()
|
||||||
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
|
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
|
||||||
.slice(0, 2);
|
.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;
|
const hasGroundedEvidence = response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
|
||||||
|
|
||||||
if (hasGroundedEvidence) {
|
if (hasGroundedEvidence) {
|
||||||
@@ -1323,29 +1394,42 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly cards = computed(() => this.searchResponse()?.cards ?? []);
|
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 synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
|
||||||
readonly filteredCards = computed(() => {
|
readonly filteredCards = computed(() => this.visibleCards());
|
||||||
const filter = this.activeDomainFilter();
|
readonly scopeWeightingHint = computed(() => {
|
||||||
if (filter === 'all') {
|
const coverage = this.searchResponse()?.coverage;
|
||||||
return this.cards();
|
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 overflowSectionTitle = computed(() => {
|
||||||
readonly availableDomainFilters = computed<SearchDomainFilter[]>(() => {
|
const scopeDomain = this.searchResponse()?.overflow?.currentScopeDomain
|
||||||
const filters: SearchDomainFilter[] = ['all'];
|
|| this.searchResponse()?.coverage?.currentScopeDomain;
|
||||||
const domains = new Set<string>();
|
const scopeLabel = scopeDomain
|
||||||
for (const card of this.cards()) {
|
? (DOMAIN_LABELS[scopeDomain as UnifiedSearchDomain] ?? scopeDomain)
|
||||||
domains.add(card.domain);
|
: this.t('ui.search.scope.default', 'this page');
|
||||||
}
|
return this.t(
|
||||||
|
'ui.search.results.overflow',
|
||||||
for (const domain of domains) {
|
'Also relevant outside {scope}',
|
||||||
filters.push(domain as SearchDomainFilter);
|
{ scope: scopeLabel },
|
||||||
}
|
);
|
||||||
|
|
||||||
return filters;
|
|
||||||
});
|
});
|
||||||
|
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 {
|
ngOnInit(): void {
|
||||||
this.placeholderRotationHandle = setInterval(() => {
|
this.placeholderRotationHandle = setInterval(() => {
|
||||||
@@ -1363,7 +1447,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
)
|
)
|
||||||
.subscribe(() => this.consumeChatToSearchContext());
|
.subscribe(() => {
|
||||||
|
this.consumeChatToSearchContext();
|
||||||
|
if (this.isFocused()) {
|
||||||
|
this.refreshSuggestionViability();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.consumeChatToSearchContext();
|
this.consumeChatToSearchContext();
|
||||||
|
|
||||||
@@ -1373,9 +1462,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
switchMap((term) => {
|
switchMap((term) => {
|
||||||
if (term.length < 1) {
|
if (term.length < 1) {
|
||||||
this.searchResponse.set(null);
|
this.searchResponse.set(null);
|
||||||
|
this.suggestionViability.set(null);
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
this.selectedIndex.set(0);
|
this.selectedIndex.set(0);
|
||||||
this.activeDomainFilter.set('all');
|
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,16 +1498,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.searchResponse.set(response);
|
this.searchResponse.set(response);
|
||||||
this.selectedIndex.set(0);
|
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.expandedCardKey.set(null);
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
if (this.hasSearchEvidence(response)) {
|
if (this.hasSearchEvidence(response)) {
|
||||||
@@ -1454,6 +1533,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
this.consumeChatToSearchContext();
|
this.consumeChatToSearchContext();
|
||||||
this.loadRecentSearches();
|
this.loadRecentSearches();
|
||||||
this.loadServerHistory();
|
this.loadServerHistory();
|
||||||
|
this.refreshSuggestionViability();
|
||||||
}
|
}
|
||||||
|
|
||||||
onBlur(): void {
|
onBlur(): void {
|
||||||
@@ -1482,27 +1562,16 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
onQueryChange(value: string): void {
|
onQueryChange(value: string): void {
|
||||||
this.query.set(value);
|
this.query.set(value);
|
||||||
this.selectedIndex.set(0);
|
this.selectedIndex.set(0);
|
||||||
this.activeDomainFilter.set('all');
|
|
||||||
this.escapeCount = 0;
|
this.escapeCount = 0;
|
||||||
|
if (value.trim().length === 0) {
|
||||||
|
this.refreshSuggestionViability();
|
||||||
|
}
|
||||||
this.searchTerms$.next(value.trim());
|
this.searchTerms$.next(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
const count = this.getNavigableItemCount();
|
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) {
|
switch (event.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
if (count === 0) return;
|
if (count === 0) return;
|
||||||
@@ -1712,11 +1781,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigateByUrl(route);
|
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 {
|
t(key: string, fallback: string, params?: Record<string, string | number>): string {
|
||||||
const translated = this.i18n.tryT(key, params);
|
const translated = this.i18n.tryT(key, params);
|
||||||
if (translated !== null) {
|
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 {
|
private executeAction(action: EntityCardAction): void {
|
||||||
const normalizedRoute = action.route ? this.normalizeActionRoute(action.route) : undefined;
|
const normalizedRoute = action.route ? this.normalizeActionRoute(action.route) : undefined;
|
||||||
|
|
||||||
@@ -1820,8 +1873,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
this.query.set('');
|
this.query.set('');
|
||||||
this.selectedIndex.set(0);
|
this.selectedIndex.set(0);
|
||||||
this.searchResponse.set(null);
|
this.searchResponse.set(null);
|
||||||
this.activeDomainFilter.set('all');
|
|
||||||
this.pendingDomainFilter.set(null);
|
|
||||||
this.expandedCardKey.set(null);
|
this.expandedCardKey.set(null);
|
||||||
this.isFocused.set(false);
|
this.isFocused.set(false);
|
||||||
this.escapeCount = 0;
|
this.escapeCount = 0;
|
||||||
@@ -1936,7 +1987,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
this.searchResponse.set(null);
|
this.searchResponse.set(null);
|
||||||
this.expandedCardKey.set(null);
|
this.expandedCardKey.set(null);
|
||||||
this.isFocused.set(true);
|
this.isFocused.set(true);
|
||||||
this.pendingDomainFilter.set(context.domain ?? null);
|
|
||||||
this.recordAmbientAction(context.action ?? 'chat_to_search', {
|
this.recordAmbientAction(context.action ?? 'chat_to_search', {
|
||||||
source: 'advisory_ai_chat',
|
source: 'advisory_ai_chat',
|
||||||
queryHint: query,
|
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 {
|
private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string {
|
||||||
const synthesisSummary = response.synthesis?.summary?.trim();
|
const synthesisSummary = response.synthesis?.summary?.trim();
|
||||||
if (synthesisSummary) {
|
if (synthesisSummary) {
|
||||||
@@ -2063,7 +2200,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.searchChatContext.setSearchToChat({
|
this.searchChatContext.setSearchToChat({
|
||||||
query: query || pageLabel,
|
query: query || pageLabel,
|
||||||
entityCards: this.cards(),
|
entityCards: this.visibleCards(),
|
||||||
synthesis: this.synthesis(),
|
synthesis: this.synthesis(),
|
||||||
suggestedPrompt,
|
suggestedPrompt,
|
||||||
});
|
});
|
||||||
@@ -2116,6 +2253,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
entityKey: options.entityKey,
|
entityKey: options.entityKey,
|
||||||
route: options.route,
|
route: options.route,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.isFocused()) {
|
||||||
|
this.refreshSuggestionViability();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildAskAiPromptForCard(card: EntityCard): string {
|
private buildAskAiPromptForCard(card: EntityCard): string {
|
||||||
@@ -2155,7 +2296,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.searchChatContext.setSearchToChat({
|
this.searchChatContext.setSearchToChat({
|
||||||
query: query || pageLabel,
|
query: query || pageLabel,
|
||||||
entityCards: this.cards(),
|
entityCards: this.visibleCards(),
|
||||||
synthesis: this.synthesis(),
|
synthesis: this.synthesis(),
|
||||||
suggestedPrompt,
|
suggestedPrompt,
|
||||||
});
|
});
|
||||||
@@ -2166,7 +2307,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasSearchEvidence(response: UnifiedSearchResponse): boolean {
|
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 {
|
private persistRecentSearches(entries: string[]): void {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
searchClient = jasmine.createSpyObj('UnifiedSearchClient', [
|
searchClient = jasmine.createSpyObj('UnifiedSearchClient', [
|
||||||
'search',
|
'search',
|
||||||
|
'evaluateSuggestions',
|
||||||
'recordAnalytics',
|
'recordAnalytics',
|
||||||
'getHistory',
|
'getHistory',
|
||||||
'clearHistory',
|
'clearHistory',
|
||||||
@@ -49,6 +50,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
mode: 'fts-only',
|
mode: 'fts-only',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
searchClient.evaluateSuggestions.and.returnValue(of(null));
|
||||||
searchClient.getHistory.and.returnValue(of([]));
|
searchClient.getHistory.and.returnValue(of([]));
|
||||||
|
|
||||||
ambientContext = jasmine.createSpyObj('AmbientContextService', [
|
ambientContext = jasmine.createSpyObj('AmbientContextService', [
|
||||||
@@ -463,6 +465,26 @@ describe('GlobalSearchComponent', () => {
|
|||||||
usedVector: false,
|
usedVector: false,
|
||||||
mode: 'fts-only',
|
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();
|
component.onFocus();
|
||||||
@@ -473,11 +495,94 @@ describe('GlobalSearchComponent', () => {
|
|||||||
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
|
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
|
||||||
expect(answerPanel).not.toBeNull();
|
expect(answerPanel).not.toBeNull();
|
||||||
expect(answerPanel?.textContent).toContain('What we found');
|
expect(answerPanel?.textContent).toContain('What we found');
|
||||||
expect(answerPanel?.textContent).toContain('One critical finding matched the current page context.');
|
expect(answerPanel?.textContent).toContain('Current-scope findings matched first, with one related policy hit held as overflow.');
|
||||||
expect(answerPanel?.textContent).toContain('Grounded in 2 source(s) across Findings, Policy.');
|
expect(answerPanel?.textContent).toContain('Grounded in 2 sources across Findings and Policy.');
|
||||||
expect(answerPanel?.textContent).toContain('findings sample');
|
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 () => {
|
it('renders a clarify answer panel when no grounded evidence is found', async () => {
|
||||||
component.onFocus();
|
component.onFocus();
|
||||||
component.onQueryChange('mystery issue');
|
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);
|
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 ({
|
test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({
|
||||||
page,
|
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.describe('Unified Search - Experience Quality UX', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupBasicMocks(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);
|
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 }) => {
|
test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => {
|
||||||
const capturedRequests: Array<Record<string, unknown>> = [];
|
const capturedRequests: Array<Record<string, unknown>> = [];
|
||||||
await page.route('**/search/query**', async (route) => {
|
await page.route('**/search/query**', async (route) => {
|
||||||
|
|||||||
@@ -287,6 +287,35 @@ export function buildResponse(
|
|||||||
sourceCount: number;
|
sourceCount: number;
|
||||||
domainsCovered: string[];
|
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 {
|
return {
|
||||||
query,
|
query,
|
||||||
@@ -307,6 +336,10 @@ export function buildResponse(
|
|||||||
usedVector: true,
|
usedVector: true,
|
||||||
mode: 'hybrid',
|
mode: 'hybrid',
|
||||||
},
|
},
|
||||||
|
suggestions: options?.suggestions,
|
||||||
|
contextAnswer: options?.contextAnswer,
|
||||||
|
overflow: options?.overflow,
|
||||||
|
coverage: options?.coverage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user