diff --git a/src/app.module.ts b/src/app.module.ts index b9bdfe0..5ea0383 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index b695002..9136c23 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -50,4 +50,13 @@ export class SessionService implements OnModuleInit { async unlockSession(agentId: string): Promise { await this.redis.del(this.key(agentId)); } + + // Generic cache operations for any module + async getCache(key: string): Promise { + return this.redis.get(key); + } + + async setCache(key: string, value: string, ttlSeconds: number): Promise { + await this.redis.set(key, value, 'EX', ttlSeconds); + } } diff --git a/src/recordings/recordings.controller.ts b/src/recordings/recordings.controller.ts new file mode 100644 index 0000000..a604892 --- /dev/null +++ b/src/recordings/recordings.controller.ts @@ -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); + } + } +} diff --git a/src/recordings/recordings.module.ts b/src/recordings/recordings.module.ts new file mode 100644 index 0000000..2170128 --- /dev/null +++ b/src/recordings/recordings.module.ts @@ -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 {} diff --git a/src/recordings/recordings.service.ts b/src/recordings/recordings.service.ts new file mode 100644 index 0000000..eb01dd7 --- /dev/null +++ b/src/recordings/recordings.service.ts @@ -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 { + 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 { + 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', + }; + } + } +}