Repository: yext/search-ui-react
Pull request #514: Deduplicate generative answer sources
Author: login: mkouzel-yext
Created: 2025-04-21T22:27:56Z | Merged: 2025-04-22T18:12:21Z

If an entity is present in search results more than once (e.g. returned by two different verticals) and is cited by the GDA, it appeared in the sources more than one. This change dedups the search results while determining the sources to prevent this.

J=TECHOPS-13971
TEST=manual




Diff:
diff --git a/.storybook/snapshots/__snapshots__/filtersearch--dropdown-unsectioned.png b/.storybook/snapshots/__snapshots__/filtersearch--dropdown-unsectioned.png
index 6f2569d9a..0f4e8c3f5 100644
Binary files a/.storybook/snapshots/__snapshots__/filtersearch--dropdown-unsectioned.png and b/.storybook/snapshots/__snapshots__/filtersearch--dropdown-unsectioned.png differ
diff --git a/package-lock.json b/package-lock.json
index 201865f0b..bc6c78f7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@yext/search-ui-react",
-  "version": "1.8.3",
+  "version": "1.8.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@yext/search-ui-react",
-      "version": "1.8.3",
+      "version": "1.8.4",
       "license": "BSD-3-Clause",
       "dependencies": {
         "@restart/ui": "^1.0.1",
diff --git a/package.json b/package.json
index ae0bc7bc0..e7a31b1a0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@yext/search-ui-react",
-  "version": "1.8.3",
+  "version": "1.8.4",
   "description": "A library of React Components for powering Yext Search integrations",
   "author": "watson@yext.com",
   "license": "BSD-3-Clause",
diff --git a/src/components/GenerativeDirectAnswer.tsx b/src/components/GenerativeDirectAnswer.tsx
index d937621d4..90f8c0ff6 100644
--- a/src/components/GenerativeDirectAnswer.tsx
+++ b/src/components/GenerativeDirectAnswer.tsx
@@ -1,7 +1,7 @@
-import { 
-  GenerativeDirectAnswerResponse, 
-  useSearchActions, 
-  useSearchState, 
+import {
+  GenerativeDirectAnswerResponse,
+  useSearchActions,
+  useSearchState,
   SearchTypeEnum,
   Result
 } from '@yext/search-headless-react';
@@ -51,8 +51,8 @@ export interface GenerativeDirectAnswerProps {
   answerHeader?: string | JSX.Element,
   /** The header for the citations section of the generative direct answer. */
   citationsHeader?: string | JSX.Element,
-  /** 
-   * The citations container component for customizing the logic that determines which results can be rendered. 
+  /**
+   * The citations container component for customizing the logic that determines which results can be rendered.
    * By default, a section for citations is displayed if the results that correspond to the
    * citations have the default minimum required info, which is `rawData.uid` and `rawData.name`.
   */
@@ -109,9 +109,9 @@ export function GenerativeDirectAnswer({
 
   return (
     <div className={cssClasses.container}>
-      <Answer 
-        gdaResponse={gdaResponse} 
-        cssClasses={cssClasses} 
+      <Answer
+        gdaResponse={gdaResponse}
+        cssClasses={cssClasses}
         answerHeader={answerHeader}
         linkClickHandler={handleClickEvent}
       />
@@ -138,8 +138,8 @@ interface AnswerProps {
  * The answer section of the Generative Direct Answer.
  */
 function Answer(props: AnswerProps) {
-  const { 
-    gdaResponse, 
+  const {
+    gdaResponse,
     cssClasses,
     answerHeader = 'AI Generated Answer',
     linkClickHandler
@@ -148,8 +148,8 @@ function Answer(props: AnswerProps) {
     <div className={cssClasses.header}>
       {answerHeader}
     </div>
-    <Markdown 
-      content={gdaResponse.directAnswer} 
+    <Markdown
+      content={gdaResponse.directAnswer}
       onLinkClick={(destinationUrl) => destinationUrl && linkClickHandler?.({destinationUrl})}
       customCssClasses={cssClasses}
     />
@@ -177,11 +177,11 @@ export interface CitationsProps {
 }
 
 /**
- * Displays the citations section of the Generative Direct Answer. 
+ * Displays the citations section of the Generative Direct Answer.
  */
 function Citations(props: CitationsProps) {
-  const { 
-    gdaResponse, 
+  const {
+    gdaResponse,
     cssClasses,
     searchResults,
     citationsHeader,
@@ -189,13 +189,18 @@ function Citations(props: CitationsProps) {
     citationClickHandler
   } = props;
   const citationResults = React.useMemo(() => {
+    // If an entity is returned by multiple different verticals, it will be present in
+    // searchResults multiple times. We want to only show it once in the citations.
+    let citationIds = new Set(gdaResponse.citations);
     return searchResults.filter(result => {
       const {uid, name} = result.rawData ?? {};
-      if (!uid || typeof uid != 'string' || !name) {
+      const dataIsInvalid = !uid || !name || typeof name != 'string' || typeof uid != 'string';
+      if (dataIsInvalid || !citationIds.has(uid)) {
         return false;
       }
-      return gdaResponse.citations.includes(uid);
-    })
+      citationIds.delete(uid);
+      return true;
+    });
   }, [gdaResponse.citations, searchResults]);
 
   if (!citationResults.length) {
@@ -225,7 +230,7 @@ export interface CitationProps {
 }
 
 /**
- * Displays a citation card for the citations section of the Generative Direct Answer. 
+ * Displays a citation card for the citations section of the Generative Direct Answer.
  */
 function Citation(props: CitationProps) {
   const {
@@ -238,8 +243,8 @@ function Citation(props: CitationProps) {
   const citationSnippet = String(description ?? answer ?? '');
   const citationUrl = typeof link === 'string' ? link : undefined;
   return (
-    <a 
-      className={cssClasses.citation} 
+    <a
+      className={cssClasses.citation}
       href={citationUrl}
       onClick={() => citationUrl && citationClickHandler?.({searchResult, destinationUrl: citationUrl})}
     >
@@ -268,4 +273,4 @@ function useReportClickEvent<T = DefaultRawDataType>(): (data: GdaClickEventData
       reportAnalyticsEvent(data, 'CTA_CLICK');
     }
   }, [reportAnalyticsEvent]);
-}
\ No newline at end of file
+}
diff --git a/tests/__fixtures__/data/universalresults.ts b/tests/__fixtures__/data/universalresults.ts
index 1e98c00ad..0483f27d5 100644
--- a/tests/__fixtures__/data/universalresults.ts
+++ b/tests/__fixtures__/data/universalresults.ts
@@ -71,8 +71,32 @@ const vertical3: VerticalResults = {
   verticalKey: 'vertical3'
 };
 
+const vertical4: VerticalResults = {
+  appliedQueryFilters: [],
+  queryDurationMillis: 500,
+  results: [{
+    rawData: {
+      name: 'title3',
+      description: 'text3',
+      uid: 'uid3'
+    },
+    source: Source.KnowledgeManager,
+    id: 'id3'
+  }],
+  resultsCount: 1,
+  source: Source.KnowledgeManager,
+  verticalKey: 'vertical4'
+};
+
 export const verticalResults = [
   vertical1,
   vertical2,
   vertical3
-];
\ No newline at end of file
+];
+
+export const verticalResultsWithDuplicateEntity = [
+  vertical1,
+  vertical2,
+  vertical3,
+  vertical4
+];
diff --git a/tests/components/GenerativeDirectAnswer.test.tsx b/tests/components/GenerativeDirectAnswer.test.tsx
index 000fc5838..209faced6 100644
--- a/tests/components/GenerativeDirectAnswer.test.tsx
+++ b/tests/components/GenerativeDirectAnswer.test.tsx
@@ -5,7 +5,10 @@ import { useAnalytics } from '../../src/hooks/useAnalytics';
 
 import { State } from '@yext/search-headless-react';
 import { mockAnswersState, ignoreLinkClickErrors } from '../__utils__/mocks';
-import { verticalResults } from '../__fixtures__/data/universalresults';
+import {
+  verticalResults,
+  verticalResultsWithDuplicateEntity
+} from '../__fixtures__/data/universalresults';
 import { generativeDirectAnswerText, generativeDirectAnswerLink, generativeDirectAnswerResponse } from '../__fixtures__/data/generativeDirectAnswer';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
@@ -27,6 +30,23 @@ const mockedState: Partial<State> = {
   }
 };
 
+const mockedStateWithDuplicateEntity: Partial<State> = {
+  generativeDirectAnswer: {
+    isLoading: false,
+    response: generativeDirectAnswerResponse,
+  },
+  universal: {
+    verticals: verticalResultsWithDuplicateEntity
+  },
+  query: {
+    queryId: '[queryId]',
+    mostRecentSearch: 'test'
+  },
+  meta: {
+    searchType: 'universal'
+  }
+};
+
 const CustomCitationsComponent = (props: CitationsProps) => {
   return (
       <>
@@ -109,6 +129,17 @@ describe('GenerativeDirectAnswer with sufficient citation fields', () => {
     expect(screen.getByText(generativeDirectAnswerText)).toBeDefined();
     expect(screen.getByText("CustomCitationsComponentTest")).toBeTruthy();
   });
+
+  it('citations are deduplicated', () => {
+    mockAnswersState(mockedStateWithDuplicateEntity);
+    render(<GenerativeDirectAnswer />);
+    expect(screen.getByText(generativeDirectAnswerText)).toBeDefined();
+    expect(screen.getByText('Sources (2)')).toBeDefined();
+
+    checkResultData(verticalResultsWithDuplicateEntity[0].results[0].rawData, false); //not a citation
+    checkResultData(verticalResultsWithDuplicateEntity[0].results[1].rawData, true);
+    checkResultData(verticalResultsWithDuplicateEntity[1].results[0].rawData, true);
+  });
 });
 
 describe('GenerativeDirectAnswer without sufficient citation fields', () => {
@@ -153,5 +184,5 @@ function checkResultData(resultData: Record<string, unknown>, shouldDisplay: boo
     } else {
       expect(screen.queryByText(resultData.description)).toBeNull();
     }
-  } 
-}
\ No newline at end of file
+  }
+}


Reviews:
user: login: k-gerner
body: I believe we need to update the version in `package.json` right?
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T22:31:36Z
user: login: mkouzel-yext
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:44:01Z
user: login: mkouzel-yext
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:44:10Z
user: login: Fondryext
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T18:00:29Z

Review comments:
diff_hunk: @@ -195,7 +195,17 @@ function Citations(props: CitationsProps) {
         return false;
       }
       return gdaResponse.citations.includes(uid);
-    })
+    }).filter((result, index, self) => {
path: src/components/GenerativeDirectAnswer.tsx
user: login: k-gerner
body: Similar to the comment on the Gerrit CR, can we condense this by using e.g. a secondary `Set` that tracks which uids we haven't seen yet?
created_at: 2025-04-21T22:30:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +195,17 @@ function Citations(props: CitationsProps) {
         return false;
       }
       return gdaResponse.citations.includes(uid);
-    })
+    }).filter((result, index, self) => {
+      // If an entity is returned by multiple different verticals, it will be present in
path: src/components/GenerativeDirectAnswer.tsx
user: login: k-gerner
body: Can we add a test case for this as well
created_at: 2025-04-21T22:31:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +195,17 @@ function Citations(props: CitationsProps) {
         return false;
       }
       return gdaResponse.citations.includes(uid);
-    })
+    }).filter((result, index, self) => {
path: src/components/GenerativeDirectAnswer.tsx
user: login: mkouzel-yext
body: Updated
created_at: 2025-04-22T17:44:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +195,17 @@ function Citations(props: CitationsProps) {
         return false;
       }
       return gdaResponse.citations.includes(uid);
-    })
+    }).filter((result, index, self) => {
+      // If an entity is returned by multiple different verticals, it will be present in
path: src/components/GenerativeDirectAnswer.tsx
user: login: mkouzel-yext
body: Added
created_at: 2025-04-22T17:44:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: coveralls
created_at: 2025-04-21T22:30:07Z
body: 
[![Coverage Status](https://coveralls.io/builds/73334562/badge)](https://coveralls.io/builds/73334562)

coverage: 87.245% (-0.6%) from 87.83%
when pulling **f7fabf7435e1bd11a3990bd1ccd4604414cbb096 on hotfix/v1.8.4**
into **3fe4af2ff5192f9c73fb9bd6c6fe01d86d9592cc on main**.

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-21T22:31:14Z
body: Current unit coverage is 92.08294062205466%
Current visual coverage is 79.34105075690115%
Current combined coverage is 92.55770136599152%
author_association: CONTRIBUTOR
user: login: mkouzel-yext
created_at: 2025-04-22T17:44:39Z
body: > I believe we need to update the version in `package.json` right?

Yes, incremented
author_association: CONTRIBUTOR
user: login: Fondryext
created_at: 2025-04-22T17:50:13Z
body: > > I believe we need to update the version in `package.json` right?
> 
> Yes, incremented

FYI there's a flow set up which makes an increment version PR when hotifx branches are made, in this case: https://github.com/yext/search-ui-react/pull/513 . Since you've done the increment here just be sure to abandon that PR
author_association: CONTRIBUTOR
user: login: mkouzel-yext
created_at: 2025-04-22T17:52:00Z
body: Ahhh I was wondering why that PR got created
author_association: CONTRIBUTOR