Sentiment Analysis Capabilities
Real-time Call Analysis
Live sentiment tracking during phone calls with instant feedback
Communication Scoring
Sentiment analysis for emails, SMS, and other text communications
Coaching Insights
Personalized recommendations to improve client interactions
Trend Analysis
Historical sentiment patterns and relationship health metrics
Sentiment Analysis Architecture
AI Processing Pipeline
Sentiment Processing Engine
// lib/ai/sentiment-analysis-service.ts
export class SentimentAnalysisService {
private sentimentModel: SentimentModel;
private emotionClassifier: EmotionClassifier;
private realEstateNLP: RealEstateNLP;
constructor() {
this.sentimentModel = new SentimentModel();
this.emotionClassifier = new EmotionClassifier();
this.realEstateNLP = new RealEstateNLP();
}
async analyzeCallRecording(
callId: string,
transcriptSegments: TranscriptSegment[]
): Promise<CallSentimentAnalysis> {
try {
const analysis: CallSentimentAnalysis = {
callId,
overallSentiment: 0,
agentSentiment: 0,
clientSentiment: 0,
emotionalJourney: [],
keyMoments: [],
coachingInsights: [],
riskFactors: [],
conversationMetrics: {
duration: 0,
agentTalkTime: 0,
clientTalkTime: 0,
interruptionCount: 0,
silenceRatio: 0
}
};
// Process each transcript segment
for (const segment of transcriptSegments) {
const segmentAnalysis = await this.analyzeTranscriptSegment(segment);
// Update overall metrics
if (segment.speaker === 'agent') {
analysis.conversationMetrics.agentTalkTime +=
(segment.endTime - segment.startTime);
} else if (segment.speaker === 'customer') {
analysis.conversationMetrics.clientTalkTime +=
(segment.endTime - segment.startTime);
}
// Track emotional journey
analysis.emotionalJourney.push({
timestamp: segment.startTime,
speaker: segment.speaker,
sentiment: segmentAnalysis.sentiment,
emotions: segmentAnalysis.emotions,
confidence: segmentAnalysis.confidence
});
// Identify key moments
if (this.isKeyMoment(segmentAnalysis)) {
analysis.keyMoments.push({
timestamp: segment.startTime,
type: this.classifyMomentType(segmentAnalysis),
description: segmentAnalysis.keyPhrase,
sentiment: segmentAnalysis.sentiment,
impact: segmentAnalysis.impact
});
}
// Detect risk factors
const riskFactors = this.detectRiskFactors(segmentAnalysis);
analysis.riskFactors.push(...riskFactors);
}
// Calculate overall sentiment scores
analysis.overallSentiment = this.calculateOverallSentiment(analysis.emotionalJourney);
analysis.agentSentiment = this.calculateSpeakerSentiment(analysis.emotionalJourney, 'agent');
analysis.clientSentiment = this.calculateSpeakerSentiment(analysis.emotionalJourney, 'customer');
// Generate coaching insights
analysis.coachingInsights = await this.generateCoachingInsights(analysis);
// Store analysis results
await this.storeAnalysisResults(analysis);
return analysis;
} catch (error) {
log.error('Call sentiment analysis failed', {
callId,
error: parseError(error)
});
throw error;
}
}
private async analyzeTranscriptSegment(
segment: TranscriptSegment
): Promise<SegmentSentimentAnalysis> {
const text = segment.text;
// Basic sentiment analysis
const sentimentResult = await this.sentimentModel.analyze(text);
// Emotion classification
const emotions = await this.emotionClassifier.classify(text);
// Real estate specific analysis
const realEstateContext = await this.realEstateNLP.analyze(text);
// Extract key phrases and intent
const keyPhrases = this.extractKeyPhrases(text);
const intent = this.classifyIntent(text, realEstateContext);
return {
segmentId: segment.id,
text,
speaker: segment.speaker,
sentiment: sentimentResult.score, // -1 to 1
confidence: sentimentResult.confidence,
emotions: emotions.map(e => ({
emotion: e.label,
intensity: e.score
})),
keyPhrase: keyPhrases[0]?.phrase || '',
intent: intent.category,
topics: realEstateContext.topics,
impact: this.calculateImpact(sentimentResult, emotions, realEstateContext)
};
}
private generateCoachingInsights(
analysis: CallSentimentAnalysis
): CoachingInsight[] {
const insights: CoachingInsight[] = [];
// Analyze talk time ratio
const totalTalkTime = analysis.conversationMetrics.agentTalkTime +
analysis.conversationMetrics.clientTalkTime;
const agentRatio = analysis.conversationMetrics.agentTalkTime / totalTalkTime;
if (agentRatio > 0.7) {
insights.push({
type: 'TALK_TIME',
severity: 'MEDIUM',
title: 'Consider Listening More',
description: 'You spoke 70% of the time. Try asking more open-ended questions to encourage client engagement.',
recommendation: 'Use phrases like "Tell me more about..." or "How do you feel about..." to encourage client participation.',
impact: 'CONVERSATION_QUALITY'
});
}
// Analyze sentiment trajectory
const sentimentTrend = this.analyzeSentimentTrend(analysis.emotionalJourney);
if (sentimentTrend === 'DECLINING') {
insights.push({
type: 'SENTIMENT_DECLINE',
severity: 'HIGH',
title: 'Client Sentiment Declined',
description: 'The client\'s sentiment became more negative during the call.',
recommendation: 'Consider reaching out with a follow-up to address any concerns that may have arisen.',
impact: 'RELATIONSHIP_HEALTH'
});
}
// Analyze emotional moments
const negativeKeyMoments = analysis.keyMoments.filter(m => m.sentiment < -0.3);
if (negativeKeyMoments.length > 0) {
insights.push({
type: 'NEGATIVE_MOMENTS',
severity: 'MEDIUM',
title: 'Address Negative Reactions',
description: `Found ${negativeKeyMoments.length} moments with negative client reactions.`,
recommendation: 'Review these moments and consider how to better handle similar situations in the future.',
impact: 'DEAL_PROGRESSION',
details: negativeKeyMoments.map(m => ({
timestamp: m.timestamp,
description: m.description
}))
});
}
// Real estate specific insights
const propertyDiscussions = analysis.keyMoments.filter(m =>
m.description.includes('property') || m.description.includes('house')
);
if (propertyDiscussions.length === 0 && analysis.conversationMetrics.duration > 300) {
insights.push({
type: 'MISSING_PROPERTY_FOCUS',
severity: 'LOW',
title: 'Limited Property Discussion',
description: 'The conversation didn\'t focus much on specific properties.',
recommendation: 'Consider steering future conversations toward specific property interests and preferences.',
impact: 'LEAD_QUALIFICATION'
});
}
return insights;
}
}
interface CallSentimentAnalysis {
callId: string;
overallSentiment: number;
agentSentiment: number;
clientSentiment: number;
emotionalJourney: EmotionalDataPoint[];
keyMoments: KeyMoment[];
coachingInsights: CoachingInsight[];
riskFactors: RiskFactor[];
conversationMetrics: ConversationMetrics;
}
interface EmotionalDataPoint {
timestamp: number;
speaker: string;
sentiment: number;
emotions: { emotion: string; intensity: number }[];
confidence: number;
}
interface KeyMoment {
timestamp: number;
type: 'POSITIVE_REACTION' | 'NEGATIVE_REACTION' | 'OBJECTION' | 'COMMITMENT' | 'QUESTION';
description: string;
sentiment: number;
impact: 'HIGH' | 'MEDIUM' | 'LOW';
}
interface CoachingInsight {
type: string;
severity: 'HIGH' | 'MEDIUM' | 'LOW';
title: string;
description: string;
recommendation: string;
impact: 'RELATIONSHIP_HEALTH' | 'DEAL_PROGRESSION' | 'CONVERSATION_QUALITY' | 'LEAD_QUALIFICATION';
details?: unknown[];
}
Real-time Sentiment Monitoring
Live Call Analysis Dashboard
// components/live-sentiment-monitor.tsx
export function LiveSentimentMonitor({ callId }: { callId: string }) {
const [sentimentData, setSentimentData] = useState<LiveSentimentData>({
currentSentiment: 0,
agentSentiment: 0,
clientSentiment: 0,
emotionalJourney: [],
activeEmotions: [],
alerts: []
});
const [isMonitoring, setIsMonitoring] = useState(false);
const socket = useSocket();
useEffect(() => {
if (!socket || !callId) return;
// Join call monitoring room
socket.emit('join-call-monitoring', { callId });
// Listen for real-time sentiment updates
socket.on('sentiment-update', (data: SentimentUpdate) => {
setSentimentData(prev => ({
...prev,
currentSentiment: data.sentiment,
emotionalJourney: [...prev.emotionalJourney, {
timestamp: Date.now(),
sentiment: data.sentiment,
speaker: data.speaker,
emotions: data.emotions
}].slice(-50), // Keep last 50 data points
activeEmotions: data.emotions,
agentSentiment: data.speaker === 'agent' ? data.sentiment : prev.agentSentiment,
clientSentiment: data.speaker === 'customer' ? data.sentiment : prev.clientSentiment
}));
});
// Listen for sentiment alerts
socket.on('sentiment-alert', (alert: SentimentAlert) => {
setSentimentData(prev => ({
...prev,
alerts: [alert, ...prev.alerts.slice(0, 4)] // Keep last 5 alerts
}));
// Show toast notification for important alerts
if (alert.severity === 'HIGH') {
toast.warning(alert.message, {
duration: 5000,
action: {
label: 'View Details',
onClick: () => openAlertDetails(alert)
}
});
}
});
setIsMonitoring(true);
return () => {
socket.off('sentiment-update');
socket.off('sentiment-alert');
socket.emit('leave-call-monitoring', { callId });
setIsMonitoring(false);
};
}, [socket, callId]);
const getSentimentColor = (sentiment: number): string => {
if (sentiment > 0.3) return 'text-green-600';
if (sentiment > -0.3) return 'text-yellow-600';
return 'text-red-600';
};
const getSentimentIcon = (sentiment: number) => {
if (sentiment > 0.3) return <SmilePlus className="h-5 w-5" />;
if (sentiment > -0.3) return <Meh className="h-5 w-5" />;
return <Frown className="h-5 w-5" />;
};
return (
<div className="space-y-4">
{/* Monitoring Status */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`
w-3 h-3 rounded-full
${isMonitoring ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}
`} />
<span className="text-sm font-medium">
{isMonitoring ? 'Live Monitoring' : 'Not Monitoring'}
</span>
</div>
<div className="text-xs text-gray-500">
Call ID: {callId}
</div>
</div>
{/* Current Sentiment */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Overall</p>
<p className={`text-lg font-bold ${getSentimentColor(sentimentData.currentSentiment)}`}>
{(sentimentData.currentSentiment * 100).toFixed(0)}%
</p>
</div>
<div className={getSentimentColor(sentimentData.currentSentiment)}>
{getSentimentIcon(sentimentData.currentSentiment)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Agent</p>
<p className={`text-lg font-bold ${getSentimentColor(sentimentData.agentSentiment)}`}>
{(sentimentData.agentSentiment * 100).toFixed(0)}%
</p>
</div>
<User className="h-5 w-5 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Client</p>
<p className={`text-lg font-bold ${getSentimentColor(sentimentData.clientSentiment)}`}>
{(sentimentData.clientSentiment * 100).toFixed(0)}%
</p>
</div>
<UserCheck className="h-5 w-5 text-purple-500" />
</div>
</CardContent>
</Card>
</div>
{/* Emotional Journey Chart */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Emotional Journey</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={sentimentData.emotionalJourney}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={['dataMin', 'dataMax']}
tickFormatter={(value) => new Date(value).toLocaleTimeString()}
/>
<YAxis domain={[-1, 1]} />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleTimeString()}
formatter={(value: number, name: string) => [
`${(value * 100).toFixed(0)}%`,
name === 'sentiment' ? 'Sentiment' : name
]}
/>
<Line
type="monotone"
dataKey="sentiment"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Active Emotions */}
{sentimentData.activeEmotions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Current Emotions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{sentimentData.activeEmotions.map((emotion, index) => (
<Badge
key={index}
variant="secondary"
className="capitalize"
>
{emotion.emotion} ({(emotion.intensity * 100).toFixed(0)}%)
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Alerts */}
{sentimentData.alerts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Recent Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{sentimentData.alerts.map((alert, index) => (
<div
key={index}
className={`
p-2 rounded border-l-4 text-sm
${alert.severity === 'HIGH'
? 'border-red-500 bg-red-50'
: alert.severity === 'MEDIUM'
? 'border-yellow-500 bg-yellow-50'
: 'border-blue-500 bg-blue-50'
}
`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{alert.type}</span>
<time className="text-xs text-gray-500">
{formatDistanceToNow(new Date(alert.timestamp))} ago
</time>
</div>
<p className="text-gray-600 mt-1">{alert.message}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Coaching Tips */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Real-time Coaching</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
{sentimentData.clientSentiment < -0.3 && (
<div className="p-2 bg-yellow-50 border border-yellow-200 rounded">
<p className="font-medium text-yellow-800">💡 Client seems concerned</p>
<p className="text-yellow-700">Try acknowledging their feelings and asking open-ended questions.</p>
</div>
)}
{sentimentData.agentSentiment < -0.2 && (
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
<p className="font-medium text-blue-800">🎯 Stay positive</p>
<p className="text-blue-700">Your tone affects the client. Take a breath and focus on solutions.</p>
</div>
)}
{sentimentData.currentSentiment > 0.4 && (
<div className="p-2 bg-green-50 border border-green-200 rounded">
<p className="font-medium text-green-800">✅ Great momentum!</p>
<p className="text-green-700">The conversation is going well. Consider moving toward next steps.</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
Sentiment Analytics Dashboard
Historical Sentiment Analysis
// components/sentiment-analytics-dashboard.tsx
export function SentimentAnalyticsDashboard() {
const { data: analytics } = useQuery({
queryKey: ['sentiment-analytics'],
queryFn: () => fetch('/api/analytics/sentiment').then(res => res.json())
});
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d'>('30d');
const [selectedMetric, setSelectedMetric] = useState<'overall' | 'calls' | 'emails'>('overall');
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Avg Sentiment</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{(analytics?.averageSentiment * 100 || 0).toFixed(0)}%
</div>
<p className="text-xs text-gray-500 mt-1">
+{analytics?.sentimentImprovement || 0}% vs last period
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Positive Interactions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{analytics?.positiveInteractions || 0}
</div>
<p className="text-xs text-gray-500 mt-1">
{((analytics?.positiveInteractions / analytics?.totalInteractions) * 100 || 0).toFixed(0)}% of total
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Risk Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{analytics?.riskAlerts || 0}
</div>
<p className="text-xs text-gray-500 mt-1">
{analytics?.resolvedAlerts || 0} resolved
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Coaching Score</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-600">
{analytics?.coachingScore || 0}/100
</div>
<p className="text-xs text-gray-500 mt-1">
Based on improvements
</p>
</CardContent>
</Card>
</div>
{/* Sentiment Trend Chart */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Sentiment Trends</CardTitle>
<div className="flex space-x-2">
<Select value={timeRange} onValueChange={(value: unknown) => setTimeRange(value)}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">7 days</SelectItem>
<SelectItem value="30d">30 days</SelectItem>
<SelectItem value="90d">90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={analytics?.sentimentTrend || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[-1, 1]} />
<Tooltip
formatter={(value: number) => [`${(value * 100).toFixed(0)}%`, 'Sentiment']}
/>
<Line
type="monotone"
dataKey="averageSentiment"
stroke="#3b82f6"
strokeWidth={2}
name="Average Sentiment"
/>
<Line
type="monotone"
dataKey="clientSentiment"
stroke="#10b981"
strokeWidth={2}
name="Client Sentiment"
/>
<Line
type="monotone"
dataKey="agentSentiment"
stroke="#f59e0b"
strokeWidth={2}
name="Agent Sentiment"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Emotion Distribution */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Top Emotions Detected</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analytics?.topEmotions?.map((emotion: unknown) => (
<div key={emotion.name} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="text-lg">{emotion.emoji}</div>
<span className="text-sm font-medium capitalize">{emotion.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{emotion.count}</span>
<div className="w-20 h-2 bg-gray-200 rounded">
<div
className="h-2 bg-blue-500 rounded transition-all"
style={{ width: `${emotion.percentage}%` }}
/>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Coaching Insights</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analytics?.recentInsights?.map((insight: unknown, index: number) => (
<div key={index} className="p-3 bg-gray-50 rounded border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{insight.title}</span>
<Badge variant={insight.severity === 'HIGH' ? 'destructive' : 'secondary'}>
{insight.severity}
</Badge>
</div>
<p className="text-xs text-gray-600">{insight.description}</p>
<p className="text-xs text-blue-600 mt-1 font-medium">
💡 {insight.recommendation}
</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Client Relationship Health */}
<Card>
<CardHeader>
<CardTitle>Client Relationship Health</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{analytics?.clientHealth?.map((client: unknown) => (
<div key={client.id} className="p-3 border rounded">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{client.name}</span>
<div className={`
w-3 h-3 rounded-full
${client.healthScore > 0.5
? 'bg-green-500'
: client.healthScore > 0
? 'bg-yellow-500'
: 'bg-red-500'
}
`} />
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>Health Score: {(client.healthScore * 100).toFixed(0)}%</p>
<p>Last Contact: {client.lastContact}</p>
<p>Sentiment Trend: {client.sentimentTrend}</p>
</div>
{client.alerts.length > 0 && (
<div className="mt-2">
<Badge variant="destructive" className="text-xs">
{client.alerts.length} alert{client.alerts.length > 1 ? 's' : ''}
</Badge>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
Next Steps
Lead Scoring
Explore AI-driven lead qualification and scoring
AI Assistant
Learn about the AI-powered assistant features
Communication Hub
Integrate sentiment analysis with communications
Sentiment analysis provides invaluable insights into client relationships, helping agents build stronger connections and close more deals through improved communication.