diff --git a/packages/atomic/dev/examples/genqa.html b/packages/atomic/dev/examples/genqa.html index 561a74803c3..f3a9665e8ca 100644 --- a/packages/atomic/dev/examples/genqa.html +++ b/packages/atomic/dev/examples/genqa.html @@ -115,7 +115,7 @@ - + diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts index a1e54ac9a6f..638b2098ad3 100644 --- a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts @@ -6,6 +6,7 @@ import { buildTabManager, type GeneratedAnswer, type GeneratedAnswerState, + type GeneratedAnswerWithFollowUps, type SearchStatus, type SearchStatusState, type TabManager, @@ -134,6 +135,12 @@ export class AtomicGeneratedAnswer @property({type: String, attribute: 'answer-configuration-id'}) answerConfigurationId?: string; + /** + * The unique identifier of the agent configuration to use to generate the answer. + */ + @property({type: String, attribute: 'agent-id'}) + agentId?: string; + /** * A list of fields to include with the citations used to generate the answer. * @@ -202,7 +209,7 @@ export class AtomicGeneratedAnswer }) @state() private generatedAnswerState!: GeneratedAnswerState; - public generatedAnswer!: GeneratedAnswer; + public generatedAnswer!: GeneratedAnswer | GeneratedAnswerWithFollowUps; @bindStateToController('searchStatus') @state() @@ -259,6 +266,9 @@ export class AtomicGeneratedAnswer ...(this.answerConfigurationId && { answerConfigurationId: this.answerConfigurationId, }), + ...(this.agentId && { + agentId: this.agentId, + }), fieldsToIncludeInCitations: this.getCitationFields(), }); this.searchStatus = buildSearchStatus(this.bindings.engine); @@ -321,6 +331,12 @@ export class AtomicGeneratedAnswer } } + private canAskFollowUp(): this is { + generatedAnswer: GeneratedAnswerWithFollowUps; + } { + return 'askFollowUp' in this.generatedAnswer; + } + @bindingGuard() @errorGuard() render() { @@ -365,6 +381,13 @@ export class AtomicGeneratedAnswer aria-label=${this.bindings.i18n.t('generated-answer-title')} >
${this.renderContent()}
+ `; @@ -372,6 +395,7 @@ export class AtomicGeneratedAnswer // Used by bindStateToController decorator via onUpdateCallbackMethod option public onGeneratedAnswerStateUpdate = () => { + console.log(this.generatedAnswerState); if ( this.generatedAnswerState.isVisible !== this.controller.data.isVisible ) { diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts new file mode 100644 index 00000000000..872277e0dac --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -0,0 +1,66 @@ +import type { + BaseQueryFn, + CombinedState, + FetchArgs, + FetchBaseQueryError, + QueryDefinition, + RetryOptions, +} from '@reduxjs/toolkit/query'; +import type {FollowUpAnswerParams} from '../../../features/follow-up-answers/follow-up-answer-request.js'; +import type {SearchAppState} from '../../../state/search-app-state.js'; +import type { + ConfigurationSection, + FollowUpAnswersSection, + GeneratedAnswerSection, + TabSection, +} from '../../../state/state-sections.js'; +import type {HeadAnswerEndpointArgs} from './endpoints/head-answer-endpoint.js'; +import type {GeneratedAnswerServerState} from './shared-types.js'; + +export interface AnswerGenerationApiSection { + // CombinedState is an internal type from RTK Query that is used directly to break dependency on actual + // use of RTK Query for the Stream Answer API. This exposes the internal state of RTKQ but allows us to + // type this object over using an `unknown` type. + answerGenerationApi: CombinedState< + { + generateHeadAnswer: QueryDefinition< + HeadAnswerEndpointArgs, + BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError, + {} & RetryOptions, + {} + >, + never, + GeneratedAnswerServerState, + 'answerGenerationApi' + >; + generateFollowUpAnswer: QueryDefinition< + FollowUpAnswerParams, + BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError, + {} & RetryOptions, + {} + >, + never, + GeneratedAnswerServerState, + 'answerGenerationApi' + >; + }, + never, + 'answerGenerationApi' + >; +} + +export type AnswerGenerationApiState = { + searchHub: string; + pipeline: string; +} & AnswerGenerationApiSection & + ConfigurationSection & + Partial & + GeneratedAnswerSection & + FollowUpAnswersSection & + Partial; diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts new file mode 100644 index 00000000000..0ce6e464727 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts @@ -0,0 +1,9 @@ +import {createApi, retry} from '@reduxjs/toolkit/query'; +import {dynamicBaseQuery} from '../answer-slice.js'; + +export const answerGenerationApi = createApi({ + reducerPath: 'answerGenerationApi', + refetchOnMountOrArgChange: true, + baseQuery: retry(dynamicBaseQuery, {maxRetries: 3}), + endpoints: () => ({}), +}); diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts new file mode 100644 index 00000000000..de50ccddbf3 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -0,0 +1,62 @@ +import type {FollowUpAnswerParams} from '../../../../features/follow-up-answers/follow-up-answer-request.js'; +import {answerGenerationApi} from '../answer-generation-api.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; +import type {GeneratedAnswerServerState} from '../shared-types.js'; +import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; +import {followUpAnswerStrategy} from '../streaming/strategies/follow-up-answer-strategy.js'; + +export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ + overrideExisting: true, + endpoints: (builder) => ({ + generateFollowUpAnswer: builder.query< + GeneratedAnswerServerState, + FollowUpAnswerParams + >({ + queryFn: () => { + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: false, + isLoading: true, + }, + }; + }, + serializeQueryArgs: ({endpointName, queryArgs}) => { + // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. + // Keys must match, because if anything in the query changes, it's not the same query anymore. + // Analytics data is excluded entirely as it contains volatile fields that change during streaming. + const {q} = queryArgs; + + // Standard RTK key, with analytics excluded + return `${endpointName}_${JSON.stringify(q)}`; + }, + async onQueryStarted(args, {getState, updateCachedData, dispatch}) { + await streamAnswerWithStrategy< + FollowUpAnswerParams, + AnswerGenerationApiState, + GeneratedAnswerServerState + >( + args, + { + getState: getState as () => AnswerGenerationApiState, + updateCachedData, + dispatch, + }, + followUpAnswerStrategy + ); + }, + }), + }), +}); + +export const initiateFollowUpAnswerGeneration = ( + params: FollowUpAnswerParams +) => { + return followUpAnswerEndpoint.endpoints.generateFollowUpAnswer.initiate( + params + ); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts new file mode 100644 index 00000000000..c68934211bd --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts @@ -0,0 +1,70 @@ +import type {AnyFacetRequest} from '../../../../features/facets/generic/interfaces/generic-facet-request.js'; +import {selectHeadAnswerApiQueryParams} from '../../../../features/generated-answer/answer-api-selectors.js'; +import type { + AnalyticsParam, + PipelineRuleParameters, +} from '../../../search/search-api-params.js'; +import {answerGenerationApi} from '../answer-generation-api.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; +import type {GeneratedAnswerServerState} from '../shared-types.js'; +import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; +import {headAnswerStrategy} from '../streaming/strategies/head-answer-strategy.js'; + +export type HeadAnswerEndpointArgs = { + q: string; + facets?: AnyFacetRequest[]; + searchHub?: string; + pipeline?: string; + pipelineRuleParameters: PipelineRuleParameters; + locale: string; +} & AnalyticsParam; + +export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ + overrideExisting: true, + endpoints: (builder) => ({ + generateHeadAnswer: builder.query< + GeneratedAnswerServerState, + HeadAnswerEndpointArgs + >({ + queryFn: () => { + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: false, + isLoading: true, + }, + }; + }, + async onQueryStarted(args, {getState, updateCachedData, dispatch}) { + await streamAnswerWithStrategy< + HeadAnswerEndpointArgs, + AnswerGenerationApiState, + GeneratedAnswerServerState + >( + args, + { + getState: getState as () => AnswerGenerationApiState, + updateCachedData, + dispatch, + }, + headAnswerStrategy + ); + }, + }), + }), +}); + +export const initiateHeadAnswerGeneration = ( + params: HeadAnswerEndpointArgs +) => { + return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params); +}; + +export const selectHeadAnswer = (state: AnswerGenerationApiState) => { + const params = selectHeadAnswerApiQueryParams(state); + return headAnswerEndpoint.endpoints.generateHeadAnswer.select(params)(state); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/shared-types.ts b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts new file mode 100644 index 00000000000..bce372058b4 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts @@ -0,0 +1,35 @@ +import type {GeneratedContentFormat} from '../../../features/generated-answer/generated-response-format.js'; +import type {GeneratedAnswerCitation} from '../../generated-answer/generated-answer-event-payload.js'; + +export interface GeneratedAnswerServerState { + answerId?: string; + contentFormat?: GeneratedContentFormat; + answer?: string; + citations?: GeneratedAnswerCitation[]; + generated?: boolean; + isStreaming: boolean; + isLoading: boolean; + error?: {message: string; code: number}; +} + +export interface StreamPayload { + textDelta?: string; + padding?: string; + answerGenerated?: boolean; + contentFormat: GeneratedContentFormat; + citations: GeneratedAnswerCitation[]; +} + +export type PayloadType = + | 'genqa.headerMessageType' + | 'genqa.messageType' + | 'genqa.citationsType' + | 'genqa.endOfStreamType'; + +export interface Message { + payloadType: PayloadType; + payload: string; + finishReason?: string; + errorMessage?: string; + code?: number; +} diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts new file mode 100644 index 00000000000..129e48c51e1 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts @@ -0,0 +1,65 @@ +import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; +import {fetchEventSource} from '../../../../utils/fetch-event-source/fetch.js'; +import type {Message} from '../shared-types.js'; +import type {StreamingStrategy} from './strategies/strategy-types.js'; + +type StateWithConfiguration = { + configuration: { + accessToken: string; + }; +}; +export const streamAnswerWithStrategy = < + TArgs, + TState extends StateWithConfiguration, + TDraft, +>( + args: TArgs, + api: { + getState: () => TState; + dispatch: ThunkDispatch; + updateCachedData: (updater: (draft: TDraft) => void) => void; + }, + strategy: StreamingStrategy +) => { + const {dispatch, updateCachedData, getState} = api; + const endpointUrl = strategy.buildEndpointUrl(getState()); + const { + configuration: {accessToken}, + } = getState(); + + return fetchEventSource(endpointUrl, { + method: 'POST', + body: JSON.stringify(args), + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Accept-Encoding': '*', + }, + fetch, + onopen: async (response) => { + strategy.events.handleOpen(response, updateCachedData, dispatch); + }, + onclose: () => { + strategy.events.handleClose(updateCachedData, dispatch); + }, + onerror: (error) => { + strategy.events.handleError(error); + }, + onmessage: (event) => { + const message: Required = JSON.parse(event.data); + strategy.events.handleMessage.error?.( + message, + updateCachedData, + dispatch + ); + + const messageType = message.payloadType; + strategy.events.handleMessage[messageType]?.( + message, + updateCachedData, + dispatch + ); + }, + }); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts new file mode 100644 index 00000000000..83b70cd9fcb --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts @@ -0,0 +1,68 @@ +import type { + GeneratedAnswerServerState, + Message, + StreamPayload, +} from '../shared-types.js'; + +export const handleAnswerId = ( + draft: GeneratedAnswerServerState, + answerId: string +) => { + if (answerId) { + draft.answerId = answerId; + } +}; + +export const handleHeaderMessage = ( + draft: GeneratedAnswerServerState, + payload: Pick +) => { + const {contentFormat} = payload; + draft.contentFormat = contentFormat; + draft.isStreaming = true; + draft.isLoading = false; +}; + +export const handleMessage = ( + draft: GeneratedAnswerServerState, + payload: Pick +) => { + if (draft.answer === undefined) { + draft.answer = payload.textDelta; + } else if (typeof payload.textDelta === 'string') { + draft.answer = draft.answer.concat(payload.textDelta); + } +}; + +export const handleCitations = ( + draft: GeneratedAnswerServerState, + payload: Pick +) => { + draft.citations = payload.citations; +}; + +export const handleEndOfStream = ( + draft: GeneratedAnswerServerState, + payload: Pick +) => { + draft.generated = payload.answerGenerated; + draft.isStreaming = false; +}; + +export const handleError = ( + draft: GeneratedAnswerServerState, + message: Required +) => { + const errorMessage = message.errorMessage || 'Unknown error occurred'; + + draft.error = { + message: errorMessage, + code: message.code!, + }; + draft.isStreaming = false; + draft.isLoading = false; + // Throwing an error here breaks the client and prevents the error from reaching the state. + console.error( + `Generated answer error: ${errorMessage} (code: ${message.code})` + ); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts new file mode 100644 index 00000000000..8d1bdfe2eb1 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts @@ -0,0 +1,122 @@ +import { + setActiveFollowUpAnswerContentFormat, + setActiveFollowUpAnswerId, + setActiveFollowUpCannotAnswer, + setActiveFollowUpIsAnswerGenerated, + setActiveFollowUpIsLoading, + setActiveFollowUpIsStreaming, + updateActiveFollowUpAnswerCitations, + updateActiveFollowUpAnswerMessage, +} from '../../../../../features/follow-up-answers/follow-up-answers-actions.js'; +import { + logGeneratedAnswerResponseLinked, + logGeneratedAnswerStreamEnd, +} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; +import type { + GeneratedAnswerServerState, + StreamPayload, +} from '../../shared-types.js'; +import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; +import { + handleAnswerId, + handleCitations, + handleEndOfStream, + handleError, + handleHeaderMessage, + handleMessage, +} from '../event-handlers.js'; +import type {StreamingStrategy} from './strategy-types.js'; + +export const followUpAnswerStrategy: StreamingStrategy< + GeneratedAnswerServerState, + AnswerGenerationApiState +> = { + buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), + events: { + handleOpen: (response, updateCachedData, dispatch) => { + const answerId = response.headers.get('x-answer-id'); + if (answerId) { + updateCachedData((draft) => { + handleAnswerId(draft, answerId); + }); + dispatch(setActiveFollowUpAnswerId(answerId)); + } + }, + + handleClose: (updateCachedData, dispatch) => { + updateCachedData((draft) => { + dispatch(setActiveFollowUpCannotAnswer(!draft.generated)); + }); + }, + + handleError: (error) => { + throw error; + }, + + handleMessage: { + 'genqa.headerMessageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.contentFormat) { + updateCachedData((draft) => { + handleHeaderMessage(draft, payload); + }); + dispatch(setActiveFollowUpAnswerContentFormat(payload.contentFormat)); + dispatch(setActiveFollowUpIsStreaming(true)); + dispatch(setActiveFollowUpIsLoading(false)); + } + }, + + 'genqa.messageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.textDelta) { + updateCachedData((draft) => { + handleMessage(draft, payload); + }); + dispatch( + updateActiveFollowUpAnswerMessage({textDelta: payload.textDelta}) + ); + } + }, + + 'genqa.citationsType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.citations) { + updateCachedData((draft) => { + handleCitations(draft, payload); + }); + dispatch( + updateActiveFollowUpAnswerCitations({citations: payload.citations}) + ); + } + }, + + 'genqa.endOfStreamType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + updateCachedData((draft) => { + handleEndOfStream(draft, payload); + }); + dispatch(setActiveFollowUpIsAnswerGenerated(!!payload.answerGenerated)); + dispatch(setActiveFollowUpIsStreaming(false)); + dispatch(setActiveFollowUpIsLoading(false)); + dispatch(logGeneratedAnswerStreamEnd(payload.answerGenerated ?? false)); + dispatch(logGeneratedAnswerResponseLinked()); + }, + error: (message, updateCachedData) => { + if (message.finishReason === 'ERROR' && message.errorMessage) { + updateCachedData((draft) => { + handleError(draft, message); + }); + } + }, + }, + }, +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts new file mode 100644 index 00000000000..2dd94ba7893 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts @@ -0,0 +1,118 @@ +import { + setAnswerContentFormat, + setAnswerId, + setCannotAnswer, + setIsAnswerGenerated, + setIsLoading, + setIsStreaming, + updateCitations, + updateMessage, +} from '../../../../../features/generated-answer/generated-answer-actions.js'; +import { + logGeneratedAnswerResponseLinked, + logGeneratedAnswerStreamEnd, +} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; +import type { + GeneratedAnswerServerState, + StreamPayload, +} from '../../shared-types.js'; +import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; +import { + handleAnswerId, + handleCitations, + handleEndOfStream, + handleError, + handleHeaderMessage, + handleMessage, +} from '../event-handlers.js'; +import type {StreamingStrategy} from './strategy-types.js'; + +export const headAnswerStrategy: StreamingStrategy< + GeneratedAnswerServerState, + AnswerGenerationApiState +> = { + buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), + events: { + handleOpen: (response, updateCachedData, dispatch) => { + const answerId = response.headers.get('x-answer-id'); + if (answerId) { + updateCachedData((draft) => { + handleAnswerId(draft, answerId); + }); + dispatch(setAnswerId(answerId)); + } + }, + + handleClose: (updateCachedData, dispatch) => { + updateCachedData((draft) => { + dispatch(setCannotAnswer(!draft.generated)); + }); + }, + + handleError: (error) => { + throw error; + }, + + handleMessage: { + 'genqa.headerMessageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.contentFormat) { + updateCachedData((draft) => { + handleHeaderMessage(draft, payload); + }); + dispatch(setAnswerContentFormat(payload.contentFormat)); + dispatch(setIsStreaming(true)); + dispatch(setIsLoading(false)); + } + }, + + 'genqa.messageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.textDelta) { + updateCachedData((draft) => { + handleMessage(draft, payload); + }); + dispatch(updateMessage({textDelta: payload.textDelta})); + } + }, + + 'genqa.citationsType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.citations) { + updateCachedData((draft) => { + handleCitations(draft, payload); + }); + dispatch(updateCitations({citations: payload.citations})); + } + }, + + 'genqa.endOfStreamType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + updateCachedData((draft) => { + handleEndOfStream(draft, payload); + }); + dispatch(setIsAnswerGenerated(!!payload.answerGenerated)); + dispatch(setIsStreaming(false)); + dispatch(setIsLoading(false)); + dispatch(logGeneratedAnswerStreamEnd(payload.answerGenerated ?? false)); + dispatch(logGeneratedAnswerResponseLinked()); + }, + error: (message, updateCachedData) => { + if (message.finishReason === 'ERROR' && message.errorMessage) { + updateCachedData((draft) => { + handleError(draft, message); + }); + } + }, + }, + }, +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts new file mode 100644 index 00000000000..5e0a92becea --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts @@ -0,0 +1,34 @@ +import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; +import type {Message, PayloadType} from '../../shared-types.js'; + +type EventType = PayloadType | 'error'; + +export interface StreamingStrategy { + buildEndpointUrl: (state: TState) => string; + + events: { + handleOpen: ( + response: Response, + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void; + + handleClose: ( + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void; + + handleError: (error: unknown) => void; + + handleMessage: Partial< + Record< + EventType, + ( + message: Required, + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void + > + >; + }; +} diff --git a/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts b/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts new file mode 100644 index 00000000000..466e5253424 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts @@ -0,0 +1,20 @@ +import {getOrganizationEndpoint} from '../../../platform-client.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; + +export const buildHeadAnswerEndpointUrl = ( + state: AnswerGenerationApiState +): string => { + const {configuration, generatedAnswer} = state; + const {organizationId, environment} = configuration; + const platformEndpoint = getOrganizationEndpoint(organizationId, environment); + + if ( + !platformEndpoint || + !organizationId || + !generatedAnswer.answerConfigurationId + ) { + throw new Error('Missing required parameters for answer endpoint'); + } + const basePath = `/rest/organizations/${organizationId}/answer/v1/configs`; + return `${platformEndpoint}${basePath}/${generatedAnswer.answerConfigurationId}/generate`; +}; diff --git a/packages/headless/src/api/knowledge/answer-slice.ts b/packages/headless/src/api/knowledge/answer-slice.ts index 695972207a9..028ad4b7565 100644 --- a/packages/headless/src/api/knowledge/answer-slice.ts +++ b/packages/headless/src/api/knowledge/answer-slice.ts @@ -18,7 +18,7 @@ type StateNeededByAnswerSlice = ConfigurationSection & GeneratedAnswerSection; * `dynamicBaseQuery` is passed to the baseQuery of the createApi, * but note that the baseQuery will not be used if a queryFn is provided in the createApi endpoint */ -const dynamicBaseQuery: BaseQueryFn< +export const dynamicBaseQuery: BaseQueryFn< string | FetchArgs, unknown, FetchBaseQueryError diff --git a/packages/headless/src/api/search/search-api-params.ts b/packages/headless/src/api/search/search-api-params.ts index 6dbcffcb309..acc4cc2db79 100644 --- a/packages/headless/src/api/search/search-api-params.ts +++ b/packages/headless/src/api/search/search-api-params.ts @@ -131,7 +131,7 @@ export interface PipelineRuleParams { pipelineRuleParameters?: PipelineRuleParameters; } -type PipelineRuleParameters = { +export type PipelineRuleParameters = { mlGenerativeQuestionAnswering?: GenQAParameters; }; diff --git a/packages/headless/src/app/engine.ts b/packages/headless/src/app/engine.ts index fb099d366bf..85f785deb65 100644 --- a/packages/headless/src/app/engine.ts +++ b/packages/headless/src/app/engine.ts @@ -12,6 +12,7 @@ import type { } from '@reduxjs/toolkit'; import type {Logger} from 'pino'; import {getRelayInstanceFromState} from '../api/analytics/analytics-relay-client.js'; +import {answerGenerationApi} from '../api/knowledge/answer-generation/answer-generation-api.js'; import {answerApi} from '../api/knowledge/stream-answer-api.js'; import { disableAnalytics, @@ -33,6 +34,7 @@ import type {EngineConfiguration} from './engine-configuration.js'; import {instantlyCallableThunkActionMiddleware} from './instantly-callable-middleware.js'; import type {LoggerOptions} from './logger.js'; import {logActionErrorMiddleware} from './logger-middlewares.js'; +import {generateAnswerListener} from './middleware/generate-answer-listener-middleware.js'; import { getNavigatorContext, type NavigatorContext, @@ -372,7 +374,10 @@ function createMiddleware( renewTokenMiddleware, logActionErrorMiddleware(logger), analyticsMiddleware, - ].concat(answerApi.middleware, options.middlewares || []); + ] + .concat(answerApi.middleware, options.middlewares || []) + .concat(answerGenerationApi.middleware, options.middlewares || []) + .concat(generateAnswerListener.middleware); } export const nextAnalyticsUsageWithServiceFeatureWarning = diff --git a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts new file mode 100644 index 00000000000..495ba5dba83 --- /dev/null +++ b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts @@ -0,0 +1,43 @@ +import { + createListenerMiddleware, + type ThunkDispatch, + type UnknownAction, +} from '@reduxjs/toolkit'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import { + generateHeadAnswer, + resetAnswer, +} from '../../features/generated-answer/generated-answer-actions.js'; +import {isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI} from '../../features/generated-answer/generated-answer-selectors.js'; +import {selectQuery} from '../../features/query/query-selectors.js'; +import {executeSearch} from '../../features/search/search-actions.js'; +import type {SearchThunkExtraArguments} from '../search-thunk-extra-arguments.js'; +import type {RootState} from '../store.js'; + +export const generateAnswerListener = createListenerMiddleware< + RootState, + ThunkDispatch< + AnswerGenerationApiState, + SearchThunkExtraArguments, + UnknownAction + > +>(); + +generateAnswerListener.startListening({ + actionCreator: executeSearch.pending, + + effect: async (_action, listenerApi) => { + listenerApi.dispatch(resetAnswer()); + const state = listenerApi.getState(); + + const q = selectQuery(state)?.q; + const queryIsEmpty = !q || q.trim() === ''; + const isGeneratedAnswerFeatureEnabled = + isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI(state); + + if (!isGeneratedAnswerFeatureEnabled || queryIsEmpty) { + return; + } + listenerApi.dispatch(generateHeadAnswer()); + }, +}); diff --git a/packages/headless/src/app/store.ts b/packages/headless/src/app/store.ts index 53fb60db29e..0ff2efe97cc 100644 --- a/packages/headless/src/app/store.ts +++ b/packages/headless/src/app/store.ts @@ -53,3 +53,6 @@ export function configureStore({ } export type Store = ReturnType; +export type RootState = ReturnType< + ReturnType['getState'] +>; diff --git a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts index ec1e8a478f4..f609fd709e9 100644 --- a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts +++ b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts @@ -153,6 +153,10 @@ export interface GeneratedAnswerProps extends GeneratedAnswerPropsInitialState { * The answer configuration ID used to leverage coveo answer management capabilities. */ answerConfigurationId?: string; + /** + * The agent identifier to use to generate the answer. + */ + agentId?: string; /** * A list of indexed fields to include in the citations returned with the generated answer. */ diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts index 88c7b8c8f99..57205802df8 100644 --- a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -11,6 +11,10 @@ import type { } from '../core/generated-answer/headless-core-generated-answer.js'; import {buildSearchAPIGeneratedAnswer} from '../core/generated-answer/headless-searchapi-generated-answer.js'; import {buildAnswerApiGeneratedAnswer} from '../knowledge/generated-answer/headless-answerapi-generated-answer.js'; +import { + buildGeneratedAnswerWithFollowUps, + type GeneratedAnswerWithFollowUps, +} from '../knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; export type { GeneratedAnswerCitation, @@ -34,21 +38,27 @@ export type { export function buildGeneratedAnswer( engine: SearchEngine, props: GeneratedAnswerProps = {} -): GeneratedAnswer { +): GeneratedAnswer | GeneratedAnswerWithFollowUps { warnIfUsingNextAnalyticsModeForServiceFeature( engine.state.configuration.analytics.analyticsMode ); - const controller = props.answerConfigurationId - ? buildAnswerApiGeneratedAnswer( + const controller = props.agentId + ? buildGeneratedAnswerWithFollowUps( engine, generatedAnswerAnalyticsClient, props ) - : buildSearchAPIGeneratedAnswer( - engine, - generatedAnswerAnalyticsClient, - props - ); + : props.answerConfigurationId + ? buildAnswerApiGeneratedAnswer( + engine, + generatedAnswerAnalyticsClient, + props + ) + : buildSearchAPIGeneratedAnswer( + engine, + generatedAnswerAnalyticsClient, + props + ); return { ...controller, diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts new file mode 100644 index 00000000000..b3a714d54e3 --- /dev/null +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts @@ -0,0 +1,134 @@ +import {answerGenerationApi} from '../../../api/knowledge/answer-generation/answer-generation-api.js'; +import {selectHeadAnswer} from '../../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import type {InsightEngine} from '../../../app/insight-engine/insight-engine.js'; +import type {SearchEngine} from '../../../app/search-engine/search-engine.js'; +import {generateFollowUpAnswer} from '../../../features/follow-up-answers/follow-up-answers-actions.js'; +import {followUpAnswersReducer as followUpAnswers} from '../../../features/follow-up-answers/follow-up-answers-slice.js'; +import type {FollowUpAnswersState} from '../../../features/follow-up-answers/follow-up-answers-state.js'; +import { + generateHeadAnswer, + updateAnswerConfigurationId, +} from '../../../features/generated-answer/generated-answer-actions.js'; +import {queryReducer as query} from '../../../features/query/query-slice.js'; +import type {GeneratedAnswerState} from '../../../index.js'; +import type { + FollowUpAnswersSection, + GeneratedAnswerSection, + QuerySection, +} from '../../../state/state-sections.js'; +import {loadReducerError} from '../../../utils/errors.js'; +import { + buildCoreGeneratedAnswer, + type GeneratedAnswer, + type GeneratedAnswerAnalyticsClient, + type GeneratedAnswerProps, +} from '../../core/generated-answer/headless-core-generated-answer.js'; + +interface GeneratedAnswerWithFollowUpsState extends GeneratedAnswerState { + followUpAnswers: FollowUpAnswersState; +} + +export interface GeneratedAnswerWithFollowUps extends GeneratedAnswer { + /** + * The state of the GeneratedAnswer controller. + */ + state: GeneratedAnswerWithFollowUpsState; + /** + * Asks a follow-up question. + * @param question - The follow-up question to ask. + */ + askFollowUp(question: string): void; +} + +/** + * + * @internal + * + * Creates a `GeneratedAnswerWithFollowUps` controller instance using the Answer API stream pattern. + * + * @param engine - The headless engine. + * @param props - The configurable `GeneratedAnswerWithFollowUps` properties. + * @returns A `GeneratedAnswerWithFollowUps` controller instance. + */ +export function buildGeneratedAnswerWithFollowUps( + engine: SearchEngine | InsightEngine, + analyticsClient: GeneratedAnswerAnalyticsClient, + props: GeneratedAnswerProps = {} +): GeneratedAnswerWithFollowUps { + if (!loadReducers(engine)) { + throw loadReducerError; + } + + const {...controller} = buildCoreGeneratedAnswer( + engine, + analyticsClient, + props + ); + const getState = () => engine.state; + engine.dispatch(updateAnswerConfigurationId(props.agentId!)); + + return { + ...controller, + get state() { + const clientState = getState().generatedAnswer; + const serverState = selectHeadAnswer(engine.state)?.data; + const followUpAnswersState = getState().followUpAnswers; + + return { + /** Server-owned (RTK Query) */ + answer: serverState?.answer, + answerContentFormat: serverState?.contentFormat, + citations: serverState?.citations ?? [], + isLoading: serverState?.isLoading ?? false, + isStreaming: serverState?.isStreaming ?? false, + error: serverState?.error, + answerId: serverState?.answerId, + isAnswerGenerated: Boolean(serverState?.generated), + cannotAnswer: + !serverState?.isLoading && + !serverState?.isStreaming && + !serverState?.answer, + + /** Client-owned (Redux) */ + isVisible: clientState.isVisible, + expanded: clientState.expanded, + liked: clientState.liked, + disliked: clientState.disliked, + feedbackSubmitted: clientState.feedbackSubmitted, + feedbackModalOpen: clientState.feedbackModalOpen, + isEnabled: clientState.isEnabled, + responseFormat: clientState.responseFormat, + fieldsToIncludeInCitations: clientState.fieldsToIncludeInCitations, + answerGenerationMode: clientState.answerGenerationMode, + id: clientState.id, + + /** Follow-up answers state */ + followUpAnswers: followUpAnswersState, + }; + }, + retry() { + engine.dispatch(generateHeadAnswer()); + }, + askFollowUp(question: string) { + console.log('Asking follow-up question:', question); + engine.dispatch(generateFollowUpAnswer(question)); + }, + }; +} + +function loadReducers( + engine: SearchEngine | InsightEngine +): engine is SearchEngine< + GeneratedAnswerSection & + FollowUpAnswersSection & + QuerySection & { + answerGenerationApi: ReturnType; + } +> { + engine.addReducers({ + [answerGenerationApi.reducerPath]: answerGenerationApi.reducer, + query, + followUpAnswers, + }); + return true; +} diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts b/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts new file mode 100644 index 00000000000..17c4948b310 --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts @@ -0,0 +1,32 @@ +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {selectFieldsToIncludeInCitation} from '../generated-answer/generated-answer-selectors.js'; +import {selectPipeline} from '../pipeline/select-pipeline.js'; +import {selectSearchHub} from '../search-hub/search-hub-selectors.js'; + +export type FollowUpAnswerParams = ReturnType< + typeof constructGenerateFollowUpAnswerParams +>; + +export const constructGenerateFollowUpAnswerParams = ( + followUpQuestion: string, + state: AnswerGenerationApiState +) => { + const conversationId = state.followUpAnswers.id; + + const searchHub = selectSearchHub(state); + const pipeline = selectPipeline(state); + const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; + + return { + conversationId, + q: followUpQuestion, + pipelineRuleParameters: { + mlGenerativeQuestionAnswering: { + responseFormat: state.generatedAnswer.responseFormat, + citationsFieldToInclude, + }, + }, + ...(searchHub?.length && {searchHub}), + ...(pipeline?.length && {pipeline}), + }; +}; diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts new file mode 100644 index 00000000000..6940a987c6d --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts @@ -0,0 +1,134 @@ +import { + ArrayValue, + BooleanValue, + NumberValue, + RecordValue, + StringValue, +} from '@coveo/bueno'; +import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import type { + GeneratedAnswerCitationsPayload, + GeneratedAnswerMessagePayload, +} from '../../api/generated-answer/generated-answer-event-payload.js'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {initiateFollowUpAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.js'; +import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; +import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; +import { + requiredNonEmptyString, + validatePayload, +} from '../../utils/validate-payload.js'; +import { + answerContentFormatSchema, + citationSchema, + type GeneratedAnswerErrorPayload, +} from '../generated-answer/generated-answer-actions.js'; +import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js'; +import {constructGenerateFollowUpAnswerParams} from './follow-up-answer-request.js'; + +const stringValue = new StringValue({required: true}); + +export const setIsEnabled = createAction( + 'generatedAnswer/setIsEnabled', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const addFollowUpAnswer = createAction( + 'generatedAnswer/addFollowUpAnswer' +); + +export const updateActiveFollowUpAnswerMessage = createAction( + 'generatedAnswer/updateActiveFollowUpMessage', + (payload: GeneratedAnswerMessagePayload) => + validatePayload(payload, { + textDelta: stringValue, + }) +); + +export const updateActiveFollowUpAnswerCitations = createAction( + 'generatedAnswer/updateActiveFollowUpCitations', + (payload: GeneratedAnswerCitationsPayload) => + validatePayload(payload, { + citations: new ArrayValue({ + required: true, + each: new RecordValue({ + values: citationSchema, + }), + }), + }) +); + +export const updateActiveFollowUpError = createAction( + 'generatedAnswer/updateActiveFollowUpError', + (payload: GeneratedAnswerErrorPayload) => + validatePayload(payload, { + message: new StringValue(), + code: new NumberValue({min: 0}), + }) +); + +export const setActiveFollowUpIsLoading = createAction( + 'generatedAnswer/setActiveFollowUpIsLoading', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpIsStreaming = createAction( + 'generatedAnswer/setActiveFollowUpIsStreaming', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpAnswerContentFormat = createAction( + 'generatedAnswer/setActiveFollowUpAnswerContentFormat', + (payload: GeneratedContentFormat) => + validatePayload(payload, answerContentFormatSchema) +); + +export const setActiveFollowUpAnswerId = createAction( + 'generatedAnswer/setActiveFollowUpAnswerId', + (payload: string) => validatePayload(payload, requiredNonEmptyString) +); + +export const setActiveFollowUpIsAnswerGenerated = createAction( + 'generatedAnswer/setActiveFollowUpIsAnswerGenerated', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpCannotAnswer = createAction( + 'generatedAnswer/setActiveFollowUpCannotAnswer', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const generateFollowUpAnswer = createAsyncThunk< + void, + string, + AsyncThunkOptions +>( + 'generatedAnswerConversation/generateFollowUpAnswer', + async (question, {getState, dispatch, extra: {logger}}) => { + const state = getState() as AnswerGenerationApiState; + if (!state.generatedAnswer.answerConfigurationId) { + logger.warn( + 'Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID.' + ); + return; + } + + dispatch(addFollowUpAnswer(question)); + const generateFollowUpAnswerParams = constructGenerateFollowUpAnswerParams( + question, + state + ); + await dispatch( + initiateFollowUpAnswerGeneration({ + ...generateFollowUpAnswerParams, + q: question, + }) + ); + } +); diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts new file mode 100644 index 00000000000..c00ee2ed114 --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts @@ -0,0 +1,126 @@ +import {createReducer} from '@reduxjs/toolkit'; +import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; +import {filterOutDuplicatedCitations} from '../generated-answer/utils/generated-answer-citation-utils.js'; +import { + addFollowUpAnswer, + setActiveFollowUpAnswerContentFormat, + setActiveFollowUpAnswerId, + setActiveFollowUpCannotAnswer, + setActiveFollowUpIsAnswerGenerated, + setActiveFollowUpIsLoading, + setActiveFollowUpIsStreaming, + setIsEnabled, + updateActiveFollowUpAnswerCitations, + updateActiveFollowUpAnswerMessage, + updateActiveFollowUpError, +} from './follow-up-answers-actions.js'; +import { + createInitialFollowUpAnswer, + type FollowUpAnswer, + type FollowUpAnswersState, + getFollowUpAnswersInitialState, +} from './follow-up-answers-state.js'; + +function getActiveFollowUp( + state: FollowUpAnswersState +): FollowUpAnswer | undefined { + return state.answers[state.answers.length - 1]; +} + +export const followUpAnswersReducer = createReducer( + getFollowUpAnswersInitialState(), + (builder) => + builder + .addCase(setIsEnabled, (state, {payload}) => { + state.isEnabled = payload; + }) + .addCase(addFollowUpAnswer, (state, {payload}) => { + state.answers.push(createInitialFollowUpAnswer(payload)); + }) + .addCase(updateActiveFollowUpAnswerMessage, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = true; + if (!followUpAnswer.answer) { + followUpAnswer.answer = ''; + } + + followUpAnswer.answer += payload.textDelta; + delete followUpAnswer.error; + }) + .addCase(updateActiveFollowUpAnswerCitations, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = true; + followUpAnswer.citations = filterOutDuplicatedCitations([ + ...followUpAnswer.citations, + ...payload.citations, + ]); + delete followUpAnswer.error; + }) + .addCase(updateActiveFollowUpError, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = false; + followUpAnswer.error = { + ...payload, + isRetryable: payload.code === RETRYABLE_STREAM_ERROR_CODE, + }; + followUpAnswer.citations = []; + delete followUpAnswer.answer; + }) + .addCase(setActiveFollowUpIsLoading, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isLoading = payload; + }) + .addCase(setActiveFollowUpIsStreaming, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isStreaming = payload; + }) + .addCase(setActiveFollowUpAnswerContentFormat, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.answerContentFormat = payload; + }) + .addCase(setActiveFollowUpAnswerId, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.answerId = payload; + }) + .addCase(setActiveFollowUpIsAnswerGenerated, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isAnswerGenerated = payload; + }) + .addCase(setActiveFollowUpCannotAnswer, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.cannotAnswer = payload; + }) +); diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts new file mode 100644 index 00000000000..49e23540eea --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts @@ -0,0 +1,49 @@ +import type {GeneratedAnswerBase} from '../generated-answer/generated-answer-state.js'; + +export interface FollowUpAnswer extends GeneratedAnswerBase { + /** The question asked for this follow-up answer. */ + question: string; +} + +/** + * The follow-up answers state. + */ +export interface FollowUpAnswersState { + /** The unique identifier of the follow-up answer session. */ + id: string; + /** + * Determines if the follow-up answer feature is enabled. + */ + isEnabled: boolean; + /** + * The follow-up answers. + */ + answers: FollowUpAnswer[]; + /** + * Can ask more follow-up answers. + */ + canAskMore: boolean; +} + +export function getFollowUpAnswersInitialState(): FollowUpAnswersState { + return { + id: '', + isEnabled: false, + answers: [], + canAskMore: false, + }; +} + +export const createInitialFollowUpAnswer = ( + question: string +): FollowUpAnswer => ({ + question: question, + isLoading: false, + isStreaming: false, + citations: [], + liked: false, + disliked: false, + feedbackSubmitted: false, + isAnswerGenerated: false, + cannotAnswer: false, +}); diff --git a/packages/headless/src/features/generated-answer/answer-api-selectors.ts b/packages/headless/src/features/generated-answer/answer-api-selectors.ts index 169bf073af3..a42d5a4c530 100644 --- a/packages/headless/src/features/generated-answer/answer-api-selectors.ts +++ b/packages/headless/src/features/generated-answer/answer-api-selectors.ts @@ -27,3 +27,14 @@ export const selectAnswerApiQueryParams = createSelector( (state) => state.generatedAnswer?.answerApiQueryParams, (answerApiQueryParams) => answerApiQueryParams ?? skipToken ); + +/** + * If answer params are not available, returns `skipToken`, a special value from RTK Query + * that tells RTK Query to "skip" running a query or selector until the params are ready. + * + * @see https://redux-toolkit.js.org/rtk-query/usage-with-typescript#skipping-queries-with-typescript-using-skiptoken + */ +export const selectHeadAnswerApiQueryParams = createSelector( + (state) => state.generatedAnswer?.headAnswerApiQueryParams, + (headAnswerApiQueryParams) => headAnswerApiQueryParams ?? skipToken +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts index 2388c2d982a..fd2255b7849 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -16,6 +16,11 @@ import type { GeneratedAnswerStreamEventData, } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import { + type HeadAnswerEndpointArgs, + initiateHeadAnswerGeneration, +} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import {fetchAnswer} from '../../api/knowledge/stream-answer-api.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; @@ -39,6 +44,7 @@ import { import { buildStreamingRequest, constructAnswerAPIQueryParams, + constructGenerateHeadAnswerParams, } from './generated-answer-request.js'; import { type GeneratedContentFormat, @@ -54,7 +60,7 @@ type StateNeededByGeneratedAnswerStream = ConfigurationSection & const stringValue = new StringValue({required: true}); const optionalStringValue = new StringValue(); const booleanValue = new BooleanValue({required: true}); -const citationSchema = { +export const citationSchema = { id: stringValue, title: stringValue, uri: stringValue, @@ -62,10 +68,11 @@ const citationSchema = { clickUri: optionalStringValue, }; -const answerContentFormatSchema = new StringValue({ - required: true, - constrainTo: generatedContentFormat, -}); +export const answerContentFormatSchema = + new StringValue({ + required: true, + constrainTo: generatedContentFormat, + }); export interface GeneratedAnswerErrorPayload { message?: string; @@ -215,6 +222,12 @@ export const setAnswerApiQueryParams = createAction( validatePayload(payload, new RecordValue({})) ); +export const setHeadAnswerApiQueryParams = createAction( + 'generatedAnswer/setHeadAnswerApiQueryParams', + (payload: HeadAnswerEndpointArgs) => + validatePayload(payload, new RecordValue({})) +); + interface StreamAnswerArgs { setAbortControllerRef: (ref: AbortController) => void; } @@ -361,3 +374,29 @@ export const generateAnswer = createAsyncThunk< } } ); + +export const generateHeadAnswer = createAsyncThunk< + void, + void, + AsyncThunkOptions +>( + 'generatedAnswerConversation/generateHeadAnswer', + async (_, {getState, dispatch, extra: {navigatorContext, logger}}) => { + const state = getState() as AnswerGenerationApiState; + if (!state.generatedAnswer.answerConfigurationId) { + logger.warn( + 'Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID.' + ); + return; + } + + dispatch(resetAnswer()); + const generateHeadAnswerParams = constructGenerateHeadAnswerParams( + state, + navigatorContext + ); + dispatch(setHeadAnswerApiQueryParams(generateHeadAnswerParams)); + await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); + } +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-request.ts b/packages/headless/src/features/generated-answer/generated-answer-request.ts index 074fc15b0df..791f2ace9b8 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-request.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-request.ts @@ -1,6 +1,8 @@ import type {HistoryElement} from '../../api/analytics/coveo.analytics/history-store.js'; import HistoryStore from '../../api/analytics/coveo.analytics/history-store.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import {getOrganizationEndpoint} from '../../api/platform-client.js'; import type {BaseParam} from '../../api/platform-service-params.js'; @@ -31,6 +33,7 @@ import { import {selectSearchActionCause} from '../../features/search/search-selectors.js'; import {selectSearchHub} from '../../features/search-hub/search-hub-selectors.js'; import {selectActiveTab} from '../../features/tab-set/tab-set-selectors.js'; +import type {SearchAppState} from '../../state/search-app-state.js'; import type { ConfigurationSection, GeneratedAnswerSection, @@ -74,7 +77,7 @@ export const buildStreamingRequest = async ( export const constructAnswerAPIQueryParams = ( state: StreamAnswerAPIState, navigatorContext: NavigatorContext -): AnswerApiQueryParams => { +) => { const q = selectQuery(state)?.q; const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); @@ -161,8 +164,45 @@ export const constructAnswerAPIQueryParams = ( }; }; +// export type HeadAnswerParams = ReturnType< +// typeof constructGenerateHeadAnswerParams +// >; + +export const constructGenerateHeadAnswerParams = ( + state: AnswerGenerationApiState, + navigatorContext: NavigatorContext +): HeadAnswerEndpointArgs => { + const q = selectQuery(state)?.q; + const facetParams = getGeneratedFacetParams(state); + const analyticsParams = fromAnalyticsStateToAnalyticsParams( + state.configuration.analytics, + navigatorContext, + {actionCause: selectSearchActionCause(state)} + ); + const locale = selectLocale(state); + + const searchHub = selectSearchHub(state); + const pipeline = selectPipeline(state); + const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; + + return { + q: q || '', + ...(facetParams.length && {facets: facetParams}), + pipelineRuleParameters: { + mlGenerativeQuestionAnswering: { + responseFormat: state.generatedAnswer.responseFormat, + citationsFieldToInclude, + }, + }, + ...(searchHub?.length && {searchHub}), + ...(pipeline?.length && {pipeline}), + ...analyticsParams, + locale, + }; +}; + const getGeneratedFacetParams = ( - state: StreamAnswerAPIState + state: Partial ): AnyFacetRequest[] => getFacets(state) ?.map((facetRequest) => @@ -180,7 +220,9 @@ const getActionsHistory = ( : [], }); -const buildAdvancedSearchQueryParams = (state: StreamAnswerAPIState) => { +export const buildAdvancedSearchQueryParams = ( + state: Partial +) => { const advancedSearchQueryParams = selectAdvancedSearchQueries(state); const mergedCq = buildConstantQuery(state); diff --git a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts index 515b9a41038..c33da8502e5 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts @@ -12,7 +12,10 @@ export const generativeQuestionAnsweringIdSelector = ( state: Partial ): string | undefined => { // If using the AnswerApi, we return the answerId first. - if (isGeneratedAnswerSection(state)) { + if ( + isGeneratedAnswerFeatureEnabledWithAnswerAPI(state) || + isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI(state) + ) { return state.generatedAnswer?.answerId; } @@ -25,13 +28,20 @@ export const generativeQuestionAnsweringIdSelector = ( return undefined; }; -const isGeneratedAnswerSection = ( +export const isGeneratedAnswerFeatureEnabledWithAnswerAPI = ( state: Partial ): state is StreamAnswerAPIState => 'answer' in state && 'generatedAnswer' in state && !isNullOrUndefined(state.generatedAnswer?.answerConfigurationId); +export const isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI = ( + state: Partial +): state is StreamAnswerAPIState => + 'answerGenerationApi' in state && + 'generatedAnswer' in state && + !isNullOrUndefined(state.generatedAnswer?.answerConfigurationId); + const isSearchSection = ( state: Partial | StreamAnswerAPIState ): state is SearchSection => diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index 49d394deedd..4e7466c8139 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -1,5 +1,6 @@ import {createReducer} from '@reduxjs/toolkit'; import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import { closeGeneratedAnswerFeedbackModal, @@ -16,6 +17,7 @@ import { setAnswerGenerationMode, setAnswerId, setCannotAnswer, + setHeadAnswerApiQueryParams, setId, setIsAnswerGenerated, setIsEnabled, @@ -137,6 +139,9 @@ export const generatedAnswerReducer = createReducer( .addCase(setAnswerApiQueryParams, (state, {payload}) => { state.answerApiQueryParams = payload as AnswerApiQueryParams; }) + .addCase(setHeadAnswerApiQueryParams, (state, {payload}) => { + state.headAnswerApiQueryParams = payload as HeadAnswerEndpointArgs; + }) .addCase(setAnswerId, (state, {payload}) => { state.answerId = payload; }) diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index e88e2b7a329..5fa7d5b87ce 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -1,22 +1,12 @@ import type {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import type { GeneratedContentFormat, GeneratedResponseFormat, } from './generated-response-format.js'; -/** - * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. - * - * @group Controllers - * @category GeneratedAnswer - */ -export interface GeneratedAnswerState { - id: string; - /** - * Determines if the generated answer is visible. - */ - isVisible: boolean; +export interface GeneratedAnswerBase { /** * Determines if the generated answer is loading. */ @@ -25,10 +15,6 @@ export interface GeneratedAnswerState { * Determines if the generated answer is streaming. */ isStreaming: boolean; - /** - * Determines if the generated answer is enabled. - */ - isEnabled: boolean; /** * The generated answer. */ @@ -51,14 +37,6 @@ export interface GeneratedAnswerState { * Determines if the generated answer is disliked, or downvoted by the end user. */ disliked: boolean; - /** - * The desired format options for the generated answer. - */ - responseFormat: GeneratedResponseFormat; - /** - * Determines if the feedback modal is currently opened. - */ - feedbackModalOpen: boolean; /** * The generated answer error. */ @@ -71,22 +49,50 @@ export interface GeneratedAnswerState { * Determines if the generated answer feedback was submitted. */ feedbackSubmitted: boolean; - /** - * A list of indexed fields to include in the citations returned with the generated answer. - */ - fieldsToIncludeInCitations: string[]; /** * Determines if the answer is generated. */ isAnswerGenerated: boolean; + /** + * Whether an answer cannot be generated after a query is executed. + */ + cannotAnswer: boolean; + /** The unique identifier of the answer returned by the Answer API. */ + answerId?: string; +} + +/** + * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. + * + * @group Controllers + * @category GeneratedAnswer + */ +export interface GeneratedAnswerState extends GeneratedAnswerBase { + /** + * Determines if the generated answer is visible. + */ + isVisible: boolean; /** * Whether the answer is expanded. */ expanded: boolean; + id: string; /** - * Whether an answer cannot be generated after a query is executed. + * Determines if the feedback modal is currently opened. */ - cannotAnswer: boolean; + feedbackModalOpen: boolean; + /** + * Determines if the generated answer is enabled. + */ + isEnabled: boolean; + /** + * The desired format options for the generated answer. + */ + responseFormat: GeneratedResponseFormat; + /** + * A list of indexed fields to include in the citations returned with the generated answer. + */ + fieldsToIncludeInCitations: string[]; /** * The answer configuration unique identifier. */ @@ -95,8 +101,10 @@ export interface GeneratedAnswerState { * The query parameters used for the answer API request cache key */ answerApiQueryParams?: AnswerApiQueryParams; - /** The unique identifier of the answer returned by the Answer API. */ - answerId?: string; + /** + * The query parameters used for the head answer API request + */ + headAnswerApiQueryParams?: HeadAnswerEndpointArgs; /** The current mode of answer generation. */ answerGenerationMode: 'automatic' | 'manual'; } diff --git a/packages/headless/src/features/search/search-request.ts b/packages/headless/src/features/search/search-request.ts index 025846e5edb..a1fdbf4d5ad 100644 --- a/packages/headless/src/features/search/search-request.ts +++ b/packages/headless/src/features/search/search-request.ts @@ -189,7 +189,7 @@ function getRangeFacetRequests(state: T) { }); } -export function buildConstantQuery(state: StateNeededBySearchRequest) { +export function buildConstantQuery(state: Partial) { const cq = state.advancedSearchQueries?.cq.trim() || ''; const activeTab = Object.values(state.tabSet || {}).find( (tab) => tab.isActive diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts index b8f15d65256..cd32e2654fc 100644 --- a/packages/headless/src/index.ts +++ b/packages/headless/src/index.ts @@ -251,6 +251,7 @@ export type { InstantResultsState, } from './controllers/instant-results/instant-results.js'; export {buildInstantResults} from './controllers/instant-results/instant-results.js'; +export type {GeneratedAnswerWithFollowUps} from './controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; export type { Pager, PagerInitialState, diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index 1e07ea8107b..83d9563ce79 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -48,6 +48,7 @@ import type {DateFacetSetState} from '../features/facets/range-facets/date-facet import type {NumericFacetSetState} from '../features/facets/range-facets/numeric-facet-set/numeric-facet-set-state.js'; import type {FieldsState} from '../features/fields/fields-state.js'; import type {FoldingState} from '../features/folding/folding-state.js'; +import type {FollowUpAnswersState} from '../features/follow-up-answers/follow-up-answers-state.js'; import type {AnswerApiQueryParams} from '../features/generated-answer/generated-answer-request.js'; import type {GeneratedAnswerState} from '../features/generated-answer/generated-answer-state.js'; import type {HistoryState} from '../features/history/history-state.js'; @@ -509,6 +510,13 @@ export interface GeneratedAnswerSection { generatedAnswer: GeneratedAnswerState; } +export interface FollowUpAnswersSection { + /** + * The properties related to generative question answering. + */ + followUpAnswers: FollowUpAnswersState; +} + export interface InsightUserActionsSection { /** * The insight user actions state. diff --git a/packages/headless/src/utils/facet-utils.ts b/packages/headless/src/utils/facet-utils.ts index b26c11b4474..ff2b84bcf0b 100644 --- a/packages/headless/src/utils/facet-utils.ts +++ b/packages/headless/src/utils/facet-utils.ts @@ -3,10 +3,8 @@ import {getFacetRequests} from '../features/facets/generic/interfaces/generic-fa import type {AnyFacetValue} from '../features/facets/generic/interfaces/generic-facet-response.js'; import type {RangeFacetSetState} from '../features/facets/range-facets/generic/interfaces/range-facet.js'; import type {SearchAppState} from '../state/search-app-state.js'; -import type {ConfigurationSection} from '../state/state-sections.js'; -type StateNeededBySearchRequest = ConfigurationSection & - Partial; +type StateNeededBySearchRequest = Partial; type SortCriteria = { type: string;