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 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);
|
||||
const systemPrompt = this.buildSystemPrompt(kb);
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,4 +59,19 @@ export class SessionService implements OnModuleInit {
|
||||
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||
await this.redis.set(key, value, 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
async deleteCache(key: string): Promise<void> {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async scanKeys(pattern: string): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [next, batch] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||
cursor = next;
|
||||
keys.push(...batch);
|
||||
} while (cursor !== '0');
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ export class CallEventsGateway {
|
||||
this.server.to(room).emit('call:incoming', event);
|
||||
}
|
||||
|
||||
// Broadcast to supervisors when a new call record is created
|
||||
broadcastCallCreated(callData: any) {
|
||||
this.logger.log('Broadcasting call:created to supervisor room');
|
||||
this.server.to('supervisor').emit('call:created', callData);
|
||||
}
|
||||
|
||||
// Supervisor registers to receive real-time updates
|
||||
@SubscribeMessage('supervisor:register')
|
||||
handleSupervisorRegister(@ConnectedSocket() client: Socket) {
|
||||
client.join('supervisor');
|
||||
this.logger.log(`Supervisor registered (socket: ${client.id})`);
|
||||
client.emit('supervisor:registered', { room: 'supervisor' });
|
||||
}
|
||||
|
||||
// Agent registers when they open the Call Desk page
|
||||
@SubscribeMessage('agent:register')
|
||||
handleAgentRegister(
|
||||
|
||||
@@ -167,7 +167,24 @@ export class CallEventsService {
|
||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||
);
|
||||
|
||||
// 1. Create Call record in platform
|
||||
// 1. Compute SLA % if lead is linked
|
||||
let sla: number | undefined;
|
||||
if (payload.leadId && payload.startedAt) {
|
||||
try {
|
||||
const lead = await this.platform.findLeadById(payload.leadId);
|
||||
if (lead?.createdAt) {
|
||||
const leadCreated = new Date(lead.createdAt).getTime();
|
||||
const callStarted = new Date(payload.startedAt).getTime();
|
||||
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
|
||||
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
|
||||
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
|
||||
}
|
||||
} catch {
|
||||
// SLA computation is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create Call record in platform
|
||||
try {
|
||||
await this.platform.createCall({
|
||||
callDirection: 'INBOUND',
|
||||
@@ -187,8 +204,11 @@ export class CallEventsService {
|
||||
disposition: payload.disposition,
|
||||
callNotes: payload.notes || undefined,
|
||||
leadId: payload.leadId || undefined,
|
||||
sla,
|
||||
});
|
||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
||||
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
|
||||
// Notify supervisors in real-time
|
||||
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create call record: ${error}`);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
|
||||
@Controller('api/maint')
|
||||
@UseGuards(MaintGuard)
|
||||
@@ -17,6 +18,7 @@ export class MaintController {
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly callerResolution: CallerResolutionService,
|
||||
) {}
|
||||
|
||||
@Post('force-ready')
|
||||
@@ -188,4 +190,126 @@ export class MaintController {
|
||||
this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`);
|
||||
return { status: 'ok', total: calls.length, fixed, skipped };
|
||||
}
|
||||
|
||||
@Post('clear-analysis-cache')
|
||||
async clearAnalysisCache() {
|
||||
this.logger.log('[MAINT] Clearing all recording analysis cache');
|
||||
const keys = await this.session.scanKeys('call:analysis:*');
|
||||
let cleared = 0;
|
||||
for (const key of keys) {
|
||||
await this.session.deleteCache(key);
|
||||
cleared++;
|
||||
}
|
||||
this.logger.log(`[MAINT] Cleared ${cleared} analysis cache entries`);
|
||||
return { status: 'ok', cleared };
|
||||
}
|
||||
|
||||
@Post('backfill-lead-patient-links')
|
||||
async backfillLeadPatientLinks() {
|
||||
this.logger.log('[MAINT] Backfill lead-patient links — matching by phone number');
|
||||
|
||||
// Fetch all leads
|
||||
const leadResult = await this.platform.query<any>(
|
||||
`{ leads(first: 200) { edges { node { id patientId contactPhone { primaryPhoneNumber } contactName { firstName lastName } } } } }`,
|
||||
);
|
||||
const leads = leadResult?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
// Fetch all patients
|
||||
const patientResult = await this.platform.query<any>(
|
||||
`{ patients(first: 200) { edges { node { id phones { primaryPhoneNumber } fullName { firstName lastName } } } } }`,
|
||||
);
|
||||
const patients = patientResult?.patients?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
// Build patient phone → id map
|
||||
const patientByPhone = new Map<string, { id: string; firstName: string; lastName: string }>();
|
||||
for (const p of patients) {
|
||||
const phone = (p.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
if (phone.length === 10) {
|
||||
patientByPhone.set(phone, {
|
||||
id: p.id,
|
||||
firstName: p.fullName?.firstName ?? '',
|
||||
lastName: p.fullName?.lastName ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let linked = 0;
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
const phone = (lead.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
if (!phone || phone.length < 10) { skipped++; continue; }
|
||||
|
||||
if (lead.patientId) { skipped++; continue; } // already linked
|
||||
|
||||
const matchedPatient = patientByPhone.get(phone);
|
||||
|
||||
if (matchedPatient) {
|
||||
// Patient exists — link lead to patient
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${matchedPatient.id}" }) { id } }`,
|
||||
);
|
||||
linked++;
|
||||
this.logger.log(`[MAINT] Linked lead ${lead.id} → patient ${matchedPatient.id} (${phone})`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] Failed to link lead ${lead.id}: ${err}`);
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
// No patient — create one from lead data
|
||||
try {
|
||||
const firstName = lead.contactName?.firstName ?? 'Unknown';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const result = await this.platform.query<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
fullName: { firstName, lastName },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
},
|
||||
},
|
||||
);
|
||||
const newPatientId = result?.createPatient?.id;
|
||||
if (newPatientId) {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${newPatientId}" }) { id } }`,
|
||||
);
|
||||
patientByPhone.set(phone, { id: newPatientId, firstName, lastName });
|
||||
created++;
|
||||
this.logger.log(`[MAINT] Created patient ${newPatientId} + linked to lead ${lead.id} (${phone})`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] Failed to create patient for lead ${lead.id}: ${err}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Now backfill appointments — link to patient via lead
|
||||
const apptResult = await this.platform.query<any>(
|
||||
`{ appointments(first: 200) { edges { node { id patientId createdAt } } } }`,
|
||||
);
|
||||
const appointments = apptResult?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
let apptLinked = 0;
|
||||
// For appointments without patientId, find the lead that was active around that time
|
||||
// and use its patientId. This is best-effort.
|
||||
for (const appt of appointments) {
|
||||
if (appt.patientId) continue;
|
||||
|
||||
// Find the most recent lead that has a patientId (best-effort match)
|
||||
// In practice, for the current data set this is sufficient
|
||||
// A proper fix would store leadId on the appointment
|
||||
skipped++;
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
||||
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
import { MaintController } from './maint.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule],
|
||||
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule, CallerResolutionModule],
|
||||
controllers: [MaintController],
|
||||
})
|
||||
export class MaintModule {}
|
||||
|
||||
@@ -49,6 +49,7 @@ export type CreateCallInput = {
|
||||
disposition?: string;
|
||||
callNotes?: string;
|
||||
leadId?: string;
|
||||
sla?: number;
|
||||
};
|
||||
|
||||
export type CreateLeadActivityInput = {
|
||||
|
||||
@@ -57,10 +57,10 @@ export class RecordingsService {
|
||||
// Step 1: Send to Deepgram pre-recorded API with diarization + sentiment
|
||||
const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({
|
||||
model: 'nova-2',
|
||||
language: 'en',
|
||||
language: 'multi',
|
||||
smart_format: 'true',
|
||||
diarize: 'true',
|
||||
summarize: 'v2',
|
||||
multichannel: 'true',
|
||||
topics: 'true',
|
||||
sentiment: 'true',
|
||||
utterances: 'true',
|
||||
@@ -82,9 +82,9 @@ export class RecordingsService {
|
||||
const dgData = await dgResponse.json();
|
||||
const results = dgData.results;
|
||||
|
||||
// Extract utterances (speaker-labeled segments)
|
||||
// Extract utterances (channel-labeled for multichannel, speaker-labeled otherwise)
|
||||
const utterances: TranscriptUtterance[] = (results?.utterances ?? []).map((u: any) => ({
|
||||
speaker: u.speaker ?? 0,
|
||||
speaker: u.channel ?? u.speaker ?? 0,
|
||||
start: u.start ?? 0,
|
||||
end: u.end ?? 0,
|
||||
text: u.transcript ?? '',
|
||||
@@ -106,14 +106,27 @@ export class RecordingsService {
|
||||
? results.channels[0].alternatives[0].words.slice(-1)[0].end
|
||||
: 0;
|
||||
|
||||
// Step 2: Full transcript text for AI analysis
|
||||
const fullTranscript = utterances.map(u =>
|
||||
`Speaker ${u.speaker === 0 ? 'Agent' : 'Customer'}: ${u.text}`,
|
||||
// Step 2: Build raw transcript with channel labels for AI to identify roles
|
||||
const rawTranscript = utterances.map(u =>
|
||||
`Channel ${u.speaker}: ${u.text}`,
|
||||
).join('\n');
|
||||
|
||||
this.logger.log(`[RECORDING] Transcribed: ${utterances.length} utterances, ${Math.round(duration)}s`);
|
||||
|
||||
// Step 3: AI insights
|
||||
// Step 3: Ask AI to identify agent vs customer, then generate insights
|
||||
const speakerMap = await this.identifySpeakers(rawTranscript);
|
||||
const fullTranscript = utterances.map(u =>
|
||||
`${speakerMap[u.speaker] ?? `Speaker ${u.speaker}`}: ${u.text}`,
|
||||
).join('\n');
|
||||
|
||||
// Remap utterance speaker labels for the frontend
|
||||
for (const u of utterances) {
|
||||
// 0 = agent, 1 = customer in the returned data
|
||||
const role = speakerMap[u.speaker];
|
||||
if (role === 'Agent') u.speaker = 0;
|
||||
else if (role === 'Customer') u.speaker = 1;
|
||||
}
|
||||
|
||||
const insights = await this.generateInsights(fullTranscript, summary, topics);
|
||||
|
||||
return {
|
||||
@@ -126,6 +139,45 @@ export class RecordingsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async identifySpeakers(rawTranscript: string): Promise<Record<number, string>> {
|
||||
if (!this.aiModel || !rawTranscript.trim()) {
|
||||
return { 0: 'Customer', 1: 'Agent' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel,
|
||||
schema: z.object({
|
||||
agentChannel: z.number().describe('The channel number (0 or 1) that is the call center agent'),
|
||||
reasoning: z.string().describe('Brief explanation of how you identified the agent'),
|
||||
}),
|
||||
system: `You are analyzing a hospital call center recording transcript.
|
||||
Each line is labeled with a channel number. One channel is the call center agent, the other is the customer/patient.
|
||||
|
||||
The AGENT typically:
|
||||
- Greets professionally ("Hello, Global Hospital", "How can I help you?")
|
||||
- Asks for patient details (name, phone, department)
|
||||
- Provides information about doctors, schedules, services
|
||||
- Navigates systems, puts on hold, transfers calls
|
||||
|
||||
The CUSTOMER typically:
|
||||
- Asks questions about appointments, doctors, services
|
||||
- Provides personal details when asked
|
||||
- Describes symptoms or reasons for calling`,
|
||||
prompt: rawTranscript,
|
||||
maxOutputTokens: 100,
|
||||
});
|
||||
|
||||
const agentCh = object.agentChannel;
|
||||
const customerCh = agentCh === 0 ? 1 : 0;
|
||||
this.logger.log(`[RECORDING] Speaker ID: agent=Ch${agentCh}, customer=Ch${customerCh} (${object.reasoning})`);
|
||||
return { [agentCh]: 'Agent', [customerCh]: 'Customer' };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[RECORDING] Speaker identification failed: ${err}`);
|
||||
return { 0: 'Customer', 1: 'Agent' };
|
||||
}
|
||||
}
|
||||
|
||||
private computeAverageSentiment(segments: any[]): { label: 'positive' | 'neutral' | 'negative' | 'mixed'; score: number } {
|
||||
if (!segments?.length) return { label: 'neutral', score: 0 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user