mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
feat: recording analysis module with Deepgram + AI insights + Redis cache
- RecordingsModule: POST /api/recordings/analyze - Deepgram pre-recorded API: diarize, summarize, topics, sentiment, utterances - AI insights via OpenAI generateObject: call outcome, coaching, compliance, satisfaction - Redis cache: 7-day TTL per callId, check before hitting Deepgram/OpenAI - Generic getCache/setCache added to SessionService for cross-module use Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { CallAssistModule } from './call-assist/call-assist.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||
import { MaintModule } from './maint/maint.module';
|
||||
import { RecordingsModule } from './recordings/recordings.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { MaintModule } from './maint/maint.module';
|
||||
SearchModule,
|
||||
SupervisorModule,
|
||||
MaintModule,
|
||||
RecordingsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -50,4 +50,13 @@ export class SessionService implements OnModuleInit {
|
||||
async unlockSession(agentId: string): Promise<void> {
|
||||
await this.redis.del(this.key(agentId));
|
||||
}
|
||||
|
||||
// Generic cache operations for any module
|
||||
async getCache(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||
await this.redis.set(key, value, 'EX', ttlSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
53
src/recordings/recordings.controller.ts
Normal file
53
src/recordings/recordings.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||
import { RecordingsService } from './recordings.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
const CACHE_TTL = 7 * 24 * 3600; // 7 days
|
||||
|
||||
@Controller('api/recordings')
|
||||
export class RecordingsController {
|
||||
private readonly logger = new Logger(RecordingsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly recordings: RecordingsService,
|
||||
private readonly session: SessionService,
|
||||
) {}
|
||||
|
||||
@Post('analyze')
|
||||
async analyze(@Body() body: { recordingUrl: string; callId?: string }) {
|
||||
if (!body.recordingUrl) {
|
||||
throw new HttpException('recordingUrl required', 400);
|
||||
}
|
||||
|
||||
const cacheKey = body.callId ? `call:analysis:${body.callId}` : null;
|
||||
|
||||
// Check Redis cache first
|
||||
if (cacheKey) {
|
||||
try {
|
||||
const cached = await this.session.getCache(cacheKey);
|
||||
if (cached) {
|
||||
this.logger.log(`[RECORDING] Cache hit: ${cacheKey}`);
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.logger.log(`[RECORDING] Cache miss — analyzing: ${body.recordingUrl} callId=${body.callId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const analysis = await this.recordings.analyzeRecording(body.recordingUrl);
|
||||
this.logger.log(`[RECORDING] Analysis complete: ${analysis.transcript.length} utterances, sentiment=${analysis.sentiment}`);
|
||||
|
||||
// Cache the result
|
||||
if (cacheKey) {
|
||||
this.session.setCache(cacheKey, JSON.stringify(analysis), CACHE_TTL)
|
||||
.catch(err => this.logger.warn(`[RECORDING] Cache write failed: ${err}`));
|
||||
}
|
||||
|
||||
return analysis;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[RECORDING] Analysis failed: ${error.message}`);
|
||||
throw new HttpException(error.message ?? 'Analysis failed', 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/recordings/recordings.module.ts
Normal file
11
src/recordings/recordings.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RecordingsController } from './recordings.controller';
|
||||
import { RecordingsService } from './recordings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [RecordingsController],
|
||||
providers: [RecordingsService],
|
||||
})
|
||||
export class RecordingsModule {}
|
||||
198
src/recordings/recordings.service.ts
Normal file
198
src/recordings/recordings.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generateObject } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
||||
|
||||
export type TranscriptWord = {
|
||||
word: string;
|
||||
start: number;
|
||||
end: number;
|
||||
speaker: number;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type TranscriptUtterance = {
|
||||
speaker: number;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type CallAnalysis = {
|
||||
transcript: TranscriptUtterance[];
|
||||
summary: string | null;
|
||||
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||
sentimentScore: number;
|
||||
insights: {
|
||||
keyTopics: string[];
|
||||
actionItems: string[];
|
||||
coachingNotes: string[];
|
||||
complianceFlags: string[];
|
||||
patientSatisfaction: string;
|
||||
callOutcome: string;
|
||||
};
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RecordingsService {
|
||||
private readonly logger = new Logger(RecordingsService.name);
|
||||
private readonly deepgramApiKey: string;
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||
this.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
||||
if (!this.deepgramApiKey) throw new Error('DEEPGRAM_API_KEY not configured');
|
||||
|
||||
this.logger.log(`[RECORDING] Analyzing: ${recordingUrl}`);
|
||||
|
||||
// Step 1: Send to Deepgram pre-recorded API with diarization + sentiment
|
||||
const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({
|
||||
model: 'nova-2',
|
||||
language: 'en',
|
||||
smart_format: 'true',
|
||||
diarize: 'true',
|
||||
summarize: 'v2',
|
||||
topics: 'true',
|
||||
sentiment: 'true',
|
||||
utterances: 'true',
|
||||
}), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${this.deepgramApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: recordingUrl }),
|
||||
});
|
||||
|
||||
if (!dgResponse.ok) {
|
||||
const err = await dgResponse.text();
|
||||
this.logger.error(`[RECORDING] Deepgram failed: ${dgResponse.status} ${err}`);
|
||||
throw new Error(`Deepgram transcription failed: ${dgResponse.status}`);
|
||||
}
|
||||
|
||||
const dgData = await dgResponse.json();
|
||||
const results = dgData.results;
|
||||
|
||||
// Extract utterances (speaker-labeled segments)
|
||||
const utterances: TranscriptUtterance[] = (results?.utterances ?? []).map((u: any) => ({
|
||||
speaker: u.speaker ?? 0,
|
||||
start: u.start ?? 0,
|
||||
end: u.end ?? 0,
|
||||
text: u.transcript ?? '',
|
||||
}));
|
||||
|
||||
// Extract summary
|
||||
const summary = results?.summary?.short ?? null;
|
||||
|
||||
// Extract sentiment from Deepgram
|
||||
const sentiments = results?.sentiments?.segments ?? [];
|
||||
const avgSentiment = this.computeAverageSentiment(sentiments);
|
||||
|
||||
// Extract topics
|
||||
const topics = results?.topics?.segments?.flatMap((s: any) =>
|
||||
(s.topics ?? []).map((t: any) => t.topic),
|
||||
) ?? [];
|
||||
|
||||
const duration = results?.channels?.[0]?.alternatives?.[0]?.words?.length > 0
|
||||
? results.channels[0].alternatives[0].words.slice(-1)[0].end
|
||||
: 0;
|
||||
|
||||
// Step 2: Full transcript text for AI analysis
|
||||
const fullTranscript = utterances.map(u =>
|
||||
`Speaker ${u.speaker === 0 ? 'Agent' : 'Customer'}: ${u.text}`,
|
||||
).join('\n');
|
||||
|
||||
this.logger.log(`[RECORDING] Transcribed: ${utterances.length} utterances, ${Math.round(duration)}s`);
|
||||
|
||||
// Step 3: AI insights
|
||||
const insights = await this.generateInsights(fullTranscript, summary, topics);
|
||||
|
||||
return {
|
||||
transcript: utterances,
|
||||
summary,
|
||||
sentiment: avgSentiment.label,
|
||||
sentimentScore: avgSentiment.score,
|
||||
insights,
|
||||
durationSec: Math.round(duration),
|
||||
};
|
||||
}
|
||||
|
||||
private computeAverageSentiment(segments: any[]): { label: 'positive' | 'neutral' | 'negative' | 'mixed'; score: number } {
|
||||
if (!segments?.length) return { label: 'neutral', score: 0 };
|
||||
|
||||
let positive = 0, negative = 0, neutral = 0;
|
||||
for (const seg of segments) {
|
||||
const s = seg.sentiment ?? 'neutral';
|
||||
if (s === 'positive') positive++;
|
||||
else if (s === 'negative') negative++;
|
||||
else neutral++;
|
||||
}
|
||||
|
||||
const total = segments.length;
|
||||
const score = (positive - negative) / total;
|
||||
|
||||
if (positive > negative * 2) return { label: 'positive', score };
|
||||
if (negative > positive * 2) return { label: 'negative', score };
|
||||
if (positive > 0 && negative > 0) return { label: 'mixed', score };
|
||||
return { label: 'neutral', score };
|
||||
}
|
||||
|
||||
private async generateInsights(
|
||||
transcript: string,
|
||||
summary: string | null,
|
||||
topics: string[],
|
||||
): Promise<CallAnalysis['insights']> {
|
||||
if (!this.aiModel || !transcript.trim()) {
|
||||
return {
|
||||
keyTopics: topics.slice(0, 5),
|
||||
actionItems: [],
|
||||
coachingNotes: [],
|
||||
complianceFlags: [],
|
||||
patientSatisfaction: 'Unknown',
|
||||
callOutcome: 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel,
|
||||
schema: z.object({
|
||||
keyTopics: z.array(z.string()).describe('Main topics discussed (max 5)'),
|
||||
actionItems: z.array(z.string()).describe('Follow-up actions needed'),
|
||||
coachingNotes: z.array(z.string()).describe('Agent performance observations — what went well and what could improve'),
|
||||
complianceFlags: z.array(z.string()).describe('Any compliance concerns (HIPAA, patient safety, misinformation)'),
|
||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||
}),
|
||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
||||
Analyze the following call recording transcript and provide structured insights.
|
||||
Be specific, brief, and actionable. Focus on healthcare context.
|
||||
${summary ? `\nCall summary: ${summary}` : ''}
|
||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||
prompt: transcript,
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
|
||||
return object;
|
||||
} catch (err) {
|
||||
this.logger.error(`[RECORDING] AI insights failed: ${err}`);
|
||||
return {
|
||||
keyTopics: topics.slice(0, 5),
|
||||
actionItems: [],
|
||||
coachingNotes: [],
|
||||
complianceFlags: [],
|
||||
patientSatisfaction: 'Analysis unavailable',
|
||||
callOutcome: 'Analysis unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user