// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { useEffect, useRef, useState } from 'react'; import { Badge, Box, Button, ColumnLayout, Container, Grid, Header, Link, Popover, SpaceBetween, StatusIndicator, Tabs, TextContent, Toggle, } from '@awsui/components-react'; import rehypeRaw from 'rehype-raw'; import ReactMarkdown from 'react-markdown'; import { TranslateClient, TranslateTextCommand } from '@aws-sdk/client-translate'; import { Logger } from 'aws-amplify'; import { StandardRetryStrategy } from '@aws-sdk/middleware-retry'; import RecordingPlayer from '../recording-player'; import useSettingsContext from '../../contexts/settings'; import { DONE_STATUS, IN_PROGRESS_STATUS } from '../common/get-recording-status'; import { InfoLink } from '../common/info-link'; import { getWeightedSentimentLabel } from '../common/sentiment'; import { VoiceToneFluctuationChart, SentimentFluctuationChart, SentimentPerQuarterChart, } from './sentiment-charts'; import './CallPanel.css'; import { SentimentTrendIcon } from '../sentiment-trend-icon/SentimentTrendIcon'; import { SentimentIcon } from '../sentiment-icon/SentimentIcon'; import useAppContext from '../../contexts/app'; import awsExports from '../../aws-exports'; const logger = new Logger('CallPanel'); // comprehend PII types const piiTypes = [ 'BANK_ACCOUNT_NUMBER', 'BANK_ROUTING', 'CREDIT_DEBIT_NUMBER', 'CREDIT_DEBIT_CVV', 'CREDIT_DEBIT_EXPIRY', 'PIN', 'EMAIL', 'ADDRESS', 'NAME', 'PHONE', 'SSN', ]; const piiTypesSplitRegEx = new RegExp(`\\[(${piiTypes.join('|')})\\]`); const MAXIMUM_ATTEMPTS = 100; const MAXIMUM_RETRY_DELAY = 1000; const languageCodes = [ { value: '', label: 'Choose a Language' }, { value: 'af', label: 'Afrikaans' }, { value: 'sq', label: 'Albanian' }, { value: 'am', label: 'Amharic' }, { value: 'ar', label: 'Arabic' }, { value: 'hy', label: 'Armenian' }, { value: 'az', label: 'Azerbaijani' }, { value: 'bn', label: 'Bengali' }, { value: 'bs', label: 'Bosnian' }, { value: 'bg', label: 'Bulgarian' }, { value: 'ca', label: 'Catalan' }, { value: 'zh', label: 'Chinese (Simplified)' }, { value: 'zh-TW', label: 'Chinese (Traditional)' }, { value: 'hr', label: 'Croatian' }, { value: 'cs', label: 'Czech' }, { value: 'da', label: 'Danish' }, { value: 'fa-AF', label: 'Dari' }, { value: 'nl', label: 'Dutch' }, { value: 'en', label: 'English' }, { value: 'et', label: 'Estonian' }, { value: 'fa', label: 'Farsi (Persian)' }, { value: 'tl', label: 'Filipino, Tagalog' }, { value: 'fi', label: 'Finnish' }, { value: 'fr', label: 'French' }, { value: 'fr-CA', label: 'French (Canada)' }, { value: 'ka', label: 'Georgian' }, { value: 'de', label: 'German' }, { value: 'el', label: 'Greek' }, { value: 'gu', label: 'Gujarati' }, { value: 'ht', label: 'Haitian Creole' }, { value: 'ha', label: 'Hausa' }, { value: 'he', label: 'Hebrew' }, { value: 'hi', label: 'Hindi' }, { value: 'hu', label: 'Hungarian' }, { value: 'is', label: 'Icelandic' }, { value: 'id', label: 'Indonesian' }, { value: 'ga', label: 'Irish' }, { value: 'it', label: 'Italian' }, { value: 'ja', label: 'Japanese' }, { value: 'kn', label: 'Kannada' }, { value: 'kk', label: 'Kazakh' }, { value: 'ko', label: 'Korean' }, { value: 'lv', label: 'Latvian' }, { value: 'lt', label: 'Lithuanian' }, { value: 'mk', label: 'Macedonian' }, { value: 'ms', label: 'Malay' }, { value: 'ml', label: 'Malayalam' }, { value: 'mt', label: 'Maltese' }, { value: 'mr', label: 'Marathi' }, { value: 'mn', label: 'Mongolian' }, { value: 'no', label: 'Norwegian (Bokmål)' }, { value: 'ps', label: 'Pashto' }, { value: 'pl', label: 'Polish' }, { value: 'pt', label: 'Portuguese (Brazil)' }, { value: 'pt-PT', label: 'Portuguese (Portugal)' }, { value: 'pa', label: 'Punjabi' }, { value: 'ro', label: 'Romanian' }, { value: 'ru', label: 'Russian' }, { value: 'sr', label: 'Serbian' }, { value: 'si', label: 'Sinhala' }, { value: 'sk', label: 'Slovak' }, { value: 'sl', label: 'Slovenian' }, { value: 'so', label: 'Somali' }, { value: 'es', label: 'Spanish' }, { value: 'es-MX', label: 'Spanish (Mexico)' }, { value: 'sw', label: 'Swahili' }, { value: 'sv', label: 'Swedish' }, { value: 'ta', label: 'Tamil' }, { value: 'te', label: 'Telugu' }, { value: 'th', label: 'Thai' }, { value: 'tr', label: 'Turkish' }, { value: 'uk', label: 'Ukrainian' }, { value: 'ur', label: 'Urdu' }, { value: 'uz', label: 'Uzbek' }, { value: 'vi', label: 'Vietnamese' }, { value: 'cy', label: 'Welsh' }, ]; /* eslint-disable react/prop-types, react/destructuring-assignment */ const CallAttributes = ({ item, setToolsOpen }) => ( setToolsOpen(true)} />}> Call Attributes } >
Call ID
{item.callId}
Agent
{item.agentId}
Initiation Timestamp
{item.initiationTimeStamp}
Last Update Timestamp
{item.updatedAt}
Duration
{item.conversationDurationTimeStamp}
Caller Phone Number
{item.callerPhoneNumber}
System Phone Number
{item.systemPhoneNumber}
Status {` ${item.recordingStatusLabel} `}
{item?.pcaUrl?.length && (
Post Call Analytics
)} {item?.recordingUrl?.length && item?.recordingStatusLabel !== IN_PROGRESS_STATUS && (
Recording Audio
)}
); const CallCategories = ({ item }) => { const { settings } = useSettingsContext(); const regex = settings?.CategoryAlertRegex ?? '.*'; const categories = item.callCategories || []; const categoryComponents = categories.map((t, i) => { const className = t.match(regex) ? 'transcript-segment-category-match-alert' : 'transcript-segment-category-match'; return ( /* eslint-disable-next-line react/no-array-index-key */
{/* eslint-disable-next-line react/no-array-index-key */} {t.trim()}
); }); return ( Info } > Call Categories } > {categoryComponents} ); }; // eslint-disable-next-line arrow-body-style const CallSummary = ({ item }) => { return ( Info } > Call Summary } > {/* eslint-disable-next-line react/no-array-index-key */} {item.callSummaryText ?? 'No summary available'} ), }, ]} /> {/* eslint-disable-next-line react/no-array-index-key */} {item.issuesDetected ?? 'No issue detected'} ), }, ]} /> ); }; const getSentimentImage = (segment) => { const { sentiment, sentimentScore, sentimentWeighted } = segment; if (!sentiment) { // returns an empty div to maintain spacing return
; } const weightedSentimentLabel = getWeightedSentimentLabel(sentimentWeighted); return (
Sentiment
{sentiment}
Sentiment Scores
{JSON.stringify(sentimentScore)}
Weighted Sentiment
{sentimentWeighted}
} >
); }; const getTimestampFromSeconds = (secs) => { if (!secs || Number.isNaN(secs)) { return '00:00.0'; } return new Date(secs * 1000).toISOString().substr(14, 7); }; const TranscriptContent = ({ segment, translateCache }) => { const { settings } = useSettingsContext(); const regex = settings?.CategoryAlertRegex ?? '.*'; const { transcript, segmentId, channel, targetLanguage, agentTranscript, translateOn } = segment; const k = segmentId.concat('-', targetLanguage); // prettier-ignore const currTranslated = translateOn && targetLanguage !== '' && translateCache[k] !== undefined && translateCache[k].translated !== undefined ? translateCache[k].translated : ''; const result = currTranslated !== undefined ? currTranslated : ''; const transcriptPiiSplit = transcript.split(piiTypesSplitRegEx); const transcriptComponents = transcriptPiiSplit.map((t, i) => { if (piiTypes.includes(t)) { // eslint-disable-next-line react/no-array-index-key return {`${t}`}; } let className = ''; let text = t; let translatedText = result; switch (channel) { case 'AGENT_ASSISTANT': className = 'transcript-segment-agent-assist'; break; case 'AGENT': text = agentTranscript !== undefined && agentTranscript ? text : ''; translatedText = agentTranscript !== undefined && agentTranscript ? translatedText : ''; break; case 'CATEGORY_MATCH': if (text.match(regex)) { className = 'transcript-segment-category-match-alert'; text = `Alert: ${text}`; } else { className = 'transcript-segment-category-match'; text = `Category: ${text}`; } break; default: break; } return ( // prettier-ignore // eslint-disable-next-line react/no-array-index-key {text.trim()} {translatedText.trim()} ); }); return ( {transcriptComponents} ); }; const TranscriptSegment = ({ segment, translateCache }) => { const { channel } = segment; if (channel === 'CATEGORY_MATCH') { const categoryText = `${segment.transcript}`; const newSegment = segment; newSegment.transcript = categoryText; // We will return a special version of the grid that's specifically only for category. return ( {getSentimentImage(segment)} ); } const channelClass = channel === 'AGENT_ASSISTANT' ? 'transcript-segment-agent-assist' : ''; return ( {getSentimentImage(segment)} {segment.channel} {`${getTimestampFromSeconds(segment.startTime)} - ${getTimestampFromSeconds(segment.endTime)}`} ); }; const CallInProgressTranscript = ({ item, callTranscriptPerCallId, autoScroll, translateClient, targetLanguage, agentTranscript, translateOn, collapseSentiment, }) => { const bottomRef = useRef(); const [turnByTurnSegments, setTurnByTurnSegments] = useState([]); const [translateCache, setTranslateCache] = useState({}); const [cacheSeen, setCacheSeen] = useState({}); const [lastUpdated, setLastUpdated] = useState(Date.now()); const [updateFlag, setUpdateFlag] = useState(false); // channels: AGENT, AGENT_ASSIST, CALLER, CATEGORY_MATCH, // AGENT_VOICETONE, CALLER_VOICETONE const maxChannels = 6; const { callId } = item; const transcriptsForThisCallId = callTranscriptPerCallId[callId] || {}; const transcriptChannels = Object.keys(transcriptsForThisCallId).slice(0, maxChannels); const getSegments = () => { const currentTurnByTurnSegments = transcriptChannels .map((c) => { const { segments } = transcriptsForThisCallId[c]; return segments; }) // sort entries by end time .reduce((p, c) => [...p, ...c].sort((a, b) => a.endTime - b.endTime), []) .map((c) => { const t = c; return t; }); return currentTurnByTurnSegments; }; const updateTranslateCache = (seg) => { const promises = []; // prettier-ignore for (let i = 0; i < seg.length; i += 1) { const k = seg[i].segmentId.concat('-', targetLanguage); // prettier-ignore if (translateCache[k] === undefined) { // Now call translate API const params = { Text: seg[i].transcript, SourceLanguageCode: 'auto', TargetLanguageCode: targetLanguage, }; const command = new TranslateTextCommand(params); logger.debug('Translate API being invoked for:', seg[i].transcript, targetLanguage); promises.push( translateClient.send(command).then( (data) => { const n = {}; logger.debug('Translate API response:', seg[i].transcript, targetLanguage, data.TranslatedText); n[k] = { cacheId: k, transcript: seg[i].transcript, translated: data.TranslatedText }; return n; }, (error) => { logger.debug('Error from translate:', error); }, ), ); } } return promises; }; // Translate all segments when the call is completed. useEffect(() => { if (translateOn && targetLanguage !== '' && item.recordingStatusLabel !== IN_PROGRESS_STATUS) { const promises = updateTranslateCache(getSegments()); Promise.all(promises).then((results) => { // prettier-ignore if (results.length > 0) { setTranslateCache((state) => ({ ...state, ...results.reduce((a, b) => ({ ...a, ...b })), })); setUpdateFlag((state) => !state); } }); } }, [targetLanguage, agentTranscript, translateOn, item.recordingStatusLabel]); // Translate real-time segments when the call is in progress. useEffect(async () => { const c = getSegments(); // prettier-ignore if ( translateOn && targetLanguage !== '' && c.length > 0 && item.recordingStatusLabel === IN_PROGRESS_STATUS ) { const k = c[c.length - 1].segmentId.concat('-', targetLanguage); const n = {}; if (c[c.length - 1].isPartial === false && cacheSeen[k] === undefined) { n[k] = { seen: true }; setCacheSeen((state) => ({ ...state, ...n, })); // prettier-ignore if (translateCache[k] === undefined) { // Now call translate API const params = { Text: c[c.length - 1].transcript, SourceLanguageCode: 'auto', TargetLanguageCode: targetLanguage, }; const command = new TranslateTextCommand(params); logger.debug('Translate API being invoked for:', c[c.length - 1].transcript, targetLanguage); try { const data = await translateClient.send(command); const o = {}; logger.debug('Translate API response:', c[c.length - 1].transcript, data.TranslatedText); o[k] = { cacheId: k, transcript: c[c.length - 1].transcript, translated: data.TranslatedText, }; setTranslateCache((state) => ({ ...state, ...o, })); } catch (error) { logger.debug('Error from translate:', error); } } } if (Date.now() - lastUpdated > 500) { setUpdateFlag((state) => !state); logger.debug('Updating turn by turn with latest cache'); } } setLastUpdated(Date.now()); }, [callTranscriptPerCallId]); const getTurnByTurnSegments = () => { const currentTurnByTurnSegments = transcriptChannels .map((c) => { const { segments } = transcriptsForThisCallId[c]; return segments; }) // sort entries by end time .reduce((p, c) => [...p, ...c].sort((a, b) => a.endTime - b.endTime), []) .map((c) => { const t = c; t.agentTranscript = agentTranscript; t.targetLanguage = targetLanguage; t.translateOn = translateOn; return t; }) .map( // prettier-ignore (s) => ( s?.segmentId && s?.createdAt && (s.agentTranscript === undefined || s.agentTranscript || s.channel !== 'AGENT') && (s.channel !== 'AGENT_VOICETONE') && (s.channel !== 'CALLER_VOICETONE') && ), ); // this element is used for scrolling to bottom and to provide padding currentTurnByTurnSegments.push(
); return currentTurnByTurnSegments; }; useEffect(() => { setTurnByTurnSegments(getTurnByTurnSegments); }, [ callTranscriptPerCallId, item.recordingStatusLabel, targetLanguage, agentTranscript, translateOn, updateFlag, ]); useEffect(() => { // prettier-ignore if ( item.recordingStatusLabel === IN_PROGRESS_STATUS && autoScroll && bottomRef.current?.scrollIntoView ) { bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, [ turnByTurnSegments, autoScroll, item.recordingStatusLabel, targetLanguage, agentTranscript, translateOn, ]); return (
{turnByTurnSegments}
); }; const getAgentAssistPanel = (collapseSentiment) => { if (process.env.REACT_APP_ENABLE_LEX_AGENT_ASSIST === 'true') { return ( Info } > Agent Assist Bot } >