diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 875c2ab..98e5b90 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -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( + `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, + { + data: { + name: `AI Booking — ${patientName} (${department})`, + scheduledAt, + status: 'SCHEDULED', + doctorName, + department, + reasonForVisit: reason, + }, + }, + auth, + ); + const id = result?.createAppointment?.id; + if (id) { + return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` }; + } + return { booked: false, message: 'Appointment creation failed.' }; + } catch (err: any) { + this.logger.error(`[TOOL] book_appointment failed: ${err.message}`); + return { booked: false, message: `Failed to book: ${err.message}` }; + } + }, + }), + + create_lead: tool({ + description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.', + inputSchema: z.object({ + name: z.string().describe('Caller name'), + phoneNumber: z.string().describe('Phone number'), + interest: z.string().describe('What they are enquiring about'), + }), + execute: async ({ name, phoneNumber, interest }) => { + this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`); + try { + const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); + const result = await platformService.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `AI Enquiry — ${name}`, + contactName: { + firstName: name.split(' ')[0], + lastName: name.split(' ').slice(1).join(' ') || '', + }, + contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + source: 'PHONE', + status: 'NEW', + interestedService: interest, + }, + }, + auth, + ); + const id = result?.createLead?.id; + if (id) { + return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; + } + return { created: false, message: 'Lead creation failed.' }; + } catch (err: any) { + this.logger.error(`[TOOL] create_lead failed: ${err.message}`); + return { created: false, message: `Failed: ${err.message}` }; + } + }, + }), + + lookup_call_history: tool({ + description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.', + inputSchema: z.object({ + leadId: z.string().describe('Lead ID'), + }), + execute: async ({ leadId }) => { + const data = await platformService.queryWithAuth( + `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id direction callStatus agentName startedAt durationSec disposition + } } } }`, + undefined, auth, + ); + return { calls: data.calls.edges.map((e: any) => e.node) }; + }, + }), }, }); @@ -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( + `{ 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}`; } diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index 9136c23..5d72bf2 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -59,4 +59,19 @@ export class SessionService implements OnModuleInit { async setCache(key: string, value: string, ttlSeconds: number): Promise { await this.redis.set(key, value, 'EX', ttlSeconds); } + + async deleteCache(key: string): Promise { + await this.redis.del(key); + } + + async scanKeys(pattern: string): Promise { + 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; + } } diff --git a/src/call-events/call-events.gateway.ts b/src/call-events/call-events.gateway.ts index 1efdcf2..5e8138e 100644 --- a/src/call-events/call-events.gateway.ts +++ b/src/call-events/call-events.gateway.ts @@ -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( diff --git a/src/call-events/call-events.service.ts b/src/call-events/call-events.service.ts index 2f2c787..b5263d0 100644 --- a/src/call-events/call-events.service.ts +++ b/src/call-events/call-events.service.ts @@ -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}`); } diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 466e66f..90c68a7 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -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( + `{ 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( + `{ 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(); + 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( + `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( + `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( + `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( + `{ 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 } }; + } } diff --git a/src/maint/maint.module.ts b/src/maint/maint.module.ts index abcd5e4..a30c795 100644 --- a/src/maint/maint.module.ts +++ b/src/maint/maint.module.ts @@ -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 {} diff --git a/src/platform/platform.types.ts b/src/platform/platform.types.ts index 490d5f3..d0fef6f 100644 --- a/src/platform/platform.types.ts +++ b/src/platform/platform.types.ts @@ -49,6 +49,7 @@ export type CreateCallInput = { disposition?: string; callNotes?: string; leadId?: string; + sla?: number; }; export type CreateLeadActivityInput = { diff --git a/src/recordings/recordings.service.ts b/src/recordings/recordings.service.ts index eb01dd7..297ad8b 100644 --- a/src/recordings/recordings.service.ts +++ b/src/recordings/recordings.service.ts @@ -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> { + 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 };