Files
helix-engage-server/src/ai/ai-enrichment.service.ts
2026-04-16 11:36:10 +05:30

156 lines
5.1 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateObject } from 'ai';
import type { LanguageModel } from 'ai';
import { z } from 'zod';
import { createAiModel } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service';
type LeadContext = {
firstName?: string;
lastName?: string;
leadSource?: string;
interestedService?: string;
leadStatus?: string;
contactAttempts?: number;
createdAt?: string;
campaignId?: string;
patient?: {
age?: number;
type?: string; // 'new' | 'returning' | 'vip'
hasRecords?: boolean;
};
upcomingAppointments?: Array<{
scheduledAt?: string;
doctorName?: string;
appointmentType?: string;
status?: string;
}>;
activities?: { activityType: string; summary: string }[];
};
type EnrichmentResult = {
aiSummary: string;
aiSuggestedAction: string;
};
const enrichmentSchema = z.object({
aiSummary: z
.string()
.describe('1-2 sentence summary of who this lead is and their history'),
aiSuggestedAction: z
.string()
.describe('5-10 word suggested action for the agent'),
});
@Injectable()
export class AiEnrichmentService {
private readonly logger = new Logger(AiEnrichmentService.name);
private readonly aiModel: LanguageModel | null;
constructor(
private config: ConfigService,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
if (!this.aiModel) {
this.logger.warn('AI not configured — enrichment uses fallback');
}
}
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
if (!this.aiModel) {
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 patientContext = lead.patient
? `Age: ${lead.patient.age ?? 'Unknown'} | Type: ${lead.patient.type ?? 'Unknown'} | Prior Records: ${lead.patient.hasRecords ? 'Yes' : 'No'}`
: 'No patient record linked';
const appointmentContext = lead.upcomingAppointments?.length
? lead.upcomingAppointments
.map(
(a) =>
`- ${a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString() : 'TBD'}: ${a.appointmentType ?? 'Appointment'} with ${a.doctorName ?? 'Doctor'} (${a.status ?? 'Scheduled'})`,
)
.join('\n')
: 'No upcoming appointments';
const { object } = await generateObject({
model: this.aiModel!,
schema: enrichmentSchema,
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
leadName:
`${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
leadSource: lead.leadSource ?? 'Unknown',
interestedService: lead.interestedService ?? 'Unknown',
leadStatus: lead.leadStatus ?? 'Unknown',
daysSince,
contactAttempts: lead.contactAttempts ?? 0,
patientContext,
appointmentContext,
activities: activitiesText,
}),
});
this.logger.log(
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
);
return object;
} 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 };
}
}