From a576552f8a8dc4f35b5a4ab71899fa1ad87132b8 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 09:56:18 +0530 Subject: [PATCH] feat: pre-fetched caller context replaces tool-based patient lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/ai/ai-chat.controller.ts | 19 +- src/caller/caller-context.service.ts | 199 +++++++++++++++++++++ src/caller/caller-resolution.controller.ts | 12 +- src/caller/caller-resolution.module.ts | 5 +- 4 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 src/caller/caller-context.service.ts diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 2e5572c..d50acdf 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -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.`; } } diff --git a/src/caller/caller-context.service.ts b/src/caller/caller-context.service.ts new file mode 100644 index 0000000..ba4f130 --- /dev/null +++ b/src/caller/caller-context.service.ts @@ -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 { + 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 { + try { + const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([ + this.platform.queryWithAuth( + `{ 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( + `{ 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( + `{ 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 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'); + } +} diff --git a/src/caller/caller-resolution.controller.ts b/src/caller/caller-resolution.controller.ts index 112f8cd..aa7aa83 100644 --- a/src/caller/caller-resolution.controller.ts +++ b/src/caller/caller-resolution.controller.ts @@ -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; } } diff --git a/src/caller/caller-resolution.module.ts b/src/caller/caller-resolution.module.ts index a93a367..bf8acdf 100644 --- a/src/caller/caller-resolution.module.ts +++ b/src/caller/caller-resolution.module.ts @@ -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 {}