Files
helix-engage-server/src/ai/ai-chat.controller.ts
saridsa2 898ff65951 fix: camelCase field names + dial uses per-agent config
Defect 5: Worklist, missed-call-webhook, missed-queue, ai-chat, and
rules-engine all used legacy lowercase field names (callbackstatus,
callsourcenumber, missedcallcount, callbackattemptedat) from the old
VPS schema. Fixed to camelCase (callbackStatus, callSourceNumber,
missedCallCount, callbackAttemptedAt) matching the current SDK sync.

Defect 6: Dial endpoint used global defaults (OZONETEL_AGENT_ID env
var) instead of the logged-in agent's config. Now accepts agentId
and campaignName from the frontend request body. Falls back to
telephony config → DID-derived campaign name → explicit error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:29:19 +05:30

934 lines
50 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, 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<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('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<any>(
`{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
// 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<any>(
`{ 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<any>(
`{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ 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<any>(
`{ 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<string, number> = {};
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<any>(
`{ 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<any>(
`{ 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<any>(
`{ 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<any>(
`{ 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<any>(
`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<any>(
`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<any>(
`{ 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<string> {
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<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 & 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(` MonFri: ${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<any>(
`{ 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<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}`);
sections.push('\n## Health Packages\nFailed to load package data.');
}
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}`);
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<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
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<string> {
try {
const doctors = await this.platform.queryWithAuth<any>(
`{ 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<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?';
}
}