mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
- 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>
402 lines
20 KiB
TypeScript
402 lines
20 KiB
TypeScript
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 ? `Mon–Fri ${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?';
|
||
}
|
||
}
|