mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: transcription fix + SLA write-back + real-time supervisor events
- Deepgram: multichannel=true + language=multi (captures both speakers, multilingual) - LLM speaker identification (agent vs customer from conversational cues) - Removed summarize=v2 (incompatible with multilingual) - SLA computation on call creation (lead.createdAt → call.startedAt elapsed %) - WebSocket: supervisor room + call:created broadcast for real-time updates - Maint: clear-analysis-cache endpoint + scanKeys/deleteCache on SessionService - AI chat: rules-engine context routing with dedicated system prompt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,8 +75,28 @@ export class AiChatController {
|
||||
return;
|
||||
}
|
||||
|
||||
const kb = await this.buildKnowledgeBase(auth);
|
||||
const systemPrompt = this.buildSystemPrompt(kb);
|
||||
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 {
|
||||
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 result = streamText({
|
||||
@@ -155,15 +175,115 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
||||
const search = doctorName.toLowerCase();
|
||||
// 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 full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.toLowerCase();
|
||||
return full.includes(search);
|
||||
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)));
|
||||
});
|
||||
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}"` };
|
||||
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) };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -209,17 +329,18 @@ export class AiChatController {
|
||||
);
|
||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||
if (clinics.length) {
|
||||
sections.push('## Clinics');
|
||||
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(', ')
|
||||
: '';
|
||||
const hours = [
|
||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
||||
].filter(Boolean).join(', ');
|
||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
||||
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];
|
||||
@@ -245,7 +366,36 @@ export class AiChatController {
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
||||
sections.push('## Clinics\nFailed to load clinic data.');
|
||||
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 {
|
||||
fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = 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}` : '';
|
||||
const clinic = d.clinic?.clinicName ?? '';
|
||||
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 (clinic) sections.push(` Clinic: ${clinic}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch doctors for KB: ${err}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -311,20 +461,71 @@ export class AiChatController {
|
||||
return this.knowledgeBase;
|
||||
}
|
||||
|
||||
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 `You are an AI assistant for call center agents at a hospital.
|
||||
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
||||
|
||||
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
||||
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
||||
|
||||
RULES:
|
||||
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
|
||||
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
|
||||
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
|
||||
4. If a tool returns no data, say "I couldn't find that in our system."
|
||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
||||
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
||||
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||
7. NEVER share sensitive hospital data (revenue, salaries, internal policies).
|
||||
8. Format with bullet points for easy scanning.
|
||||
7. Format with bullet points for easy scanning.
|
||||
|
||||
KNOWLEDGE BASE (this is real data from our system):
|
||||
${kb}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user