mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: pre-fetched caller context replaces tool-based patient lookups
- CallerContextService: fetches lead profile, appointments, call history, activities in parallel. Caches in Redis (5 min TTL). Renders as human-readable KB section — no UUIDs exposed to the LLM. - Caller resolution controller: prewarms context cache on resolve (fire-and-forget) so the AI stream has a cache hit. - AI chat stream: injects caller context into system prompt KB instead of raw Lead ID. LLM answers patient questions from context, no tool calls needed for current caller data. - Eliminates UUID hallucination: LLM never sees leadId or patientId, can't pass wrong ID to wrong tool parameter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
199
src/caller/caller-context.service.ts
Normal file
199
src/caller/caller-context.service.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||
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,
|
||||
),
|
||||
patientId ? this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, 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 lead = leadData?.lead;
|
||||
if (!lead) return null;
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
|
||||
return {
|
||||
leadId,
|
||||
patientId: patientId || lead.patientId || '',
|
||||
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: (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node),
|
||||
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,
|
||||
})),
|
||||
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user