import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { SessionService } from '../auth/session.service'; import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules'; export type CallerContext = { leadId: string; patientId: string; name: string; phone: string; isNew: boolean; // Lead profile leadSource: string | null; leadStatus: string | null; interestedService: string | null; aiSummary: string | null; contactAttempts: number; lastContacted: string | null; utmCampaign: string | null; // Appointments appointments: Array<{ scheduledAt: string; status: string; doctorName: string; department: string; reasonForVisit: string | null; }>; // Recent call history calls: Array<{ startedAt: string; direction: string; duration: number | null; disposition: string | null; agentName: string | null; }>; // Lead activities activities: Array<{ activityType: string; summary: string | null; occurredAt: string; outcome: string | null; }>; // Rule-driven suggestion triggers suggestionTriggers: SuggestionTrigger[]; }; const CACHE_KEY_PREFIX = 'caller:context:'; const CACHE_TTL = 300; // 5 minutes — covers the call duration @Injectable() export class CallerContextService { private readonly logger = new Logger(CallerContextService.name); constructor( private readonly platform: PlatformGraphqlService, private readonly session: SessionService, ) {} async getOrBuild(leadId: string, patientId: string, auth: string): Promise { if (!leadId) return null; // Check cache first const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`; try { const cached = await this.session.getCache(cacheKey); if (cached) { this.logger.log(`[CALLER-CTX] Cache hit for ${leadId}`); return JSON.parse(cached); } } catch {} // Build fresh this.logger.log(`[CALLER-CTX] Building context for lead=${leadId} patient=${patientId}`); const ctx = await this.build(leadId, patientId, auth); if (ctx) { this.session.setCache(cacheKey, JSON.stringify(ctx), CACHE_TTL).catch(() => {}); } return ctx; } async invalidateCache(leadId: string): Promise { if (!leadId) return; const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`; await this.session.deleteCache(cacheKey).catch(() => {}); this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`); } // Fire-and-forget pre-warm — called from caller resolution // so the cache is hot when the AI stream fires seconds later. prewarm(leadId: string, patientId: string, auth: string): void { if (!leadId) return; this.getOrBuild(leadId, patientId, auth).catch(err => { this.logger.warn(`[CALLER-CTX] Prewarm failed: ${err.message}`); }); } private async build(leadId: string, patientId: string, auth: string): Promise { try { // Step 1: Fetch lead first to get the authoritative patientId const leadData = await this.platform.queryWithAuth( `{ lead(filter: { id: { eq: "${leadId}" } }) { id contactName { firstName lastName } contactPhone { primaryPhoneNumber } source status interestedService aiSummary contactAttempts lastContacted utmCampaign patientId } }`, undefined, auth, ); const lead = leadData?.lead; if (!lead) return null; // Use Lead's patientId as authoritative source — the input // param may be empty if caller resolution just linked them. const resolvedPatientId = patientId || lead.patientId || ''; this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`); const firstName = lead.contactName?.firstName ?? ''; const lastName = lead.contactName?.lastName ?? ''; // Step 2: Fetch appointments, calls, activities in parallel // using the resolved patientId from the Lead record. const [appointmentsData, callsData, activitiesData] = await Promise.all([ resolvedPatientId ? this.platform.queryWithAuth( `{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { scheduledAt status doctorName department reasonForVisit } } } }`, undefined, auth, ) : Promise.resolve(null), this.platform.queryWithAuth( `{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { startedAt direction durationSec disposition agentName } } } }`, undefined, auth, ), this.platform.queryWithAuth( `{ leadActivities(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { activityType summary occurredAt outcome } } } }`, undefined, auth, ), ]); const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node); const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({ startedAt: e.node.startedAt, direction: e.node.direction, duration: e.node.durationSec, disposition: e.node.disposition, agentName: e.node.agentName, })); const suggestionTriggers = evaluateSuggestionRules({ isNew: false, interestedService: lead.interestedService ?? null, leadStatus: lead.status ?? null, contactAttempts: lead.contactAttempts ?? 0, appointments, calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })), utmCampaign: lead.utmCampaign ?? null, leadSource: lead.source ?? null, }); return { leadId, patientId: resolvedPatientId, name: `${firstName} ${lastName}`.trim() || 'Unknown', phone: lead.contactPhone?.primaryPhoneNumber ?? '', isNew: false, leadSource: lead.source ?? null, leadStatus: lead.status ?? null, interestedService: lead.interestedService ?? null, aiSummary: lead.aiSummary ?? null, contactAttempts: lead.contactAttempts ?? 0, lastContacted: lead.lastContacted ?? null, utmCampaign: lead.utmCampaign ?? null, appointments, calls, activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node), suggestionTriggers, }; } catch (err: any) { this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`); return null; } } renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string { if (triggers.length === 0) return ''; const lines = [ '', 'SUGGESTION RULES (from business configuration):', 'Based on this caller\'s profile, the following suggestions should be offered.', 'Generate a natural, conversational script for each that the agent can read aloud.', 'Return them in the `suggestions` array of your JSON response.', '', ]; triggers.forEach((t, i) => { lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title} — ${t.reason}`); }); return lines.join('\n'); } renderForPrompt(ctx: CallerContext): string { const lines: string[] = []; lines.push(`## CURRENT CALLER: ${ctx.name}`); lines.push(`Phone: ${ctx.phone}`); if (ctx.leadSource) lines.push(`Source: ${ctx.leadSource}`); if (ctx.leadStatus) lines.push(`Status: ${ctx.leadStatus}`); if (ctx.interestedService) lines.push(`Interested in: ${ctx.interestedService}`); if (ctx.utmCampaign) lines.push(`Campaign: ${ctx.utmCampaign}`); if (ctx.contactAttempts > 0) lines.push(`Contact attempts: ${ctx.contactAttempts}`); if (ctx.lastContacted) lines.push(`Last contacted: ${ctx.lastContacted}`); if (ctx.aiSummary) { lines.push(`\nAI Summary: ${ctx.aiSummary}`); } if (ctx.appointments.length > 0) { lines.push(`\n### Appointments (${ctx.appointments.length})`); for (const a of ctx.appointments) { const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; lines.push(`- ${date} | ${a.doctorName ?? '?'} (${a.department ?? '?'}) | ${a.status}${a.reasonForVisit ? ` | ${a.reasonForVisit}` : ''}`); } } else { lines.push('\nNo appointments on record.'); } if (ctx.calls.length > 0) { lines.push(`\n### Call History (last ${ctx.calls.length})`); for (const c of ctx.calls) { const date = c.startedAt ? new Date(c.startedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; const dur = c.duration ? `${Math.floor(c.duration / 60)}m${c.duration % 60}s` : '?'; lines.push(`- ${date} | ${c.direction ?? '?'} | ${dur} | ${c.disposition ?? 'No disposition'}${c.agentName ? ` | Agent: ${c.agentName}` : ''}`); } } if (ctx.activities.length > 0) { lines.push(`\n### Recent Activity (last ${ctx.activities.length})`); for (const a of ctx.activities) { const date = a.occurredAt ? new Date(a.occurredAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; lines.push(`- ${date} | ${a.activityType}${a.summary ? `: ${a.summary}` : ''}${a.outcome ? ` → ${a.outcome}` : ''}`); } } return lines.join('\n'); } }