Files
helix-engage-server/src/ai/ai-chat.controller.ts
saridsa2 9688d5144e feat: migrate AI to Vercel AI SDK, add OpenAI provider, fix worklist
- Replace raw @anthropic-ai/sdk with Vercel AI SDK (generateText, tool, generateObject)
- Add provider abstraction (ai-provider.ts) — swap OpenAI/Anthropic via env var
- AI chat controller: dynamic KB from platform (clinics, packages, insurance), zero hardcoding
- AI enrichment service: use generateObject with Zod schema instead of manual JSON parsing
- Worklist: resolve agent name from platform currentUser API instead of JWT decode
- Worklist: fix GraphQL field names to match platform remapping (source, status, direction, etc.)
- Config: add AI_PROVIDER, AI_MODEL, OPENAI_API_KEY env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:45:05 +05:30

402 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateText, tool, stepCountIs } from 'ai';
import type { LanguageModel } from 'ai';
import { z } from 'zod';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { createAiModel, isAiConfigured } from './ai-provider';
type ChatRequest = {
message: string;
context?: { callerPhone?: string; leadId?: string; leadName?: string };
};
@Controller('api/ai')
export class AiChatController {
private readonly logger = new Logger(AiChatController.name);
private readonly aiModel: LanguageModel | null;
private knowledgeBase: string | null = null;
private kbLoadedAt = 0;
private readonly kbTtlMs = 5 * 60 * 1000;
constructor(
private config: ConfigService,
private platform: PlatformGraphqlService,
) {
this.aiModel = createAiModel(config);
if (!this.aiModel) {
this.logger.warn('AI not configured — chat uses fallback');
} else {
const provider = config.get<string>('ai.provider') ?? 'openai';
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
this.logger.log(`AI configured: ${provider}/${model}`);
}
}
@Post('chat')
async chat(@Body() body: ChatRequest, @Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
if (!body.message?.trim()) throw new HttpException('message required', 400);
const msg = body.message.trim();
const ctx = body.context;
let prefix = '';
if (ctx) {
const parts: string[] = [];
if (ctx.leadName) parts.push(`Caller: ${ctx.leadName}`);
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
if (parts.length) prefix = `[Active call: ${parts.join(', ')}]\n\n`;
}
if (!this.aiModel) {
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' };
}
try {
return await this.chatWithTools(`${prefix}${msg}`, auth);
} catch (err) {
this.logger.error(`AI chat error: ${err}`);
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' };
}
}
private async buildKnowledgeBase(auth: string): Promise<string> {
const now = Date.now();
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
return this.knowledgeBase;
}
this.logger.log('Building knowledge base from platform data...');
const sections: string[] = [];
try {
const clinicData = await this.platform.queryWithAuth<any>(
`{ clinics(first: 20) { edges { node {
id name clinicName
addressCustom { addressStreet1 addressCity addressState addressPostcode }
weekdayHours saturdayHours sundayHours
status walkInAllowed onlineBooking
cancellationWindowHours arriveEarlyMin requiredDocuments
acceptsCash acceptsCard acceptsUpi
} } } }`,
undefined, auth,
);
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
if (clinics.length) {
sections.push('## Clinics');
for (const c of clinics) {
const addr = c.addressCustom
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
: '';
const hours = [
c.weekdayHours ? `MonFri ${c.weekdayHours}` : '',
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
].filter(Boolean).join(', ');
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
}
const rulesClinic = clinics[0];
const rules: string[] = [];
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
if (rulesClinic.onlineBooking) rules.push('Online booking available');
if (rules.length) {
sections.push('\n### Booking Rules');
sections.push(rules.map(r => `- ${r}`).join('\n'));
}
const payments: string[] = [];
if (rulesClinic.acceptsCash === 'YES') payments.push('Cash');
if (rulesClinic.acceptsCard === 'YES') payments.push('Cards');
if (rulesClinic.acceptsUpi === 'YES') payments.push('UPI');
if (payments.length) {
sections.push('\n### Payments');
sections.push(`Accepted: ${payments.join(', ')}.`);
}
}
} catch (err) {
this.logger.warn(`Failed to fetch clinics: ${err}`);
}
try {
const pkgData = await this.platform.queryWithAuth<any>(
`{ healthPackages(first: 30, filter: { active: { eq: true } }) { edges { node {
id name packageName description
price { amountMicros currencyCode }
discountedPrice { amountMicros currencyCode }
department inclusions durationMin eligibility
packageTests { edges { node { labTest { testName category } order } } }
} } } }`,
undefined, auth,
);
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
if (packages.length) {
sections.push('\n## Health Packages');
for (const p of packages) {
const price = p.price ? `${p.price.amountMicros / 1_000_000}` : '';
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
const dept = p.department ? ` [${p.department}]` : '';
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
const tests = p.packageTests?.edges
?.map((e: any) => e.node)
?.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
?.map((t: any) => t.labTest?.testName)
?.filter(Boolean);
if (tests?.length) {
sections.push(` Tests: ${tests.join(', ')}`);
} else if (p.inclusions) {
sections.push(` Includes: ${p.inclusions}`);
}
}
}
} catch (err) {
this.logger.warn(`Failed to fetch health packages: ${err}`);
}
try {
const insData = await this.platform.queryWithAuth<any>(
`{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node {
id name insurerName tpaName settlementType planTypesAccepted
} } } }`,
undefined, auth,
);
const insurers = insData.insurancePartners.edges.map((e: any) => e.node);
if (insurers.length) {
sections.push('\n## Insurance Partners');
const names = insurers.map((i: any) => {
const settlement = i.settlementType ? ` (${i.settlementType.toLowerCase()})` : '';
return `${i.insurerName ?? i.name}${settlement}`;
});
sections.push(names.join(', '));
}
} catch (err) {
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
}
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
this.kbLoadedAt = now;
this.logger.log(`Knowledge base built (${this.knowledgeBase.length} chars)`);
return this.knowledgeBase;
}
private buildSystemPrompt(kb: string): string {
return `You are an AI assistant for call center agents at a hospital.
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
RULES:
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
4. If a tool returns no data, say "I couldn't find that in our system."
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
6. NEVER give medical advice, diagnosis, or treatment recommendations.
7. NEVER share sensitive hospital data (revenue, salaries, internal policies).
8. Format with bullet points for easy scanning.
${kb}`;
}
private async chatWithTools(userMessage: string, auth: string) {
const kb = await this.buildKnowledgeBase(auth);
const systemPrompt = this.buildSystemPrompt(kb);
const platformService = this.platform;
const { text, steps } = await generateText({
model: this.aiModel!,
system: systemPrompt,
prompt: userMessage,
stopWhen: stepCountIs(5),
tools: {
lookup_patient: tool({
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.',
inputSchema: z.object({
phone: z.string().optional().describe('Phone number to search'),
name: z.string().optional().describe('Patient/lead name to search'),
}),
execute: async ({ phone, name }) => {
const data = await platformService.queryWithAuth<any>(
`{ leads(first: 50) { edges { node {
id name contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
contactEmail { primaryEmail }
source status interestedService assignedAgent
leadScore contactAttempts firstContacted lastContacted
aiSummary aiSuggestedAction patientId campaignId
} } } }`,
undefined, auth,
);
const leads = data.leads.edges.map((e: any) => e.node);
const phoneClean = (phone ?? '').replace(/\D/g, '');
const nameClean = (name ?? '').toLowerCase();
const matched = leads.filter((l: any) => {
if (phoneClean) {
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
}
if (nameClean) {
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
if (fn.includes(nameClean)) return true;
}
return false;
});
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
return { found: true, count: matched.length, leads: matched };
},
}),
lookup_appointments: tool({
description: 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
inputSchema: z.object({
patientId: z.string().describe('Patient ID'),
}),
execute: async ({ patientId }) => {
const data = await platformService.queryWithAuth<any>(
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name scheduledAt durationMin appointmentType status
doctorName department reasonForVisit doctorId
} } } }`,
undefined, auth,
);
return { appointments: data.appointments.edges.map((e: any) => e.node) };
},
}),
lookup_call_history: tool({
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
inputSchema: z.object({
leadId: z.string().describe('Lead ID'),
}),
execute: async ({ leadId }) => {
const data = await platformService.queryWithAuth<any>(
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name direction callStatus agentName startedAt durationSec disposition
} } } }`,
undefined, auth,
);
return { calls: data.calls.edges.map((e: any) => e.node) };
},
}),
lookup_lead_activities: tool({
description: 'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
inputSchema: z.object({
leadId: z.string().describe('Lead ID'),
}),
execute: async ({ leadId }) => {
const data = await platformService.queryWithAuth<any>(
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id activityType summary occurredAt performedBy channel
} } } }`,
undefined, auth,
);
return { activities: data.leadActivities.edges.map((e: any) => e.node) };
},
}),
lookup_doctor: tool({
description: 'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
inputSchema: z.object({
doctorName: z.string().describe('Doctor name (e.g. "Patel", "Sharma")'),
}),
execute: async ({ doctorName }) => {
const data = await platformService.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience
visitingHours
consultationFeeNew { amountMicros currencyCode }
consultationFeeFollowUp { amountMicros currencyCode }
active registrationNumber
clinic { id name clinicName }
} } } }`,
undefined, auth,
);
const doctors = data.doctors.edges.map((e: any) => e.node);
const search = doctorName.toLowerCase();
const matched = doctors.filter((d: any) => {
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
return full.includes(search);
});
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}"` };
return {
found: true,
doctors: matched.map((d: any) => ({
...d,
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
feeNewFormatted: d.consultationFeeNew ? `${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
feeFollowUpFormatted: d.consultationFeeFollowUp ? `${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
})),
};
},
}),
},
});
const toolCallCount = steps.filter(s => s.toolCalls?.length).length;
this.logger.log(`Response (${text.length} chars, ${toolCallCount} tool steps)`);
return {
reply: text,
sources: toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
confidence: 'high',
};
}
private async fallback(msg: string, auth: string): Promise<string> {
try {
const doctors = await this.platform.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
name fullName { firstName lastName } department specialty visitingHours
consultationFeeNew { amountMicros currencyCode }
clinic { name clinicName }
} } } }`,
undefined, auth,
);
const docs = doctors.doctors.edges.map((e: any) => e.node);
const l = msg.toLowerCase();
const matchedDoc = docs.find((d: any) => {
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
return l.split(/\s+/).some((w: string) => w.length > 2 && full.includes(w));
});
if (matchedDoc) {
const fee = matchedDoc.consultationFeeNew ? `${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
const clinic = matchedDoc.clinic?.clinicName ?? '';
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
}
if (l.includes('doctor') || l.includes('available')) {
return 'Doctors: ' + docs.map((d: any) =>
`${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`
).join(', ') + '.';
}
if (l.includes('package') || l.includes('checkup') || l.includes('screening')) {
const pkgs = await this.platform.queryWithAuth<any>(
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
undefined, auth,
);
const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
if (packages.length) {
return 'Packages: ' + packages.map((p: any) =>
`${p.packageName}${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`
).join(' | ') + '.';
}
}
} catch {
// platform unreachable
}
return 'I can help with: doctor schedules, patient lookup, appointments, packages, insurance. What do you need?';
}
}