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:
2026-03-25 09:19:52 +05:30
parent 488f524f84
commit 70e0f6fc3e
2 changed files with 342 additions and 1 deletions

View 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>
);
};

View File

@@ -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>
</> </>
); );