diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx index be3b234ba..130a84702 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx @@ -1,6 +1,6 @@ import { SearchOrAskAiErrorCallout } from '../../SearchOrAskAiErrorCallout' import { useSearchActions, useSearchTerm } from '../search.store' -import { useSearchFilters, type FilterType } from '../useSearchFilters' +import { useSearchFilters, type TypeFilter } from '../useSearchFilters' import { useSearchQuery } from '../useSearchQuery' import { SearchResultListItem } from './SearchResultsListItem' import { @@ -18,7 +18,6 @@ import { import { css } from '@emotion/react' import { useDebounce } from '@uidotdev/usehooks' import { useEffect, useRef, useCallback } from 'react' -import type { MouseEvent } from 'react' interface SearchResultsProps { onKeyDown?: ( @@ -61,9 +60,10 @@ export const SearchResults = ({ const { data, error, isLoading } = useSearchQuery() - const { selectedFilters, handleFilterClick, filteredResults, counts } = + const { selectedFilter, handleFilterClick, filteredResults, counts } = useSearchFilters({ results: data?.results ?? [], + aggregations: data?.aggregations, }) const isInitialLoading = isLoading && !data @@ -84,7 +84,7 @@ export const SearchResults = ({ {!error && ( <> - onFilterClick: (filter: FilterType, event?: MouseEvent) => void + selectedFilter: TypeFilter + onFilterClick: (filter: TypeFilter) => void counts: { apiResultsCount: number docsResultsCount: number @@ -245,12 +245,12 @@ const Filter = ({ color="text" // @ts-expect-error: xs is valid size according to EuiButton docs size="xs" - fill={selectedFilters.has('all')} + fill={selectedFilter === 'all'} isLoading={isLoading} - onClick={(e: MouseEvent) => onFilterClick('all', e)} + onClick={() => onFilterClick('all')} css={buttonStyle} aria-label={`Show all results, ${totalCount} total`} - aria-pressed={selectedFilters.has('all')} + aria-pressed={selectedFilter === 'all'} > {isLoading ? 'ALL' : `ALL (${totalCount})`} @@ -264,12 +264,12 @@ const Filter = ({ color="text" // @ts-expect-error: xs is valid size according to EuiButton docs size="xs" - fill={selectedFilters.has('doc')} + fill={selectedFilter === 'doc'} isLoading={isLoading} - onClick={(e: MouseEvent) => onFilterClick('doc', e)} + onClick={() => onFilterClick('doc')} css={buttonStyle} aria-label={`Filter to documentation results, ${docsResultsCount} available`} - aria-pressed={selectedFilters.has('doc')} + aria-pressed={selectedFilter === 'doc'} > {isLoading ? 'DOCS' : `DOCS (${docsResultsCount})`} @@ -283,12 +283,12 @@ const Filter = ({ color="text" // @ts-expect-error: xs is valid size according to EuiButton docs size="xs" - fill={selectedFilters.has('api')} + fill={selectedFilter === 'api'} isLoading={isLoading} - onClick={(e: MouseEvent) => onFilterClick('api', e)} + onClick={() => onFilterClick('api')} css={buttonStyle} aria-label={`Filter to API results, ${apiResultsCount} available`} - aria-pressed={selectedFilters.has('api')} + aria-pressed={selectedFilter === 'api'} > {isLoading ? 'API' : `API (${apiResultsCount})`} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts index 3b4710863..dd8615e79 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts @@ -1,11 +1,15 @@ import { create } from 'zustand/react' +export type TypeFilter = 'all' | 'doc' | 'api' + interface SearchState { searchTerm: string page: number + typeFilter: TypeFilter actions: { setSearchTerm: (term: string) => void setPageNumber: (page: number) => void + setTypeFilter: (filter: TypeFilter) => void clearSearchTerm: () => void } } @@ -13,13 +17,17 @@ interface SearchState { export const searchStore = create((set) => ({ searchTerm: '', page: 1, + typeFilter: 'all', actions: { setSearchTerm: (term: string) => set({ searchTerm: term }), setPageNumber: (page: number) => set({ page }), - clearSearchTerm: () => set({ searchTerm: '' }), + setTypeFilter: (filter: TypeFilter) => + set({ typeFilter: filter, page: 0 }), + clearSearchTerm: () => set({ searchTerm: '', typeFilter: 'all' }), }, })) export const useSearchTerm = () => searchStore((state) => state.searchTerm) export const usePageNumber = () => searchStore((state) => state.page) +export const useTypeFilter = () => searchStore((state) => state.typeFilter) export const useSearchActions = () => searchStore((state) => state.actions) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts index 0e643384e..5a48770de 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts @@ -1,67 +1,42 @@ +import { + useTypeFilter, + useSearchActions, + type TypeFilter, +} from './search.store' import { SearchResponse } from './useSearchQuery' -import { useState, useMemo } from 'react' -import type { MouseEvent } from 'react' - -export type FilterType = 'all' | 'doc' | 'api' interface UseSearchFiltersOptions { results: SearchResponse['results'] + aggregations?: SearchResponse['aggregations'] } -export const useSearchFilters = ({ results }: UseSearchFiltersOptions) => { - const [selectedFilters, setSelectedFilters] = useState>( - new Set(['all']) - ) - - const isMultiSelectModifierPressed = (event?: MouseEvent): boolean => { - return !!(event && (event.metaKey || event.altKey || event.ctrlKey)) - } +export const useSearchFilters = ({ + results, + aggregations, +}: UseSearchFiltersOptions) => { + const typeFilter = useTypeFilter() + const { setTypeFilter } = useSearchActions() - const toggleFilter = ( - currentFilters: Set, - filter: FilterType - ): Set => { - const newFilters = new Set(currentFilters) - newFilters.delete('all') - if (newFilters.has(filter)) { - newFilters.delete(filter) - } else { - newFilters.add(filter) - } - return newFilters.size === 0 ? new Set(['all']) : newFilters + const handleFilterClick = (filter: TypeFilter) => { + setTypeFilter(filter) } - const handleFilterClick = (filter: FilterType, event?: MouseEvent) => { - if (filter === 'all') { - setSelectedFilters(new Set(['all'])) - return - } + // Results come pre-filtered from the server, so we just return them directly + const filteredResults = results - if (isMultiSelectModifierPressed(event)) { - setSelectedFilters((prev) => toggleFilter(prev, filter)) - } else { - setSelectedFilters(new Set([filter])) - } - } - - const filteredResults = useMemo(() => { - if (selectedFilters.has('all')) { - return results - } - return results.filter((result) => selectedFilters.has(result.type)) - }, [results, selectedFilters]) - - const counts = useMemo(() => { - const apiResultsCount = results.filter((r) => r.type === 'api').length - const docsResultsCount = results.filter((r) => r.type === 'doc').length - const totalCount = docsResultsCount + apiResultsCount - return { apiResultsCount, docsResultsCount, totalCount } - }, [results]) + const typeAggregations = aggregations?.type + const apiResultsCount = typeAggregations?.['api'] ?? 0 + const docsResultsCount = typeAggregations?.['doc'] ?? 0 + const totalCount = docsResultsCount + apiResultsCount + const counts = { apiResultsCount, docsResultsCount, totalCount } return { - selectedFilters, + selectedFilter: typeFilter, handleFilterClick, filteredResults, counts, } } + +// Re-export TypeFilter for convenience +export type { TypeFilter } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts index 6bca5d3ad..424f645c0 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts @@ -8,7 +8,7 @@ import { import { traceSpan } from '../../../telemetry/tracing' import { createApiErrorFromResponse, shouldRetry } from '../errorHandling' import { ApiError } from '../errorHandling' -import { usePageNumber, useSearchTerm } from './search.store' +import { usePageNumber, useSearchTerm, useTypeFilter } from './search.store' import { useIsSearchAwaitingNewInput, useSearchCooldownActions, @@ -41,12 +41,17 @@ const SearchResultItem = z.object({ export type SearchResultItem = z.infer +const SearchAggregations = z.object({ + type: z.record(z.string(), z.number()).optional(), +}) + const SearchResponse = z.object({ results: z.array(SearchResultItem), totalResults: z.number(), pageCount: z.number(), pageNumber: z.number(), pageSize: z.number(), + aggregations: SearchAggregations.optional(), }) export type SearchResponse = z.infer @@ -54,6 +59,7 @@ export type SearchResponse = z.infer export const useSearchQuery = () => { const searchTerm = useSearchTerm() const pageNumber = usePageNumber() + 1 + const typeFilter = useTypeFilter() const trimmedSearchTerm = searchTerm.trim() const debouncedSearchTerm = useDebounce(trimmedSearchTerm, 300) const isCooldownActive = useIsSearchCooldownActive() @@ -80,7 +86,11 @@ export const useSearchQuery = () => { const query = useQuery({ queryKey: [ 'search', - { searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber }, + { + searchTerm: debouncedSearchTerm.toLowerCase(), + pageNumber, + typeFilter, + }, ], queryFn: async ({ signal }) => { // Don't create span for empty searches @@ -101,6 +111,11 @@ export const useSearchQuery = () => { page: pageNumber.toString(), }) + // Only add type filter if not 'all' + if (typeFilter !== 'all') { + params.set('type', typeFilter) + } + const response = await fetch( '/docs/_api/v1/search?' + params.toString(), { signal } @@ -140,10 +155,14 @@ export const useSearchQuery = () => { queryClient.cancelQueries({ queryKey: [ 'search', - { searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber }, + { + searchTerm: debouncedSearchTerm.toLowerCase(), + pageNumber, + typeFilter, + }, ], }) - }, [queryClient, debouncedSearchTerm, pageNumber]) + }, [queryClient, debouncedSearchTerm, pageNumber, typeFilter]) return { ...query, diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchIngestChannel.Mapping.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchIngestChannel.Mapping.cs index 4b75c315f..ced9c8fcd 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchIngestChannel.Mapping.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchIngestChannel.Mapping.cs @@ -113,7 +113,8 @@ protected static string CreateMapping(string? inferenceId) => $$""" { "properties": { - "url" : { + "type": { "type" : "keyword", "normalizer": "keyword_normalizer" }, + "url": { "type": "keyword", "fields": { "match": { "type": "text" }, diff --git a/src/api/Elastic.Documentation.Api.Core/Search/ISearchGateway.cs b/src/api/Elastic.Documentation.Api.Core/Search/ISearchGateway.cs index 29f7125d6..938802d3e 100644 --- a/src/api/Elastic.Documentation.Api.Core/Search/ISearchGateway.cs +++ b/src/api/Elastic.Documentation.Api.Core/Search/ISearchGateway.cs @@ -6,10 +6,18 @@ namespace Elastic.Documentation.Api.Core.Search; public interface ISearchGateway { - Task<(int TotalHits, List Results)> SearchAsync( + Task SearchAsync( string query, int pageNumber, int pageSize, + string? filter = null, Cancel ctx = default ); } + +public record SearchResult +{ + public required int TotalHits { get; init; } + public required List Results { get; init; } + public IReadOnlyDictionary Aggregations { get; init; } = new Dictionary(); +} diff --git a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs index c94e541a4..38f492dca 100644 --- a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs @@ -8,21 +8,23 @@ namespace Elastic.Documentation.Api.Core.Search; public partial class SearchUsecase(ISearchGateway searchGateway, ILogger logger) { - public async Task Search(SearchRequest request, Cancel ctx = default) + public async Task Search(SearchApiRequest request, Cancel ctx = default) { - var (totalHits, results) = await searchGateway.SearchAsync( + var searchResult = await searchGateway.SearchAsync( request.Query, request.PageNumber, request.PageSize, + request.TypeFilter, ctx ); - var response = new SearchResponse + var response = new SearchApiResponse { - Results = results, - TotalResults = totalHits, + Results = searchResult.Results, + TotalResults = searchResult.TotalHits, PageNumber = request.PageNumber, PageSize = request.PageSize, + Aggregations = new SearchAggregations { Type = searchResult.Aggregations } }; LogSearchResults( @@ -30,7 +32,7 @@ public async Task Search(SearchRequest request, Cancel ctx = def response.PageSize, response.PageNumber, request.Query, - new SearchResultsLogProperties(results.Select(i => i.Url).ToArray()) + new SearchResultsLogProperties(searchResult.Results.Select(i => i.Url).ToArray()) ); return response; @@ -42,24 +44,31 @@ public async Task Search(SearchRequest request, Cancel ctx = def private sealed record SearchResultsLogProperties(string[] Urls); } -public record SearchRequest +public record SearchApiRequest { public required string Query { get; init; } public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = 20; + public string? TypeFilter { get; init; } } -public record SearchResponse +public record SearchApiResponse { public required IEnumerable Results { get; init; } public required int TotalResults { get; init; } public required int PageNumber { get; init; } public required int PageSize { get; init; } + public SearchAggregations Aggregations { get; init; } = new(); public int PageCount => TotalResults > 0 ? (int)Math.Ceiling((double)TotalResults / PageSize) : 0; } +public record SearchAggregations +{ + public IReadOnlyDictionary Type { get; init; } = new Dictionary(); +} + public record SearchResultItemParent { public required string Title { get; init; } diff --git a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs index 3081c418b..6e8688c12 100644 --- a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs @@ -18,8 +18,9 @@ public record InputMessage(string Role, MessagePart[] Parts); public record OutputMessage(string Role, MessagePart[] Parts, string FinishReason); [JsonSerializable(typeof(AskAiRequest))] -[JsonSerializable(typeof(SearchRequest))] -[JsonSerializable(typeof(SearchResponse))] +[JsonSerializable(typeof(SearchApiRequest))] +[JsonSerializable(typeof(SearchApiResponse))] +[JsonSerializable(typeof(SearchAggregations))] [JsonSerializable(typeof(InputMessage))] [JsonSerializable(typeof(OutputMessage[]))] [JsonSerializable(typeof(MessagePart))] diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs index 9d0661a37..be7c6befc 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs @@ -5,12 +5,12 @@ using System.Globalization; using System.Text.Json.Serialization; using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.Aggregations; using Elastic.Clients.Elasticsearch.Core.Explain; using Elastic.Clients.Elasticsearch.Core.Search; using Elastic.Clients.Elasticsearch.QueryDsl; using Elastic.Clients.Elasticsearch.Serialization; using Elastic.Documentation.Api.Core.Search; -using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Search; using Elastic.Transport; @@ -54,8 +54,8 @@ public ElasticsearchGateway(ElasticsearchOptions elasticsearchOptions, SearchCon public async Task CanConnect(Cancel ctx) => (await _client.PingAsync(ctx)).IsValidResponse; - public async Task<(int TotalHits, List Results)> SearchAsync(string query, int pageNumber, int pageSize, Cancel ctx = default) => - await HybridSearchWithRrfAsync(query, pageNumber, pageSize, ctx); + public async Task SearchAsync(string query, int pageNumber, int pageSize, string? filter = null, Cancel ctx = default) => + await SearchImplementation(query, pageNumber, pageSize, filter, ctx); /// /// Extracts the ruleset name from the index name. @@ -237,85 +237,84 @@ private static Query BuildSemanticQuery(string searchQuery) => new TermsQueryField(["/docs", "/docs/", "/docs/404", "/docs/404/"])) && !(Query)new TermQuery { Field = Infer.Field(f => f.Hidden), Value = true }; - public async Task<(int TotalHits, List Results)> HybridSearchWithRrfAsync(string query, int pageNumber, int pageSize, - Cancel ctx = default) + public async Task SearchImplementation(string query, int pageNumber, int pageSize, string? filter = null, Cancel ctx = default) { - _logger.LogInformation("Starting RRF hybrid search for '{Query}' with pageNumber={PageNumber}, pageSize={PageSize}", query, pageNumber, pageSize); - const string preTag = ""; const string postTag = ""; var searchQuery = query; var lexicalQuery = BuildLexicalQuery(searchQuery); + // Build post_filter for type filtering (applied after aggregations are computed) + Query? postFilter = null; + if (!string.IsNullOrWhiteSpace(filter)) + postFilter = new TermQuery { Field = Infer.Field(f => f.Type), Value = filter }; + try { - var response = await _client.SearchAsync(s => s - .Indices(_elasticsearchOptions.IndexName) - .From(Math.Max(pageNumber - 1, 0) * pageSize) - .Size(pageSize) - .Query(lexicalQuery) - // .Retriever(r => r - // .Rrf(rrf => rrf - // .Filter(BuildFilter()) - // .Retrievers( - // // Lexical/Traditional search retriever - // ret => ret.Standard(std => std.Query(lexicalSearchRetriever)), - // // Semantic search retriever - // ret => ret.Standard(std => std.Query(semanticSearchRetriever)) - // ) - // .RankConstant(60) // Controls how much weight is given to document ranking - // .RankWindowSize(100) - // ) - // ) - .Source(sf => sf - .Filter(f => f - .Includes( - e => e.Type, - e => e.Title, - e => e.SearchTitle, - e => e.Url, - e => e.Description, - e => e.Parents, - e => e.Headings - ) + var response = await _client.SearchAsync(s => + { + _ = s + .Indices(_elasticsearchOptions.IndexName) + .From(Math.Max(pageNumber - 1, 0) * pageSize) + .Size(pageSize) + .Query(lexicalQuery) + .Aggregations(agg => agg + .Add("type", a => a.Terms(t => t.Field(f => f.Type))) ) - ) - .Highlight(h => h - .RequireFieldMatch(true) - .Fields(f => f - .Add(Infer.Field(d => d.SearchTitle.Suffix("completion")), hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .BoundaryChars(":.!?\t\n") - .BoundaryScanner(BoundaryScanner.Sentence) - .BoundaryMaxScan(15) - .FragmentOffset(0) - .HighlightQuery(q => q.Match(m => m - .Field(d => d.SearchTitle.Suffix("completion")) - .Query(searchQuery) - .Analyzer("highlight_analyzer") - )) - .PreTags(preTag) - .PostTags(postTag)) - .Add(Infer.Field(d => d.StrippedBody), hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .BoundaryChars(":.!?\t\n") - .BoundaryScanner(BoundaryScanner.Sentence) - .BoundaryMaxScan(15) - .FragmentOffset(0) - .HighlightQuery(q => q.Match(m => m - .Field(d => d.StrippedBody) - .Query(searchQuery) - .Analyzer("highlight_analyzer") - )) - .PreTags(preTag) - .PostTags(postTag)) + .Source(sf => sf + .Filter(f => f + .Includes( + e => e.Type, + e => e.Title, + e => e.SearchTitle, + e => e.Url, + e => e.Description, + e => e.Parents, + e => e.Headings + ) + ) ) - ), ctx); + .Highlight(h => h + .RequireFieldMatch(true) + .Fields(f => f + .Add(Infer.Field(d => d.SearchTitle.Suffix("completion")), hf => hf + .FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .BoundaryChars(":.!?\t\n") + .BoundaryScanner(BoundaryScanner.Sentence) + .BoundaryMaxScan(15) + .FragmentOffset(0) + .HighlightQuery(q => q.Match(m => m + .Field(d => d.SearchTitle.Suffix("completion")) + .Query(searchQuery) + .Analyzer("highlight_analyzer") + )) + .PreTags(preTag) + .PostTags(postTag)) + .Add(Infer.Field(d => d.StrippedBody), hf => hf + .FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .BoundaryChars(":.!?\t\n") + .BoundaryScanner(BoundaryScanner.Sentence) + .BoundaryMaxScan(15) + .FragmentOffset(0) + .HighlightQuery(q => q.Match(m => m + .Field(d => d.StrippedBody) + .Query(searchQuery) + .Analyzer("highlight_analyzer") + )) + .PreTags(preTag) + .PostTags(postTag)) + ) + ); + + // Apply post_filter if a filter is specified + if (postFilter is not null) + _ = s.PostFilter(postFilter); + }, ctx); if (!response.IsValidResponse) { @@ -334,7 +333,7 @@ private static Query BuildSemanticQuery(string searchQuery) => } } - private static (int TotalHits, List Results) ProcessSearchResponse(SearchResponse response) + private static SearchResult ProcessSearchResponse(SearchResponse response) { var totalHits = (int)response.Total; @@ -373,7 +372,20 @@ private static (int TotalHits, List Results) ProcessSearchResp }; }).ToList(); - return (totalHits, results); + // Extract aggregations + var aggregations = new Dictionary(); + if (response.Aggregations?.TryGetValue("type", out var typeAgg) == true && typeAgg is StringTermsAggregate stringTermsAgg) + { + foreach (var bucket in stringTermsAgg.Buckets) + aggregations[bucket.Key.ToString()!] = bucket.DocCount; + } + + return new SearchResult + { + TotalHits = totalHits, + Results = results, + Aggregations = aggregations + }; } /// @@ -484,7 +496,7 @@ private static string FormatExplanation(ExplanationDetail? explanation, int inde Cancel ctx = default) { // First, get the top result - var searchResults = await HybridSearchWithRrfAsync(query, 1, 1, ctx); + var searchResults = await SearchImplementation(query, 1, 1, null, ctx); var topResultUrl = searchResults.Results.FirstOrDefault()?.Url; if (string.IsNullOrEmpty(topResultUrl)) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/MockSearchGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/MockSearchGateway.cs index ee425bd45..1cc498020 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/MockSearchGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/MockSearchGateway.cs @@ -82,14 +82,26 @@ public class MockSearchGateway : ISearchGateway } ]; - public async Task<(int TotalHits, List Results)> SearchAsync(string query, int pageNumber, int pageSize, CancellationToken ctx = default) + public async Task SearchAsync(string query, int pageNumber, int pageSize, string? filter = null, CancellationToken ctx = default) { var filteredResults = Results .Where(item => - item.Title.Equals(query, StringComparison.OrdinalIgnoreCase) || - item.Description?.Equals(query, StringComparison.OrdinalIgnoreCase) == true) + item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || + item.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) .ToList(); + // Apply type filter if specified + if (!string.IsNullOrWhiteSpace(filter)) + filteredResults = filteredResults.Where(item => item.Type == filter).ToList(); + + // Calculate aggregations before filtering + var aggregations = Results + .Where(item => + item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || + item.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) + .GroupBy(item => item.Type) + .ToDictionary(g => g.Key, g => (long)g.Count()); + var pagedResults = filteredResults .Skip((pageNumber - 1) * pageSize) .Take(pageSize) @@ -97,7 +109,12 @@ public class MockSearchGateway : ISearchGateway Console.WriteLine($"MockSearchGateway: Paged results count: {pagedResults.Count}"); - return await Task.Delay(1000, ctx) - .ContinueWith(_ => (TotalHits: filteredResults.Count, Results: pagedResults), ctx); + await Task.Delay(1000, ctx); + return new SearchResult + { + TotalHits = filteredResults.Count, + Results = pagedResults, + Aggregations = aggregations + }; } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index e336f23b2..971da58ca 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -44,14 +44,16 @@ private static void MapSearchEndpoint(IEndpointRouteBuilder group) async ( [FromQuery(Name = "q")] string query, [FromQuery(Name = "page")] int? pageNumber, + [FromQuery(Name = "type")] string? typeFilter, SearchUsecase searchUsecase, Cancel ctx ) => { - var searchRequest = new SearchRequest + var searchRequest = new SearchApiRequest { Query = query, - PageNumber = pageNumber ?? 1 + PageNumber = pageNumber ?? 1, + TypeFilter = typeFilter }; var searchResponse = await searchUsecase.Search(searchRequest, ctx); return Results.Ok(searchResponse); diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index ed74ca9ac..b83b798e9 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -57,8 +57,9 @@ [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest))] [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse))] [JsonSerializable(typeof(AskAiRequest))] -[JsonSerializable(typeof(SearchRequest))] -[JsonSerializable(typeof(SearchResponse))] +[JsonSerializable(typeof(SearchApiRequest))] +[JsonSerializable(typeof(SearchApiResponse))] +[JsonSerializable(typeof(SearchAggregations))] internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext; // Make the Program class accessible for integration testing diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs index cee13b28a..a06257526 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/Search/SearchIntegrationTests.cs @@ -55,7 +55,7 @@ public async Task SearchEndpointReturnsExpectedFirstResult(string query, string // Assert - Response should be successful response.EnsureSuccessStatusCode(); - var searchResponse = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var searchResponse = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); searchResponse.Should().NotBeNull("Search response should be deserialized"); // Log results for debugging @@ -93,12 +93,12 @@ public async Task SearchEndpointWithPaginationReturnsCorrectPage() // Act - Get first page var page1Response = await searchFixture.HttpClient!.GetAsync($"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=1", TestContext.Current.CancellationToken); page1Response.EnsureSuccessStatusCode(); - var page1Data = await page1Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var page1Data = await page1Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); // Act - Get second page var page2Response = await searchFixture.HttpClient.GetAsync($"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=2", TestContext.Current.CancellationToken); page2Response.EnsureSuccessStatusCode(); - var page2Data = await page2Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var page2Data = await page2Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert page1Data.Should().NotBeNull(); diff --git a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs index 08368479d..b2a470b7a 100644 --- a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs +++ b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs @@ -78,12 +78,14 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - Perform the search - var (totalHits, results) = await gateway.HybridSearchWithRrfAsync(query, 1, 5, TestContext.Current.CancellationToken); + var searchResult = await gateway.SearchImplementation(query, 1, 5, null, TestContext.Current.CancellationToken); // Log basic results output.WriteLine($"Query: {query}"); - output.WriteLine($"Total hits: {totalHits}"); - output.WriteLine($"Results returned: {results.Count}"); + output.WriteLine($"Total hits: {searchResult.TotalHits}"); + output.WriteLine($"Results returned: {searchResult.Results.Count}"); + + var results = searchResult.Results; results.Should().NotBeEmpty($"Search for '{query}' should return results");