mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
- CallerContextService: added invalidateCache(leadId) method - CallerResolutionController: POST /api/caller/invalidate-context endpoint — frontend calls after appointment mutations to bust stale AI context cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
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<CallerContext | null> {
|
|
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<void> {
|
|
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<CallerContext | null> {
|
|
try {
|
|
// Step 1: Fetch lead first to get the authoritative patientId
|
|
const leadData = await this.platform.queryWithAuth<any>(
|
|
`{ 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<any>(
|
|
`{ 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<any>(
|
|
`{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
|
startedAt direction durationSec disposition agentName
|
|
} } } }`,
|
|
undefined, auth,
|
|
),
|
|
this.platform.queryWithAuth<any>(
|
|
`{ 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');
|
|
}
|
|
}
|