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