diff --git a/packages/headless/src/features/analytics/analytics-utils.ts b/packages/headless/src/features/analytics/analytics-utils.ts index 6ca998f4f29..70cc58ed0a2 100644 --- a/packages/headless/src/features/analytics/analytics-utils.ts +++ b/packages/headless/src/features/analytics/analytics-utils.ts @@ -787,3 +787,18 @@ export const analyticsEventItemMetadata = ( url: information.documentUri, }; }; + +export const analyticsEventItemMetadataForCitations = ( + citation: GeneratedAnswerCitation, + state: Partial +): ItemMetaData => { + const identifier = citationDocumentIdentifier(citation); + const information = partialCitationInformation(citation, state); + return { + uniqueFieldName: identifier.contentIdKey, + uniqueFieldValue: identifier.contentIdValue, + title: information.documentTitle, + author: information.documentAuthor, + url: information.documentUri, + }; +}; diff --git a/packages/headless/src/features/analytics/search-action-cause.ts b/packages/headless/src/features/analytics/search-action-cause.ts index ec35c87125e..7ffd66743c6 100644 --- a/packages/headless/src/features/analytics/search-action-cause.ts +++ b/packages/headless/src/features/analytics/search-action-cause.ts @@ -180,6 +180,10 @@ export enum SearchPageEvents { * Identifies the custom event that gets logged when a user clicks the Detach From Case result action. */ caseDetach = 'caseDetach', + /** + * Identifies the click event that gets logged when a user attaches a generated answer citation to a case. + */ + generatedAnswerCitationDocumentAttach = 'generatedAnswerCitationDocumentAttach', /** * Identifies the cause of a search request being retried in order to regenerate an answer stream that failed. */ diff --git a/packages/headless/src/features/attached-results/__snapshots__/attached-results-analytics-actions.test.ts.snap b/packages/headless/src/features/attached-results/__snapshots__/attached-results-analytics-actions.test.ts.snap index 2449ef257a9..92b7d67f4f2 100644 --- a/packages/headless/src/features/attached-results/__snapshots__/attached-results-analytics-actions.test.ts.snap +++ b/packages/headless/src/features/attached-results/__snapshots__/attached-results-analytics-actions.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`attached results analytics actions > when analyticsMode is \`next\` > logCaseAttach > should call relay.emit properly 1`] = ` +exports[`attached results analytics actions > when analyticsMode is \`next\` > #logCaseAttach > should call relay.emit properly 1`] = ` [ "InsightPanel.ItemAction", { @@ -23,7 +23,7 @@ exports[`attached results analytics actions > when analyticsMode is \`next\` > l ] `; -exports[`attached results analytics actions > when analyticsMode is \`next\` > logCaseDetach > should call relay.emit properly 1`] = ` +exports[`attached results analytics actions > when analyticsMode is \`next\` > #logCaseDetach > should call relay.emit properly 1`] = ` [ "InsightPanel.DetachItem", { @@ -42,3 +42,23 @@ exports[`attached results analytics actions > when analyticsMode is \`next\` > l }, ] `; + +exports[`attached results analytics actions > when analyticsMode is \`next\` > #logCitationDocumentDetach > should call relay.emit properly 1`] = ` +[ + "InsightPanel.DetachItem", + { + "context": { + "caseNumber": "5678", + "targetId": "1234", + "targetType": "Case", + }, + "itemMetadata": { + "author": undefined, + "title": "example documentTitle", + "uniqueFieldName": "permanentid", + "uniqueFieldValue": "example contentIDValue", + "url": "example documentUri", + }, + }, +] +`; diff --git a/packages/headless/src/features/attached-results/attached-results-actions.ts b/packages/headless/src/features/attached-results/attached-results-actions.ts index 3be5ee24963..52f8a0bd1db 100644 --- a/packages/headless/src/features/attached-results/attached-results-actions.ts +++ b/packages/headless/src/features/attached-results/attached-results-actions.ts @@ -38,6 +38,7 @@ const attachedResultPayloadDefinition = { source: nonEmptyString, title: requiredNonEmptyString, uriHash: nonEmptyString, + isAttachedFromCitation: new BooleanValue({required: false, default: false}), }; const RequiredAttachedResultRecord = new RecordValue({ @@ -48,7 +49,7 @@ const RequiredAttachedResultRecord = new RecordValue({ }); export const setAttachedResults = createAction( - 'insight/attachToCase/setAttachedResults', + 'insight/attachedResults/setAttachedResults', (payload: SetAttachedResultsActionCreatorPayload) => validatePayload(payload, { results: new ArrayValue({ @@ -62,12 +63,12 @@ export const setAttachedResults = createAction( ); export const attachResult = createAction( - 'insight/attachToCase/attach', + 'insight/attachedResults/attach', (payload: AttachedResult) => validatePayloadAndPermanentIdOrUriHash(payload) ); export const detachResult = createAction( - 'insight/attachToCase/detach', + 'insight/attachedResults/detach', (payload: AttachedResult) => validatePayloadAndPermanentIdOrUriHash(payload) ); diff --git a/packages/headless/src/features/attached-results/attached-results-analytics-actions.test.ts b/packages/headless/src/features/attached-results/attached-results-analytics-actions.test.ts index 47e8616d151..d5ad743f9b4 100644 --- a/packages/headless/src/features/attached-results/attached-results-analytics-actions.test.ts +++ b/packages/headless/src/features/attached-results/attached-results-analytics-actions.test.ts @@ -10,13 +10,17 @@ import {buildMockSearchResponse} from '../../test/mock-search-response.js'; import {buildMockSearchState} from '../../test/mock-search-state.js'; import {clearMicrotaskQueue} from '../../test/unit-test-utils.js'; import {getConfigurationInitialState} from '../configuration/configuration-state.js'; +import {getGeneratedAnswerInitialState} from '../generated-answer/generated-answer-state.js'; import { logCaseAttach, logCaseDetach, + logCitationDocumentAttach, + logCitationDocumentDetach, } from './attached-results-analytics-actions.js'; const mockLogCaseAttach = vi.fn(); const mockLogCaseDetach = vi.fn(); +const mockLogGeneratedAnswerCitationDocumentAttach = vi.fn(); const emit = vi.fn(); vi.mock('@coveo/relay'); @@ -26,6 +30,8 @@ vi.mocked(CoveoInsightClient).mockImplementation(function () { this.disable = () => {}; this.logCaseAttach = mockLogCaseAttach; this.logCaseDetach = mockLogCaseDetach; + this.logGeneratedAnswerCitationDocumentAttach = + mockLogGeneratedAnswerCitationDocumentAttach; }); vi.mocked(createRelay).mockReturnValue({ @@ -64,6 +70,15 @@ const expectedDocumentInfo = { documentAuthor: 'example author', }; +const expectedCitationDocumentInfo = { + queryPipeline: '', + documentUri: 'example documentUri', + sourceName: 'example sourceName', + documentPosition: 1, + documentTitle: 'example documentTitle', + documentUrl: 'example documentUrl', +}; + const expectedDocumentIdentifier = { contentIDKey: 'permanentid', contentIDValue: 'example contentIDValue', @@ -90,14 +105,36 @@ const resultParams = { const testResult = buildMockResult(resultParams); +const testCitation = { + id: 'citation-123', + permanentid: 'example contentIDValue', + title: 'example documentTitle', + uri: 'example documentUri', + clickUri: 'example documentUrl', + text: 'example citation text', + fields: { + urihash: 'example documentUriHash', + source: 'example sourceName', + permanentid: 'example contentIDValue', + author: 'example author', + }, + source: 'example sourceName', +}; + describe('attached results analytics actions', () => { let engine: InsightEngine; const searchState = buildMockSearchState({ results: [testResult], response: buildMockSearchResponse({ searchUid: 'example searchUid', + extendedResults: { + generativeQuestionAnsweringId: 'example-answer-id', + }, }), }); + const generatedAnswerState = { + ...getGeneratedAnswerInitialState(), + }; const caseContextState = { caseContext: { Case_Subject: exampleSubject, @@ -123,12 +160,13 @@ describe('attached results analytics actions', () => { analyticsMode: 'legacy', }, }, + generatedAnswer: generatedAnswerState, insightCaseContext: caseContextState, }) ); }); - describe('logCaseAttach', () => { + describe('#logCaseAttach', () => { it('should call coveo.analytics.logCaseAttach properly', async () => { await logCaseAttach(testResult)()( engine.dispatch, @@ -149,7 +187,7 @@ describe('attached results analytics actions', () => { }); }); - describe('logCaseDetach', () => { + describe('#logCaseDetach', () => { it('should call coveo.analytics.logCaseDetach properly', async () => { await logCaseDetach(testResult)()( engine.dispatch, @@ -166,6 +204,54 @@ describe('attached results analytics actions', () => { ); }); }); + + describe('#logCitationDocumentAttach', () => { + it('should call coveo.analytics.logGeneratedAnswerCitationDocumentAttach properly', async () => { + await logCitationDocumentAttach(testCitation)()( + engine.dispatch, + () => engine.state, + {} as ThunkExtraArguments + ); + + expect( + mockLogGeneratedAnswerCitationDocumentAttach + ).toHaveBeenCalledTimes(1); + expect( + mockLogGeneratedAnswerCitationDocumentAttach.mock.calls[0][0] + ).toStrictEqual(expectedCitationDocumentInfo); + expect( + mockLogGeneratedAnswerCitationDocumentAttach.mock.calls[0][1] + ).toStrictEqual({ + generativeQuestionAnsweringId: 'example-answer-id', + citationId: testCitation.id, + documentId: { + contentIdKey: 'permanentid', + contentIdValue: testCitation.permanentid, + }, + }); + expect( + mockLogGeneratedAnswerCitationDocumentAttach.mock.calls[0][2] + ).toStrictEqual(expectedMetadata); + }); + }); + + describe('#logCitationDocumentDetach', () => { + it('should call coveo.analytics.logCaseDetach properly', async () => { + await logCitationDocumentDetach(testCitation)()( + engine.dispatch, + () => engine.state, + {} as ThunkExtraArguments + ); + + expect(mockLogCaseDetach).toHaveBeenCalledTimes(1); + expect(mockLogCaseDetach.mock.calls[0][0]).toStrictEqual( + testCitation.fields.urihash + ); + expect(mockLogCaseDetach.mock.calls[0][1]).toStrictEqual( + expectedMetadata + ); + }); + }); }); describe('when analyticsMode is `next`', () => { @@ -180,12 +266,13 @@ describe('attached results analytics actions', () => { analyticsMode: 'next', }, }, + generatedAnswer: generatedAnswerState, insightCaseContext: caseContextState, }) ); }); - describe('logCaseAttach', () => { + describe('#logCaseAttach', () => { it('should call relay.emit properly', async () => { await logCaseAttach(testResult)()( engine.dispatch, @@ -199,7 +286,7 @@ describe('attached results analytics actions', () => { }); }); - describe('logCaseDetach', () => { + describe('#logCaseDetach', () => { it('should call relay.emit properly', async () => { await logCaseDetach(testResult)()( engine.dispatch, @@ -213,5 +300,19 @@ describe('attached results analytics actions', () => { expect(emit.mock.calls[0]).toMatchSnapshot(); }); }); + + describe('#logCitationDocumentDetach', () => { + it('should call relay.emit properly', async () => { + await logCitationDocumentDetach(testCitation)()( + engine.dispatch, + () => engine.state, + {} as ThunkExtraArguments + ); + await clearMicrotaskQueue(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0]).toMatchSnapshot(); + }); + }); }); }); diff --git a/packages/headless/src/features/attached-results/attached-results-analytics-actions.ts b/packages/headless/src/features/attached-results/attached-results-analytics-actions.ts index 3b80d334f76..5f132ec6fe1 100644 --- a/packages/headless/src/features/attached-results/attached-results-analytics-actions.ts +++ b/packages/headless/src/features/attached-results/attached-results-analytics-actions.ts @@ -1,15 +1,20 @@ import type {InsightPanel} from '@coveo/relay-event-types'; import type {Result} from '../../api/search/search/result.js'; +import type {GeneratedAnswerCitation} from '../../index.js'; import { analyticsEventItemMetadata, + analyticsEventItemMetadataForCitations, + citationDocumentIdentifier, documentIdentifier, makeInsightAnalyticsActionFactory, + partialCitationInformation, partialDocumentInformation, validateResultPayload, } from '../analytics/analytics-utils.js'; import {analyticsEventCaseContext} from '../analytics/insight-analytics-utils.js'; import {SearchPageEvents} from '../analytics/search-action-cause.js'; import {getCaseContextAnalyticsMetadata} from '../case-context/case-context-state.js'; +import {generativeQuestionAnsweringIdSelector} from '../generated-answer/generated-answer-selectors.js'; export const logCaseAttach = (result: Result) => makeInsightAnalyticsActionFactory(SearchPageEvents.caseAttach)({ @@ -55,3 +60,51 @@ export const logCaseDetach = (result: Result) => }; }, }); + +export const logCitationDocumentAttach = (citation: GeneratedAnswerCitation) => + makeInsightAnalyticsActionFactory( + SearchPageEvents.generatedAnswerCitationDocumentAttach + )({ + prefix: 'insight/generatedAnswerCitationDocumentAttach', + __legacy__getBuilder: (client, state) => { + const metadata = getCaseContextAnalyticsMetadata( + state.insightCaseContext + ); + const generativeQuestionAnsweringId = + generativeQuestionAnsweringIdSelector(state); + + if (!generativeQuestionAnsweringId || !citation) { + return null; + } + + const citationPayload = { + generativeQuestionAnsweringId, + citationId: citation.id, + documentId: citationDocumentIdentifier(citation), + }; + return client.logGeneratedAnswerCitationDocumentAttach( + partialCitationInformation(citation, state), + citationPayload, + metadata + ); + }, + }); + +export const logCitationDocumentDetach = (citation: GeneratedAnswerCitation) => + makeInsightAnalyticsActionFactory(SearchPageEvents.caseDetach)({ + prefix: 'insight/caseDetach', + __legacy__getBuilder: (client, state) => { + const uriHash = citation.fields?.urihash || ''; + return client.logCaseDetach( + uriHash, + getCaseContextAnalyticsMetadata(state.insightCaseContext) + ); + }, + analyticsType: 'InsightPanel.DetachItem', + analyticsPayloadBuilder: (state): InsightPanel.DetachItem => { + return { + itemMetadata: analyticsEventItemMetadataForCitations(citation, state), + context: analyticsEventCaseContext(state), + }; + }, + }); diff --git a/packages/headless/src/features/attached-results/attached-results-slice.test.ts b/packages/headless/src/features/attached-results/attached-results-slice.test.ts index 958b47f066f..1ce10004382 100644 --- a/packages/headless/src/features/attached-results/attached-results-slice.test.ts +++ b/packages/headless/src/features/attached-results/attached-results-slice.test.ts @@ -35,7 +35,7 @@ describe('attached results slice', () => { }); const finalState = attachedResultsReducer(state, action); expect(finalState.results).toBe(action.payload.results); - expect(finalState.results.length).toEqual(1); + expect(finalState.results.length).toBe(1); expect(finalState.results).toContainEqual(testAttachedResult); }); @@ -46,7 +46,7 @@ describe('attached results slice', () => { }); const finalState = attachedResultsReducer(state, action); expect(finalState).toStrictEqual(action.payload); - expect(finalState.results.length).toEqual(0); + expect(finalState.results.length).toBe(0); expect(finalState.loading).toBe(true); }); @@ -54,7 +54,7 @@ describe('attached results slice', () => { const testAttachedResult = createMockAttachedResult(); const action = attachResult(testAttachedResult); const finalState = attachedResultsReducer(state, action); - expect(finalState.results.length).toEqual(1); + expect(finalState.results.length).toBe(1); expect(finalState.results).toStrictEqual([testAttachedResult]); }); @@ -71,7 +71,7 @@ describe('attached results slice', () => { finalState = attachedResultsReducer(finalState, action); }); - expect(finalState.results.length).toEqual(attachedResults.length); + expect(finalState.results.length).toBe(attachedResults.length); expect(finalState.results).toStrictEqual([...attachedResults]); }); @@ -91,7 +91,7 @@ describe('attached results slice', () => { const action = detachResult(testDetachResult); const finalState = attachedResultsReducer(state, action); - expect(finalState.results.length).toEqual(0); + expect(finalState.results.length).toBe(0); }); it('#detachResult will detach an attached result', () => { @@ -106,7 +106,7 @@ describe('attached results slice', () => { const detachAction = detachResult(testDetachResult); const finalState = attachedResultsReducer(intermediateState, detachAction); - expect(finalState.results.length).toEqual(0); + expect(finalState.results.length).toBe(0); }); it('#detachResult will detach the correct result among multiple attached results', () => { diff --git a/packages/headless/src/features/attached-results/attached-results-state.ts b/packages/headless/src/features/attached-results/attached-results-state.ts index 6908bc6f01d..0324f5c4fce 100644 --- a/packages/headless/src/features/attached-results/attached-results-state.ts +++ b/packages/headless/src/features/attached-results/attached-results-state.ts @@ -39,6 +39,10 @@ export interface AttachedResult { * The uriHash of the attached result. */ uriHash?: string; + /** + * Whether the attached result is a document that was attached from a citation. + */ + isAttachedFromCitation?: boolean; } export interface AttachedResultsState { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f99f0fb107..1e5dbdf5ef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,8 +52,8 @@ catalogs: specifier: 4.0.10 version: 4.0.10 coveo.analytics: - specifier: 2.30.49 - version: 2.30.49 + specifier: 2.30.51 + version: 2.30.51 cypress: specifier: 13.7.3 version: 13.7.3 @@ -810,7 +810,7 @@ importers: version: 1.7.8 coveo.analytics: specifier: 'catalog:' - version: 2.30.49(encoding@0.1.13)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.1)) + version: 2.30.51(encoding@0.1.13)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.1)) dayjs: specifier: 'catalog:' version: 1.11.13 @@ -7416,8 +7416,8 @@ packages: coveo.analytics@2.30.45: resolution: {integrity: sha512-14ogHXPatwdzkvXzECsiAHTktukyfEjcy0ES7acooXe/uUb55ztWb8NXcnIEMFbn4wTf9y4P2g5BvYOLH8janA==} - coveo.analytics@2.30.49: - resolution: {integrity: sha512-FGpJXZVDVPaSLS/cXSSH9seynPoy7NDHGgxYwb0tuKRYcHVmh9LMp0h0rG7+fkPUkXc2FAb6MHyEKSraOjWxug==} + coveo.analytics@2.30.51: + resolution: {integrity: sha512-EDwXsVQDfMhhOcqYqMm2Qc0QcTyM8oHp56YNfuygTu3QPIkkprsgP9XS5bG4s/U5/Wg9puUIcbviaWDH+fmXcg==} create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} @@ -20645,7 +20645,7 @@ snapshots: - encoding - react-native - coveo.analytics@2.30.49(encoding@0.1.13)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.1)): + coveo.analytics@2.30.51(encoding@0.1.13)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.1)): dependencies: '@types/uuid': 9.0.8 cross-fetch: 3.2.0(encoding@0.1.13) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 435b67f070b..9c3fc00c0bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,7 +42,7 @@ catalog: '@types/react': 19.2.7 '@types/react-dom': 19.2.3 '@vitejs/plugin-react': 5.1.1 - coveo.analytics: 2.30.49 + coveo.analytics: 2.30.51 cypress: 13.7.3 cypress-repeat: 2.3.9 dayjs: 1.11.13 @@ -73,6 +73,7 @@ publicHoistPattern: minimumReleaseAge: 10080 minimumReleaseAgeExclude: - '@coveo/*' + - 'coveo.analytics' - '@vitest/*' - vitest - '@wc-toolkit/storybook-helpers'