From 1f282f3891cdbb975aef8ba11a914762bdb94a42 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Fri, 18 Apr 2025 15:40:32 -0700 Subject: [PATCH 1/5] ai uf summary --- .../components/feedback/list/feedbackList.tsx | 58 ++++++- .../feedback/list/useFeedbackMessages.tsx | 40 +++++ .../feedback/list/useFeedbackSummary.tsx | 161 ++++++++++++++++++ .../components/feedback/list/useOpenAIKey.tsx | 3 + .../app/views/feedback/feedbackListPage.tsx | 4 +- 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 static/app/components/feedback/list/useFeedbackMessages.tsx create mode 100644 static/app/components/feedback/list/useFeedbackSummary.tsx create mode 100644 static/app/components/feedback/list/useOpenAIKey.tsx diff --git a/static/app/components/feedback/list/feedbackList.tsx b/static/app/components/feedback/list/feedbackList.tsx index 0e502c5a458230..3be38cdc79a3f4 100644 --- a/static/app/components/feedback/list/feedbackList.tsx +++ b/static/app/components/feedback/list/feedbackList.tsx @@ -17,6 +17,7 @@ import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem'; import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState'; import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys'; import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {IconHappy, IconMeh, IconSad} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData'; @@ -40,8 +41,33 @@ function NoFeedback({title, subtitle}: {subtitle: string; title: string}) { ); } -export default function FeedbackList() { +interface FeedbackListProps { + feedbackSummary: { + error: Error | null; + keySentiments: Array<{ + type: 'positive' | 'negative' | 'neutral'; + value: string; + }>; + loading: boolean; + summary: string | null; + }; +} + +export default function FeedbackList({feedbackSummary}: FeedbackListProps) { const {listQueryKey} = useFeedbackQueryKeys(); + const {summary, keySentiments} = feedbackSummary; + + const getSentimentIcon = (type: string) => { + switch (type) { + case 'positive': + return ; + case 'negative': + return ; + default: + return ; + } + }; + const { isFetchingNextPage, isFetchingPreviousPage, @@ -95,6 +121,18 @@ export default function FeedbackList() { return ( + + {t('Feedback Summary')} +
{summary}
+
+ {keySentiments.map(sentiment => ( + + {getSentimentIcon(sentiment.type)} + {sentiment.value} + + ))} +
+
p.theme.innerBorder}; + display: flex; + flex-direction: column; + gap: ${space(2)}; +`; + +const Sentiment = styled('div')` + display: flex; + align-items: center; + gap: ${space(1)}; +`; + const FeedbackListItems = styled('div')` display: grid; flex-grow: 1; diff --git a/static/app/components/feedback/list/useFeedbackMessages.tsx b/static/app/components/feedback/list/useFeedbackMessages.tsx new file mode 100644 index 00000000000000..4c5cb30d809952 --- /dev/null +++ b/static/app/components/feedback/list/useFeedbackMessages.tsx @@ -0,0 +1,40 @@ +import type {FeedbackIssue} from 'sentry/utils/feedback/types'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {decodeList} from 'sentry/utils/queryString'; +import useLocationQuery from 'sentry/utils/url/useLocationQuery'; +import useOrganization from 'sentry/utils/useOrganization'; + +const FEEDBACK_STALE_TIME = 10 * 60 * 1000; + +export default function useFeedbackMessages() { + const organization = useOrganization(); + const queryView = useLocationQuery({ + fields: { + limit: 10, + queryReferrer: 'feedback_list_page', + project: decodeList, + statsPeriod: '7d', + }, + }); + + const {data, isPending, isError} = useApiQuery( + [ + `/organizations/${organization.slug}/issues/`, + { + query: { + ...queryView, + query: `issue.category:feedback status:unresolved`, + }, + }, + ], + {staleTime: FEEDBACK_STALE_TIME} + ); + + if (isPending || isError) { + return []; + } + + return data.map(feedback => { + return feedback.metadata.message; + }); +} diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx new file mode 100644 index 00000000000000..cdf42e7e01f1e0 --- /dev/null +++ b/static/app/components/feedback/list/useFeedbackSummary.tsx @@ -0,0 +1,161 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; + +import useFeedbackMessages from 'sentry/components/feedback/list/useFeedbackMessages'; +import useOpenAIKey from 'sentry/components/feedback/list/useOpenAIKey'; + +export type Sentiment = { + type: 'positive' | 'negative' | 'neutral'; + value: string; +}; + +const SUMMARY_REGEX = /Summary:(.*?)Key sentiments:/s; +const SENTIMENT_REGEX = /- (.*?):\s*(positive|negative|neutral)/gi; + +async function getSentimentSummary({ + messages, + apiKey, +}: { + apiKey: string; + messages: string[]; +}) { + const inputText = messages.map(msg => `- ${msg}`).join('\n'); + const prompt = ` +You are an AI assistant that analyzes customer feedback. Below is a list of user messages. + +${inputText} + +Figure out the top 4 specific sentiments in the messages. Be concise but also specific in the summary. + +The summary should be at most 2 sentences, and complete the sentence "Users say...". + +After the summary, for each sentiment, also indicate if it is mostly positive or negative. + +The output format should be: + +Summary: <1-2 sentence summary> +Key sentiments: +- : positive/negative/neutral +- : positive/negative/neutral +- : positive/negative/neutral +- : positive/negative/neutral +`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{role: 'user', content: prompt}], + temperature: 0.3, + }), + }); + + const data = await response.json(); + return data.choices[0].message.content; +} + +export default function useFeedbackSummary(): { + error: Error | null; + keySentiments: Sentiment[]; + loading: boolean; + summary: string | null; +} { + const apiKey = useOpenAIKey(); + const messages = useFeedbackMessages(); + + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestMadeRef = useRef(false); + + const finalResultRef = useRef<{ + keySentiments: Sentiment[]; + summary: string | null; + }>({ + summary: null, + keySentiments: [], + }); + + useEffect(() => { + if (!apiKey || !messages.length || requestMadeRef.current) { + return; + } + + setLoading(true); + setError(null); + requestMadeRef.current = true; + + getSentimentSummary({messages, apiKey}) + .then(result => { + setResponse(result); + }) + .catch(err => { + setError( + err instanceof Error ? err : new Error('Failed to get sentiment summary') + ); + }) + .finally(() => { + setLoading(false); + }); + }, [apiKey, messages]); + + const parsedResults = useMemo(() => { + if (!response) { + return finalResultRef.current; + } + + let summaryText: string | null = null; + const parsedSentiments: Sentiment[] = []; + const summaryMatch = response.match(SUMMARY_REGEX); + + if (summaryMatch?.[1]) { + summaryText = summaryMatch[1].trim(); + } + + SENTIMENT_REGEX.lastIndex = 0; + let match = SENTIMENT_REGEX.exec(response); + while (match !== null) { + if (match[1] && match[2]) { + const value = match[1].trim(); + const type = match[2].toLowerCase() as 'positive' | 'negative' | 'neutral'; + parsedSentiments.push({value, type}); + } + match = SENTIMENT_REGEX.exec(response); + } + + finalResultRef.current = { + summary: summaryText, + keySentiments: parsedSentiments, + }; + + return finalResultRef.current; + }, [response]); + + if (loading) { + return { + summary: null, + keySentiments: [], + loading: true, + error: null, + }; + } + + if (error) { + return { + summary: null, + keySentiments: [], + loading: false, + error, + }; + } + + return { + summary: parsedResults.summary, + keySentiments: parsedResults.keySentiments, + loading: false, + error: null, + }; +} diff --git a/static/app/components/feedback/list/useOpenAIKey.tsx b/static/app/components/feedback/list/useOpenAIKey.tsx new file mode 100644 index 00000000000000..8cc981d5acdf36 --- /dev/null +++ b/static/app/components/feedback/list/useOpenAIKey.tsx @@ -0,0 +1,3 @@ +export default function useOpenAIKey() { + return ''; +} diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 5de6e7eb7ad02d..8b1d3a0332b484 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -9,6 +9,7 @@ import FeedbackSearch from 'sentry/components/feedback/feedbackSearch'; import FeedbackSetupPanel from 'sentry/components/feedback/feedbackSetupPanel'; import FeedbackWhatsNewBanner from 'sentry/components/feedback/feedbackWhatsNewBanner'; import FeedbackList from 'sentry/components/feedback/list/feedbackList'; +import useFeedbackSummary from 'sentry/components/feedback/list/useFeedbackSummary'; import useCurrentFeedbackId from 'sentry/components/feedback/useCurrentFeedbackId'; import useHaveSelectedProjectsSetupFeedback, { useHaveSelectedProjectsSetupNewFeedback, @@ -44,6 +45,7 @@ export default function FeedbackListPage() { const pageFilters = usePageFilters(); const projects = useProjects(); const prefersStackedNav = usePrefersStackedNav(); + const feedbackSummary = useFeedbackSummary(); const selectedProjects = projects.projects.filter(p => pageFilters.selection.projects.includes(Number(p.id)) @@ -84,7 +86,7 @@ export default function FeedbackListPage() { {hasSetupOneFeedback || hasSlug ? ( - + From 2b823b370e14086363676537c1b71b13615c283d Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:44:53 -0700 Subject: [PATCH 2/5] :recycle: add filtering --- .../components/feedback/list/feedbackList.tsx | 74 ++++++++- .../feedback/list/useSentimentKeyword.tsx | 140 ++++++++++++++++++ 2 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 static/app/components/feedback/list/useSentimentKeyword.tsx diff --git a/static/app/components/feedback/list/feedbackList.tsx b/static/app/components/feedback/list/feedbackList.tsx index 3be38cdc79a3f4..9d1752aee1df42 100644 --- a/static/app/components/feedback/list/feedbackList.tsx +++ b/static/app/components/feedback/list/feedbackList.tsx @@ -1,4 +1,4 @@ -import {Fragment, useMemo, useRef} from 'react'; +import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRowProps} from 'react-virtualized'; import { AutoSizer, @@ -10,11 +10,13 @@ import styled from '@emotion/styled'; import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg'; +import {Tag} from 'sentry/components/core/badge/tag'; import {Tooltip} from 'sentry/components/core/tooltip'; import ErrorBoundary from 'sentry/components/errorBoundary'; import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader'; import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem'; import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState'; +import useSentimentKeyword from 'sentry/components/feedback/list/useSentimentKeyword'; import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {IconHappy, IconMeh, IconSad} from 'sentry/icons'; @@ -22,6 +24,8 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData'; import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList'; // Ensure this object is created once as it is an input to @@ -55,7 +59,37 @@ interface FeedbackListProps { export default function FeedbackList({feedbackSummary}: FeedbackListProps) { const {listQueryKey} = useFeedbackQueryKeys(); + const location = useLocation(); + const navigate = useNavigate(); + const {summary, keySentiments} = feedbackSummary; + const [selectedSentiment, setSelectedSentiment] = useState(null); + // keyword used for search when a sentiment is selected + const {keyword} = useSentimentKeyword({sentiment: selectedSentiment}); + + const prevKeywordRef = useRef(keyword); + const prevSelectedSentimentRef = useRef(selectedSentiment); + const locationRef = useRef({pathname: location.pathname, query: location.query}); + + useEffect(() => { + locationRef.current = {pathname: location.pathname, query: location.query}; + }, [location]); + + useEffect(() => { + if ( + keyword && + selectedSentiment && + (prevSelectedSentimentRef.current !== selectedSentiment || + prevKeywordRef.current !== keyword) + ) { + prevSelectedSentimentRef.current = selectedSentiment; + prevKeywordRef.current = keyword; + navigate({ + pathname: locationRef.current.pathname, + query: {...locationRef.current.query, query: keyword}, + }); + } + }, [keyword, selectedSentiment, navigate]); const getSentimentIcon = (type: string) => { switch (type) { @@ -68,6 +102,17 @@ export default function FeedbackList({feedbackSummary}: FeedbackListProps) { } }; + const getSentimentType = (type: string) => { + switch (type) { + case 'positive': + return 'success'; + case 'negative': + return 'error'; + default: + return 'warning'; + } + }; + const { isFetchingNextPage, isFetchingPreviousPage, @@ -124,14 +169,21 @@ export default function FeedbackList({feedbackSummary}: FeedbackListProps) { {t('Feedback Summary')}
{summary}
-
+ {keySentiments.map(sentiment => ( - - {getSentimentIcon(sentiment.type)} + { + const targetSentiment = (e.target as HTMLElement).textContent ?? ''; + setSelectedSentiment(targetSentiment); + }} + > {sentiment.value} - + ))} -
+
@@ -202,10 +254,16 @@ const Summary = styled('div')` gap: ${space(2)}; `; -const Sentiment = styled('div')` +const KeySentiments = styled('div')` display: flex; - align-items: center; + flex-direction: column; gap: ${space(1)}; + align-items: flex-start; +`; + +const SentimentTag = styled(Tag)` + cursor: pointer; + max-width: 100%; `; const FeedbackListItems = styled('div')` diff --git a/static/app/components/feedback/list/useSentimentKeyword.tsx b/static/app/components/feedback/list/useSentimentKeyword.tsx new file mode 100644 index 00000000000000..5fa307ade130b4 --- /dev/null +++ b/static/app/components/feedback/list/useSentimentKeyword.tsx @@ -0,0 +1,140 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; + +import useFeedbackMessages from 'sentry/components/feedback/list/useFeedbackMessages'; +import useOpenAIKey from 'sentry/components/feedback/list/useOpenAIKey'; + +const KEYWORD_REGEX = /Keyword:(.*)/; + +async function getSentimentSearchKeyword({ + messages, + apiKey, + sentiment, +}: { + apiKey: string; + messages: string[]; + sentiment: string; +}) { + const inputText = messages.map(msg => `- ${msg}`).join('\n'); + const prompt = ` +You are an AI assistant that analyzes customer feedback. Below is a list of user messages. + +${inputText} + +This is the sentiment we are looking for: ${sentiment} + +Find the messages that are most related to the sentiment, and return one keyword such that a search for that keyword returns the most relevant messages. The keyword should be present in at least one of the messages. + +The output format should be: + +Keyword: +`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{role: 'user', content: prompt}], + temperature: 0.3, + }), + }); + + const data = await response.json(); + return data.choices[0].message.content; +} + +export default function useSentimentKeyword({sentiment}: {sentiment: string | null}): { + error: Error | null; + keyword: string | null; + loading: boolean; +} { + const apiKey = useOpenAIKey(); + const messages = useFeedbackMessages(); + + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestMadeRef = useRef(false); + const previousSentimentRef = useRef(null); + + const finalResultRef = useRef<{ + keyword: string | null; + }>({ + keyword: null, + }); + + // Reset request ref when sentiment changes to a different value + useEffect(() => { + if (sentiment !== previousSentimentRef.current) { + requestMadeRef.current = false; + previousSentimentRef.current = sentiment; + } + }, [sentiment]); + + useEffect(() => { + if (!apiKey || !messages.length || requestMadeRef.current || !sentiment) { + return; + } + + setLoading(true); + setError(null); + requestMadeRef.current = true; + + getSentimentSearchKeyword({messages, apiKey, sentiment}) + .then(result => { + setResponse(result); + }) + .catch(err => { + setError( + err instanceof Error ? err : new Error('Failed to get sentiment keyword') + ); + }) + .finally(() => { + setLoading(false); + }); + }, [apiKey, messages, sentiment]); + + const parsedResults = useMemo(() => { + if (!response) { + return finalResultRef.current; + } + + let keyword: string | null = null; + const keywordMatch = response.match(KEYWORD_REGEX); + + if (keywordMatch?.[1]) { + keyword = keywordMatch[1].trim(); + } + + finalResultRef.current = { + keyword, + }; + + return finalResultRef.current; + }, [response]); + + if (loading) { + return { + keyword: null, + loading: true, + error: null, + }; + } + + if (error) { + return { + keyword: null, + loading: false, + error, + }; + } + + return { + keyword: parsedResults.keyword, + loading: false, + error: null, + }; +} From 29d3946b43985c203bc470c85cee4106b938cba8 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:30:45 -0700 Subject: [PATCH 3/5] :sparkles: thumbs --- .../components/feedback/list/feedbackList.tsx | 115 +++++++++++------- .../components/feedback/list/summaryUtils.tsx | 23 ++++ .../feedback/list/useFeedbackSummary.tsx | 14 ++- .../app/views/feedback/feedbackListPage.tsx | 12 +- 4 files changed, 117 insertions(+), 47 deletions(-) create mode 100644 static/app/components/feedback/list/summaryUtils.tsx diff --git a/static/app/components/feedback/list/feedbackList.tsx b/static/app/components/feedback/list/feedbackList.tsx index 9d1752aee1df42..b3be4c9440b4bf 100644 --- a/static/app/components/feedback/list/feedbackList.tsx +++ b/static/app/components/feedback/list/feedbackList.tsx @@ -11,15 +11,21 @@ import styled from '@emotion/styled'; import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg'; import {Tag} from 'sentry/components/core/badge/tag'; +import {Button} from 'sentry/components/core/button'; import {Tooltip} from 'sentry/components/core/tooltip'; import ErrorBoundary from 'sentry/components/errorBoundary'; import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader'; import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem'; +import { + getSentimentIcon, + getSentimentType, +} from 'sentry/components/feedback/list/summaryUtils'; import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState'; import useSentimentKeyword from 'sentry/components/feedback/list/useSentimentKeyword'; import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys'; import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {IconHappy, IconMeh, IconSad} from 'sentry/icons'; +import Placeholder from 'sentry/components/placeholder'; +import {IconThumb} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData'; @@ -55,14 +61,20 @@ interface FeedbackListProps { loading: boolean; summary: string | null; }; + isHelpful: boolean | null; + setIsHelpful: (isHelpful: boolean) => void; } -export default function FeedbackList({feedbackSummary}: FeedbackListProps) { +export default function FeedbackList({ + feedbackSummary, + setIsHelpful, + isHelpful, +}: FeedbackListProps) { const {listQueryKey} = useFeedbackQueryKeys(); const location = useLocation(); const navigate = useNavigate(); - const {summary, keySentiments} = feedbackSummary; + const {summary, keySentiments, loading: summaryLoading} = feedbackSummary; const [selectedSentiment, setSelectedSentiment] = useState(null); // keyword used for search when a sentiment is selected const {keyword} = useSentimentKeyword({sentiment: selectedSentiment}); @@ -91,28 +103,6 @@ export default function FeedbackList({feedbackSummary}: FeedbackListProps) { } }, [keyword, selectedSentiment, navigate]); - const getSentimentIcon = (type: string) => { - switch (type) { - case 'positive': - return ; - case 'negative': - return ; - default: - return ; - } - }; - - const getSentimentType = (type: string) => { - switch (type) { - case 'positive': - return 'success'; - case 'negative': - return 'error'; - default: - return 'warning'; - } - }; - const { isFetchingNextPage, isFetchingPreviousPage, @@ -167,23 +157,56 @@ export default function FeedbackList({feedbackSummary}: FeedbackListProps) { return ( - {t('Feedback Summary')} -
{summary}
- - {keySentiments.map(sentiment => ( - { - const targetSentiment = (e.target as HTMLElement).textContent ?? ''; - setSelectedSentiment(targetSentiment); - }} - > - {sentiment.value} - - ))} - + + {t('Feedback Summary')} + + { + + } + { + + } + + + {summaryLoading ? ( + + ) : ( + +
{summary}
+ + {keySentiments.map(sentiment => ( + { + const targetSentiment = (e.target as HTMLElement).textContent ?? ''; + setSelectedSentiment(targetSentiment); + }} + > + {sentiment.value} + + ))} + +
+ )}
@@ -244,6 +267,14 @@ export default function FeedbackList({feedbackSummary}: FeedbackListProps) { const SummaryHeader = styled('div')` font-weight: bold; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const Thumbs = styled('div')` + display: flex; + gap: ${space(0.25)}; `; const Summary = styled('div')` diff --git a/static/app/components/feedback/list/summaryUtils.tsx b/static/app/components/feedback/list/summaryUtils.tsx new file mode 100644 index 00000000000000..4a53cfec50665a --- /dev/null +++ b/static/app/components/feedback/list/summaryUtils.tsx @@ -0,0 +1,23 @@ +import {IconHappy, IconMeh, IconSad} from 'sentry/icons'; + +export const getSentimentIcon = (type: string) => { + switch (type) { + case 'positive': + return ; + case 'negative': + return ; + default: + return ; + } +}; + +export const getSentimentType = (type: string) => { + switch (type) { + case 'positive': + return 'success'; + case 'negative': + return 'error'; + default: + return 'warning'; + } +}; diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx index cdf42e7e01f1e0..fc90e1b5f4e60c 100644 --- a/static/app/components/feedback/list/useFeedbackSummary.tsx +++ b/static/app/components/feedback/list/useFeedbackSummary.tsx @@ -57,7 +57,7 @@ Key sentiments: return data.choices[0].message.content; } -export default function useFeedbackSummary(): { +export default function useFeedbackSummary({isHelpful}: {isHelpful: boolean | null}): { error: Error | null; keySentiments: Sentiment[]; loading: boolean; @@ -70,6 +70,7 @@ export default function useFeedbackSummary(): { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const requestMadeRef = useRef(false); + const prevIsHelpfulRef = useRef(isHelpful); const finalResultRef = useRef<{ keySentiments: Sentiment[]; @@ -80,6 +81,15 @@ export default function useFeedbackSummary(): { }); useEffect(() => { + // Refetch when isHelpful changes to false + const shouldRefetch = isHelpful === false && prevIsHelpfulRef.current !== isHelpful; + + if (shouldRefetch) { + requestMadeRef.current = false; + } + + prevIsHelpfulRef.current = isHelpful; + if (!apiKey || !messages.length || requestMadeRef.current) { return; } @@ -100,7 +110,7 @@ export default function useFeedbackSummary(): { .finally(() => { setLoading(false); }); - }, [apiKey, messages]); + }, [apiKey, messages, isHelpful]); const parsedResults = useMemo(() => { if (!response) { diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 8b1d3a0332b484..6c2eb721b603a0 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; import ErrorBoundary from 'sentry/components/errorBoundary'; @@ -45,7 +45,9 @@ export default function FeedbackListPage() { const pageFilters = usePageFilters(); const projects = useProjects(); const prefersStackedNav = usePrefersStackedNav(); - const feedbackSummary = useFeedbackSummary(); + + const [isHelpful, setIsHelpful] = useState(null); + const feedbackSummary = useFeedbackSummary({isHelpful}); const selectedProjects = projects.projects.filter(p => pageFilters.selection.projects.includes(Number(p.id)) @@ -86,7 +88,11 @@ export default function FeedbackListPage() { {hasSetupOneFeedback || hasSlug ? ( - + From 7cea10a21f72b80a97a4d07828a08079496ead89 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:39:39 -0700 Subject: [PATCH 4/5] :sparkles: sentiment over time --- .../components/feedback/list/feedbackList.tsx | 116 ++++++++------ .../feedback/list/sentimentOverTimeChart.tsx | 44 ++++++ .../feedback/list/useFeedbackMessages.tsx | 13 +- .../feedback/list/useFeedbackSummary.tsx | 2 +- .../feedback/list/useSentimentKeyword.tsx | 2 +- .../feedback/list/useSentimentOverTime.tsx | 144 ++++++++++++++++++ 6 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 static/app/components/feedback/list/sentimentOverTimeChart.tsx create mode 100644 static/app/components/feedback/list/useSentimentOverTime.tsx diff --git a/static/app/components/feedback/list/feedbackList.tsx b/static/app/components/feedback/list/feedbackList.tsx index b3be4c9440b4bf..07ef040426dab9 100644 --- a/static/app/components/feedback/list/feedbackList.tsx +++ b/static/app/components/feedback/list/feedbackList.tsx @@ -12,10 +12,12 @@ import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg'; import {Tag} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; +import {CompactSelect} from 'sentry/components/core/compactSelect'; import {Tooltip} from 'sentry/components/core/tooltip'; import ErrorBoundary from 'sentry/components/errorBoundary'; import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader'; import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem'; +import SentimentOverTimeChart from 'sentry/components/feedback/list/sentimentOverTimeChart'; import { getSentimentIcon, getSentimentType, @@ -78,6 +80,7 @@ export default function FeedbackList({ const [selectedSentiment, setSelectedSentiment] = useState(null); // keyword used for search when a sentiment is selected const {keyword} = useSentimentKeyword({sentiment: selectedSentiment}); + const [selectValue, setSelectValue] = useState('summary'); const prevKeywordRef = useRef(keyword); const prevSelectedSentimentRef = useRef(selectedSentiment); @@ -158,54 +161,67 @@ export default function FeedbackList({ - {t('Feedback Summary')} - - { - - } - { - - } - + setSelectValue(String(option.value))} + value={selectValue} + /> + {selectValue === 'summary' && ( + + { + + } + { + + } + + )} - {summaryLoading ? ( - + {selectValue === 'summary' ? ( + summaryLoading ? ( + + ) : ( + +
{summary}
+ + {keySentiments.map(sentiment => ( + { + const targetSentiment = (e.target as HTMLElement).textContent ?? ''; + setSelectedSentiment(targetSentiment); + }} + > + {sentiment.value} + + ))} + +
+ ) ) : ( - -
{summary}
- - {keySentiments.map(sentiment => ( - { - const targetSentiment = (e.target as HTMLElement).textContent ?? ''; - setSelectedSentiment(targetSentiment); - }} - > - {sentiment.value} - - ))} - -
+ )}
@@ -332,3 +348,11 @@ const EmptyMessage = styled('div')` font-size: ${p => p.theme.fontSizeExtraLarge}; } `; + +const StyledCompactSelect = styled(CompactSelect)` + > button { + border: none; + border-color: transparent; + box-shadow: none; + } +`; diff --git a/static/app/components/feedback/list/sentimentOverTimeChart.tsx b/static/app/components/feedback/list/sentimentOverTimeChart.tsx new file mode 100644 index 00000000000000..855fdfbd7a23b5 --- /dev/null +++ b/static/app/components/feedback/list/sentimentOverTimeChart.tsx @@ -0,0 +1,44 @@ +import {useTheme} from '@emotion/react'; + +import useSentimentOverTime from 'sentry/components/feedback/list/useSentimentOverTime'; +import Placeholder from 'sentry/components/placeholder'; +import {Line} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plottables/line'; +import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; +import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; +import {convertSeriesToTimeseries} from 'sentry/views/insights/common/utils/convertSeriesToTimeseries'; +import {WidgetVisualizationStates} from 'sentry/views/insights/pages/platform/laravel/widgetVisualizationStates'; + +export default function SentimentOverTimeChart() { + const {series, loading, error} = useSentimentOverTime(); + const theme = useTheme(); + + const colorPalette = theme.chart.getColorPalette(series.length - 2); + const plottables = series.map( + (ts, index) => + new Line(convertSeriesToTimeseries(ts), { + color: colorPalette[index], + alias: ts.seriesName, + }) + ); + + const hasData = series?.length; + const isLoading = loading; + + if (isLoading) { + return ; + } + + const visualization = ( + + ); + + return ; +} diff --git a/static/app/components/feedback/list/useFeedbackMessages.tsx b/static/app/components/feedback/list/useFeedbackMessages.tsx index 4c5cb30d809952..46cf03dedfcd3e 100644 --- a/static/app/components/feedback/list/useFeedbackMessages.tsx +++ b/static/app/components/feedback/list/useFeedbackMessages.tsx @@ -31,10 +31,15 @@ export default function useFeedbackMessages() { ); if (isPending || isError) { - return []; + return {messages: [], messagesWithTime: []}; } - return data.map(feedback => { - return feedback.metadata.message; - }); + return { + messages: data.map(feedback => { + return feedback.metadata.message; + }), + messagesWithTime: data.map(feedback => { + return {message: feedback.metadata.message, time: feedback.firstSeen}; + }), + }; } diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx index fc90e1b5f4e60c..148f7d364fa9f7 100644 --- a/static/app/components/feedback/list/useFeedbackSummary.tsx +++ b/static/app/components/feedback/list/useFeedbackSummary.tsx @@ -64,7 +64,7 @@ export default function useFeedbackSummary({isHelpful}: {isHelpful: boolean | nu summary: string | null; } { const apiKey = useOpenAIKey(); - const messages = useFeedbackMessages(); + const {messages} = useFeedbackMessages(); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); diff --git a/static/app/components/feedback/list/useSentimentKeyword.tsx b/static/app/components/feedback/list/useSentimentKeyword.tsx index 5fa307ade130b4..227f54cb7e0918 100644 --- a/static/app/components/feedback/list/useSentimentKeyword.tsx +++ b/static/app/components/feedback/list/useSentimentKeyword.tsx @@ -52,7 +52,7 @@ export default function useSentimentKeyword({sentiment}: {sentiment: string | nu loading: boolean; } { const apiKey = useOpenAIKey(); - const messages = useFeedbackMessages(); + const {messages} = useFeedbackMessages(); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); diff --git a/static/app/components/feedback/list/useSentimentOverTime.tsx b/static/app/components/feedback/list/useSentimentOverTime.tsx new file mode 100644 index 00000000000000..9c32926e9d6fa7 --- /dev/null +++ b/static/app/components/feedback/list/useSentimentOverTime.tsx @@ -0,0 +1,144 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; + +import useFeedbackMessages from 'sentry/components/feedback/list/useFeedbackMessages'; +import useOpenAIKey from 'sentry/components/feedback/list/useOpenAIKey'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; + +async function getSentimentOverTime({ + messagesWithTime, + apiKey, +}: { + apiKey: string; + messagesWithTime: Array<{message: string; time: string}>; +}) { + const inputText = messagesWithTime.map(msg => `${msg.message}: ${msg.time}`).join('\n'); + const prompt = ` +You are an AI assistant that analyzes customer feedback. Below is a list of user messages and the time they were sent. + +${inputText} + +For each day, output a number from 1 to 10 indcating how positive or negative the overall messages are on that day. 1 is most negative and 10 is most positive. Return each day as a unix timestamp. + +The output format should be: +: +: +: +... +`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{role: 'user', content: prompt}], + temperature: 0.3, + }), + }); + + const data = await response.json(); + return data.choices[0].message.content; +} + +export default function useSentimentOverTime(): { + error: Error | null; + loading: boolean; + series: DiscoverSeries[]; +} { + const apiKey = useOpenAIKey(); + const {messagesWithTime} = useFeedbackMessages(); + + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestMadeRef = useRef(false); + + const finalResultRef = useRef<{ + series: DiscoverSeries[]; + }>({ + series: [], + }); + + useEffect(() => { + if (!apiKey || !messagesWithTime.length || requestMadeRef.current) { + return; + } + + setLoading(true); + setError(null); + requestMadeRef.current = true; + + getSentimentOverTime({messagesWithTime, apiKey}) + .then(result => { + setResponse(result); + }) + .catch(err => { + setError( + err instanceof Error ? err : new Error('Failed to get sentiment over time') + ); + }) + .finally(() => { + setLoading(false); + }); + }, [apiKey, messagesWithTime]); + + const parsedResults = useMemo(() => { + if (!response) { + return finalResultRef.current; + } + + // parse response into a series + const seriesData = response.split('\n').map(line => { + const [day, score] = line.split(': '); + return { + name: day ?? '', + value: parseInt(score ?? '1', 10), + }; + }); + + finalResultRef.current = { + series: [ + { + data: seriesData, + seriesName: 'Sentiment Over Time', + meta: { + fields: { + day: 'date', + sentiment: 'integer', + }, + units: { + day: 'days', + }, + }, + }, + ], + }; + + return finalResultRef.current; + }, [response]); + + if (loading) { + return { + series: [], + loading: true, + error: null, + }; + } + + if (error) { + return { + series: [], + loading: false, + error, + }; + } + + return { + series: parsedResults.series, + loading: false, + error: null, + }; +} From 6713d65e2707dc1a2f1c741eabf543a427065fb4 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:56:24 -0700 Subject: [PATCH 5/5] :pencil2: update prompt --- static/app/components/feedback/list/useFeedbackSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx index 148f7d364fa9f7..252cc9a21e6030 100644 --- a/static/app/components/feedback/list/useFeedbackSummary.tsx +++ b/static/app/components/feedback/list/useFeedbackSummary.tsx @@ -24,7 +24,7 @@ You are an AI assistant that analyzes customer feedback. Below is a list of user ${inputText} -Figure out the top 4 specific sentiments in the messages. Be concise but also specific in the summary. +Figure out the top 4 specific sentiments in the messages. These sentiments should be distinct from each other and not the same concept. Be concise but also specific in the summary. The summary should be at most 2 sentences, and complete the sentence "Users say...".