From 973614749b0346f431b700fd899af242afdb5e99 Mon Sep 17 00:00:00 2001 From: Kartik Datrika Date: Thu, 16 Apr 2026 11:36:10 +0530 Subject: [PATCH] AI Summary not showing appointments fix. --- .gitignore | 4 + src/ai/ai-enrichment.service.ts | 210 ++++++----- src/call-events/call-lookup.controller.ts | 211 +++++++---- src/config/ai.defaults.ts | 296 +++++++++------- src/platform/platform-graphql.service.ts | 414 ++++++++++++++-------- 5 files changed, 702 insertions(+), 433 deletions(-) diff --git a/.gitignore b/.gitignore index 1420bea..8392119 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ lerna-debug.log* # Each environment mints its own HMAC-signed site key. data/widget.json data/widget-backups/ +.env.local +.grepai/config.yaml +.grepai/symbols.gob +.grepai/symbols.gob.lock diff --git a/src/ai/ai-enrichment.service.ts b/src/ai/ai-enrichment.service.ts index 10279c6..78b345f 100644 --- a/src/ai/ai-enrichment.service.ts +++ b/src/ai/ai-enrichment.service.ts @@ -7,107 +7,149 @@ import { createAiModel } from './ai-provider'; import { AiConfigService } from '../config/ai-config.service'; type LeadContext = { - firstName?: string; - lastName?: string; - leadSource?: string; - interestedService?: string; - leadStatus?: string; - contactAttempts?: number; - createdAt?: string; - campaignId?: string; - activities?: { activityType: string; summary: string }[]; + firstName?: string; + lastName?: string; + leadSource?: string; + interestedService?: string; + leadStatus?: string; + contactAttempts?: number; + createdAt?: string; + campaignId?: string; + patient?: { + age?: number; + type?: string; // 'new' | 'returning' | 'vip' + hasRecords?: boolean; + }; + upcomingAppointments?: Array<{ + scheduledAt?: string; + doctorName?: string; + appointmentType?: string; + status?: string; + }>; + activities?: { activityType: string; summary: string }[]; }; type EnrichmentResult = { - aiSummary: string; - aiSuggestedAction: string; + aiSummary: string; + aiSuggestedAction: string; }; const enrichmentSchema = z.object({ - aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'), - aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'), + aiSummary: z + .string() + .describe('1-2 sentence summary of who this lead is and their history'), + aiSuggestedAction: z + .string() + .describe('5-10 word suggested action for the agent'), }); @Injectable() export class AiEnrichmentService { - private readonly logger = new Logger(AiEnrichmentService.name); - private readonly aiModel: LanguageModel | null; + private readonly logger = new Logger(AiEnrichmentService.name); + private readonly aiModel: LanguageModel | null; - constructor( - private config: ConfigService, - private aiConfig: AiConfigService, - ) { - const cfg = aiConfig.getConfig(); - this.aiModel = createAiModel({ - provider: cfg.provider, - model: cfg.model, - anthropicApiKey: config.get('ai.anthropicApiKey'), - openaiApiKey: config.get('ai.openaiApiKey'), - }); - if (!this.aiModel) { - this.logger.warn('AI not configured — enrichment uses fallback'); - } + constructor( + private config: ConfigService, + private aiConfig: AiConfigService, + ) { + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); + if (!this.aiModel) { + this.logger.warn('AI not configured — enrichment uses fallback'); + } + } + + async enrichLead(lead: LeadContext): Promise { + if (!this.aiModel) { + return this.fallbackEnrichment(lead); } - async enrichLead(lead: LeadContext): Promise { - if (!this.aiModel) { - return this.fallbackEnrichment(lead); - } + try { + const daysSince = lead.createdAt + ? Math.floor( + (Date.now() - new Date(lead.createdAt).getTime()) / + (1000 * 60 * 60 * 24), + ) + : 0; - try { - const daysSince = lead.createdAt - ? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) - : 0; + const activitiesText = lead.activities?.length + ? lead.activities + .map((a) => `- ${a.activityType}: ${a.summary}`) + .join('\n') + : 'No previous interactions'; - const activitiesText = lead.activities?.length - ? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') - : 'No previous interactions'; + const patientContext = lead.patient + ? `Age: ${lead.patient.age ?? 'Unknown'} | Type: ${lead.patient.type ?? 'Unknown'} | Prior Records: ${lead.patient.hasRecords ? 'Yes' : 'No'}` + : 'No patient record linked'; - const { object } = await generateObject({ - model: this.aiModel!, - schema: enrichmentSchema, - prompt: this.aiConfig.renderPrompt('leadEnrichment', { - leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(), - leadSource: lead.leadSource ?? 'Unknown', - interestedService: lead.interestedService ?? 'Unknown', - leadStatus: lead.leadStatus ?? 'Unknown', - daysSince, - contactAttempts: lead.contactAttempts ?? 0, - activities: activitiesText, - }), - }); + const appointmentContext = lead.upcomingAppointments?.length + ? lead.upcomingAppointments + .map( + (a) => + `- ${a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString() : 'TBD'}: ${a.appointmentType ?? 'Appointment'} with ${a.doctorName ?? 'Doctor'} (${a.status ?? 'Scheduled'})`, + ) + .join('\n') + : 'No upcoming appointments'; - this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`); - return object; - } catch (error) { - this.logger.error(`AI enrichment failed: ${error}`); - return this.fallbackEnrichment(lead); - } + const { object } = await generateObject({ + model: this.aiModel!, + schema: enrichmentSchema, + prompt: this.aiConfig.renderPrompt('leadEnrichment', { + leadName: + `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(), + leadSource: lead.leadSource ?? 'Unknown', + interestedService: lead.interestedService ?? 'Unknown', + leadStatus: lead.leadStatus ?? 'Unknown', + daysSince, + contactAttempts: lead.contactAttempts ?? 0, + patientContext, + appointmentContext, + activities: activitiesText, + }), + }); + + this.logger.log( + `AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`, + ); + return object; + } catch (error) { + this.logger.error(`AI enrichment failed: ${error}`); + return this.fallbackEnrichment(lead); + } + } + + private fallbackEnrichment(lead: LeadContext): EnrichmentResult { + const daysSince = lead.createdAt + ? Math.floor( + (Date.now() - new Date(lead.createdAt).getTime()) / + (1000 * 60 * 60 * 24), + ) + : 0; + + const attempts = lead.contactAttempts ?? 0; + const service = lead.interestedService ?? 'general inquiry'; + const source = + lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source'; + + let summary: string; + let action: string; + + if (attempts === 0) { + summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`; + action = `Introduce services and offer appointment booking`; + } else if (attempts === 1) { + summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`; + action = `Follow up on previous conversation, offer appointment`; + } else { + summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`; + action = `Prioritize appointment booking — high-intent lead`; } - private fallbackEnrichment(lead: LeadContext): EnrichmentResult { - const daysSince = lead.createdAt - ? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) - : 0; - - const attempts = lead.contactAttempts ?? 0; - const service = lead.interestedService ?? 'general inquiry'; - const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source'; - - let summary: string; - let action: string; - - if (attempts === 0) { - summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`; - action = `Introduce services and offer appointment booking`; - } else if (attempts === 1) { - summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`; - action = `Follow up on previous conversation, offer appointment`; - } else { - summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`; - action = `Prioritize appointment booking — high-intent lead`; - } - - return { aiSummary: summary, aiSuggestedAction: action }; - } + return { aiSummary: summary, aiSuggestedAction: action }; + } } diff --git a/src/call-events/call-lookup.controller.ts b/src/call-events/call-lookup.controller.ts index 331caa4..90bd9c1 100644 --- a/src/call-events/call-lookup.controller.ts +++ b/src/call-events/call-lookup.controller.ts @@ -1,88 +1,147 @@ -import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Logger, + Headers, + HttpException, +} from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { AiEnrichmentService } from '../ai/ai-enrichment.service'; @Controller('api/call') export class CallLookupController { - private readonly logger = new Logger(CallLookupController.name); + private readonly logger = new Logger(CallLookupController.name); - constructor( - private readonly platform: PlatformGraphqlService, - private readonly ai: AiEnrichmentService, - ) {} + constructor( + private readonly platform: PlatformGraphqlService, + private readonly ai: AiEnrichmentService, + ) {} - @Post('lookup') - async lookupCaller( - @Body() body: { phoneNumber: string }, - @Headers('authorization') authHeader: string, - ) { - if (!authHeader) throw new HttpException('Authorization required', 401); - if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400); + @Post('lookup') + async lookupCaller( + @Body() body: { phoneNumber: string }, + @Headers('authorization') authHeader: string, + ) { + if (!authHeader) throw new HttpException('Authorization required', 401); + if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400); - const phone = body.phoneNumber.replace(/^0+/, ''); - this.logger.log(`Looking up caller: ${phone}`); + const phone = body.phoneNumber.replace(/^0+/, ''); + this.logger.log(`Looking up caller: ${phone}`); - // Query platform for leads matching this phone number - let lead = null; - let activities: any[] = []; + // Query platform for leads matching this phone number + let lead = null; + let activities: any[] = []; - try { - lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); - } catch (err) { - this.logger.warn(`Lead lookup failed: ${err}`); - } - - if (lead) { - this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`); - - // Get recent activities - try { - activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5); - } catch (err) { - this.logger.warn(`Activity fetch failed: ${err}`); - } - - // AI enrichment if no existing summary - if (!lead.aiSummary) { - try { - const enrichment = await this.ai.enrichLead({ - firstName: lead.contactName?.firstName, - lastName: lead.contactName?.lastName, - leadSource: lead.leadSource ?? undefined, - interestedService: lead.interestedService ?? undefined, - leadStatus: lead.leadStatus ?? undefined, - contactAttempts: lead.contactAttempts ?? undefined, - createdAt: lead.createdAt, - activities: activities.map((a: any) => ({ - activityType: a.activityType ?? '', - summary: a.summary ?? '', - })), - }); - - lead.aiSummary = enrichment.aiSummary; - lead.aiSuggestedAction = enrichment.aiSuggestedAction; - - // Persist AI enrichment back to platform - try { - await this.platform.updateLeadWithToken(lead.id, { - aiSummary: enrichment.aiSummary, - aiSuggestedAction: enrichment.aiSuggestedAction, - }, authHeader); - } catch (err) { - this.logger.warn(`Failed to persist AI enrichment: ${err}`); - } - } catch (err) { - this.logger.warn(`AI enrichment failed: ${err}`); - } - } - } else { - this.logger.log(`No lead found for phone ${phone}`); - } - - return { - lead, - activities, - matched: lead !== null, - }; + try { + lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); + } catch (err) { + this.logger.warn(`Lead lookup failed: ${err}`); } + + if (lead) { + this.logger.log( + `Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`, + ); + + // Get recent activities + try { + activities = await this.platform.getLeadActivitiesWithToken( + lead.id, + authHeader, + 5, + ); + } catch (err) { + this.logger.warn(`Activity fetch failed: ${err}`); + } + + // Fetch patient context if patientId exists + let patientData = null; + let upcomingAppointments: any[] = []; + if (lead.patientId) { + try { + patientData = await this.platform.getPatientWithToken( + lead.patientId, + authHeader, + ); + } catch (err) { + this.logger.warn(`Patient fetch failed: ${err}`); + } + + if (patientData) { + try { + upcomingAppointments = + await this.platform.getUpcomingAppointmentsWithToken( + lead.patientId, + authHeader, + 3, + ); + } catch (err) { + this.logger.warn(`Appointment fetch failed: ${err}`); + } + } + } + + // AI enrichment if no existing summary + // generate aiSummary everytime + // if (!lead.aiSummary) { + try { + const enrichment = await this.ai.enrichLead({ + firstName: lead.contactName?.firstName, + lastName: lead.contactName?.lastName, + leadSource: lead.leadSource ?? undefined, + interestedService: lead.interestedService ?? undefined, + leadStatus: lead.leadStatus ?? undefined, + contactAttempts: lead.contactAttempts ?? undefined, + createdAt: lead.createdAt, + patient: patientData + ? { + age: patientData.dateOfBirth + ? Math.floor( + (Date.now() - + new Date(patientData.dateOfBirth).getTime()) / + (1000 * 60 * 60 * 24 * 365.25), + ) + : undefined, + type: patientData.patientType, + hasRecords: true, + } + : undefined, + upcomingAppointments, + activities: activities.map((a: any) => ({ + activityType: a.activityType ?? '', + summary: a.summary ?? '', + })), + }); + + lead.aiSummary = enrichment.aiSummary; + lead.aiSuggestedAction = enrichment.aiSuggestedAction; + + // Persist AI enrichment back to platform + try { + await this.platform.updateLeadWithToken( + lead.id, + { + aiSummary: enrichment.aiSummary, + aiSuggestedAction: enrichment.aiSuggestedAction, + }, + authHeader, + ); + } catch (err) { + this.logger.warn(`Failed to persist AI enrichment: ${err}`); + } + } catch (err) { + this.logger.warn(`AI enrichment failed: ${err}`); + } + // } + } else { + this.logger.log(`No lead found for phone ${phone}`); + } + + return { + lead, + activities, + matched: lead !== null, + }; + } } diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index 2837615..c3e3009 100644 --- a/src/config/ai.defaults.ts +++ b/src/config/ai.defaults.ts @@ -24,47 +24,47 @@ export type AiProvider = 'openai' | 'anthropic'; // 2. add a default entry in DEFAULT_AI_PROMPTS below // 3. add the corresponding renderPrompt call in the consuming service export const AI_ACTOR_KEYS = [ - 'widgetChat', - 'ccAgentHelper', - 'supervisorChat', - 'leadEnrichment', - 'callInsight', - 'callAssist', - 'recordingAnalysis', + 'widgetChat', + 'ccAgentHelper', + 'supervisorChat', + 'leadEnrichment', + 'callInsight', + 'callAssist', + 'recordingAnalysis', ] as const; export type AiActorKey = (typeof AI_ACTOR_KEYS)[number]; export type AiPromptConfig = { - // Human-readable name shown in the wizard UI. - label: string; - // One-line description of when this persona is invoked. - description: string; - // Variables the template can reference, with a one-line hint each. - // Surfaced in the edit slideout so admins know what `{{var}}` they - // can use without reading code. - variables: Array<{ key: string; description: string }>; - // The current template (may be admin-edited). - template: string; - // The original baseline so we can offer a "reset to default" button. - defaultTemplate: string; - // Audit fields — when this prompt was last edited and by whom. - // null on the default-supplied entries. - lastEditedAt: string | null; - lastEditedBy: string | null; + // Human-readable name shown in the wizard UI. + label: string; + // One-line description of when this persona is invoked. + description: string; + // Variables the template can reference, with a one-line hint each. + // Surfaced in the edit slideout so admins know what `{{var}}` they + // can use without reading code. + variables: Array<{ key: string; description: string }>; + // The current template (may be admin-edited). + template: string; + // The original baseline so we can offer a "reset to default" button. + defaultTemplate: string; + // Audit fields — when this prompt was last edited and by whom. + // null on the default-supplied entries. + lastEditedAt: string | null; + lastEditedBy: string | null; }; export type AiConfig = { - provider: AiProvider; - model: string; - // 0..2, controls randomness. Default 0.7 matches the existing hardcoded - // values used in WidgetChatService and AI tools. - temperature: number; - // Per-actor system prompt templates. Keyed by AiActorKey so callers can - // do `config.prompts.widgetChat.template` and missing keys are caught - // at compile time. - prompts: Record; - version?: number; - updatedAt?: string; + provider: AiProvider; + model: string; + // 0..2, controls randomness. Default 0.7 matches the existing hardcoded + // values used in WidgetChatService and AI tools. + temperature: number; + // Per-actor system prompt templates. Keyed by AiActorKey so callers can + // do `config.prompts.widgetChat.template` and missing keys are caught + // at compile time. + prompts: Record; + version?: number; + updatedAt?: string; }; // --------------------------------------------------------------------------- @@ -153,8 +153,16 @@ Lead details: - Lead age: {{daysSince}} days - Contact attempts: {{contactAttempts}} +Patient Context: +{{patientContext}} + +Upcoming Appointments: +{{appointmentContext}} + Recent activity: -{{activities}}`; +{{activities}} + +Generate a 1-2 sentence summary of the lead and a 5-10 word suggested action for the agent.`; const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}. Generate a brief, actionable insight about this lead based on their interaction history. @@ -185,102 +193,146 @@ Be specific, brief, and actionable. Focus on healthcare context. // `template` and `defaultTemplate` — what every actor starts with on a // fresh boot. const promptDefault = ( - label: string, - description: string, - variables: Array<{ key: string; description: string }>, - template: string, + label: string, + description: string, + variables: Array<{ key: string; description: string }>, + template: string, ): AiPromptConfig => ({ - label, - description, - variables, - template, - defaultTemplate: template, - lastEditedAt: null, - lastEditedBy: null, + label, + description, + variables, + template, + defaultTemplate: template, + lastEditedAt: null, + lastEditedBy: null, }); export const DEFAULT_AI_PROMPTS: Record = { - widgetChat: promptDefault( - 'Website widget chat', - 'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.', - [ - { key: 'hospitalName', description: 'Branded hospital display name from theme.json' }, - { key: 'userName', description: 'Visitor first name (or "there" if unknown)' }, - { key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' }, - { key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' }, - ], - WIDGET_CHAT_DEFAULT, - ), - ccAgentHelper: promptDefault( - 'CC agent helper', - 'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.', - [ - { key: 'hospitalName', description: 'Branded hospital display name' }, - { key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' }, - ], - CC_AGENT_HELPER_DEFAULT, - ), - supervisorChat: promptDefault( - 'Supervisor assistant', - 'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.', - [ - { key: 'hospitalName', description: 'Branded hospital display name' }, - ], - SUPERVISOR_CHAT_DEFAULT, - ), - leadEnrichment: promptDefault( - 'Lead enrichment', - 'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.', - [ - { key: 'leadName', description: 'Lead first + last name' }, - { key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' }, - { key: 'interestedService', description: 'What the lead enquired about' }, - { key: 'leadStatus', description: 'Current lead status' }, - { key: 'daysSince', description: 'Days since the lead was created' }, - { key: 'contactAttempts', description: 'Prior contact attempts count' }, - { key: 'activities', description: 'Pre-rendered recent activity summary' }, - ], - LEAD_ENRICHMENT_DEFAULT, - ), - callInsight: promptDefault( - 'Post-call insight', - 'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.', - [ - { key: 'hospitalName', description: 'Branded hospital display name' }, - ], - CALL_INSIGHT_DEFAULT, - ), - callAssist: promptDefault( - 'Live call whisper', - 'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.', - [ - { key: 'hospitalName', description: 'Branded hospital display name' }, - { key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' }, - ], - CALL_ASSIST_DEFAULT, - ), - recordingAnalysis: promptDefault( - 'Call recording analysis', - 'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.', - [ - { key: 'hospitalName', description: 'Branded hospital display name' }, - { key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' }, - { key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' }, - ], - RECORDING_ANALYSIS_DEFAULT, - ), + widgetChat: promptDefault( + 'Website widget chat', + 'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.', + [ + { + key: 'hospitalName', + description: 'Branded hospital display name from theme.json', + }, + { + key: 'userName', + description: 'Visitor first name (or "there" if unknown)', + }, + { + key: 'branchContext', + description: 'Pre-rendered branch-selection instructions block', + }, + { + key: 'knowledgeBase', + description: 'Pre-rendered list of departments + doctors + clinics', + }, + ], + WIDGET_CHAT_DEFAULT, + ), + ccAgentHelper: promptDefault( + 'CC agent helper', + 'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.', + [ + { key: 'hospitalName', description: 'Branded hospital display name' }, + { + key: 'knowledgeBase', + description: + 'Pre-rendered hospital knowledge base (clinics, doctors, packages)', + }, + ], + CC_AGENT_HELPER_DEFAULT, + ), + supervisorChat: promptDefault( + 'Supervisor assistant', + 'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.', + [{ key: 'hospitalName', description: 'Branded hospital display name' }], + SUPERVISOR_CHAT_DEFAULT, + ), + leadEnrichment: promptDefault( + 'Lead enrichment', + 'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.', + [ + { key: 'leadName', description: 'Lead first + last name' }, + { + key: 'leadSource', + description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)', + }, + { key: 'interestedService', description: 'What the lead enquired about' }, + { key: 'leadStatus', description: 'Current lead status' }, + { key: 'daysSince', description: 'Days since the lead was created' }, + { key: 'contactAttempts', description: 'Prior contact attempts count' }, + { + key: 'patientContext', + description: + 'Patient age, type (new/returning), and whether they have prior records', + }, + { + key: 'appointmentContext', + description: + 'Next 3 scheduled appointments (date, doctor, type, status)', + }, + { + key: 'activities', + description: + 'Pre-rendered recent activity summary (last 5 interactions)', + }, + ], + LEAD_ENRICHMENT_DEFAULT, + ), + callInsight: promptDefault( + 'Post-call insight', + 'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.', + [{ key: 'hospitalName', description: 'Branded hospital display name' }], + CALL_INSIGHT_DEFAULT, + ), + callAssist: promptDefault( + 'Live call whisper', + 'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.', + [ + { key: 'hospitalName', description: 'Branded hospital display name' }, + { + key: 'context', + description: + 'Pre-rendered call context (current lead, recent activities, available doctors)', + }, + ], + CALL_ASSIST_DEFAULT, + ), + recordingAnalysis: promptDefault( + 'Call recording analysis', + 'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.', + [ + { key: 'hospitalName', description: 'Branded hospital display name' }, + { + key: 'summaryBlock', + description: + 'Optional pre-rendered "Call summary: ..." line (empty when none)', + }, + { + key: 'topicsBlock', + description: + 'Optional pre-rendered "Detected topics: ..." line (empty when none)', + }, + ], + RECORDING_ANALYSIS_DEFAULT, + ), }; export const DEFAULT_AI_CONFIG: AiConfig = { - provider: 'openai', - model: 'gpt-4o-mini', - temperature: 0.7, - prompts: DEFAULT_AI_PROMPTS, + provider: 'openai', + model: 'gpt-4o-mini', + temperature: 0.7, + prompts: DEFAULT_AI_PROMPTS, }; // Field-by-field mapping from the legacy env vars used by ai-provider.ts // (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env. -export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick }> = [ - { env: 'AI_PROVIDER', field: 'provider' }, - { env: 'AI_MODEL', field: 'model' }, +export const AI_ENV_SEEDS: Array<{ + env: string; + field: keyof Pick; +}> = [ + { env: 'AI_PROVIDER', field: 'provider' }, + { env: 'AI_MODEL', field: 'model' }, ]; diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index 68bcffa..58280ae 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -1,48 +1,58 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types'; +import type { + LeadNode, + LeadActivityNode, + CreateCallInput, + CreateLeadActivityInput, + UpdateLeadInput, +} from './platform.types'; @Injectable() export class PlatformGraphqlService { - private readonly graphqlUrl: string; - private readonly apiKey: string; + private readonly graphqlUrl: string; + private readonly apiKey: string; - constructor(private config: ConfigService) { - this.graphqlUrl = config.get('platform.graphqlUrl')!; - this.apiKey = config.get('platform.apiKey')!; + constructor(private config: ConfigService) { + this.graphqlUrl = config.get('platform.graphqlUrl')!; + this.apiKey = config.get('platform.apiKey')!; + } + + // Server-to-server query using API key + async query(query: string, variables?: Record): Promise { + return this.queryWithAuth(query, variables, `Bearer ${this.apiKey}`); + } + + // Query using a passed-through auth header (user JWT) + async queryWithAuth( + query: string, + variables: Record | undefined, + authHeader: string, + ): Promise { + const response = await axios.post( + this.graphqlUrl, + { query, variables }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: authHeader, + }, + }, + ); + + if (response.data.errors) { + throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`); } - // Server-to-server query using API key - async query(query: string, variables?: Record): Promise { - return this.queryWithAuth(query, variables, `Bearer ${this.apiKey}`); - } + return response.data.data; + } - // Query using a passed-through auth header (user JWT) - async queryWithAuth(query: string, variables: Record | undefined, authHeader: string): Promise { - const response = await axios.post( - this.graphqlUrl, - { query, variables }, - { - headers: { - 'Content-Type': 'application/json', - 'Authorization': authHeader, - }, - }, - ); - - if (response.data.errors) { - throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`); - } - - return response.data.data; - } - - async findLeadByPhone(phone: string): Promise { - // Note: The exact filter syntax for PHONES fields depends on the platform - // This queries leads and filters client-side by phone number - const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>( - `query FindLeads($first: Int) { + async findLeadByPhone(phone: string): Promise { + // Note: The exact filter syntax for PHONES fields depends on the platform + // This queries leads and filters client-side by phone number + const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>( + `query FindLeads($first: Int) { leads(first: $first, orderBy: { createdAt: DescNullsLast }) { edges { node { @@ -58,20 +68,26 @@ export class PlatformGraphqlService { } } }`, - { first: 100 }, + { first: 100 }, + ); + + // Client-side phone matching (strip non-digits for comparison) + const normalizedPhone = phone.replace(/\D/g, ''); + return ( + data.leads.edges.find((edge) => { + const leadPhones = edge.node.contactPhone ?? []; + return leadPhones.some( + (p) => + p.number.replace(/\D/g, '').endsWith(normalizedPhone) || + normalizedPhone.endsWith(p.number.replace(/\D/g, '')), ); + })?.node ?? null + ); + } - // Client-side phone matching (strip non-digits for comparison) - const normalizedPhone = phone.replace(/\D/g, ''); - return data.leads.edges.find(edge => { - const leadPhones = edge.node.contactPhone ?? []; - return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, ''))); - })?.node ?? null; - } - - async findLeadById(id: string): Promise { - const data = await this.query<{ lead: LeadNode }>( - `query FindLead($id: ID!) { + async findLeadById(id: string): Promise { + const data = await this.query<{ lead: LeadNode }>( + `query FindLead($id: ID!) { lead(id: $id) { id createdAt contactName { firstName lastName } @@ -83,51 +99,58 @@ export class PlatformGraphqlService { aiSummary aiSuggestedAction } }`, - { id }, - ); - return data.lead; - } + { id }, + ); + return data.lead; + } - async updateLead(id: string, input: UpdateLeadInput): Promise { - const data = await this.query<{ updateLead: LeadNode }>( - `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { + async updateLead(id: string, input: UpdateLeadInput): Promise { + const data = await this.query<{ updateLead: LeadNode }>( + `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id leadStatus aiSummary aiSuggestedAction } }`, - { id, data: input }, - ); - return data.updateLead; - } + { id, data: input }, + ); + return data.updateLead; + } - async createCall(input: CreateCallInput): Promise<{ id: string }> { - const data = await this.query<{ createCall: { id: string } }>( - `mutation CreateCall($data: CallCreateInput!) { + async createCall(input: CreateCallInput): Promise<{ id: string }> { + const data = await this.query<{ createCall: { id: string } }>( + `mutation CreateCall($data: CallCreateInput!) { createCall(data: $data) { id } }`, - { data: input }, - ); - return data.createCall; - } + { data: input }, + ); + return data.createCall; + } - async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> { - const data = await this.query<{ createLeadActivity: { id: string } }>( - `mutation CreateLeadActivity($data: LeadActivityCreateInput!) { + async createLeadActivity( + input: CreateLeadActivityInput, + ): Promise<{ id: string }> { + const data = await this.query<{ createLeadActivity: { id: string } }>( + `mutation CreateLeadActivity($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`, - { data: input }, - ); - return data.createLeadActivity; - } + { data: input }, + ); + return data.createLeadActivity; + } - // --- Token passthrough versions (for user-driven requests) --- + // --- Token passthrough versions (for user-driven requests) --- - async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise { - const normalizedPhone = phone.replace(/\D/g, ''); - const last10 = normalizedPhone.slice(-10); + async findLeadByPhoneWithToken( + phone: string, + authHeader: string, + ): Promise { + const normalizedPhone = phone.replace(/\D/g, ''); + const last10 = normalizedPhone.slice(-10); - const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( - `query FindLeads($first: Int) { + const data = await this.queryWithAuth<{ + leads: { edges: { node: LeadNode }[] }; + }>( + `query FindLeads($first: Int) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { @@ -143,28 +166,43 @@ export class PlatformGraphqlService { } } }`, - { first: 200 }, - authHeader, - ); + { first: 200 }, + authHeader, + ); - // Client-side phone matching - return data.leads.edges.find(edge => { - const phones = edge.node.contactPhone ?? []; - if (Array.isArray(phones)) { - return phones.some((p: any) => { - const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); - return num.endsWith(last10) || last10.endsWith(num); - }); - } - // Handle single phone object - const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, ''); + // Client-side phone matching + return ( + data.leads.edges.find((edge) => { + const phones = edge.node.contactPhone ?? []; + if (Array.isArray(phones)) { + return phones.some((p: any) => { + const num = (p.number ?? p.primaryPhoneNumber ?? '').replace( + /\D/g, + '', + ); return num.endsWith(last10) || last10.endsWith(num); - })?.node ?? null; - } + }); + } + // Handle single phone object + const num = ( + (phones as any).primaryPhoneNumber ?? + (phones as any).number ?? + '' + ).replace(/\D/g, ''); + return num.endsWith(last10) || last10.endsWith(num); + })?.node ?? null + ); + } - async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise { - const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( - `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { + async getLeadActivitiesWithToken( + leadId: string, + authHeader: string, + limit = 5, + ): Promise { + const data = await this.queryWithAuth<{ + leadActivities: { edges: { node: LeadActivityNode }[] }; + }>( + `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { @@ -173,44 +211,51 @@ export class PlatformGraphqlService { } } }`, - { filter: { leadId: { eq: leadId } }, first: limit }, - authHeader, - ); - return data.leadActivities.edges.map(e => e.node); - } + { filter: { leadId: { eq: leadId } }, first: limit }, + authHeader, + ); + return data.leadActivities.edges.map((e) => e.node); + } - async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise { - // Response fragment deliberately excludes `leadStatus` — the staging - // platform schema has this field renamed to `status`. Selecting the - // old name rejects the whole mutation. Callers don't use the - // returned fragment today, so returning just the id + AI fields - // keeps this working across both schema shapes without a wider - // rename hotfix. - const data = await this.queryWithAuth<{ updateLead: LeadNode }>( - `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { + async updateLeadWithToken( + id: string, + input: UpdateLeadInput, + authHeader: string, + ): Promise { + // Response fragment deliberately excludes `leadStatus` — the staging + // platform schema has this field renamed to `status`. Selecting the + // old name rejects the whole mutation. Callers don't use the + // returned fragment today, so returning just the id + AI fields + // keeps this working across both schema shapes without a wider + // rename hotfix. + const data = await this.queryWithAuth<{ updateLead: LeadNode }>( + `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id aiSummary aiSuggestedAction } }`, - { id, data: input }, - authHeader, - ); - return data.updateLead; - } + { id, data: input }, + authHeader, + ); + return data.updateLead; + } - // Fetch a single lead by id with the caller's JWT. Used by the - // lead-enrich flow when the agent explicitly renames a caller from - // the appointment/enquiry form and we need to regenerate the lead's - // AI summary against fresh identity. - // - // The selected fields deliberately use the staging-aligned names - // (`status`, `source`, `lastContacted`) rather than the older - // `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the - // query would be rejected on staging. - async findLeadByIdWithToken(id: string, authHeader: string): Promise { - try { - const data = await this.queryWithAuth<{ lead: any }>( - `query FindLead($id: UUID!) { + // Fetch a single lead by id with the caller's JWT. Used by the + // lead-enrich flow when the agent explicitly renames a caller from + // the appointment/enquiry form and we need to regenerate the lead's + // AI summary against fresh identity. + // + // The selected fields deliberately use the staging-aligned names + // (`status`, `source`, `lastContacted`) rather than the older + // `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the + // query would be rejected on staging. + async findLeadByIdWithToken( + id: string, + authHeader: string, + ): Promise { + try { + const data = await this.queryWithAuth<{ lead: any }>( + `query FindLead($id: UUID!) { lead(filter: { id: { eq: $id } }) { id createdAt @@ -225,15 +270,17 @@ export class PlatformGraphqlService { aiSuggestedAction } }`, - { id }, - authHeader, - ); - return data.lead ?? null; - } catch { - // Fall back to edge-style query in case the singular field - // doesn't exist on this platform version. - const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>( - `query FindLead($id: UUID!) { + { id }, + authHeader, + ); + return data.lead ?? null; + } catch { + // Fall back to edge-style query in case the singular field + // doesn't exist on this platform version. + const data = await this.queryWithAuth<{ + leads: { edges: { node: any }[] }; + }>( + `query FindLead($id: UUID!) { leads(filter: { id: { eq: $id } }, first: 1) { edges { node { @@ -252,18 +299,83 @@ export class PlatformGraphqlService { } } }`, - { id }, - authHeader, - ); - return data.leads.edges[0]?.node ?? null; - } + { id }, + authHeader, + ); + return data.leads.edges[0]?.node ?? null; } + } - // --- Server-to-server versions (for webhooks, background jobs) --- + async getPatientWithToken( + patientId: string, + authHeader: string, + ): Promise { + try { + const data = await this.queryWithAuth<{ patient: any }>( + `query GetPatient($id: UUID!) { + patient(filter: { id: { eq: $id } }) { + id + fullName { firstName lastName } + dateOfBirth + patientType + } + }`, + { id: patientId }, + authHeader, + ); + return data.patient ?? null; + } catch { + return null; + } + } - async getLeadActivities(leadId: string, limit = 3): Promise { - const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( - `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { + async getUpcomingAppointmentsWithToken( + patientId: string, + authHeader: string, + limit = 3, + ): Promise { + try { + const data = await this.queryWithAuth<{ + appointments: { edges: { node: any }[] }; + }>( + `query GetAppointments($filter: AppointmentFilterInput, $first: Int) { + appointments(filter: $filter, first: $first, orderBy: [{ scheduledAt: AscNullsLast }]) { + edges { + node { + id + scheduledAt + appointmentType + doctorName + status + } + } + } + }`, + { + filter: { + patientId: { eq: patientId }, + scheduledAt: { gte: new Date().toISOString() }, + }, + first: limit, + }, + authHeader, + ); + return data.appointments.edges.map((e) => e.node); + } catch { + return []; + } + } + + // --- Server-to-server versions (for webhooks, background jobs) --- + + async getLeadActivities( + leadId: string, + limit = 3, + ): Promise { + const data = await this.query<{ + leadActivities: { edges: { node: LeadActivityNode }[] }; + }>( + `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) { edges { node { @@ -272,8 +384,8 @@ export class PlatformGraphqlService { } } }`, - { filter: { leadId: { eq: leadId } }, first: limit }, - ); - return data.leadActivities.edges.map(e => e.node); - } + { filter: { leadId: { eq: leadId } }, first: limit }, + ); + return data.leadActivities.edges.map((e) => e.node); + } }