diff --git a/src/ai/ai-enrichment.service.ts b/src/ai/ai-enrichment.service.ts new file mode 100644 index 0000000..05a4afb --- /dev/null +++ b/src/ai/ai-enrichment.service.ts @@ -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('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 { + // 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 }; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 5917928..06a3a9f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import configuration from './config/configuration'; import { PlatformModule } from './platform/platform.module'; +import { ExotelModule } from './exotel/exotel.module'; @Module({ imports: [ @@ -10,6 +11,7 @@ import { PlatformModule } from './platform/platform.module'; isGlobal: true, }), PlatformModule, + ExotelModule, ], }) export class AppModule {} diff --git a/src/exotel/.gitkeep b/src/exotel/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/exotel/exotel.controller.ts b/src/exotel/exotel.controller.ts new file mode 100644 index 0000000..4cbb38f --- /dev/null +++ b/src/exotel/exotel.controller.ts @@ -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' }; + } +} diff --git a/src/exotel/exotel.module.ts b/src/exotel/exotel.module.ts new file mode 100644 index 0000000..63d412e --- /dev/null +++ b/src/exotel/exotel.module.ts @@ -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 {} diff --git a/src/exotel/exotel.service.ts b/src/exotel/exotel.service.ts new file mode 100644 index 0000000..51564a4 --- /dev/null +++ b/src/exotel/exotel.service.ts @@ -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; + } +} diff --git a/src/exotel/exotel.types.ts b/src/exotel/exotel.types.ts new file mode 100644 index 0000000..f1d4b75 --- /dev/null +++ b/src/exotel/exotel.types.ts @@ -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; +};