// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: MIT-0 import Kendra, { QueryRequest } from "aws-sdk/clients/kendra"; import { AWSError } from "aws-sdk"; import { PromiseResult } from "aws-sdk/lib/request"; import "bootstrap/dist/css/bootstrap.min.css"; import React, { ChangeEvent } from "react"; import { Spinner } from "react-bootstrap"; import { QueryResultType, Relevance, QuerySuggestionsMode } from "./constants"; import { getSampleIndexDetails } from "./exampleData/getSampleIndexDetails"; import { getSearchResults } from "./exampleData/getSearchResults"; import { AvailableFacetManager } from "./facets/AvailableFacetManager"; import { Facets } from "./facets/Facets"; import { SelectedFacetManager } from "./facets/SelectedFacetManager"; import { DataSourceNameLookup, getAttributeTypeLookup, getDataSourceNameLookup, IndexFieldNameToDocumentAttributeValueType, } from "./facets/utils"; import Pagination from "./pagination/Pagination"; import ResultPanel from "./resultsPanel/ResultPanel"; import "./search.scss"; import SearchBar from "./searchBar/SearchBar"; import _ from "lodash"; import { AvailableSortingAttributesManager } from "./sorting/AvailableSortingAttributesManager"; import { SelectedSortingAttributeManager } from "./sorting/SelectedSortingAttributeManager"; import { DEFAULT_SORT_ATTRIBUTE, SortOrderEnum } from "./sorting/constants"; import S3 from "aws-sdk/clients/s3"; import { isNullOrUndefined } from "./utils"; import LanguageOption from "./languageOption/LanguageOption"; import AmbiguousOption from "./ambiguousOption/AmbiguousOption"; import { DEFAULT_LANGUAGE } from "./constants"; import { dynamodb, tableName } from "../services/Kendra"; import { DynamoDBClient, QueryCommandInput as DynamoQueryCommandInput } from "@aws-sdk/client-dynamodb"; import { QueryCommand as DynamoQueryCommand } from "@aws-sdk/client-dynamodb"; interface SearchProps { /* An authenticated instance of the Kendra SDK */ kendra?: Kendra; /* The ID of an index in the account the Kendra SDK is authenticated for */ indexId: string; s3?: S3; dynamodb?: DynamoDBClient; facetConfiguration?: { facetsToShowWhenUncollapsed: number; showCount: boolean; updateAvailableFacetsWhenFilterChange: boolean; facetPanelDefaultOpen: boolean; }; } interface SearchState { dataReady: boolean; searchResults: Kendra.QueryResult; topResults: Kendra.QueryResultItemList; faqResults: Kendra.QueryResultItemList; docResults: Kendra.QueryResultItemList; currentPageNumber: number; queryText: string; error?: AWSError; index?: Kendra.DescribeIndexResponse; facetsOpen: boolean; suggestionsEnabled: boolean; // Faceting state attributeTypeLookup?: IndexFieldNameToDocumentAttributeValueType; availableFacets: AvailableFacetManager; dataSourceNameLookup?: DataSourceNameLookup; selectedFacets: SelectedFacetManager; // Sorting state availableSortingAttributes: AvailableSortingAttributesManager; selectedSortingAttribute: SelectedSortingAttributeManager; // datasources language language: string; // ambiguous search flag isAmbiguous: boolean // statistical information dulation: string, // dulation to search executedQuery: string // executed query which can be typed query and ambiguous query } export default class Search extends React.Component { constructor(props: SearchProps) { super(props); this.state = { dataReady: false, searchResults: {}, topResults: [], faqResults: [], docResults: [], currentPageNumber: 1, queryText: "", error: undefined, attributeTypeLookup: undefined, availableFacets: AvailableFacetManager.empty(), selectedFacets: SelectedFacetManager.empty(), index: undefined, facetsOpen: (this.props.facetConfiguration && this.props.facetConfiguration.facetPanelDefaultOpen) ?? false, availableSortingAttributes: AvailableSortingAttributesManager.empty(), selectedSortingAttribute: SelectedSortingAttributeManager.default(), suggestionsEnabled: false, // datasource language language: DEFAULT_LANGUAGE, // ambiguous search flag isAmbiguous: false, // statistical information dulation: "", // dulation to search executedQuery: "" // executed query which can be typed query and ambiguous query }; } async componentDidMount() { const { indexId, kendra } = this.props; if (kendra) { const listDataSourcePromise = this.listDataSources(kendra, indexId); const describeQuerySuggestionsConfigPromise = this.describeQuerySuggestionsConfig(); try { // Create attribute type lookup from index const index = await kendra .describeIndex({ Id: indexId, }) .promise(); this.setState({ attributeTypeLookup: getAttributeTypeLookup(index), index: index, }); // Get available sorting attributes from index meta data if (index.DocumentMetadataConfigurations) { this.setState({ availableSortingAttributes: this.state.availableSortingAttributes.fromIndexMetadata( index.DocumentMetadataConfigurations ), }); } // Create data source name lookup const dataSources = await listDataSourcePromise; const qsConfig = await describeQuerySuggestionsConfigPromise; this.setState({ dataSourceNameLookup: getDataSourceNameLookup(dataSources), suggestionsEnabled: (qsConfig && qsConfig.Mode === QuerySuggestionsMode.ENABLED) ? true : false, }); } catch (e: any) { this.setState({ error: e, }); } } else { // The SDK is not configured, use mock data this.setState({ ...getSampleIndexDetails(), }); } } listDataSources = async ( kendra: Kendra, indexId: string ): Promise => { try { let listDsResponse: PromiseResult< Kendra.ListDataSourcesResponse, AWSError > | null = await kendra .listDataSources({ IndexId: indexId, }) .promise(); const dataSources = listDsResponse.SummaryItems || []; while (listDsResponse?.$response.hasNextPage()) { const nextPage = listDsResponse.$response.nextPage(); if (nextPage) { listDsResponse = await nextPage.promise(); if (listDsResponse?.SummaryItems) { dataSources.push(...listDsResponse.SummaryItems); } } else { listDsResponse = null; } } return dataSources; } catch (e: any) { this.setState({ error: e, }); } return null; }; // get synonym from dynamodb getSynonym = async (k: string) => { const params: DynamoQueryCommandInput = { TableName: tableName, KeyConditionExpression: "keyword = :k", ExpressionAttributeValues: { ":k": { S: k } } } const result = await dynamodb?.send(new DynamoQueryCommand(params)) return result } // get ambiguous query by using synonym getAmbiguousQuery = async (queryText: string) => { let ambiguousQuery = ""; // convert full-width space into half-width space const query = queryText.replace(' ', ' '); // split query text by half-width space const ql = query.split(' '); // add OR operator into query if there are synonym for (const q of ql) { try { const synonyms = await this.getSynonym(q); let rc = synonyms?.Count ?? 0; if (rc === 0) { ambiguousQuery = ambiguousQuery + q } else { ambiguousQuery = ambiguousQuery + '(' + q + ' OR ' for (let i = 0; i < rc; i++) { if (i < rc - 1) { ambiguousQuery = ambiguousQuery + synonyms?.Items?.[0].synonym.L?.[i].S + ' OR ' } else { ambiguousQuery = ambiguousQuery + synonyms?.Items?.[0].synonym.L?.[i].S + ') ' } } } } catch (e) { } } return ambiguousQuery } private getResultsHelper = async ( queryText: string, pageNumber: number, filter?: Kendra.AttributeFilter ) => { this.setState({ dataReady: false }); // start measurement const start = performance.now(); // set language code of datasources if (isNullOrUndefined(filter)) { filter = { "EqualsTo": { "Key": "_language_code", "Value": { "StringValue": this.state.language } } }; } else { filter?.AndAllFilters?.push({ EqualsTo: { Key: "_language_code", Value: { "StringValue": this.state.language } } }) } let ambiguousQuery = ""; // modify queryText to do ambiguous Search if (this.state.isAmbiguous) { ambiguousQuery = await this.getAmbiguousQuery(queryText) } let results: Kendra.QueryResult = getSearchResults(pageNumber, filter); const queryRequest: QueryRequest = { IndexId: this.props.indexId, QueryText: ambiguousQuery ? ambiguousQuery : queryText, // apply ambiguousQuery if exist PageNumber: pageNumber, AttributeFilter: filter ? filter : undefined, }; const sortingAttribute = this.state.selectedSortingAttribute.getSelectedSortingAttribute(); const sortingOrder = this.state.selectedSortingAttribute.getSelectedSortingOrder(); if (sortingAttribute !== DEFAULT_SORT_ATTRIBUTE) { queryRequest.SortingConfiguration = { DocumentAttributeKey: sortingAttribute, SortOrder: sortingOrder!, }; } if (this.props.kendra) { try { results = await this.props.kendra.query(queryRequest).promise(); } catch (e: any) { this.setState({ searchResults: {}, topResults: [], faqResults: [], docResults: [], dataReady: true, error: e, }); return; } } else { console.error( "WARNING: No Kendra SDK instance provided, using dummy data" ); } // stop measurement const end = performance.now(); // dulation const elapsed = (end - start) / 1000; const elapsedStr = elapsed.toPrecision(3); // show dulation this.setState({ dulation: elapsedStr}) // show executed query this.setState({ executedQuery: ambiguousQuery ? ambiguousQuery : queryText }) const tempTopResults: Kendra.QueryResultItemList = []; const tempFAQResults: Kendra.QueryResultItemList = []; const tempDocumentResults: Kendra.QueryResultItemList = []; if (results && results.ResultItems) { results.ResultItems.forEach((result: Kendra.QueryResultItem) => { if (!isNullOrUndefined(this.props.s3) && result.DocumentURI) { try { let res = result.DocumentURI.split("/"); if (res[2].startsWith("s3")) { //The URI points to an object in an S3 bucket //Get presigned url from s3 let bucket = res[3]; let key = res[4]; for (var i = 5; i < res.length; i++) { key = key + "/" + res[i]; } let params = { Bucket: bucket, Key: key }; let url = this.props.s3!.getSignedUrl("getObject", params); result.DocumentURI = url; } } catch { // Just do nothing, so the documentURI are still as before } } switch (result.Type) { case QueryResultType.Answer: tempTopResults.push(result); break; case QueryResultType.QuestionAnswer: tempFAQResults.push(result); break; case QueryResultType.Document: tempDocumentResults.push(result); break; default: break; } }); // Only update availableFacets in two situations: // 1. There is no filter // 2. There is filter and the updateAvailableFacetsWhenFilterChange flag is true if ( !filter || (filter && this.props.facetConfiguration?.updateAvailableFacetsWhenFilterChange) ) { this.setState({ availableFacets: AvailableFacetManager.fromQueryResult(results), }); } this.setState({ searchResults: results, topResults: tempTopResults, faqResults: tempFAQResults, docResults: tempDocumentResults, dataReady: true, error: undefined, }); } else { this.setState({ searchResults: {}, topResults: tempTopResults, faqResults: tempFAQResults, docResults: tempDocumentResults, dataReady: true, error: undefined, }); } this.setState({ currentPageNumber: pageNumber, queryText: queryText, }); }; // When submitting query from search bar, reset facets and sorting attributes getResults = async (queryText: string, pageNumber: number = 1) => { this.setState( { selectedFacets: this.state.selectedFacets.clearAll(), selectedSortingAttribute: SelectedSortingAttributeManager.default(), }, () => { this.getResultsHelper(queryText, pageNumber); } ); }; getResultsOnPageChanging = async ( queryText: string, pageNumber: number = 1 ) => { this.computeFilterAndReSubmitQuery(queryText, pageNumber); }; submitFeedback = async ( relevance: Relevance, resultItem: Kendra.QueryResultItem ) => { if (!this.props.kendra) { console.error( "WARNING: No Kendra SDK instance provided, submit feedback ignored" ); return; } else if (!this.props.indexId) { console.error( "WARNING: No Kendra Index Id provided, submit feedback ignored" ); return; } const queryResult = this.state.searchResults; if (relevance !== Relevance.Click) { // Explicit relevance feedback const feedbackRequest: Kendra.SubmitFeedbackRequest = { IndexId: this.props.indexId, QueryId: queryResult.QueryId as string, RelevanceFeedbackItems: [ { RelevanceValue: relevance as string, ResultId: resultItem.Id as string, }, ], }; this.props.kendra.submitFeedback(feedbackRequest).promise(); } else { // Click feedback const feedbackRequest: Kendra.Types.SubmitFeedbackRequest = { IndexId: this.props.indexId, QueryId: queryResult.QueryId as string, ClickFeedbackItems: [ { ClickTime: new Date(), ResultId: resultItem.Id as string, }, ], }; this.props.kendra.submitFeedback(feedbackRequest).promise(); } }; getQuerySuggestions = async ( queryText: string ) : Promise => { const getQuerySuggestionsRequest : Kendra.Types.GetQuerySuggestionsRequest = { IndexId: this.props.indexId, QueryText: queryText, }; if (this.props.kendra) { const response = await this.props.kendra.getQuerySuggestions(getQuerySuggestionsRequest).promise(); if (response) { return response.Suggestions; } } }; describeQuerySuggestionsConfig = async () : Promise => { const describeQuerySuggestionsConfigRequest : Kendra.Types.DescribeQuerySuggestionsConfigRequest = { IndexId: this.props.indexId, }; if (this.props.kendra) { return await this.props.kendra.describeQuerySuggestionsConfig(describeQuerySuggestionsConfigRequest).promise(); } }; private getErrorNotification = () => { return (
{!_.isEmpty(this.state.error?.message) ? this.state.error?.message : this.state.error?.code}
); }; private computeFilterAndReSubmitQuery( queryText: string, pageNumber: number = 1 ) { const filter = this.state.selectedFacets.buildAttributeFilter( this.state.attributeTypeLookup ); this.getResultsHelper(queryText, pageNumber, filter); } onSelectedFacetsChanged = (updatedSelectedFacets: SelectedFacetManager) => { this.setState( { selectedFacets: updatedSelectedFacets, }, () => { this.computeFilterAndReSubmitQuery(this.state.queryText); } ); }; handleClickExpander = () => { this.setState({ ...this.state, facetsOpen: !this.state.facetsOpen, }); }; onSortingAttributeChange = (event: ChangeEvent) => { this.setState( { selectedSortingAttribute: this.state.selectedSortingAttribute.setSelectedSortingAttribute( event.currentTarget.value ), }, () => { this.computeFilterAndReSubmitQuery(this.state.queryText); } ); }; onSortingOrderChange = ( event: React.MouseEvent ) => { const sortingOrder = this.state.selectedSortingAttribute.getSelectedSortingOrder(); if (sortingOrder === SortOrderEnum.Desc) { this.setState( { selectedSortingAttribute: this.state.selectedSortingAttribute.setSelectedSortingOrder( SortOrderEnum.Asc ), }, () => { this.computeFilterAndReSubmitQuery(this.state.queryText); } ); } else if (sortingOrder === SortOrderEnum.Asc) { this.setState( { selectedSortingAttribute: this.state.selectedSortingAttribute.setSelectedSortingOrder( SortOrderEnum.Desc ), }, () => { this.computeFilterAndReSubmitQuery(this.state.queryText); } ); } }; render() { return (
{this.state.error && this.getErrorNotification()} { this.setState({ language: event.target.value }) }} /> { this.setState((value) => { return { isAmbiguous: !value.isAmbiguous } }) }}/> {this.state.queryText && this.state.dataReady && (
{this.state.searchResults.TotalNumberOfResults === 0 && (
Kendra didn't match any results to your query.
)} {this.state.searchResults.TotalNumberOfResults !== 0 && (
)}
)} {this.state.queryText && !this.state.dataReady && (
)}
); } }