From 1dd8413297fed556c7498e27cb0da4c21768d577 Mon Sep 17 00:00:00 2001 From: Kartik Datrika Date: Thu, 16 Apr 2026 14:54:20 +0530 Subject: [PATCH] Revert "AI Summary not showing appointments fix." This reverts commit 973614749b0346f431b700fd899af242afdb5e99. --- .gitignore | 4 - src/ai/ai-enrichment.service.ts | 210 +++++------ src/call-events/call-lookup.controller.ts | 199 ++++------- src/config/ai.defaults.ts | 296 +++++++--------- src/platform/platform-graphql.service.ts | 414 ++++++++-------------- 5 files changed, 427 insertions(+), 696 deletions(-) diff --git a/.gitignore b/.gitignore index 8392119..1420bea 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,3 @@ 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 78b345f..10279c6 100644 --- a/src/ai/ai-enrichment.service.ts +++ b/src/ai/ai-enrichment.service.ts @@ -7,149 +7,107 @@ 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; - 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 }[]; + firstName?: string; + lastName?: string; + leadSource?: string; + interestedService?: string; + leadStatus?: string; + contactAttempts?: number; + createdAt?: string; + campaignId?: 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'); - } - } - - async enrichLead(lead: LeadContext): Promise { - if (!this.aiModel) { - return this.fallbackEnrichment(lead); + 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'); + } } - try { - const daysSince = lead.createdAt - ? Math.floor( - (Date.now() - new Date(lead.createdAt).getTime()) / - (1000 * 60 * 60 * 24), - ) - : 0; + async enrichLead(lead: LeadContext): Promise { + if (!this.aiModel) { + return this.fallbackEnrichment(lead); + } - const activitiesText = lead.activities?.length - ? lead.activities - .map((a) => `- ${a.activityType}: ${a.summary}`) - .join('\n') - : 'No previous interactions'; + try { + const daysSince = lead.createdAt + ? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) + : 0; - 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 activitiesText = lead.activities?.length + ? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') + : 'No previous interactions'; - 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'; + 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 { 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`; + 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); + } } - return { aiSummary: summary, aiSuggestedAction: action }; - } + 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 }; + } } diff --git a/src/call-events/call-lookup.controller.ts b/src/call-events/call-lookup.controller.ts index 90bd9c1..331caa4 100644 --- a/src/call-events/call-lookup.controller.ts +++ b/src/call-events/call-lookup.controller.ts @@ -1,147 +1,88 @@ -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}`); - } - - // Fetch patient context if patientId exists - let patientData = null; - let upcomingAppointments: any[] = []; - if (lead.patientId) { try { - patientData = await this.platform.getPatientWithToken( - lead.patientId, - authHeader, - ); + lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); } catch (err) { - this.logger.warn(`Patient fetch failed: ${err}`); + this.logger.warn(`Lead lookup failed: ${err}`); } - if (patientData) { - try { - upcomingAppointments = - await this.platform.getUpcomingAppointmentsWithToken( - lead.patientId, - authHeader, - 3, - ); - } catch (err) { - this.logger.warn(`Appointment fetch 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}`); } - } - // 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, + }; } - - return { - lead, - activities, - matched: lead !== null, - }; - } } diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index c3e3009..2837615 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,16 +153,8 @@ Lead details: - Lead age: {{daysSince}} days - Contact attempts: {{contactAttempts}} -Patient Context: -{{patientContext}} - -Upcoming Appointments: -{{appointmentContext}} - Recent activity: -{{activities}} - -Generate a 1-2 sentence summary of the lead and a 5-10 word suggested action for the agent.`; +{{activities}}`; const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}. Generate a brief, actionable insight about this lead based on their interaction history. @@ -193,146 +185,102 @@ 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: '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, - ), + 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, + ), }; 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 58280ae..68bcffa 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -1,58 +1,48 @@ 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')!; - } - - // 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)}`); + constructor(private config: ConfigService) { + this.graphqlUrl = config.get('platform.graphqlUrl')!; + this.apiKey = config.get('platform.apiKey')!; } - return response.data.data; - } + // Server-to-server query using API key + async query(query: string, variables?: Record): Promise { + return this.queryWithAuth(query, variables, `Bearer ${this.apiKey}`); + } - 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) { + // 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) { leads(first: $first, orderBy: { createdAt: DescNullsLast }) { edges { node { @@ -68,26 +58,20 @@ export class PlatformGraphqlService { } } }`, - { 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, '')), + { first: 100 }, ); - })?.node ?? null - ); - } - async findLeadById(id: string): Promise { - const data = await this.query<{ lead: LeadNode }>( - `query FindLead($id: ID!) { + // 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!) { lead(id: $id) { id createdAt contactName { firstName lastName } @@ -99,58 +83,51 @@ 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 { @@ -166,43 +143,28 @@ 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, - '', - ); + // 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, ''); return num.endsWith(last10) || last10.endsWith(num); - }); - } - // 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 - ); - } + })?.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 { @@ -211,51 +173,44 @@ 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 @@ -270,17 +225,15 @@ 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 { @@ -299,83 +252,18 @@ export class PlatformGraphqlService { } } }`, - { id }, - authHeader, - ); - return data.leads.edges[0]?.node ?? null; + { id }, + authHeader, + ); + return data.leads.edges[0]?.node ?? null; + } } - } - 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; - } - } + // --- Server-to-server versions (for webhooks, background jobs) --- - 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) { + 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 { @@ -384,8 +272,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); + } }