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:
2026-04-01 16:59:23 +05:30
parent b8556cf440
commit 5e3ccbd040
8 changed files with 461 additions and 33 deletions

View File

@@ -75,8 +75,28 @@ export class AiChatController {
return; 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 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 platformService = this.platform;
const result = streamText({ const result = streamText({
@@ -155,15 +175,115 @@ export class AiChatController {
undefined, auth, undefined, auth,
); );
const doctors = data.doctors.edges.map((e: any) => e.node); 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 matched = doctors.filter((d: any) => {
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.toLowerCase(); const fn = (d.fullName?.firstName ?? '').toLowerCase();
return full.includes(search); 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 }; 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); const clinics = clinicData.clinics.edges.map((e: any) => e.node);
if (clinics.length) { if (clinics.length) {
sections.push('## Clinics'); sections.push('## CLINICS & TIMINGS');
for (const c of clinics) { for (const c of clinics) {
const name = c.clinicName ?? c.name;
const addr = c.addressCustom const addr = c.addressCustom
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ') ? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
: ''; : '';
const hours = [ sections.push(`### ${name}`);
c.weekdayHours ? `MonFri ${c.weekdayHours}` : '', if (addr) sections.push(` Address: ${addr}`);
c.saturdayHours ? `Sat ${c.saturdayHours}` : '', if (c.weekdayHours) sections.push(` MonFri: ${c.weekdayHours}`);
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed', if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
].filter(Boolean).join(', '); sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`); if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
} }
const rulesClinic = clinics[0]; const rulesClinic = clinics[0];
@@ -245,7 +366,36 @@ export class AiChatController {
} }
} catch (err) { } catch (err) {
this.logger.warn(`Failed to fetch clinics: ${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 { try {
@@ -311,20 +461,71 @@ export class AiChatController {
return this.knowledgeBase; 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 { private buildSystemPrompt(kb: string): string {
return `You are an AI assistant for call center agents at a hospital. return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls. 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: RULES:
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data. 1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system. 2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
3. For hospital info (clinics, packages, insurance), use the knowledge base below. 3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
4. If a tool returns no data, say "I couldn't find that in our system." 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. 5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
6. NEVER give medical advice, diagnosis, or treatment recommendations. 6. NEVER give medical advice, diagnosis, or treatment recommendations.
7. NEVER share sensitive hospital data (revenue, salaries, internal policies). 7. Format with bullet points for easy scanning.
8. Format with bullet points for easy scanning.
KNOWLEDGE BASE (this is real data from our system):
${kb}`; ${kb}`;
} }

View File

@@ -59,4 +59,19 @@ export class SessionService implements OnModuleInit {
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> { async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
await this.redis.set(key, value, 'EX', ttlSeconds); 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;
}
} }

View File

@@ -35,6 +35,20 @@ export class CallEventsGateway {
this.server.to(room).emit('call:incoming', event); 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 // Agent registers when they open the Call Desk page
@SubscribeMessage('agent:register') @SubscribeMessage('agent:register')
handleAgentRegister( handleAgentRegister(

View File

@@ -167,7 +167,24 @@ export class CallEventsService {
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`, `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 { try {
await this.platform.createCall({ await this.platform.createCall({
callDirection: 'INBOUND', callDirection: 'INBOUND',
@@ -187,8 +204,11 @@ export class CallEventsService {
disposition: payload.disposition, disposition: payload.disposition,
callNotes: payload.notes || undefined, callNotes: payload.notes || undefined,
leadId: payload.leadId || 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) { } catch (error) {
this.logger.error(`Failed to create call record: ${error}`); this.logger.error(`Failed to create call record: ${error}`);
} }

View File

@@ -5,6 +5,7 @@ import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { SupervisorService } from '../supervisor/supervisor.service'; import { SupervisorService } from '../supervisor/supervisor.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
@Controller('api/maint') @Controller('api/maint')
@UseGuards(MaintGuard) @UseGuards(MaintGuard)
@@ -17,6 +18,7 @@ export class MaintController {
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly session: SessionService, private readonly session: SessionService,
private readonly supervisor: SupervisorService, private readonly supervisor: SupervisorService,
private readonly callerResolution: CallerResolutionService,
) {} ) {}
@Post('force-ready') @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}`); this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`);
return { status: 'ok', total: calls.length, fixed, skipped }; 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 } };
}
} }

View File

@@ -3,10 +3,11 @@ import { PlatformModule } from '../platform/platform.module';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SupervisorModule } from '../supervisor/supervisor.module'; import { SupervisorModule } from '../supervisor/supervisor.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { MaintController } from './maint.controller'; import { MaintController } from './maint.controller';
@Module({ @Module({
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule], imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule, CallerResolutionModule],
controllers: [MaintController], controllers: [MaintController],
}) })
export class MaintModule {} export class MaintModule {}

View File

@@ -49,6 +49,7 @@ export type CreateCallInput = {
disposition?: string; disposition?: string;
callNotes?: string; callNotes?: string;
leadId?: string; leadId?: string;
sla?: number;
}; };
export type CreateLeadActivityInput = { export type CreateLeadActivityInput = {

View File

@@ -57,10 +57,10 @@ export class RecordingsService {
// Step 1: Send to Deepgram pre-recorded API with diarization + sentiment // Step 1: Send to Deepgram pre-recorded API with diarization + sentiment
const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({ const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({
model: 'nova-2', model: 'nova-2',
language: 'en', language: 'multi',
smart_format: 'true', smart_format: 'true',
diarize: 'true', diarize: 'true',
summarize: 'v2', multichannel: 'true',
topics: 'true', topics: 'true',
sentiment: 'true', sentiment: 'true',
utterances: 'true', utterances: 'true',
@@ -82,9 +82,9 @@ export class RecordingsService {
const dgData = await dgResponse.json(); const dgData = await dgResponse.json();
const results = dgData.results; 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) => ({ const utterances: TranscriptUtterance[] = (results?.utterances ?? []).map((u: any) => ({
speaker: u.speaker ?? 0, speaker: u.channel ?? u.speaker ?? 0,
start: u.start ?? 0, start: u.start ?? 0,
end: u.end ?? 0, end: u.end ?? 0,
text: u.transcript ?? '', text: u.transcript ?? '',
@@ -106,14 +106,27 @@ export class RecordingsService {
? results.channels[0].alternatives[0].words.slice(-1)[0].end ? results.channels[0].alternatives[0].words.slice(-1)[0].end
: 0; : 0;
// Step 2: Full transcript text for AI analysis // Step 2: Build raw transcript with channel labels for AI to identify roles
const fullTranscript = utterances.map(u => const rawTranscript = utterances.map(u =>
`Speaker ${u.speaker === 0 ? 'Agent' : 'Customer'}: ${u.text}`, `Channel ${u.speaker}: ${u.text}`,
).join('\n'); ).join('\n');
this.logger.log(`[RECORDING] Transcribed: ${utterances.length} utterances, ${Math.round(duration)}s`); 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); const insights = await this.generateInsights(fullTranscript, summary, topics);
return { 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 } { private computeAverageSentiment(segments: any[]): { label: 'positive' | 'neutral' | 'negative' | 'mixed'; score: number } {
if (!segments?.length) return { label: 'neutral', score: 0 }; if (!segments?.length) return { label: 'neutral', score: 0 };