Skip to content

Commit

Permalink
feat(atomic): added Generated Answer component (#3003)
Browse files Browse the repository at this point in the history
* feat(headless): added generative answer feature

* feat(headless): added slice tests

* feat(headless): added client

* feat(headless): subscribe

* feat(headless): temp retry logic

* feat(headless): client retry

* feat(headless): exports

* feat(headless): added error codes

* feat(atomic): prelim components

* feat(atomic): tag fix

* feat(headless): actually building a controller lol

* feat(headless): actually building a controller lol

* feat(headless): id and error handling tweaks

* feat(headless): added stream id to search event custom data

* feat(headless): complete event might contain delta

* feat(headless): complete event might contain delta

* fix(headless): started adding payload schemas

* feat(genqa): enum rename

* feat(atomic): added feedback buttons

* feat(headless): started adding analytics actions

* feat(atomic): added citations

* feat(headless): citatio interface export

* feat(atomic): added localized strings

* feat(headless): punctuation

* feat(atomic): show when loading

* feat(headless): new citations schema

* feat(atomic): no score

* feat(headless): removed score

* feat(atomic): updated loader

* feat(headless): removed oncomplete

* feat(headless): added retryable state flag

* feat(atomic): used analytics actions

* feat(atomic): fixed loading transition

* feat(headless): tests for setIsLoading

* feat(atomic): citation max width

* feat(headless): added more controller tests

* feat(headless): removed unused analytics util

* feat(headless): moved handlers to asyncthunk

* feat(headless): string literal

* feat(atomic): added genqa example page

* feat(headless): added like and dislike to the state

* feat(headless): error payload type

* feat(headless): renamed and added citation seletor

* feat(atomic): feedback button state

* feat(headless): switched libs

* feat(headless): reset error state

* feat(atomic): feedback button dimensions

* feat(headless): controller tests

* feat(headless): changed handlers for action injection

* feat(headless): controller tests

* feat(headless): changed handlers for action injection

* feat(headless): onpopen handler

* feat(atomic): added retry button

* feat(atomic): started tests

* feat(headless): added arguments to fatalerror

* feat(atomic): added tests

* feat(headless): pr feedback

* feat(atomic): added tests

* feat(headless): changed client callbacks

* feat(atomic): added tests

* feat(headless): switch it up

* feat(headless): changed accept header

* feat(atomic): added tests

* feat(atomic): added retry test

* feat(headless): setting error before throw from onerror

* feat(headless): wrong error

* feat(atomic): retry button tests

* feat(atomic): unknown instead of any

* feat(atomic): title to citation links

* feat(atomic): pr feedback

* feat(atomic): css and a11y feedback implemented

* feat(atomic): props interface

* feat(atomic): removed smart snippets from genqa example

* feat(atomic): ordered list

* feat(atomic): key on top level element
  • Loading branch information
nathanlb authored Jun 28, 2023
1 parent 9e7c023 commit 2f2096b
Show file tree
Hide file tree
Showing 19 changed files with 964 additions and 0 deletions.
49 changes: 49 additions & 0 deletions packages/atomic/cypress/e2e/generated-answer-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {RouteAlias} from '../fixtures/fixture-common';
import {TestFixture, generateComponentHTML} from '../fixtures/test-fixture';

export const getStreamInterceptAlias = (streamId: string) =>
`${RouteAlias.GenQAStream}-${streamId}`;

export function mockStreamResponse(streamId: string, body: unknown) {
cy.intercept(
{
method: 'GET',
url: `**/machinelearning/streaming/${streamId}`,
},
(request) => {
request.reply(200, `data: ${JSON.stringify(body)} \n\n`, {
'content-type': 'text/event-stream',
});
}
).as(getStreamInterceptAlias(streamId).substring(1));
}

export function mockStreamError(streamId: string, errorCode: number) {
cy.intercept(
{
method: 'GET',
url: `**/machinelearning/streaming/${streamId}`,
},
(request) => {
request.reply(
errorCode,
{},
{
'content-type': 'text/event-stream',
}
);
}
).as(getStreamInterceptAlias(streamId).substring(1));
}

export const addGeneratedAnswer =
(streamId?: string) => (fixture: TestFixture) => {
const element = generateComponentHTML('atomic-generated-answer');
fixture.withElement(element).withCustomResponse((response) => {
if (streamId) {
response.extendedResults = {
generativeQuestionAnsweringId: streamId,
};
}
});
};
26 changes: 26 additions & 0 deletions packages/atomic/cypress/e2e/generated-answer-selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const generatedAnswerComponent = 'atomic-generated-answer';
export const GeneratedAnswerSelectors = {
shadow: () => cy.get(generatedAnswerComponent).shadow(),
container: () => GeneratedAnswerSelectors.shadow().find('[part="container"]'),
content: () =>
GeneratedAnswerSelectors.shadow().find('[part="generated-content"]'),
answer: () =>
GeneratedAnswerSelectors.shadow().find('[part="generated-text"]'),
headerLabel: () =>
GeneratedAnswerSelectors.shadow().find('[part="header-label"]'),
likeButton: () =>
GeneratedAnswerSelectors.shadow().find('.feedback-button.like'),
dislikeButton: () =>
GeneratedAnswerSelectors.shadow().find('.feedback-button.dislike'),
citation: () => GeneratedAnswerSelectors.shadow().find('.citation'),
citationsLabel: () =>
GeneratedAnswerSelectors.shadow().find('.citations-label'),
citationTitle: () =>
GeneratedAnswerSelectors.citation().find('.citation-title'),
citationIndex: () =>
GeneratedAnswerSelectors.citation().find('.citation-index'),
loader: () => GeneratedAnswerSelectors.shadow().find('.typing-indicator'),
retryContainer: () =>
GeneratedAnswerSelectors.shadow().find('[part="retry-container"]'),
retryButton: () => GeneratedAnswerSelectors.retryContainer().find('button'),
};
174 changes: 174 additions & 0 deletions packages/atomic/cypress/e2e/generated-answer.cypress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {TestFixture} from '../fixtures/test-fixture';
import {
addGeneratedAnswer,
getStreamInterceptAlias,
mockStreamError,
mockStreamResponse,
} from './generated-answer-actions';
import {GeneratedAnswerSelectors} from './generated-answer-selectors';

describe('Generated Answer Test Suites', () => {
describe('Generated Answer', () => {
function setupGeneratedAnswer(streamId?: string) {
new TestFixture().with(addGeneratedAnswer(streamId)).init();
}

describe('when no stream ID is returned', () => {
beforeEach(() => {
setupGeneratedAnswer();
});

it('should not display the component', () => {
GeneratedAnswerSelectors.container().should('not.exist');
});
});

describe('when a stream ID is returned', () => {
describe('when a message event is received', () => {
const streamId = crypto.randomUUID();

const testTextDelta = 'Some text';
const testMessagePayload = {
payloadType: 'genqa.messageType',
payload: JSON.stringify({
textDelta: testTextDelta,
}),
finishReason: 'COMPLETED',
};

beforeEach(() => {
mockStreamResponse(streamId, testMessagePayload);
setupGeneratedAnswer(streamId);
cy.wait(getStreamInterceptAlias(streamId));
});

it('should log the stream ID in the search event custom data', () => {
TestFixture.getUACustomData().then((customData) => {
expect(customData).to.have.property(
'generativeQuestionAnsweringId',
streamId
);
});
});

it('should display the message', () => {
GeneratedAnswerSelectors.answer().should('have.text', testTextDelta);
});

it('should display feedback buttons', () => {
GeneratedAnswerSelectors.likeButton().should('exist');
GeneratedAnswerSelectors.dislikeButton().should('exist');
});
});

describe('when a citation event is received', () => {
const streamId = crypto.randomUUID();

const testCitation = {
id: 'some-id-123',
title: 'Some Title',
uri: 'https://www.coveo.com',
permanentid: 'some-permanent-id-123',
clickUri: 'https://www.coveo.com/en',
};
const testMessagePayload = {
payloadType: 'genqa.citationsType',
payload: JSON.stringify({
citations: [testCitation],
}),
finishReason: 'COMPLETED',
};

beforeEach(() => {
mockStreamResponse(streamId, testMessagePayload);
setupGeneratedAnswer(streamId);
cy.wait(getStreamInterceptAlias(streamId));
});

it('should display the citation link', () => {
GeneratedAnswerSelectors.citationsLabel().should(
'have.text',
'Learn more'
);
GeneratedAnswerSelectors.citationTitle().should(
'have.text',
testCitation.title
);
GeneratedAnswerSelectors.citationIndex().should('have.text', '1');
GeneratedAnswerSelectors.citation().should(
'have.attr',
'href',
testCitation.clickUri
);
});
});

describe('when an error event is received', () => {
const streamId = crypto.randomUUID();

const testErrorPayload = {
finishReason: 'ERROR',
errorMessage: 'An error message',
errorCode: 500,
};

beforeEach(() => {
mockStreamResponse(streamId, testErrorPayload);
setupGeneratedAnswer(streamId);
cy.wait(getStreamInterceptAlias(streamId));
});

it('should not display the component', () => {
GeneratedAnswerSelectors.container().should('not.exist');
});
});

describe('when the stream connection fails', () => {
const streamId = crypto.randomUUID();

describe('Non-retryable error (4XX)', () => {
beforeEach(() => {
mockStreamError(streamId, 406);
setupGeneratedAnswer(streamId);
cy.wait(getStreamInterceptAlias(streamId));
});

it('should not show the component', () => {
GeneratedAnswerSelectors.container().should('not.exist');
});
});

describe('Retryable error', () => {
[500, 429].forEach((errorCode) => {
describe(`${errorCode} error`, () => {
beforeEach(() => {
Cypress.on('uncaught:exception', () => false);
mockStreamError(streamId, 500);
setupGeneratedAnswer(streamId);
cy.wait(getStreamInterceptAlias(streamId));
});

it('should retry the stream 3 times then offer a retry button', () => {
for (let times = 0; times < 3; times++) {
GeneratedAnswerSelectors.container().should('not.exist');

cy.wait(getStreamInterceptAlias(streamId));
}

const retryAlias = '@retrySearch';
cy.intercept({
method: 'POST',
url: '**/rest/search/v2?*',
}).as(retryAlias.substring(1));

GeneratedAnswerSelectors.retryButton().click();

cy.wait(retryAlias);
});
});
});
});
});
});
});
});
1 change: 1 addition & 0 deletions packages/atomic/cypress/fixtures/fixture-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const RouteAlias = {
FacetSearch: '@coveoFacetSearch',
Quickview: '@coveoQuickview',
Locale: '@locale',
GenQAStream: '@genQAStream',
};

export const ConsoleAliases = {
Expand Down
13 changes: 13 additions & 0 deletions packages/atomic/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ export namespace Components {
}
interface AtomicFrequentlyBoughtTogether {
}
interface AtomicGeneratedAnswer {
}
interface AtomicHtml {
/**
* Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify).
Expand Down Expand Up @@ -2124,6 +2126,12 @@ declare global {
prototype: HTMLAtomicFrequentlyBoughtTogetherElement;
new (): HTMLAtomicFrequentlyBoughtTogetherElement;
};
interface HTMLAtomicGeneratedAnswerElement extends Components.AtomicGeneratedAnswer, HTMLStencilElement {
}
var HTMLAtomicGeneratedAnswerElement: {
prototype: HTMLAtomicGeneratedAnswerElement;
new (): HTMLAtomicGeneratedAnswerElement;
};
interface HTMLAtomicHtmlElement extends Components.AtomicHtml, HTMLStencilElement {
}
var HTMLAtomicHtmlElement: {
Expand Down Expand Up @@ -2822,6 +2830,7 @@ declare global {
"atomic-format-number": HTMLAtomicFormatNumberElement;
"atomic-format-unit": HTMLAtomicFormatUnitElement;
"atomic-frequently-bought-together": HTMLAtomicFrequentlyBoughtTogetherElement;
"atomic-generated-answer": HTMLAtomicGeneratedAnswerElement;
"atomic-html": HTMLAtomicHtmlElement;
"atomic-icon": HTMLAtomicIconElement;
"atomic-insight-edit-toggle": HTMLAtomicInsightEditToggleElement;
Expand Down Expand Up @@ -3253,6 +3262,8 @@ declare namespace LocalJSX {
}
interface AtomicFrequentlyBoughtTogether {
}
interface AtomicGeneratedAnswer {
}
interface AtomicHtml {
/**
* Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify).
Expand Down Expand Up @@ -4779,6 +4790,7 @@ declare namespace LocalJSX {
"atomic-format-number": AtomicFormatNumber;
"atomic-format-unit": AtomicFormatUnit;
"atomic-frequently-bought-together": AtomicFrequentlyBoughtTogether;
"atomic-generated-answer": AtomicGeneratedAnswer;
"atomic-html": AtomicHtml;
"atomic-icon": AtomicIcon;
"atomic-insight-edit-toggle": AtomicInsightEditToggle;
Expand Down Expand Up @@ -4917,6 +4929,7 @@ declare module "@stencil/core" {
"atomic-format-number": LocalJSX.AtomicFormatNumber & JSXBase.HTMLAttributes<HTMLAtomicFormatNumberElement>;
"atomic-format-unit": LocalJSX.AtomicFormatUnit & JSXBase.HTMLAttributes<HTMLAtomicFormatUnitElement>;
"atomic-frequently-bought-together": LocalJSX.AtomicFrequentlyBoughtTogether & JSXBase.HTMLAttributes<HTMLAtomicFrequentlyBoughtTogetherElement>;
"atomic-generated-answer": LocalJSX.AtomicGeneratedAnswer & JSXBase.HTMLAttributes<HTMLAtomicGeneratedAnswerElement>;
"atomic-html": LocalJSX.AtomicHtml & JSXBase.HTMLAttributes<HTMLAtomicHtmlElement>;
"atomic-icon": LocalJSX.AtomicIcon & JSXBase.HTMLAttributes<HTMLAtomicIconElement>;
"atomic-insight-edit-toggle": LocalJSX.AtomicInsightEditToggle & JSXBase.HTMLAttributes<HTMLAtomicInsightEditToggleElement>;
Expand Down
Loading

0 comments on commit 2f2096b

Please sign in to comment.