Skip to content

Commit

Permalink
feat(headless): Support the automatic query correction feature for th…
Browse files Browse the repository at this point in the history
…e insight use case (#4598)

[SFINT-5680](https://coveord.atlassian.net/browse/SFINT-5680)


## IN THIS PR:
1- We now allow the SAPI to automatically correct the queries that
contain a typo.

This was done by specifying the parameter queryCorrection in the search
request for the insight panel.

To support this in the Headless library we needed to update [ Insight
Search Actions Thunk
Processor](https://github.com/coveo/ui-kit/blob/master/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.ts)
by adding a new logic to the `processQueryCorrectionsOrContinue` that
handles the query correction feature using the **modern** way instead of
the **classic** way that consists of sending a whole new search query
with the corrected query returned by the SAPI.

In short: We now can correct with 1 request instead of 2.


2- Also added unit tests in headless to support this in the insight
usecase


## DEMO INSIGHT (queryCorrectionMode: 'next'):

https://github.com/user-attachments/assets/12db730b-fff6-4a80-a080-db7aaa07eda2

## DEMO INSIGHT (queryCorrectionMode: 'legacy'):


https://github.com/user-attachments/assets/6fa25da6-d081-4611-b713-4eedd6302e49

## DEMO SEARCH (queryCorrectionMode: 'next'):


https://github.com/user-attachments/assets/52f29cae-a3b6-44c2-b4df-22cc8df692ab

## DEMO SEARCH (queryCorrectionMode: 'legacy'):


https://github.com/user-attachments/assets/595acfbd-afc4-4f38-bdb4-5193511c0564




## TESTS:

<img width="793" alt="image"
src="https://github.com/user-attachments/assets/5220fc3e-58ff-43e7-b87c-38eb0643db02">

[SFINT-5680]:
https://coveord.atlassian.net/browse/SFINT-5680?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
SimonMilord authored Nov 18, 2024
1 parent 8344a9f commit fbd4835
Show file tree
Hide file tree
Showing 9 changed files with 447 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EnableDidYouMeanParam,
FacetsParam,
FieldsToIncludeParam,
QueryCorrectionParam,
FirstResultParam,
PipelineRuleParams,
QueryParam,
Expand All @@ -33,6 +34,7 @@ export type InsightQueryRequest = InsightParam &
SortCriteriaParam &
FieldsToIncludeParam &
EnableDidYouMeanParam &
QueryCorrectionParam &
ConstantQueryParam &
TabParam &
FoldingParam &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,39 @@ import {
buildCoreDidYouMean,
DidYouMean,
DidYouMeanState,
DidYouMeanProps,
DidYouMeanOptions,
} from '../../core/did-you-mean/headless-core-did-you-mean.js';

export type {QueryCorrection, WordCorrection, DidYouMean, DidYouMeanState};
export type {
QueryCorrection,
WordCorrection,
DidYouMean,
DidYouMeanState,
DidYouMeanProps,
DidYouMeanOptions,
};

/**
* The insight DidYouMean controller is responsible for handling query corrections.
* When a query returns no result but finds a possible query correction, the controller either suggests the correction or
* automatically triggers a new query with the suggested term.
*
* @param engine - The insight engine.
* @param engine - The headless engine.
* @param props - The configurable `DidYouMean` properties.
*
* @group Controllers
* @category DidYouMean
*/
export function buildDidYouMean(engine: InsightEngine): DidYouMean {
const controller = buildCoreDidYouMean(engine, {
options: {queryCorrectionMode: 'legacy'},
});
export function buildDidYouMean(
engine: InsightEngine,
props: DidYouMeanProps = {
options: {
queryCorrectionMode: 'legacy',
},
}
): DidYouMean {
const controller = buildCoreDidYouMean(engine, props);
const {dispatch} = engine;

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,53 +115,105 @@ describe('AsyncInsightSearchThunkProcessor', () => {
expect(logQueryError).toHaveBeenCalledWith(theError);
});

it('process properly when there are no results returned and there is a did you mean correction', async () => {
const processor = new AsyncInsightSearchThunkProcessor<{}>(config);
const mappedRequest: MappedSearchRequest<InsightQueryRequest> = {
request: buildMockInsightQueryRequest(),
mappings: initialSearchMappings(),
};

const originalResponseWithNoResultsAndCorrection = buildMockSearchResponse({
results: [],
queryCorrections: [
{
correctedQuery: 'bar',
wordCorrections: [
{correctedWord: 'foo', length: 3, offset: 0, originalWord: 'foo'},
],
},
],
});

const responseAfterCorrection = buildMockSearchResponse({
results: [buildMockResult({uniqueId: '123'})],
describe('query correction processing', () => {
describe('legacy query correction processing', () => {
it('should automatically correct the query by triggering a second search request', async () => {
const processor = new AsyncInsightSearchThunkProcessor<{}>(config);
const mappedRequest: MappedSearchRequest<InsightQueryRequest> = {
request: buildMockInsightQueryRequest(),
mappings: initialSearchMappings(),
};

const originalResponseWithNoResultsAndCorrection =
buildMockSearchResponse({
results: [],
queryCorrections: [
{
correctedQuery: 'bar',
wordCorrections: [
{
correctedWord: 'foo',
length: 3,
offset: 0,
originalWord: 'foo',
},
],
},
],
});

const responseAfterCorrection = buildMockSearchResponse({
results: [buildMockResult({uniqueId: '123'})],
});

(config.extra.apiClient.query as Mock).mockReturnValue(
Promise.resolve({success: responseAfterCorrection})
);

const fetched = {
response: {
success: originalResponseWithNoResultsAndCorrection,
},
duration: 123,
queryExecuted: 'foo',
requestExecuted: mappedRequest.request,
};

const processed = (await processor.process(
fetched
)) as ExecuteSearchThunkReturn;

expect(config.dispatch).toHaveBeenCalledWith(updateQuery({q: 'bar'}));
expect(config.extra.apiClient.query).toHaveBeenCalled();
expect(processed.response).toEqual({
...responseAfterCorrection,
queryCorrections:
originalResponseWithNoResultsAndCorrection.queryCorrections,
});
expect(processed.automaticallyCorrected).toBe(true);
});
});

(config.extra.apiClient.query as Mock).mockReturnValue(
Promise.resolve({success: responseAfterCorrection})
);

const fetched = {
response: {
success: originalResponseWithNoResultsAndCorrection,
},
duration: 123,
queryExecuted: 'foo',
requestExecuted: mappedRequest.request,
};

const processed = (await processor.process(
fetched
)) as ExecuteSearchThunkReturn;

expect(config.dispatch).toHaveBeenCalledWith(updateQuery({q: 'bar'}));
expect(config.extra.apiClient.query).toHaveBeenCalled();
expect(processed.response).toEqual({
...responseAfterCorrection,
queryCorrections:
originalResponseWithNoResultsAndCorrection.queryCorrections,
describe('next query correction processing', () => {
it('should automatically correct the query without triggering a second search request', async () => {
const processor = new AsyncInsightSearchThunkProcessor<{}>(config);
const mappedRequest: MappedSearchRequest<InsightQueryRequest> = {
request: buildMockInsightQueryRequest(),
mappings: initialSearchMappings(),
};

const originalResponseWithResultsAndChangedQuery =
buildMockSearchResponse({
results: [buildMockResult()],
queryCorrection: {
correctedQuery: 'bar',
originalQuery: 'foo',
corrections: [],
},
});

const fetched = {
response: {
success: originalResponseWithResultsAndChangedQuery,
},
duration: 123,
queryExecuted: 'foo',
requestExecuted: mappedRequest.request,
};

const processed = (await processor.process(
fetched
)) as ExecuteSearchThunkReturn;

expect(config.dispatch).toHaveBeenCalledWith(updateQuery({q: 'bar'}));
expect(config.extra.apiClient.query).not.toHaveBeenCalled();
expect(processed.response).toMatchObject(
originalResponseWithResultsAndChangedQuery
);
expect(processed.automaticallyCorrected).toBe(true);
expect(processed.originalQuery).toBe('foo');
expect(processed.queryExecuted).toBe('bar');
});
});
expect(processed.automaticallyCorrected).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {isNullOrUndefined} from '@coveo/bueno';
import {ThunkDispatch, AnyAction} from '@reduxjs/toolkit';
import {
SearchOptions,
Expand All @@ -11,7 +12,6 @@ import {
import {InsightQueryRequest} from '../../api/service/insight/query/query-request.js';
import {ClientThunkExtraArguments} from '../../app/thunk-extra-arguments.js';
import {applyDidYouMeanCorrection} from '../did-you-mean/did-you-mean-actions.js';
import {emptyLegacyCorrection} from '../did-you-mean/did-you-mean-state.js';
import {snapshot} from '../history/history-actions.js';
import {extractHistory} from '../history/history-state.js';
import {updateQuery} from '../query/query-actions.js';
Expand All @@ -26,6 +26,17 @@ import {StateNeededByExecuteSearch} from './insight-search-actions.js';
import {logQueryError} from './insight-search-analytics-actions.js';
import {buildInsightSearchRequest} from './insight-search-request.js';

interface FetchedResponse {
response: SuccessResponse | ErrorResponse;
duration: number;
queryExecuted: string;
requestExecuted: InsightQueryRequest;
}

type ValidReturnTypeFromProcessingStep<RejectionType> =
| ExecuteSearchThunkReturn
| RejectionType;

export interface AsyncThunkConfig {
getState: () => StateNeededByExecuteSearch;
dispatch: ThunkDispatch<
Expand All @@ -37,19 +48,17 @@ export interface AsyncThunkConfig {
extra: ClientThunkExtraArguments<InsightAPIClient>;
}

interface FetchedResponse {
response: SuccessResponse | ErrorResponse;
duration: number;
queryExecuted: string;
requestExecuted: InsightQueryRequest;
}

type ValidReturnTypeFromProcessingStep<RejectionType> =
| ExecuteSearchThunkReturn
| RejectionType;
type QueryCorrectionCallback = (modification: string) => void;

export class AsyncInsightSearchThunkProcessor<RejectionType> {
constructor(private config: AsyncThunkConfig) {}
constructor(
private config: AsyncThunkConfig,
private onUpdateQueryForCorrection: QueryCorrectionCallback = (
modification
) => {
this.dispatch(updateQuery({q: modification}));
}
) {}

public async fetchFromAPI(
{request, mappings}: MappedSearchRequest<InsightQueryRequest>,
Expand Down Expand Up @@ -94,17 +103,56 @@ export class AsyncInsightSearchThunkProcessor<RejectionType> {
private async processQueryCorrectionsOrContinue(
fetched: FetchedResponse
): Promise<ValidReturnTypeFromProcessingStep<RejectionType> | null> {
if (
!this.shouldReExecuteTheQueryWithCorrections(fetched) ||
isErrorResponse(fetched.response)
) {
const state = this.getState();
const successResponse = this.getSuccessResponse(fetched);

if (!successResponse || !state.didYouMean) {
return null;
}

const {enableDidYouMean, automaticallyCorrectQuery} = state.didYouMean;
const {results, queryCorrections, queryCorrection} = successResponse;

if (!enableDidYouMean || !automaticallyCorrectQuery) {
return null;
}

const shouldExecuteLegacyDidYouMeanAutoCorrection =
results.length === 0 && queryCorrections && queryCorrections.length !== 0;

const shouldExecuteNextDidYouMeanAutoCorrection =
!isNullOrUndefined(queryCorrection) &&
!isNullOrUndefined(queryCorrection.correctedQuery);

const shouldExitWithNoAutoCorrection =
!shouldExecuteLegacyDidYouMeanAutoCorrection &&
!shouldExecuteNextDidYouMeanAutoCorrection;

if (shouldExitWithNoAutoCorrection) {
return null;
}
const processedDidYouMean = shouldExecuteLegacyDidYouMeanAutoCorrection
? await this.processLegacyDidYouMeanAutoCorrection(fetched)
: this.processNextDidYouMeanAutoCorrection(fetched);

this.dispatch(snapshot(extractHistory(this.getState())));

const {correctedQuery} = fetched.response.success.queryCorrections
? fetched.response.success.queryCorrections[0]
: emptyLegacyCorrection();
return processedDidYouMean;
}

private async processLegacyDidYouMeanAutoCorrection(
originalFetchedResponse: FetchedResponse
): Promise<ExecuteSearchThunkReturn | RejectionType | null> {
const originalQuery = this.getCurrentQuery();
const originalSearchSuccessResponse = this.getSuccessResponse(
originalFetchedResponse
)!;

if (!originalSearchSuccessResponse.queryCorrections) {
return null;
}

const {correctedQuery} = originalSearchSuccessResponse.queryCorrections[0];

const retried =
await this.automaticallyRetryQueryWithCorrection(correctedQuery);
Expand All @@ -120,32 +168,34 @@ export class AsyncInsightSearchThunkProcessor<RejectionType> {
...retried,
response: {
...retried.response.success,
queryCorrections: fetched.response.success.queryCorrections,
queryCorrections: originalSearchSuccessResponse.queryCorrections,
},
automaticallyCorrected: true,
originalQuery,
};
}

private shouldReExecuteTheQueryWithCorrections(
fetched: FetchedResponse
): boolean {
const state = this.getState();
const successResponse = this.getSuccessResponse(fetched);
private processNextDidYouMeanAutoCorrection(
originalFetchedResponse: FetchedResponse
): ExecuteSearchThunkReturn {
const successResponse = this.getSuccessResponse(originalFetchedResponse)!;
const {correctedQuery, originalQuery} = successResponse.queryCorrection!;

if (
state.didYouMean?.enableDidYouMean === true &&
successResponse?.results.length === 0 &&
successResponse?.queryCorrections &&
successResponse?.queryCorrections.length !== 0
) {
return true;
}
return false;
this.onUpdateQueryForCorrection(correctedQuery);

return {
...originalFetchedResponse,
response: {
...successResponse,
},
queryExecuted: correctedQuery,
automaticallyCorrected: true,
originalQuery,
};
}

private async automaticallyRetryQueryWithCorrection(correction: string) {
this.dispatch(updateQuery({q: correction}));
this.onUpdateQueryForCorrection(correction);
const state = this.getState();
const fetched = await this.fetchFromAPI(
await buildInsightSearchRequest(state)
Expand Down
Loading

0 comments on commit fbd4835

Please sign in to comment.