From 415e994754b36690b71c714c02b1d34722b67a5a Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 17 Apr 2024 18:26:18 -0700 Subject: [PATCH] Surface FCS badge. --- src/components/ChatItem.tsx | 10 ++- src/components/ChatView.tsx | 26 ++++++-- src/components/FactualConsistencyBadge.tsx | 47 ++++++++++++++ src/types.ts | 1 + src/useChat.ts | 47 ++++++++++---- src/vui/components/_index.scss | 2 + src/vui/components/badge/Badge.tsx | 50 +++++++++++++++ src/vui/components/badge/_index.scss | 64 +++++++++++++++++++ .../components/context/Context.test.util.tsx | 18 ++++++ src/vui/components/context/Context.tsx | 50 +++++++++++++++ src/vui/components/index.ts | 6 ++ src/vui/components/link/Link.tsx | 49 ++++++++++++++ src/vui/components/link/_index.scss | 12 ++++ src/vui/components/link/types.ts | 17 +++++ 14 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 src/components/FactualConsistencyBadge.tsx create mode 100644 src/vui/components/badge/Badge.tsx create mode 100644 src/vui/components/badge/_index.scss create mode 100644 src/vui/components/context/Context.test.util.tsx create mode 100644 src/vui/components/context/Context.tsx create mode 100644 src/vui/components/link/Link.tsx create mode 100644 src/vui/components/link/_index.scss create mode 100644 src/vui/components/link/types.ts diff --git a/src/components/ChatItem.tsx b/src/components/ChatItem.tsx index 25a40c2..393608d 100644 --- a/src/components/ChatItem.tsx +++ b/src/components/ChatItem.tsx @@ -38,6 +38,7 @@ type Props = { question?: string; answer?: string; searchResults?: DeserializedSearchResult[]; + factualConsistencyScore?: React.ReactNode; onRetry?: () => void; isStreaming?: boolean; }; @@ -47,7 +48,7 @@ type Props = { * Defaults to showing just the user-supplied message, if it has not yet been answered. * Otherwise, shows both question and answer, plus applicable references. */ -export const ChatItem = ({ question, answer, searchResults, onRetry, isStreaming }: Props) => { +export const ChatItem = ({ question, answer, searchResults, factualConsistencyScore, onRetry, isStreaming }: Props) => { const [isReferencesOpen, setIsReferencesOpen] = useState(false); let content; @@ -114,6 +115,13 @@ export const ChatItem = ({ question, answer, searchResults, onRetry, isStreaming )} + {factualConsistencyScore && ( + <> + + {factualConsistencyScore} + + )} + {reorderedSearchResults && reorderedSearchResults.length > 0 && ( <> diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index f3bc5bd..d4bdf15 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,10 +1,11 @@ -import { Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { VuiButtonSecondary, VuiFlexContainer, VuiFlexItem, VuiSpacer } from "../vui"; +import { Fragment, ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { VuiButtonSecondary, VuiFlexContainer, VuiFlexItem, VuiSpacer, VuiContextProvider } from "../vui"; import { QueryInput } from "./QueryInput"; import { ChatItem } from "./ChatItem"; import { useChat } from "../useChat"; import { Loader } from "./Loader"; import { ChatBubbleIcon, MinimizeIcon } from "./Icons"; +import { FactualConsistencyBadge } from "./FactualConsistencyBadge"; import { SummaryLanguage } from "types"; const inputSizeToQueryInputSize = { @@ -142,7 +143,7 @@ export const ChatView = ({ const historyItems = useMemo( () => messageHistory.map((turn, index) => { - const { question, answer, results } = turn; + const { question, answer, results, factualConsistencyScore } = turn; const onRetry = hasError && index === messageHistory.length - 1 ? () => sendMessage({ query: question, isRetry: true }) @@ -150,7 +151,15 @@ export const ChatView = ({ return ( - + + } + onRetry={onRetry} + /> {index < messageHistory.length - 1 && } ); @@ -171,7 +180,7 @@ export const ChatView = ({ useEffect(updateScrollPosition, [isLoading, activeMessage]); - return isOpen ? ( + const content = isOpen ? (
@@ -201,6 +210,11 @@ export const ChatView = ({ question={activeMessage.question} answer={activeMessage.answer} searchResults={activeMessage.results} + factualConsistencyScore={ + enableFactualConsistencyScore && ( + + ) + } onRetry={ hasError ? () => sendMessage({ query: activeMessage.question, isRetry: true }) : undefined } @@ -241,4 +255,6 @@ export const ChatView = ({ {title} ); + + return {content}; }; diff --git a/src/components/FactualConsistencyBadge.tsx b/src/components/FactualConsistencyBadge.tsx new file mode 100644 index 0000000..ca95651 --- /dev/null +++ b/src/components/FactualConsistencyBadge.tsx @@ -0,0 +1,47 @@ +import { VuiBadge, VuiFlexContainer, VuiLink, VuiSpinner, VuiText } from "../vui"; + +interface Props { + score?: number; +} + +export const FactualConsistencyBadge = ({ score }: Props) => { + let badge; + + if (score === undefined) { + badge = Calculating Factual Consistency Scoreā€¦; + } else { + const sanitizedScore = parseFloat(score.toFixed(2)); + let badgeColor = "neutral"; + + if (sanitizedScore === 0) { + badgeColor = "danger"; + } else if (sanitizedScore === 1) { + badgeColor = "success"; + } + + badge = ( + + Factual Consistency Score: {sanitizedScore} + + ); + } + + return ( + + {score === undefined && } + + {badge} + + +

+ + What's this? + +

+
+
+ ); +}; diff --git a/src/types.ts b/src/types.ts index 14e6ddc..281cccb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,4 +93,5 @@ export type ChatTurn = { question: string; answer: string; results: DeserializedSearchResult[]; + factualConsistencyScore?: number; }; diff --git a/src/useChat.ts b/src/useChat.ts index 52ad271..925c77f 100644 --- a/src/useChat.ts +++ b/src/useChat.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ChatTurn, SummaryLanguage } from "types"; -import { ChatDetail, streamQuery, StreamUpdate } from "@vectara/stream-query-client"; +import { ChatDetail, FactualConsistencyDetail, streamQuery, StreamUpdate } from "@vectara/stream-query-client"; import { sendSearchRequest } from "utils/sendSearchRequest"; import { deserializeSearchResponse } from "utils/deserializeSearchResponse"; @@ -46,6 +46,7 @@ export const useChat = ({ const sendMessage = async ({ query, isRetry = false }: { query: string; isRetry?: boolean }) => { if (isLoading) return; + if (isRetry) { setHasError(false); } @@ -121,7 +122,8 @@ export const useChat = ({ id: response.summary[0].chat.turnId, question: recentQuestion.current, answer: response?.summary[0].text ?? "", - results: deserializeSearchResponse(response) ?? [] + results: deserializeSearchResponse(response) ?? [], + factualConsistencyScore: response.summary[0].factualConsistency?.score } ]); setActiveMessage(null); @@ -140,7 +142,11 @@ export const useChat = ({ setConversationId(null); }; - const onStreamUpdate = ({ references, details, updatedText, isDone }: StreamUpdate) => { + const onStreamUpdate = (update: StreamUpdate) => { + const { references, details, updatedText, isDone } = update; + + const factualConsistencyScore = extractFactualConsistencyScore(update); + if (updatedText) { setIsStreamingResponse(true); setIsLoading(false); @@ -152,18 +158,26 @@ export const useChat = ({ setConversationId(chatDetail.data.conversationId ?? null); } - if (isDone) { + setActiveMessage((prev) => ({ + id: chatDetail ? chatDetail.data.turnId : "", + question: recentQuestion.current, + answer: updatedText ?? "", + results: [...(prev?.results ?? []), ...(references ?? [])], + factualConsistencyScore + })); + + const isFactualConsistencyScoreComplete = enableFactualConsistencyScore + ? factualConsistencyScore !== undefined + : true; + const isResponseComplete = isDone && isFactualConsistencyScoreComplete; + + if (isResponseComplete) { setIsStreamingResponse(false); - } else { - setActiveMessage((prev) => ({ - id: chatDetail ? chatDetail.data.turnId : "", - question: recentQuestion.current, - answer: updatedText ?? "", - results: [...(prev?.results ?? []), ...(references ?? [])] - })); } }; + // Handle this in an effect instead of directly in the onStreamUpdate callback + // because onStreamUpdate doesn't have access to the latest state of activeMessage. useEffect(() => { if (!isStreamingResponse && activeMessage) { setMessageHistory([...messageHistory, activeMessage]); @@ -181,3 +195,14 @@ export const useChat = ({ hasError }; }; + +const extractFactualConsistencyScore = (update: StreamUpdate) => { + const { details } = update; + const factualConsistencyDetail = details?.find((detail) => detail.type === "factualConsistency") as + | FactualConsistencyDetail + | undefined; + + if (factualConsistencyDetail) { + return factualConsistencyDetail.data.score; + } +}; diff --git a/src/vui/components/_index.scss b/src/vui/components/_index.scss index ae51853..52c7aa5 100644 --- a/src/vui/components/_index.scss +++ b/src/vui/components/_index.scss @@ -1,7 +1,9 @@ @import "../styleUtils/index"; @import "accordion/index"; +@import "badge/index"; @import "button/index"; @import "flex/index"; +@import "link/index"; @import "searchInput/index"; @import "spinner/index"; @import "spacer/index"; diff --git a/src/vui/components/badge/Badge.tsx b/src/vui/components/badge/Badge.tsx new file mode 100644 index 0000000..7bd6a60 --- /dev/null +++ b/src/vui/components/badge/Badge.tsx @@ -0,0 +1,50 @@ +import { MouseEvent } from "react"; +import classNames from "classnames"; +import { getTrackingProps } from "../../utils/getTrackingProps"; +import { useVuiContext } from "../context/Context"; +import { LinkProps } from "../link/types"; + +export const BADGE_COLOR = ["accent", "primary", "danger", "warning", "success", "neutral"] as const; + +type Props = { + children: React.ReactNode; + className?: string; + color: (typeof BADGE_COLOR)[number]; + onClick?: (event: MouseEvent) => void; + href?: LinkProps["href"]; + target?: LinkProps["target"]; + track?: LinkProps["track"]; +}; + +export const VuiBadge = ({ children, className, color, onClick, href, target, track, ...rest }: Props) => { + const { createLink } = useVuiContext(); + + const classes = classNames(className, "vuiBadge", `vuiBadge--${color}`, { + "vuiBadge--clickable": onClick ?? href + }); + + if (onClick) { + return ( + + ); + } + + if (href) { + return createLink({ + className: classes, + href, + onClick, + children, + target, + ...getTrackingProps(track) + }); + } + + return ( +
+ {children} +
+ ); +}; diff --git a/src/vui/components/badge/_index.scss b/src/vui/components/badge/_index.scss new file mode 100644 index 0000000..5e845da --- /dev/null +++ b/src/vui/components/badge/_index.scss @@ -0,0 +1,64 @@ +@use "sass:map"; + +.vuiBadge { + display: inline-block; + font-size: $fontSizeSmall; + line-height: 1; + padding: $sizeXxs $sizeXs; + border-radius: $sizeS; + font-family: inherit; + white-space: nowrap; + text-decoration: none; +} + +.vuiBadge--clickable { + cursor: pointer; +} + +// Color +$color: ( + accent: ( + "color": $colorAccent, + "background-color": transparentize($colorAccent, 0.9), + "border-color": transparentize($colorAccent, 0.9) + ), + primary: ( + "color": $colorPrimary, + "background-color": transparentize($colorPrimary, 0.9), + "border-color": transparentize($colorPrimary, 0.9) + ), + success: ( + "color": $colorSuccess, + "background-color": transparentize($colorSuccess, 0.9), + "border-color": transparentize($colorSuccess, 0.9) + ), + warning: ( + "color": $colorWarning, + "background-color": transparentize($colorWarning, 0.9), + "border-color": transparentize($colorWarning, 0.9) + ), + danger: ( + "color": $colorDanger, + "background-color": transparentize($colorDanger, 0.9), + "border-color": transparentize($colorDanger, 0.9) + ), + neutral: ( + "color": $colorText, + "background-color": $colorLightShade, + "border-color": transparentize($colorText, 0.9) + ) +); + +@each $colorName, $colorValue in $color { + .vuiBadge--#{$colorName} { + color: #{map.get($colorValue, "color")} !important; + background-color: #{map.get($colorValue, "background-color")}; + border: 1px solid #{map.get($colorValue, "border-color")}; + transition: all $transitionSpeed; + + &.vuiBadge--clickable:hover { + border-color: #{map.get($colorValue, "color")}; + text-decoration: none; + } + } +} diff --git a/src/vui/components/context/Context.test.util.tsx b/src/vui/components/context/Context.test.util.tsx new file mode 100644 index 0000000..e71e012 --- /dev/null +++ b/src/vui/components/context/Context.test.util.tsx @@ -0,0 +1,18 @@ +import { render } from "@testing-library/react"; +import { Link } from "react-router-dom"; +import { LinkProps } from "../link/types"; +import { VuiContextProvider } from "./Context"; + +const linkProvider = (linkConfig: LinkProps) => { + const { className, href, onClick, children, ...rest } = linkConfig; + + return ( + + {children} + + ); +}; + +export const renderWithContext = (children: React.ReactNode, ...rest: any) => { + return render({children}, ...rest); +}; diff --git a/src/vui/components/context/Context.tsx b/src/vui/components/context/Context.tsx new file mode 100644 index 0000000..b3e05c4 --- /dev/null +++ b/src/vui/components/context/Context.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, ReactNode } from "react"; +import { LinkProps } from "../link/types"; + +type LinkProvider = (linkConfig: LinkProps) => JSX.Element; +type PathProvider = () => string; + +interface VuiContextType { + createLink: LinkProvider; + getPath: PathProvider; + DrawerTitle: keyof JSX.IntrinsicElements; +} + +const VuiContext = createContext(undefined); + +type Props = { + children: ReactNode; + linkProvider?: LinkProvider; + pathProvider?: PathProvider; + drawerTitle?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; +}; + +export const VuiContextProvider = ({ children, linkProvider, pathProvider, drawerTitle = "h2" }: Props) => { + const createLink = (linkConfig: LinkProps) => { + if (linkProvider) return linkProvider(linkConfig); + + const { className, href, onClick, children, ...rest } = linkConfig; + + return ( + + {children} + + ); + }; + + const getPath = () => { + return pathProvider ? pathProvider() : window.location.pathname; + }; + + const DrawerTitle = drawerTitle as keyof JSX.IntrinsicElements; + + return {children}; +}; + +export const useVuiContext = () => { + const context = useContext(VuiContext); + if (context === undefined) { + throw new Error("useVuiContext must be used within a VuiContextProvider"); + } + return context; +}; diff --git a/src/vui/components/index.ts b/src/vui/components/index.ts index fb920ef..f6e931b 100644 --- a/src/vui/components/index.ts +++ b/src/vui/components/index.ts @@ -1,8 +1,11 @@ import { VuiAccordion } from "./accordion/Accordion"; +import { VuiBadge } from "./badge/Badge"; import { VuiButtonPrimary } from "./button/ButtonPrimary"; import { VuiButtonSecondary } from "./button/ButtonSecondary"; +import { VuiContextProvider } from "./context/Context"; import { VuiFlexContainer } from "./flex/FlexContainer"; import { VuiFlexItem } from "./flex/FlexItem"; +import { VuiLink } from "./link/Link"; import { VuiSearchInput } from "./searchInput/SearchInput"; import { VuiSpacer } from "./spacer/Spacer"; import { VuiSpinner } from "./spinner/Spinner"; @@ -16,10 +19,13 @@ export { TEXT_SIZE, TITLE_SIZE, VuiAccordion, + VuiBadge, VuiButtonPrimary, VuiButtonSecondary, + VuiContextProvider, VuiFlexContainer, VuiFlexItem, + VuiLink, VuiSearchInput, VuiSpacer, VuiSpinner, diff --git a/src/vui/components/link/Link.tsx b/src/vui/components/link/Link.tsx new file mode 100644 index 0000000..ae13faf --- /dev/null +++ b/src/vui/components/link/Link.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import { getTrackingProps } from "../../utils/getTrackingProps"; +import { useVuiContext } from "../context/Context"; +import { LinkProps } from "./types"; + +export const VuiLinkInternal = ({ ...rest }: LinkProps) => { + return ; +}; + +export const VuiLink = ({ children, href, target, onClick, className, track, isAnchor, ...rest }: LinkProps) => { + const { createLink } = useVuiContext(); + + if (!href) { + return ( + + ); + } + + const props: { + target?: LinkProps["target"]; + rel?: string; + referrerpolicy?: string; + title?: string; + id?: string; + role?: string; + } = { ...rest, ...getTrackingProps(track) }; + + if (target === "_blank") { + props.target = target; + } + + if (isAnchor) { + return ( + + {children} + + ); + } + + return createLink({ + className: classNames("vuiLink", className), + href, + onClick, + children, + ...props + }); +}; diff --git a/src/vui/components/link/_index.scss b/src/vui/components/link/_index.scss new file mode 100644 index 0000000..bb6b06c --- /dev/null +++ b/src/vui/components/link/_index.scss @@ -0,0 +1,12 @@ +.vuiLink { + color: $colorPrimary !important; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.vuiLink--button { + display: inline; +} diff --git a/src/vui/components/link/types.ts b/src/vui/components/link/types.ts new file mode 100644 index 0000000..e7311b9 --- /dev/null +++ b/src/vui/components/link/types.ts @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; + +export type LinkProps = { + children: ReactNode; + href?: string; + className?: string; + target?: "_blank"; + onClick?: React.MouseEventHandler; + track?: boolean; + // ...rest + title?: string; + id?: string; + role?: string; + isAnchor?: boolean; + tabIndex?: number; + "data-testid"?: string; +};