mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Appointments created via WhatsApp had null patientId — lookup_appointments couldn't find them. Now resolves patient before booking and includes patientId in the createAppointment mutation. Fixed in both flow tool registry and legacy messaging service. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
421 lines
23 KiB
TypeScript
421 lines
23 KiB
TypeScript
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { generateText, tool, stepCountIs } from 'ai';
|
|
import { z } from 'zod';
|
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
|
import { MessagingConversationService } from './messaging-conversation.service';
|
|
import { FlowExecutionService } from './flow/flow-execution.service';
|
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
|
import { CallerContextService } from '../caller/caller-context.service';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { createAiModel } from '../ai/ai-provider';
|
|
import { AiConfigService } from '../config/ai-config.service';
|
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
|
import type { NormalizedMessage, ListSection, InteractiveButton } from './types';
|
|
import type { LanguageModel } from 'ai';
|
|
|
|
@Injectable()
|
|
export class MessagingService {
|
|
private readonly logger = new Logger(MessagingService.name);
|
|
private readonly aiModel: LanguageModel | null;
|
|
private readonly auth: string;
|
|
|
|
constructor(
|
|
private config: ConfigService,
|
|
private provider: MessagingProvider,
|
|
private conversation: MessagingConversationService,
|
|
private caller: CallerResolutionService,
|
|
private callerContext: CallerContextService,
|
|
private platform: PlatformGraphqlService,
|
|
private aiConfig: AiConfigService,
|
|
@Optional() private flowExecution: FlowExecutionService,
|
|
) {
|
|
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'),
|
|
});
|
|
|
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
|
|
|
if (this.aiModel) {
|
|
this.logger.log(`WhatsApp AI configured: ${cfg.provider}/${cfg.model}`);
|
|
} else {
|
|
this.logger.warn('WhatsApp AI not configured — will send fallback replies');
|
|
}
|
|
}
|
|
|
|
async handleInbound(message: NormalizedMessage): Promise<void> {
|
|
const { phone, name, text } = message;
|
|
const replyId = message.interactiveReply?.id;
|
|
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}${replyId ? ` [reply_id=${replyId}]` : ''}`);
|
|
|
|
// Delegate to flow engine if published flows exist
|
|
if (this.flowExecution?.hasFlows()) {
|
|
this.logger.log(`[WA] Delegating to flow engine`);
|
|
await this.flowExecution.handleMessage(message);
|
|
return;
|
|
}
|
|
|
|
// Fallback: hardcoded AI chat (legacy — will be removed once flows are validated)
|
|
if (!this.aiModel) {
|
|
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
|
|
return;
|
|
}
|
|
|
|
// 1. Resolve caller
|
|
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
|
|
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
|
|
return null;
|
|
});
|
|
|
|
// 2. Build context
|
|
let callerContextPrompt = '';
|
|
if (resolved && !resolved.isNew && resolved.leadId) {
|
|
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
|
|
if (ctx) {
|
|
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
|
|
}
|
|
}
|
|
|
|
// 3. Load conversation history
|
|
const history = await this.conversation.getHistory(phone);
|
|
// For interactive replies, include the selection ID so the AI can
|
|
// extract structured data (e.g. "doc:{uuid}:{name}" → doctorId)
|
|
let userContent = text;
|
|
if (message.type === 'interactive_reply' && message.interactiveReply?.id) {
|
|
userContent = `[Selected: ${message.interactiveReply.title}] (selection_id: ${message.interactiveReply.id})`;
|
|
}
|
|
const messages = [
|
|
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
|
{ role: 'user' as const, content: userContent },
|
|
];
|
|
|
|
// 4. Build system prompt
|
|
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
|
|
|
// 5. Build tools
|
|
const tools = this.buildTools(phone);
|
|
|
|
// 6. Run AI
|
|
try {
|
|
const result = await generateText({
|
|
model: this.aiModel,
|
|
system: systemPrompt,
|
|
messages,
|
|
tools,
|
|
stopWhen: stepCountIs(5),
|
|
});
|
|
|
|
const reply = result.text?.trim();
|
|
if (reply) {
|
|
await this.provider.sendText(phone, reply);
|
|
}
|
|
|
|
// 7. Persist conversation
|
|
await this.conversation.addMessages(phone, [
|
|
{ role: 'user', content: text, timestamp: Date.now() },
|
|
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
|
|
]);
|
|
|
|
} catch (err: any) {
|
|
this.logger.error(`[WA] AI error: ${err.message}`);
|
|
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
|
|
}
|
|
}
|
|
|
|
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
|
|
// Pull hospital name from theme config if available
|
|
const hospitalName = this.config.get<string>('theme.hospitalName') ?? 'our hospital';
|
|
|
|
return `You are a friendly WhatsApp assistant for ${hospitalName}. You help patients with:
|
|
- Answering questions about departments, doctors, timings, fees
|
|
- Booking appointments
|
|
- Checking existing appointments
|
|
|
|
APPOINTMENT BOOKING FLOW — follow this exact sequence:
|
|
1. When the patient wants to book, IMMEDIATELY call send_department_list. Do NOT ask "which department" in text.
|
|
2. When the patient picks a department (selection_id starts with "dept:"), IMMEDIATELY call send_doctor_list with the department name after "dept:".
|
|
3. When the patient picks a doctor (selection_id starts with "doc:"), IMMEDIATELY call send_slot_list. Extract the doctorId from the selection_id format "doc:{doctorId}:{doctorName}" — use the UUID between the first and second colon as doctorId, and the text after the second colon as doctorName.
|
|
4. When the patient picks a slot (selection_id starts with "slot:"), call send_confirm_buttons with a summary. Extract the datetime from "slot:{doctorId}:{datetime}".
|
|
5. When the patient taps Confirm (selection_id = "confirm_booking"), call book_appointment with all collected details.
|
|
6. After booking, send a confirmation with doctor name, date, time, and reference number.
|
|
|
|
CRITICAL: Always use the interactive list/button tools. Never ask questions in text when a tool exists. When a user message contains "selection_id:", parse it and call the appropriate tool immediately.
|
|
|
|
OTHER RULES:
|
|
- Be concise — WhatsApp messages should be short (2-3 sentences max).
|
|
- No markdown formatting (no **, ##, bullets). Plain text only.
|
|
- If the patient mentions a specific department or doctor upfront, skip ahead in the flow.
|
|
- If the patient asks something you can't help with, suggest they call ${hospitalName} directly.
|
|
- Always be warm and professional. Use the patient's name when known.
|
|
- Reply in the same language the patient uses. Button/list labels stay in English.
|
|
|
|
CURRENT PATIENT:
|
|
Name: ${name || 'Unknown'}
|
|
Phone: ${phone}
|
|
${isNew ? 'New patient — no prior records.' : ''}
|
|
${callerContext ? `\n${callerContext}` : ''}`;
|
|
}
|
|
|
|
private buildTools(phone: string) {
|
|
const provider = this.provider;
|
|
const platform = this.platform;
|
|
const auth = this.auth;
|
|
const logger = this.logger;
|
|
const callerService = this.caller;
|
|
|
|
return {
|
|
lookup_appointments: tool({
|
|
description: 'Look up existing appointments for the current patient.',
|
|
inputSchema: z.object({
|
|
patientId: z.string().optional().describe('Patient ID — omit to use current caller'),
|
|
}),
|
|
execute: async ({ patientId }) => {
|
|
let pid = patientId;
|
|
if (!pid) {
|
|
const resolved = await callerService.resolve(phone, auth).catch(() => null);
|
|
pid = resolved?.patientId;
|
|
}
|
|
if (!pid) return { appointments: [], message: 'No patient record found.' };
|
|
|
|
const data = await platform.query<any>(
|
|
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
|
id scheduledAt appointmentStatus doctorName department reasonForVisit
|
|
} } } }`,
|
|
);
|
|
const appts = data.appointments.edges.map((e: any) => e.node);
|
|
logger.log(`[WA-TOOL] lookup_appointments: ${appts.length} found`);
|
|
return { appointments: appts };
|
|
},
|
|
}),
|
|
|
|
send_department_list: tool({
|
|
description: 'Send an interactive WhatsApp list of available departments. Call when patient wants to book but hasn\'t specified a department.',
|
|
inputSchema: z.object({}),
|
|
execute: async () => {
|
|
const data = await platform.query<any>(
|
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
|
);
|
|
const departments = [...new Set(
|
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
|
)] as string[];
|
|
|
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
|
|
|
const sections: ListSection[] = [{
|
|
title: 'Departments',
|
|
rows: departments.slice(0, 10).map(d => ({
|
|
id: `dept:${d}`,
|
|
title: d.substring(0, 24),
|
|
})),
|
|
}];
|
|
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
|
|
logger.log(`[WA-TOOL] send_department_list: ${departments.length} departments`);
|
|
return { sent: true, departments };
|
|
},
|
|
}),
|
|
|
|
send_doctor_list: tool({
|
|
description: 'Send an interactive WhatsApp list of doctors in a department. Call after patient selects a department.',
|
|
inputSchema: z.object({
|
|
department: z.string().describe('Department name'),
|
|
}),
|
|
execute: async ({ department }) => {
|
|
const data = await platform.query<any>(
|
|
`{ doctors(first: 50) { edges { node {
|
|
id fullName { firstName lastName }
|
|
department specialty
|
|
consultationFeeNew { amountMicros currencyCode }
|
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
|
} } } }`,
|
|
);
|
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
|
const deptDocs = allDocs.filter((d: any) =>
|
|
d.department?.toLowerCase() === department.toLowerCase(),
|
|
);
|
|
|
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
|
|
|
const sections: ListSection[] = [{
|
|
title: department.substring(0, 24),
|
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
|
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
|
const fee = d.consultationFeeNew?.amountMicros
|
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
|
: '';
|
|
return {
|
|
id: `doc:${d.id}:${docName}`,
|
|
title: docName.substring(0, 24),
|
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
|
};
|
|
}),
|
|
}];
|
|
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
|
logger.log(`[WA-TOOL] send_doctor_list: ${deptDocs.length} doctors in ${department}`);
|
|
return { sent: true, count: deptDocs.length };
|
|
},
|
|
}),
|
|
|
|
send_slot_list: tool({
|
|
description: 'Send available time slots for a doctor as a WhatsApp list. Call after patient selects a doctor.',
|
|
inputSchema: z.object({
|
|
doctorId: z.string().describe('Doctor ID from the list selection'),
|
|
doctorName: z.string().describe('Doctor name for display'),
|
|
date: z.string().optional().describe('Date in YYYY-MM-DD. Defaults to tomorrow.'),
|
|
}),
|
|
execute: async ({ doctorId, doctorName, date }) => {
|
|
// Default to tomorrow, use IST for day-of-week matching
|
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
|
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
|
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
|
|
|
const data = await platform.query<any>(
|
|
`{ doctors(first: 50) { edges { node {
|
|
id fullName { firstName lastName }
|
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
|
} } } }`,
|
|
);
|
|
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
|
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
|
if (!doctor) {
|
|
return { sent: false, message: `Doctor not found.` };
|
|
}
|
|
|
|
// Find visit slots for the target day-of-week
|
|
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
|
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
|
|
|
if (!daySlots.length) {
|
|
return { sent: false, message: `${doctorName} is not available on ${targetDay.charAt(0) + targetDay.slice(1).toLowerCase()} (${targetDate}). Please choose a different date.` };
|
|
}
|
|
|
|
// Generate hourly time slots from startTime-endTime
|
|
const timeSlots: { time: string; clinic: string }[] = [];
|
|
for (const ds of daySlots) {
|
|
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
|
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
|
const clinicName = ds.clinic?.clinicName ?? '';
|
|
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
|
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
|
}
|
|
}
|
|
|
|
if (!timeSlots.length) {
|
|
return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` };
|
|
}
|
|
|
|
const sections: ListSection[] = [{
|
|
title: targetDate, // section title max 24 chars
|
|
rows: timeSlots.map((s) => ({
|
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
|
title: s.time, // row title max 24 chars
|
|
description: s.clinic || undefined,
|
|
})),
|
|
}];
|
|
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
|
logger.log(`[WA-TOOL] send_slot_list: ${timeSlots.length} slots for ${doctorName} on ${targetDate} (${targetDay})`);
|
|
return { sent: true, slots: timeSlots.length };
|
|
},
|
|
}),
|
|
|
|
send_confirm_buttons: tool({
|
|
description: 'Send confirmation buttons before booking. Call after all details are collected.',
|
|
inputSchema: z.object({
|
|
summary: z.string().describe('Appointment summary to show the patient'),
|
|
}),
|
|
execute: async ({ summary }) => {
|
|
const buttons: InteractiveButton[] = [
|
|
{ id: 'confirm_booking', title: 'Confirm' },
|
|
{ id: 'cancel_booking', title: 'Cancel' },
|
|
];
|
|
await provider.sendButtons(phone, summary, buttons);
|
|
logger.log(`[WA-TOOL] send_confirm_buttons`);
|
|
return { sent: true };
|
|
},
|
|
}),
|
|
|
|
book_appointment: tool({
|
|
description: 'Book the appointment after patient confirms. Only call AFTER the patient taps Confirm.',
|
|
inputSchema: z.object({
|
|
patientName: z.string().describe('Patient name'),
|
|
phoneNumber: z.string().describe('Patient phone number'),
|
|
department: z.string().describe('Department'),
|
|
doctorName: z.string().describe('Doctor name'),
|
|
scheduledAt: z.string().describe('ISO datetime'),
|
|
reason: z.string().describe('Reason for visit'),
|
|
}),
|
|
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
|
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
|
try {
|
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
|
const resolved = await callerService.resolve(cleanPhone, auth).catch(() => null);
|
|
|
|
// Conflict check: same doctor + same date
|
|
const bookingDate = scheduledAt.split('T')[0];
|
|
const existingAppts = await platform.query<any>(
|
|
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
|
).catch(() => ({ appointments: { edges: [] } }));
|
|
|
|
const conflicts = existingAppts.appointments.edges
|
|
.map((e: any) => e.node)
|
|
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
|
|
|
// Check if this patient already has a booking with this doctor on the same date
|
|
const patientConflict = conflicts.find((a: any) =>
|
|
a.patientName?.toLowerCase().includes(patientName.split(' ')[0].toLowerCase()),
|
|
);
|
|
if (patientConflict) {
|
|
logger.log(`[WA-BOOK] Conflict: patient already booked with ${doctorName} on ${bookingDate}`);
|
|
return { booked: false, message: `You already have an appointment with ${doctorName} on ${bookingDate}. Would you like to choose a different date?` };
|
|
}
|
|
|
|
// Check if the doctor has too many appointments at this exact time
|
|
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
|
if (slotConflicts.length >= 3) {
|
|
logger.log(`[WA-BOOK] Conflict: ${doctorName} fully booked at ${scheduledAt} (${slotConflicts.length} existing)`);
|
|
return { booked: false, message: `${doctorName} is fully booked at this time. Please choose a different slot.` };
|
|
}
|
|
|
|
let patientId = resolved?.patientId;
|
|
if (resolved?.isNew) {
|
|
const firstName = patientName.split(' ')[0];
|
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
|
try {
|
|
const p = await platform.query<any>(
|
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
|
);
|
|
const patientId = p?.createPatient?.id;
|
|
await platform.query<any>(
|
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
|
);
|
|
} catch (err: any) {
|
|
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
const result = await platform.query<any>(
|
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, ...(patientId ? { patientId } : {}) } },
|
|
);
|
|
const id = result?.createAppointment?.id;
|
|
if (id) {
|
|
logger.log(`[WA-BOOK] Success: appointmentId=${id}`);
|
|
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
|
|
}
|
|
return { booked: false, message: 'Booking failed. Please try again.' };
|
|
} catch (err: any) {
|
|
logger.error(`[WA-BOOK] Failed: ${err.message}`);
|
|
return { booked: false, message: 'Booking failed. Please call us directly.' };
|
|
}
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
}
|