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:
@@ -6,6 +6,7 @@ import type { LanguageModel } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { CallerContextService } from '../caller/caller-context.service';
|
||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||
@@ -28,6 +29,7 @@ export class AiChatController {
|
||||
private platform: PlatformGraphqlService,
|
||||
private aiConfig: AiConfigService,
|
||||
private caller: CallerResolutionService,
|
||||
private callerContext: CallerContextService,
|
||||
) {
|
||||
const cfg = aiConfig.getConfig();
|
||||
this.aiModel = createAiModel({
|
||||
@@ -96,15 +98,16 @@ export class AiChatController {
|
||||
const kb = await this.buildKnowledgeBase(auth);
|
||||
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.`;
|
||||
// Inject pre-fetched caller context (appointments, call history,
|
||||
// activities, AI summary) so the LLM can answer from the KB
|
||||
// without tool calls. No UUIDs exposed — only human-readable data.
|
||||
if (ctx?.leadId) {
|
||||
const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth);
|
||||
if (callerCtx) {
|
||||
systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`;
|
||||
}
|
||||
} else if (ctx?.callerPhone) {
|
||||
systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { CallerResolutionService } from './caller-resolution.service';
|
||||
import { CallerContextService } from './caller-context.service';
|
||||
|
||||
@Controller('api/caller')
|
||||
export class CallerResolutionController {
|
||||
private readonly logger = new Logger(CallerResolutionController.name);
|
||||
|
||||
constructor(private readonly resolution: CallerResolutionService) {}
|
||||
constructor(
|
||||
private readonly resolution: CallerResolutionService,
|
||||
private readonly callerContext: CallerContextService,
|
||||
) {}
|
||||
|
||||
@Post('resolve')
|
||||
async resolve(
|
||||
@@ -21,6 +25,12 @@ export class CallerResolutionController {
|
||||
|
||||
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
||||
const result = await this.resolution.resolve(phone, auth);
|
||||
|
||||
// Pre-warm caller context cache so the AI chat has it ready
|
||||
if (result.leadId) {
|
||||
this.callerContext.prewarm(result.leadId, result.patientId, auth);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CallerResolutionController } from './caller-resolution.controller';
|
||||
import { CallerResolutionService } from './caller-resolution.service';
|
||||
import { CallerContextService } from './caller-context.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||
controllers: [CallerResolutionController],
|
||||
providers: [CallerResolutionService],
|
||||
exports: [CallerResolutionService],
|
||||
providers: [CallerResolutionService, CallerContextService],
|
||||
exports: [CallerResolutionService, CallerContextService],
|
||||
})
|
||||
export class CallerResolutionModule {}
|
||||
|
||||
Reference in New Issue
Block a user