import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request, Response } from 'express'; import { generateText, streamText, 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'; import { AiConfigService } from '../config/ai-config.service'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils'; 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, private aiConfig: AiConfigService, ) { const cfg = aiConfig.getConfig(); this.aiModel = createAiModel({ provider: cfg.provider, model: cfg.model, anthropicApiKey: config.get('ai.anthropicApiKey'), openaiApiKey: config.get('ai.openaiApiKey'), }); if (!this.aiModel) { this.logger.warn('AI not configured — chat uses fallback'); } else { this.logger.log(`AI configured: ${cfg.provider}/${cfg.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' }; } } @Post('stream') async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) { if (!auth) throw new HttpException('Authorization required', 401); const body = req.body; const messages = body.messages ?? []; if (!messages.length) throw new HttpException('messages required', 400); if (!this.aiModel) { res.status(500).json({ error: 'AI not configured' }); return; } const ctx = body.context; let systemPrompt: string; // Rules engine context — use rules-specific system prompt if (ctx?.type === 'rules-engine') { systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig); } else if (ctx?.type === 'supervisor') { systemPrompt = this.buildSupervisorSystemPrompt(); } else { const kb = await this.buildKnowledgeBase(auth); systemPrompt = this.buildSystemPrompt(kb); // Inject caller context so the AI knows who is selected if (ctx) { const parts: string[] = []; if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`); if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); if (parts.length) { systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`; } } } const platformService = this.platform; const isSupervisor = ctx?.type === 'supervisor'; // Supervisor tools — agent performance, campaign stats, team metrics const supervisorTools = { get_agent_performance: tool({ description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.', inputSchema: z.object({ agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'), }), execute: async ({ agentName }) => { const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([ platformService.queryWithAuth( `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, undefined, auth, ), platformService.queryWithAuth( `{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, auth, ), platformService.queryWithAuth( // Field names are label-derived camelCase on the // current platform schema. The legacy lowercase // names (ozonetelagentid etc.) only still exist on // staging workspaces that were synced from an // older SDK. See agent-config.service.ts for the // canonical explanation. `{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`, undefined, auth, ), platformService.queryWithAuth( `{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`, undefined, auth, ), ]); const calls = callsData.calls.edges.map((e: any) => e.node); const leads = leadsData.leads.edges.map((e: any) => e.node); const agents = agentsData.agents.edges.map((e: any) => e.node); const followUps = followUpsData.followUps.edges.map((e: any) => e.node); const agentMetrics = agents .filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase())) .map((agent: any) => { const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId); const totalCalls = agentCalls.length; const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length; const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length; const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name); const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length; const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0; return { name: agent.name, totalCalls, completed, missed, appointmentsBooked: apptBooked, conversionRate: `${conversionRate}%`, assignedLeads: agentLeads.length, pendingFollowUps, npsScore: agent.npsScore, maxIdleMinutes: agent.maxIdleMinutes, minNpsThreshold: agent.minNpsThreshold, minConversionPercent: agent.minConversion, belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold, belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion, }; }); return { agents: agentMetrics, totalAgents: agentMetrics.length }; }, }), get_campaign_stats: tool({ description: 'Get campaign performance stats — lead counts, conversion rates, sources.', inputSchema: z.object({}), execute: async () => { const [campaignsData, leadsData] = await Promise.all([ platformService.queryWithAuth( `{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`, undefined, auth, ), platformService.queryWithAuth( `{ leads(first: 200) { edges { node { id campaignId status } } } }`, undefined, auth, ), ]); const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node); const leads = leadsData.leads.edges.map((e: any) => e.node); return { campaigns: campaigns.map((c: any) => { const campaignLeads = leads.filter((l: any) => l.campaignId === c.id); const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length; return { name: c.campaignName, status: c.campaignStatus, platform: c.platform, totalLeads: campaignLeads.length, converted, conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%', budget: c.budget ? `₹${c.budget.amountMicros / 1_000_000}` : null, }; }), }; }, }), get_call_summary: tool({ description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.', inputSchema: z.object({ period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'), }), execute: async ({ period }) => { const data = await platformService.queryWithAuth( `{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, undefined, auth, ); const allCalls = data.calls.edges.map((e: any) => e.node); // Filter by period const now = new Date(); const start = new Date(now); if (period === 'today') start.setHours(0, 0, 0, 0); else if (period === 'month') start.setDate(start.getDate() - 30); else start.setDate(start.getDate() - 7); // default week const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start); const total = calls.length; const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length; const outbound = total - inbound; const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length; const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length; const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0); const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0; const dispositions: Record = {}; for (const c of calls) { if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1; } return { period: period ?? 'week', total, inbound, outbound, missed, completed, missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%', avgDurationSeconds: avgDuration, dispositions, }; }, }), get_sla_breaches: tool({ description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.', inputSchema: z.object({}), execute: async () => { const data = await platformService.queryWithAuth( `{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`, undefined, auth, ); const breached = data.calls.edges .map((e: any) => e.node) .filter((c: any) => (c.sla ?? 0) > 100); return { breachedCount: breached.length, items: breached.map((c: any) => ({ id: c.id, phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown', slaPercent: c.sla, missedAt: c.startedAt, agent: c.agentName, })), }; }, }), }; // Agent tools — patient lookup, appointments, doctors const agentTools = { lookup_patient: tool({ description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.', 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( `{ leads(first: 50) { edges { node { id name contactName { firstName lastName } contactPhone { primaryPhoneNumber } source status interestedService contactAttempts lastContacted aiSummary aiSuggestedAction patientId } } } }`, 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 appointments for a patient. Returns doctor, department, date, status.', inputSchema: z.object({ patientId: z.string().describe('Patient ID'), }), execute: async ({ patientId }) => { const data = await platformService.queryWithAuth( `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { id scheduledAt status doctorName department reasonForVisit } } } }`, undefined, auth, ); return { appointments: data.appointments.edges.map((e: any) => e.node) }; }, }), lookup_doctor: tool({ description: 'Get doctor details — schedule, clinic, fees, specialty.', inputSchema: z.object({ doctorName: z.string().describe('Doctor name'), }), execute: async ({ doctorName }) => { const data = await platformService.queryWithAuth( `{ doctors(first: 10) { edges { node { id fullName { firstName lastName } department specialty consultationFeeNew { amountMicros currencyCode } ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, undefined, auth, ); const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node)); // Strip "Dr." prefix and search flexibly const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim(); const searchWords = search.split(/\s+/); const matched = doctors.filter((d: any) => { const fn = (d.fullName?.firstName ?? '').toLowerCase(); const ln = (d.fullName?.lastName ?? '').toLowerCase(); const full = `${fn} ${ln}`; return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w))); }); this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`); if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` }; return { found: true, doctors: matched }; }, }), book_appointment: tool({ description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, preferred date/time, and reason before calling this.', inputSchema: z.object({ patientName: z.string().describe('Full name of the patient'), phoneNumber: z.string().describe('Patient phone number'), department: z.string().describe('Department for the appointment'), doctorName: z.string().describe('Doctor name'), scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'), reason: z.string().describe('Reason for visit'), }), execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => { this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`); try { const result = await platformService.queryWithAuth( `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, { data: { name: `AI Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, }, }, auth, ); const id = result?.createAppointment?.id; if (id) { return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` }; } return { booked: false, message: 'Appointment creation failed.' }; } catch (err: any) { this.logger.error(`[TOOL] book_appointment failed: ${err.message}`); return { booked: false, message: `Failed to book: ${err.message}` }; } }, }), create_lead: tool({ description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.', inputSchema: z.object({ name: z.string().describe('Caller name'), phoneNumber: z.string().describe('Phone number'), interest: z.string().describe('What they are enquiring about'), }), execute: async ({ name, phoneNumber, interest }) => { this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`); try { const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); const result = await platformService.queryWithAuth( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: `AI Enquiry — ${name}`, contactName: { firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' ') || '', }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'PHONE', status: 'NEW', interestedService: interest, }, }, auth, ); const id = result?.createLead?.id; if (id) { return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; } return { created: false, message: 'Lead creation failed.' }; } catch (err: any) { this.logger.error(`[TOOL] create_lead failed: ${err.message}`); return { created: false, message: `Failed: ${err.message}` }; } }, }), 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( `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, undefined, auth, ); return { calls: data.calls.edges.map((e: any) => e.node) }; }, }), }; const result = streamText({ model: this.aiModel, system: systemPrompt, messages, stopWhen: stepCountIs(5), tools: isSupervisor ? supervisorTools : agentTools, }); const response = result.toTextStreamResponse(); res.status(response.status); response.headers.forEach((value, key) => res.setHeader(key, value)); if (response.body) { const reader = response.body.getReader(); const pump = async () => { while (true) { const { done, value } = await reader.read(); if (done) { res.end(); break; } res.write(value); } }; pump().catch(() => res.end()); } else { res.end(); } } private async buildKnowledgeBase(auth: string): Promise { const now = Date.now(); if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) { this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`); return this.knowledgeBase; } this.logger.log('Building knowledge base from platform data...'); const sections: string[] = []; try { const clinicData = await this.platform.queryWithAuth( `{ 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 & TIMINGS'); for (const c of clinics) { const name = c.clinicName ?? c.name; const addr = c.addressCustom ? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ') : ''; sections.push(`### ${name}`); if (addr) sections.push(` Address: ${addr}`); if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`); if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`); sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`); if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`); } 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}`); sections.push('## CLINICS\nFailed to load clinic data.'); } // Add doctors to KB try { const docData = await this.platform.queryWithAuth( `{ doctors(first: 20) { edges { node { id fullName { firstName lastName } department specialty consultationFeeNew { amountMicros currencyCode } ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, undefined, auth, ); const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node)); if (doctors.length) { sections.push('\n## DOCTORS'); for (const d of doctors) { const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim(); const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : ''; // List ALL clinics this doctor visits in the KB so // the AI can answer questions like "where can I see // Dr. X" without needing a follow-up tool call. const clinics = d.clinics.map((c) => c.clinicName).join(', '); sections.push(`### ${name}`); sections.push(` Department: ${d.department ?? 'N/A'}`); sections.push(` Specialty: ${d.specialty ?? 'N/A'}`); if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`); if (fee) sections.push(` Consultation fee: ${fee}`); if (clinics) sections.push(` Clinics: ${clinics}`); } } } catch (err) { this.logger.warn(`Failed to fetch doctors for KB: ${err}`); } try { const pkgData = await this.platform.queryWithAuth( `{ 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}`); sections.push('\n## Health Packages\nFailed to load package data.'); } try { const insData = await this.platform.queryWithAuth( `{ 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}`); sections.push('\n## Insurance Partners\nFailed to load insurance data.'); } 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 buildSupervisorSystemPrompt(): string { return this.aiConfig.renderPrompt('supervisorChat', { hospitalName: this.getHospitalName(), }); } // Best-effort hospital name lookup for the AI prompts. Falls back // to a generic label so prompt rendering never throws. private getHospitalName(): string { return process.env.HOSPITAL_NAME ?? 'the hospital'; } private buildRulesSystemPrompt(currentConfig: any): string { const configJson = JSON.stringify(currentConfig, null, 2); return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist. ## YOUR ROLE You help the supervisor understand and optimize priority scoring rules. You explain concepts clearly and make specific recommendations based on their current configuration. ## SCORING FORMULA finalScore = baseWeight × slaMultiplier × campaignMultiplier - **Base Weight** (0-10): Each task type (missed calls, follow-ups, campaign leads, 2nd/3rd attempts) has a configurable weight. Higher weight = higher priority in the worklist. - **SLA Multiplier**: Time-based urgency curve. Formula: elapsed^1.6 (accelerates as SLA deadline approaches). Past SLA breach: 1.0 + (excess × 0.5). This means items get increasingly urgent as they approach their SLA deadline. - **Campaign Multiplier**: (campaignWeight/10) × (sourceWeight/10). IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35. - **SLA Thresholds**: Each task type has an SLA in minutes. Missed calls default to 12h (720min), follow-ups to 1d (1440min), campaign leads to 2d (2880min). ## SLA STATUS COLORS - Green (low): < 50% SLA elapsed - Amber (medium): 50-80% SLA elapsed - Red (high): 80-100% SLA elapsed - Dark red pulsing (critical): > 100% SLA elapsed (breached) ## PRIORITY RULES vs AUTOMATION RULES - **Priority Rules** (what the supervisor is configuring now): Determine worklist order. Computed in real-time at request time. No permanent data changes. - **Automation Rules** (coming soon): Trigger durable actions — assign leads to agents, escalate SLA breaches to supervisors, update lead status automatically. These write back to entity fields and need a draft/publish workflow. ## BEST PRACTICES FOR HOSPITAL CALL CENTERS - Missed calls should have the highest weight (8-10) — these are patients who tried to reach you - Follow-ups should be high (7-9) — you committed to calling them back - Campaign leads vary by campaign value (5-8) - SLA for missed calls: 4-12 hours (shorter = more responsive) - SLA for follow-ups: 12-24 hours - High-value campaigns (IVF, cancer screening): weight 8-9 - General campaigns (health checkup): weight 5-7 - WhatsApp/Phone leads convert better than social media → weight them higher ## CURRENT CONFIGURATION ${configJson} ## RULES 1. Be concise — under 100 words unless asked for detail 2. When recommending changes, be specific: "Set missed call weight to 9, SLA to 8 hours" 3. Explain WHY a change matters: "This ensures IVF patients get called within 4 hours" 4. Reference the scoring formula when explaining scores 5. If asked about automation rules, explain the concept and say it's coming soon`; } private buildSystemPrompt(kb: string): string { return this.aiConfig.renderPrompt('ccAgentHelper', { hospitalName: this.getHospitalName(), knowledgeBase: kb, }); } private async chatWithTools(userMessage: string, auth: string) { const kb = await this.buildKnowledgeBase(auth); this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`); const systemPrompt = this.buildSystemPrompt(kb); this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`); 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( `{ 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( `{ 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( `{ 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( `{ 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( `{ doctors(first: 10) { edges { node { id name fullName { firstName lastName } department specialty qualifications yearsOfExperience consultationFeeNew { amountMicros currencyCode } consultationFeeFollowUp { amountMicros currencyCode } active registrationNumber ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, undefined, auth, ); const doctors = normalizeDoctors(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, // Multi-clinic doctors show as // "Koramangala / Indiranagar" so the // model has the full picture without // a follow-up tool call. clinicName: d.clinics.length > 0 ? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ') : '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 { try { const doctors = await this.platform.queryWithAuth( `{ doctors(first: 10) { edges { node { id name fullName { firstName lastName } department specialty consultationFeeNew { amountMicros currencyCode } ${DOCTOR_VISIT_SLOTS_FRAGMENT} } } } }`, undefined, auth, ); const docs = normalizeDoctors(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( `{ 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?'; } }