mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: call control, recording, CDR, missed calls, live call assist
- Call Control API (CONFERENCE/HOLD/MUTE/KICK_CALL) - Recording pause/unpause - Fetch CDR Detailed (call history with recordings) - Abandon Calls (missed calls from Ozonetel) - Call Assist WebSocket gateway (Deepgram STT + OpenAI suggestions) - Call Assist service (lead context loading) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
src/call-assist/call-assist.service.ts
Normal file
123
src/call-assist/call-assist.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generateText } from 'ai';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class CallAssistService {
|
||||
private readonly logger = new Logger(CallAssistService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly platformApiKey: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
) {
|
||||
this.aiModel = createAiModel(config);
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
if (!authHeader) return 'No platform context available.';
|
||||
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (leadId) {
|
||||
const leadResult = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
||||
id name contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
source status interestedService
|
||||
lastContacted contactAttempts
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
);
|
||||
const lead = leadResult.leads.edges[0]?.node;
|
||||
if (lead) {
|
||||
const name = lead.contactName
|
||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||
: lead.name;
|
||||
parts.push(`CALLER: ${name}`);
|
||||
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||
}
|
||||
|
||||
const apptResult = await this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
);
|
||||
const appts = apptResult.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.patientId === leadId);
|
||||
if (appts.length > 0) {
|
||||
parts.push('\nPAST APPOINTMENTS:');
|
||||
for (const a of appts) {
|
||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`);
|
||||
}
|
||||
}
|
||||
} else if (callerPhone) {
|
||||
parts.push(`CALLER: Unknown (${callerPhone})`);
|
||||
parts.push('No lead record found — this may be a new enquiry.');
|
||||
}
|
||||
|
||||
const docResult = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
);
|
||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
||||
if (docs.length > 0) {
|
||||
parts.push('\nAVAILABLE DOCTORS:');
|
||||
for (const d of docs) {
|
||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n') || 'No context available.';
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to load call context: ${err}`);
|
||||
return 'Context loading failed.';
|
||||
}
|
||||
}
|
||||
|
||||
async getSuggestion(transcript: string, context: string): Promise<string> {
|
||||
if (!this.aiModel || !transcript.trim()) return '';
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.aiModel,
|
||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
||||
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
||||
|
||||
${context}
|
||||
|
||||
RULES:
|
||||
- Keep suggestions under 2 sentences
|
||||
- Focus on actionable next steps the agent should take NOW
|
||||
- If customer mentions a doctor or department, suggest available slots
|
||||
- If customer wants to cancel or reschedule, note relevant appointment details
|
||||
- If customer sounds upset, suggest empathetic response
|
||||
- Do NOT repeat what the agent already knows`,
|
||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||
maxOutputTokens: 150,
|
||||
});
|
||||
return text;
|
||||
} catch (err) {
|
||||
this.logger.error(`AI suggestion failed: ${err}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user