mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- Team module: POST /api/team/members (in-place employee creation with temp password + Redis cache), PUT /api/team/members/:id, GET temp password endpoint. Uses signUpInWorkspace — no email invites. - Dockerfile: rewritten as multi-stage build (builder + runtime) so native modules compile for target arch. Fixes darwin→linux crash. - .dockerignore: exclude dist, node_modules, .env, .git, data/ - package-lock.json: regenerated against public npmjs.org (was pointing at localhost:4873 Verdaccio — broke docker builds) - Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors helper for visit-slot-aware queries across 6 consumers - AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped setup-state with workspace ID isolation, AI prompt defaults overhaul - Agent config: camelCase field fix for SDK-synced workspaces - Session service: workspace-scoped Redis key prefixing for setup state - Recordings/supervisor/widget services: updated to use doctor-utils shared fragments instead of inline visitingHours queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
934 lines
50 KiB
TypeScript
934 lines
50 KiB
TypeScript
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(` 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<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?';
|
||
}
|
||
}
|