mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: call recording analysis with Deepgram diarization + AI insights
- Deepgram pre-recorded API: transcription with diarization, sentiment, topics, summary - OpenAI structured insights: call outcome, patient satisfaction, coaching notes, action items, compliance flags - Slideout panel UI with audio player, speaker-labeled transcript, sentiment badge - AI pill button in recordings table between Caller and Type columns - Redis caching (7-day TTL) to avoid re-analyzing the same recording Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
302
src/components/call-desk/recording-analysis.tsx
Normal file
302
src/components/call-desk/recording-analysis.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faWaveformLines, faSpinner, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type Utterance = {
|
||||||
|
speaker: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Insights = {
|
||||||
|
keyTopics: string[];
|
||||||
|
actionItems: string[];
|
||||||
|
coachingNotes: string[];
|
||||||
|
complianceFlags: string[];
|
||||||
|
patientSatisfaction: string;
|
||||||
|
callOutcome: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Analysis = {
|
||||||
|
transcript: Utterance[];
|
||||||
|
summary: string | null;
|
||||||
|
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||||
|
sentimentScore: number;
|
||||||
|
insights: Insights;
|
||||||
|
durationSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentimentConfig = {
|
||||||
|
positive: { label: 'Positive', color: 'success' as const },
|
||||||
|
neutral: { label: 'Neutral', color: 'gray' as const },
|
||||||
|
negative: { label: 'Negative', color: 'error' as const },
|
||||||
|
mixed: { label: 'Mixed', color: 'warning' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (sec: number): string => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (sec: number | null): string => {
|
||||||
|
if (!sec) return '';
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline audio player for the slideout header
|
||||||
|
const SlideoutPlayer = ({ url }: { url: string }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
|
||||||
|
setPlaying(!playing);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-tertiary">{playing ? 'Playing...' : 'Play recording'}</span>
|
||||||
|
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insights section rendered after analysis completes
|
||||||
|
const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RecordingAnalysisSlideoutProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
recordingUrl: string;
|
||||||
|
callId: string;
|
||||||
|
agentName: string | null;
|
||||||
|
callerNumber: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
durationSec: number | null;
|
||||||
|
disposition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecordingAnalysisSlideout = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
recordingUrl,
|
||||||
|
callId,
|
||||||
|
agentName,
|
||||||
|
callerNumber,
|
||||||
|
direction,
|
||||||
|
startedAt,
|
||||||
|
durationSec,
|
||||||
|
disposition,
|
||||||
|
}: RecordingAnalysisSlideoutProps) => {
|
||||||
|
const [analysis, setAnalysis] = useState<Analysis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasTriggered = useRef(false);
|
||||||
|
|
||||||
|
// Auto-trigger analysis when the slideout opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || hasTriggered.current) return;
|
||||||
|
hasTriggered.current = true;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [isOpen, recordingUrl, callId]);
|
||||||
|
|
||||||
|
const dirLabel = direction === 'INBOUND' ? 'Inbound' : 'Outbound';
|
||||||
|
const dirColor = direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
|
const formattedPhone = callerNumber
|
||||||
|
? formatPhone({ number: callerNumber, callingCode: '+91' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
<SlideoutMenu.Header onClose={close}>
|
||||||
|
<div className="flex flex-col gap-1.5 pr-8">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Call Analysis</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-tertiary">
|
||||||
|
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||||
|
{agentName && <span>{agentName}</span>}
|
||||||
|
{formattedPhone && (
|
||||||
|
<>
|
||||||
|
<span className="text-quaternary">-</span>
|
||||||
|
<span>{formattedPhone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-quaternary">
|
||||||
|
{startedAt && <span>{formatDateOnly(startedAt)}</span>}
|
||||||
|
{durationSec != null && durationSec > 0 && <span>{formatDuration(durationSec)}</span>}
|
||||||
|
{disposition && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">
|
||||||
|
{disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<SlideoutPlayer url={recordingUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Header>
|
||||||
|
|
||||||
|
<SlideoutMenu.Content>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-6 animate-spin text-brand-secondary" />
|
||||||
|
<p className="text-sm text-tertiary">Analyzing recording...</p>
|
||||||
|
<p className="text-xs text-quaternary">Transcribing and generating insights</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-12">
|
||||||
|
<p className="text-sm text-error-primary">{error}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={<FontAwesomeIcon icon={faWaveformLines} data-icon className="size-3.5" />}
|
||||||
|
onClick={() => {
|
||||||
|
hasTriggered.current = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && !loading && (
|
||||||
|
<AnalysisResults analysis={analysis} />
|
||||||
|
)}
|
||||||
|
</SlideoutMenu.Content>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SlideoutMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separated analysis results display for readability
|
||||||
|
const AnalysisResults = ({ analysis }: { analysis: Analysis }) => {
|
||||||
|
const sentCfg = sentimentConfig[analysis.sentiment];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Sentiment + topics */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge size="sm" color={sentCfg.color} type="pill-color">{sentCfg.label}</Badge>
|
||||||
|
{analysis.insights.keyTopics.slice(0, 4).map((topic) => (
|
||||||
|
<Badge key={topic} size="sm" color="gray" type="pill-color">{topic}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{analysis.summary && (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Summary</span>
|
||||||
|
<p className="mt-1 text-sm text-primary">{analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call outcome */}
|
||||||
|
<div className="rounded-lg bg-brand-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-brand-tertiary uppercase tracking-wider">Call Outcome</span>
|
||||||
|
<p className="mt-1 text-sm text-primary_on-brand font-medium">{analysis.insights.callOutcome}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<InsightsSection label="Patient Satisfaction">
|
||||||
|
<p className="text-sm text-primary">{analysis.insights.patientSatisfaction}</p>
|
||||||
|
</InsightsSection>
|
||||||
|
|
||||||
|
{analysis.insights.actionItems.length > 0 && (
|
||||||
|
<InsightsSection label="Action Items">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.actionItems.map((item, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.coachingNotes.length > 0 && (
|
||||||
|
<InsightsSection label="Coaching Notes">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.coachingNotes.map((note, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.complianceFlags.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-error-primary uppercase tracking-wider">Compliance Flags</span>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{analysis.insights.complianceFlags.map((flag, i) => (
|
||||||
|
<li key={i} className="text-sm text-error-primary">- {flag}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
{analysis.transcript.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Transcript</span>
|
||||||
|
<div className="mt-2 space-y-2 rounded-lg bg-secondary p-3">
|
||||||
|
{analysis.transcript.map((u, i) => {
|
||||||
|
const isAgent = u.speaker === 0;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="shrink-0 text-xs text-quaternary tabular-nums w-10">{formatTimestamp(u.start)}</span>
|
||||||
|
<span className={cx(
|
||||||
|
'text-xs font-semibold shrink-0 w-16',
|
||||||
|
isAgent ? 'text-brand-secondary' : 'text-success-primary',
|
||||||
|
)}>
|
||||||
|
{isAgent ? 'Agent' : 'Customer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-primary">{u.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { faMagnifyingGlass, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
|
import { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/base/input/input';
|
|||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
|
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone, formatDateOnly } from '@/lib/format';
|
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export const CallRecordingsPage = () => {
|
|||||||
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
@@ -110,6 +112,7 @@ export const CallRecordingsPage = () => {
|
|||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="Agent" isRowHeader />
|
<Table.Head label="Agent" isRowHeader />
|
||||||
<Table.Head label="Caller" />
|
<Table.Head label="Caller" />
|
||||||
|
<Table.Head label="AI" className="w-14" />
|
||||||
<Table.Head label="Type" className="w-20" />
|
<Table.Head label="Type" className="w-20" />
|
||||||
<Table.Head label="Date" className="w-28" />
|
<Table.Head label="Date" className="w-28" />
|
||||||
<Table.Head label="Duration" className="w-20" />
|
<Table.Head label="Duration" className="w-20" />
|
||||||
@@ -132,6 +135,21 @@ export const CallRecordingsPage = () => {
|
|||||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlideoutCallId(call.id);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||||
|
title="AI Analysis"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -159,7 +177,28 @@ export const CallRecordingsPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis slideout */}
|
||||||
|
{(() => {
|
||||||
|
const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null;
|
||||||
|
if (!call?.recording?.primaryLinkUrl) return null;
|
||||||
|
return (
|
||||||
|
<RecordingAnalysisSlideout
|
||||||
|
isOpen={true}
|
||||||
|
onOpenChange={(open) => { if (!open) setSlideoutCallId(null); }}
|
||||||
|
recordingUrl={call.recording.primaryLinkUrl}
|
||||||
|
callId={call.id}
|
||||||
|
agentName={call.agentName}
|
||||||
|
callerNumber={call.callerNumber?.primaryPhoneNumber ?? null}
|
||||||
|
direction={call.direction}
|
||||||
|
startedAt={call.startedAt}
|
||||||
|
durationSec={call.durationSec}
|
||||||
|
disposition={call.disposition}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user