mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +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 { 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 { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||
|
||||
@@ -63,6 +64,7 @@ export const CallRecordingsPage = () => {
|
||||
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
@@ -110,6 +112,7 @@ export const CallRecordingsPage = () => {
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Caller" />
|
||||
<Table.Head label="AI" className="w-14" />
|
||||
<Table.Head label="Type" className="w-20" />
|
||||
<Table.Head label="Date" className="w-28" />
|
||||
<Table.Head label="Duration" className="w-20" />
|
||||
@@ -132,6 +135,21 @@ export const CallRecordingsPage = () => {
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</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>
|
||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||
</Table.Cell>
|
||||
@@ -159,7 +177,28 @@ export const CallRecordingsPage = () => {
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user