mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: add Exotel webhook controller and service for call event parsing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
113
src/ai/ai-enrichment.service.ts
Normal file
113
src/ai/ai-enrichment.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
|
||||||
|
type LeadContext = {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
leadSource?: string;
|
||||||
|
interestedService?: string;
|
||||||
|
leadStatus?: string;
|
||||||
|
contactAttempts?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
campaignId?: string;
|
||||||
|
activities?: { activityType: string; summary: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnrichmentResult = {
|
||||||
|
aiSummary: string;
|
||||||
|
aiSuggestedAction: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiEnrichmentService {
|
||||||
|
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||||
|
private client: Anthropic | null = null;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
const apiKey = config.get<string>('ai.anthropicApiKey');
|
||||||
|
if (apiKey) {
|
||||||
|
this.client = new Anthropic({ apiKey });
|
||||||
|
} else {
|
||||||
|
this.logger.warn('ANTHROPIC_API_KEY not set — AI enrichment disabled, using fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
||||||
|
// Fallback if no API key configured
|
||||||
|
if (!this.client) {
|
||||||
|
return this.fallbackEnrichment(lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const daysSince = lead.createdAt
|
||||||
|
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const activitiesText = lead.activities?.length
|
||||||
|
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
||||||
|
: 'No previous interactions';
|
||||||
|
|
||||||
|
const prompt = `You are an AI assistant for a hospital call center at Global Hospital.
|
||||||
|
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
||||||
|
|
||||||
|
Lead details:
|
||||||
|
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
||||||
|
- Source: ${lead.leadSource ?? 'Unknown'}
|
||||||
|
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
||||||
|
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
||||||
|
- Lead age: ${daysSince} days
|
||||||
|
- Contact attempts: ${lead.contactAttempts ?? 0}
|
||||||
|
|
||||||
|
Recent activity:
|
||||||
|
${activitiesText}
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON (no markdown, no code blocks):
|
||||||
|
{"aiSummary": "1-2 sentence summary of who this lead is and their history", "aiSuggestedAction": "5-10 word suggested action for the agent"}`;
|
||||||
|
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
max_tokens: 200,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
||||||
|
const parsed = JSON.parse(text.trim());
|
||||||
|
|
||||||
|
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||||
|
return {
|
||||||
|
aiSummary: parsed.aiSummary,
|
||||||
|
aiSuggestedAction: parsed.aiSuggestedAction,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`AI enrichment failed: ${error}`);
|
||||||
|
return this.fallbackEnrichment(lead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||||
|
const daysSince = lead.createdAt
|
||||||
|
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const attempts = lead.contactAttempts ?? 0;
|
||||||
|
const service = lead.interestedService ?? 'general inquiry';
|
||||||
|
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
let action: string;
|
||||||
|
|
||||||
|
if (attempts === 0) {
|
||||||
|
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
||||||
|
action = `Introduce services and offer appointment booking`;
|
||||||
|
} else if (attempts === 1) {
|
||||||
|
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
||||||
|
action = `Follow up on previous conversation, offer appointment`;
|
||||||
|
} else {
|
||||||
|
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
||||||
|
action = `Prioritize appointment booking — high-intent lead`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aiSummary: summary, aiSuggestedAction: action };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
|
import { ExotelModule } from './exotel/exotel.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -10,6 +11,7 @@ import { PlatformModule } from './platform/platform.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
|
ExotelModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
24
src/exotel/exotel.controller.ts
Normal file
24
src/exotel/exotel.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpCode } from '@nestjs/common';
|
||||||
|
import { ExotelService } from './exotel.service';
|
||||||
|
import type { ExotelWebhookPayload } from './exotel.types';
|
||||||
|
|
||||||
|
@Controller('webhooks/exotel')
|
||||||
|
export class ExotelController {
|
||||||
|
private readonly logger = new Logger(ExotelController.name);
|
||||||
|
|
||||||
|
constructor(private readonly exotelService: ExotelService) {}
|
||||||
|
|
||||||
|
@Post('call-status')
|
||||||
|
@HttpCode(200)
|
||||||
|
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||||
|
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
|
||||||
|
|
||||||
|
const callEvent = this.exotelService.parseWebhook(payload);
|
||||||
|
|
||||||
|
// TODO: Forward to CallEventsService (Task 4)
|
||||||
|
// For now, just log
|
||||||
|
this.logger.log(`Call event: ${JSON.stringify(callEvent)}`);
|
||||||
|
|
||||||
|
return { status: 'received' };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/exotel/exotel.module.ts
Normal file
10
src/exotel/exotel.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ExotelController } from './exotel.controller';
|
||||||
|
import { ExotelService } from './exotel.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ExotelController],
|
||||||
|
providers: [ExotelService],
|
||||||
|
exports: [ExotelService],
|
||||||
|
})
|
||||||
|
export class ExotelModule {}
|
||||||
31
src/exotel/exotel.service.ts
Normal file
31
src/exotel/exotel.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { ExotelWebhookPayload, CallEvent } from './exotel.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExotelService {
|
||||||
|
private readonly logger = new Logger(ExotelService.name);
|
||||||
|
|
||||||
|
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||||
|
const { event_details, call_details } = payload;
|
||||||
|
|
||||||
|
const eventType = event_details.event_type === 'answered' ? 'answered'
|
||||||
|
: event_details.event_type === 'terminal' ? 'ended'
|
||||||
|
: 'ringing';
|
||||||
|
|
||||||
|
const callEvent: CallEvent = {
|
||||||
|
exotelCallSid: call_details.call_sid,
|
||||||
|
eventType,
|
||||||
|
direction: call_details.direction,
|
||||||
|
callerPhone: call_details.customer_details?.number ?? '',
|
||||||
|
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
|
||||||
|
agentPhone: call_details.assigned_agent_details?.number ?? '',
|
||||||
|
duration: call_details.total_talk_time,
|
||||||
|
recordingUrl: call_details.recordings?.[0]?.url,
|
||||||
|
callStatus: call_details.call_status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
|
||||||
|
return callEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/exotel/exotel.types.ts
Normal file
35
src/exotel/exotel.types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Exotel webhook payload (from their API docs)
|
||||||
|
export type ExotelWebhookPayload = {
|
||||||
|
event_details: {
|
||||||
|
event_type: 'answered' | 'terminal';
|
||||||
|
};
|
||||||
|
call_details: {
|
||||||
|
call_sid: string;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
call_status?: string;
|
||||||
|
total_talk_time?: number;
|
||||||
|
assigned_agent_details?: {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
};
|
||||||
|
customer_details?: {
|
||||||
|
number: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
recordings?: { url: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal call event (normalized)
|
||||||
|
export type CallEvent = {
|
||||||
|
exotelCallSid: string;
|
||||||
|
eventType: 'ringing' | 'answered' | 'ended';
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
callerPhone: string;
|
||||||
|
agentName: string;
|
||||||
|
agentPhone: string;
|
||||||
|
duration?: number;
|
||||||
|
recordingUrl?: string;
|
||||||
|
callStatus?: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user