AI Summary not showing appointments fix.

This commit is contained in:
Kartik Datrika
2026-04-16 11:36:10 +05:30
parent 6adb3985cb
commit 973614749b
5 changed files with 702 additions and 433 deletions

4
.gitignore vendored
View File

@@ -42,3 +42,7 @@ lerna-debug.log*
# Each environment mints its own HMAC-signed site key. # Each environment mints its own HMAC-signed site key.
data/widget.json data/widget.json
data/widget-backups/ data/widget-backups/
.env.local
.grepai/config.yaml
.grepai/symbols.gob
.grepai/symbols.gob.lock

View File

@@ -7,107 +7,149 @@ import { createAiModel } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service'; import { AiConfigService } from '../config/ai-config.service';
type LeadContext = { type LeadContext = {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
leadSource?: string; leadSource?: string;
interestedService?: string; interestedService?: string;
leadStatus?: string; leadStatus?: string;
contactAttempts?: number; contactAttempts?: number;
createdAt?: string; createdAt?: string;
campaignId?: string; campaignId?: string;
activities?: { activityType: string; summary: 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 = { type EnrichmentResult = {
aiSummary: string; aiSummary: string;
aiSuggestedAction: string; aiSuggestedAction: string;
}; };
const enrichmentSchema = z.object({ const enrichmentSchema = z.object({
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'), aiSummary: z
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'), .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() @Injectable()
export class AiEnrichmentService { export class AiEnrichmentService {
private readonly logger = new Logger(AiEnrichmentService.name); private readonly logger = new Logger(AiEnrichmentService.name);
private readonly aiModel: LanguageModel | null; private readonly aiModel: LanguageModel | null;
constructor( constructor(
private config: ConfigService, private config: ConfigService,
private aiConfig: AiConfigService, private aiConfig: AiConfigService,
) { ) {
const cfg = aiConfig.getConfig(); const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({ this.aiModel = createAiModel({
provider: cfg.provider, provider: cfg.provider,
model: cfg.model, model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'), anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'), openaiApiKey: config.get<string>('ai.openaiApiKey'),
}); });
if (!this.aiModel) { if (!this.aiModel) {
this.logger.warn('AI not configured — enrichment uses fallback'); this.logger.warn('AI not configured — enrichment uses fallback');
} }
}
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
if (!this.aiModel) {
return this.fallbackEnrichment(lead);
} }
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> { try {
if (!this.aiModel) { const daysSince = lead.createdAt
return this.fallbackEnrichment(lead); ? Math.floor(
} (Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0;
try { const activitiesText = lead.activities?.length
const daysSince = lead.createdAt ? lead.activities
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) .map((a) => `- ${a.activityType}: ${a.summary}`)
: 0; .join('\n')
: 'No previous interactions';
const activitiesText = lead.activities?.length const patientContext = lead.patient
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') ? `Age: ${lead.patient.age ?? 'Unknown'} | Type: ${lead.patient.type ?? 'Unknown'} | Prior Records: ${lead.patient.hasRecords ? 'Yes' : 'No'}`
: 'No previous interactions'; : 'No patient record linked';
const { object } = await generateObject({ const appointmentContext = lead.upcomingAppointments?.length
model: this.aiModel!, ? lead.upcomingAppointments
schema: enrichmentSchema, .map(
prompt: this.aiConfig.renderPrompt('leadEnrichment', { (a) =>
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(), `- ${a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString() : 'TBD'}: ${a.appointmentType ?? 'Appointment'} with ${a.doctorName ?? 'Doctor'} (${a.status ?? 'Scheduled'})`,
leadSource: lead.leadSource ?? 'Unknown', )
interestedService: lead.interestedService ?? 'Unknown', .join('\n')
leadStatus: lead.leadStatus ?? 'Unknown', : 'No upcoming appointments';
daysSince,
contactAttempts: lead.contactAttempts ?? 0,
activities: activitiesText,
}),
});
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`); const { object } = await generateObject({
return object; model: this.aiModel!,
} catch (error) { schema: enrichmentSchema,
this.logger.error(`AI enrichment failed: ${error}`); prompt: this.aiConfig.renderPrompt('leadEnrichment', {
return this.fallbackEnrichment(lead); 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 { return { aiSummary: summary, aiSuggestedAction: action };
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 };
}
} }

View File

@@ -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 { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AiEnrichmentService } from '../ai/ai-enrichment.service'; import { AiEnrichmentService } from '../ai/ai-enrichment.service';
@Controller('api/call') @Controller('api/call')
export class CallLookupController { export class CallLookupController {
private readonly logger = new Logger(CallLookupController.name); private readonly logger = new Logger(CallLookupController.name);
constructor( constructor(
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly ai: AiEnrichmentService, private readonly ai: AiEnrichmentService,
) {} ) {}
@Post('lookup') @Post('lookup')
async lookupCaller( async lookupCaller(
@Body() body: { phoneNumber: string }, @Body() body: { phoneNumber: string },
@Headers('authorization') authHeader: string, @Headers('authorization') authHeader: string,
) { ) {
if (!authHeader) throw new HttpException('Authorization required', 401); if (!authHeader) throw new HttpException('Authorization required', 401);
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400); if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
const phone = body.phoneNumber.replace(/^0+/, ''); const phone = body.phoneNumber.replace(/^0+/, '');
this.logger.log(`Looking up caller: ${phone}`); this.logger.log(`Looking up caller: ${phone}`);
// Query platform for leads matching this phone number // Query platform for leads matching this phone number
let lead = null; let lead = null;
let activities: any[] = []; let activities: any[] = [];
try { try {
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
} catch (err) { } catch (err) {
this.logger.warn(`Lead lookup failed: ${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,
};
} }
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,
};
}
} }

View File

@@ -24,47 +24,47 @@ export type AiProvider = 'openai' | 'anthropic';
// 2. add a default entry in DEFAULT_AI_PROMPTS below // 2. add a default entry in DEFAULT_AI_PROMPTS below
// 3. add the corresponding renderPrompt call in the consuming service // 3. add the corresponding renderPrompt call in the consuming service
export const AI_ACTOR_KEYS = [ export const AI_ACTOR_KEYS = [
'widgetChat', 'widgetChat',
'ccAgentHelper', 'ccAgentHelper',
'supervisorChat', 'supervisorChat',
'leadEnrichment', 'leadEnrichment',
'callInsight', 'callInsight',
'callAssist', 'callAssist',
'recordingAnalysis', 'recordingAnalysis',
] as const; ] as const;
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number]; export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
export type AiPromptConfig = { export type AiPromptConfig = {
// Human-readable name shown in the wizard UI. // Human-readable name shown in the wizard UI.
label: string; label: string;
// One-line description of when this persona is invoked. // One-line description of when this persona is invoked.
description: string; description: string;
// Variables the template can reference, with a one-line hint each. // Variables the template can reference, with a one-line hint each.
// Surfaced in the edit slideout so admins know what `{{var}}` they // Surfaced in the edit slideout so admins know what `{{var}}` they
// can use without reading code. // can use without reading code.
variables: Array<{ key: string; description: string }>; variables: Array<{ key: string; description: string }>;
// The current template (may be admin-edited). // The current template (may be admin-edited).
template: string; template: string;
// The original baseline so we can offer a "reset to default" button. // The original baseline so we can offer a "reset to default" button.
defaultTemplate: string; defaultTemplate: string;
// Audit fields — when this prompt was last edited and by whom. // Audit fields — when this prompt was last edited and by whom.
// null on the default-supplied entries. // null on the default-supplied entries.
lastEditedAt: string | null; lastEditedAt: string | null;
lastEditedBy: string | null; lastEditedBy: string | null;
}; };
export type AiConfig = { export type AiConfig = {
provider: AiProvider; provider: AiProvider;
model: string; model: string;
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded // 0..2, controls randomness. Default 0.7 matches the existing hardcoded
// values used in WidgetChatService and AI tools. // values used in WidgetChatService and AI tools.
temperature: number; temperature: number;
// Per-actor system prompt templates. Keyed by AiActorKey so callers can // Per-actor system prompt templates. Keyed by AiActorKey so callers can
// do `config.prompts.widgetChat.template` and missing keys are caught // do `config.prompts.widgetChat.template` and missing keys are caught
// at compile time. // at compile time.
prompts: Record<AiActorKey, AiPromptConfig>; prompts: Record<AiActorKey, AiPromptConfig>;
version?: number; version?: number;
updatedAt?: string; updatedAt?: string;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -153,8 +153,16 @@ Lead details:
- Lead age: {{daysSince}} days - Lead age: {{daysSince}} days
- Contact attempts: {{contactAttempts}} - Contact attempts: {{contactAttempts}}
Patient Context:
{{patientContext}}
Upcoming Appointments:
{{appointmentContext}}
Recent activity: 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}}. const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
Generate a brief, actionable insight about this lead based on their interaction history. 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 // `template` and `defaultTemplate` — what every actor starts with on a
// fresh boot. // fresh boot.
const promptDefault = ( const promptDefault = (
label: string, label: string,
description: string, description: string,
variables: Array<{ key: string; description: string }>, variables: Array<{ key: string; description: string }>,
template: string, template: string,
): AiPromptConfig => ({ ): AiPromptConfig => ({
label, label,
description, description,
variables, variables,
template, template,
defaultTemplate: template, defaultTemplate: template,
lastEditedAt: null, lastEditedAt: null,
lastEditedBy: null, lastEditedBy: null,
}); });
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = { export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
widgetChat: promptDefault( widgetChat: promptDefault(
'Website widget chat', 'Website widget chat',
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.', '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: 'hospitalName',
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' }, description: 'Branded hospital display name from theme.json',
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' }, },
], {
WIDGET_CHAT_DEFAULT, key: 'userName',
), description: 'Visitor first name (or "there" if unknown)',
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: 'branchContext',
[ description: 'Pre-rendered branch-selection instructions block',
{ key: 'hospitalName', description: 'Branded hospital display name' }, },
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' }, {
], key: 'knowledgeBase',
CC_AGENT_HELPER_DEFAULT, description: 'Pre-rendered list of departments + doctors + clinics',
), },
supervisorChat: promptDefault( ],
'Supervisor assistant', WIDGET_CHAT_DEFAULT,
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.', ),
[ ccAgentHelper: promptDefault(
{ key: 'hospitalName', description: 'Branded hospital display name' }, 'CC agent helper',
], 'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
SUPERVISOR_CHAT_DEFAULT, [
), { key: 'hospitalName', description: 'Branded hospital display name' },
leadEnrichment: promptDefault( {
'Lead enrichment', key: 'knowledgeBase',
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.', description:
[ 'Pre-rendered hospital knowledge base (clinics, doctors, packages)',
{ key: 'leadName', description: 'Lead first + last name' }, },
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' }, ],
{ key: 'interestedService', description: 'What the lead enquired about' }, CC_AGENT_HELPER_DEFAULT,
{ key: 'leadStatus', description: 'Current lead status' }, ),
{ key: 'daysSince', description: 'Days since the lead was created' }, supervisorChat: promptDefault(
{ key: 'contactAttempts', description: 'Prior contact attempts count' }, 'Supervisor assistant',
{ key: 'activities', description: 'Pre-rendered recent activity summary' }, 'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
], [{ key: 'hospitalName', description: 'Branded hospital display name' }],
LEAD_ENRICHMENT_DEFAULT, SUPERVISOR_CHAT_DEFAULT,
), ),
callInsight: promptDefault( leadEnrichment: promptDefault(
'Post-call insight', 'Lead enrichment',
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.', 'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
[ [
{ key: 'hospitalName', description: 'Branded hospital display name' }, { key: 'leadName', description: 'Lead first + last name' },
], {
CALL_INSIGHT_DEFAULT, key: 'leadSource',
), description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)',
callAssist: promptDefault( },
'Live call whisper', { key: 'interestedService', description: 'What the lead enquired about' },
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.', { key: 'leadStatus', description: 'Current lead status' },
[ { key: 'daysSince', description: 'Days since the lead was created' },
{ key: 'hospitalName', description: 'Branded hospital display name' }, { key: 'contactAttempts', description: 'Prior contact attempts count' },
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' }, {
], key: 'patientContext',
CALL_ASSIST_DEFAULT, description:
), 'Patient age, type (new/returning), and whether they have prior records',
recordingAnalysis: promptDefault( },
'Call recording analysis', {
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.', key: 'appointmentContext',
[ description:
{ key: 'hospitalName', description: 'Branded hospital display name' }, 'Next 3 scheduled appointments (date, doctor, type, status)',
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' }, },
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' }, {
], key: 'activities',
RECORDING_ANALYSIS_DEFAULT, 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 = { export const DEFAULT_AI_CONFIG: AiConfig = {
provider: 'openai', provider: 'openai',
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
temperature: 0.7, temperature: 0.7,
prompts: DEFAULT_AI_PROMPTS, prompts: DEFAULT_AI_PROMPTS,
}; };
// Field-by-field mapping from the legacy env vars used by ai-provider.ts // 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. // (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [ export const AI_ENV_SEEDS: Array<{
{ env: 'AI_PROVIDER', field: 'provider' }, env: string;
{ env: 'AI_MODEL', field: 'model' }, field: keyof Pick<AiConfig, 'provider' | 'model'>;
}> = [
{ env: 'AI_PROVIDER', field: 'provider' },
{ env: 'AI_MODEL', field: 'model' },
]; ];

View File

@@ -1,48 +1,58 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; 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() @Injectable()
export class PlatformGraphqlService { export class PlatformGraphqlService {
private readonly graphqlUrl: string; private readonly graphqlUrl: string;
private readonly apiKey: string; private readonly apiKey: string;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!; this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.apiKey = config.get<string>('platform.apiKey')!; this.apiKey = config.get<string>('platform.apiKey')!;
}
// Server-to-server query using API key
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
}
// Query using a passed-through auth header (user JWT)
async queryWithAuth<T>(
query: string,
variables: Record<string, any> | undefined,
authHeader: string,
): Promise<T> {
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 return response.data.data;
async query<T>(query: string, variables?: Record<string, any>): Promise<T> { }
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
}
// Query using a passed-through auth header (user JWT) async findLeadByPhone(phone: string): Promise<LeadNode | null> {
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> { // Note: The exact filter syntax for PHONES fields depends on the platform
const response = await axios.post( // This queries leads and filters client-side by phone number
this.graphqlUrl, const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
{ query, variables }, `query FindLeads($first: Int) {
{
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<LeadNode | null> {
// 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 }) { leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
edges { edges {
node { 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) async findLeadById(id: string): Promise<LeadNode | null> {
const normalizedPhone = phone.replace(/\D/g, ''); const data = await this.query<{ lead: LeadNode }>(
return data.leads.edges.find(edge => { `query FindLead($id: ID!) {
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<LeadNode | null> {
const data = await this.query<{ lead: LeadNode }>(
`query FindLead($id: ID!) {
lead(id: $id) { lead(id: $id) {
id createdAt id createdAt
contactName { firstName lastName } contactName { firstName lastName }
@@ -83,51 +99,58 @@ export class PlatformGraphqlService {
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} }
}`, }`,
{ id }, { id },
); );
return data.lead; return data.lead;
} }
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> { async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
const data = await this.query<{ updateLead: LeadNode }>( const data = await this.query<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { updateLead(id: $id, data: $data) {
id leadStatus aiSummary aiSuggestedAction id leadStatus aiSummary aiSuggestedAction
} }
}`, }`,
{ id, data: input }, { id, data: input },
); );
return data.updateLead; return data.updateLead;
} }
async createCall(input: CreateCallInput): Promise<{ id: string }> { async createCall(input: CreateCallInput): Promise<{ id: string }> {
const data = await this.query<{ createCall: { id: string } }>( const data = await this.query<{ createCall: { id: string } }>(
`mutation CreateCall($data: CallCreateInput!) { `mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id } createCall(data: $data) { id }
}`, }`,
{ data: input }, { data: input },
); );
return data.createCall; return data.createCall;
} }
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> { async createLeadActivity(
const data = await this.query<{ createLeadActivity: { id: string } }>( input: CreateLeadActivityInput,
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) { ): Promise<{ id: string }> {
const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id } createLeadActivity(data: $data) { id }
}`, }`,
{ data: input }, { data: input },
); );
return data.createLeadActivity; return data.createLeadActivity;
} }
// --- Token passthrough versions (for user-driven requests) --- // --- Token passthrough versions (for user-driven requests) ---
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> { async findLeadByPhoneWithToken(
const normalizedPhone = phone.replace(/\D/g, ''); phone: string,
const last10 = normalizedPhone.slice(-10); authHeader: string,
): Promise<LeadNode | null> {
const normalizedPhone = phone.replace(/\D/g, '');
const last10 = normalizedPhone.slice(-10);
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( const data = await this.queryWithAuth<{
`query FindLeads($first: Int) { leads: { edges: { node: LeadNode }[] };
}>(
`query FindLeads($first: Int) {
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
edges { edges {
node { node {
@@ -143,28 +166,43 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ first: 200 }, { first: 200 },
authHeader, authHeader,
); );
// Client-side phone matching // Client-side phone matching
return data.leads.edges.find(edge => { return (
const phones = edge.node.contactPhone ?? []; data.leads.edges.find((edge) => {
if (Array.isArray(phones)) { const phones = edge.node.contactPhone ?? [];
return phones.some((p: any) => { if (Array.isArray(phones)) {
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); return phones.some((p: any) => {
return num.endsWith(last10) || last10.endsWith(num); const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
}); /\D/g,
} '',
// Handle single phone object );
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, '');
return num.endsWith(last10) || last10.endsWith(num); 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<LeadActivityNode[]> { async getLeadActivitiesWithToken(
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( leadId: string,
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { authHeader: string,
limit = 5,
): Promise<LeadActivityNode[]> {
const data = await this.queryWithAuth<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { edges {
node { node {
@@ -173,44 +211,51 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
authHeader, authHeader,
); );
return data.leadActivities.edges.map(e => e.node); return data.leadActivities.edges.map((e) => e.node);
} }
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> { async updateLeadWithToken(
// Response fragment deliberately excludes `leadStatus` — the staging id: string,
// platform schema has this field renamed to `status`. Selecting the input: UpdateLeadInput,
// old name rejects the whole mutation. Callers don't use the authHeader: string,
// returned fragment today, so returning just the id + AI fields ): Promise<LeadNode> {
// keeps this working across both schema shapes without a wider // Response fragment deliberately excludes `leadStatus` — the staging
// rename hotfix. // platform schema has this field renamed to `status`. Selecting the
const data = await this.queryWithAuth<{ updateLead: LeadNode }>( // old name rejects the whole mutation. Callers don't use the
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { // 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) { updateLead(id: $id, data: $data) {
id aiSummary aiSuggestedAction id aiSummary aiSuggestedAction
} }
}`, }`,
{ id, data: input }, { id, data: input },
authHeader, authHeader,
); );
return data.updateLead; return data.updateLead;
} }
// Fetch a single lead by id with the caller's JWT. Used by the // 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 // lead-enrich flow when the agent explicitly renames a caller from
// the appointment/enquiry form and we need to regenerate the lead's // the appointment/enquiry form and we need to regenerate the lead's
// AI summary against fresh identity. // AI summary against fresh identity.
// //
// The selected fields deliberately use the staging-aligned names // The selected fields deliberately use the staging-aligned names
// (`status`, `source`, `lastContacted`) rather than the older // (`status`, `source`, `lastContacted`) rather than the older
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the // `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
// query would be rejected on staging. // query would be rejected on staging.
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> { async findLeadByIdWithToken(
try { id: string,
const data = await this.queryWithAuth<{ lead: any }>( authHeader: string,
`query FindLead($id: UUID!) { ): Promise<any | null> {
try {
const data = await this.queryWithAuth<{ lead: any }>(
`query FindLead($id: UUID!) {
lead(filter: { id: { eq: $id } }) { lead(filter: { id: { eq: $id } }) {
id id
createdAt createdAt
@@ -225,15 +270,17 @@ export class PlatformGraphqlService {
aiSuggestedAction aiSuggestedAction
} }
}`, }`,
{ id }, { id },
authHeader, authHeader,
); );
return data.lead ?? null; return data.lead ?? null;
} catch { } catch {
// Fall back to edge-style query in case the singular field // Fall back to edge-style query in case the singular field
// doesn't exist on this platform version. // doesn't exist on this platform version.
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>( const data = await this.queryWithAuth<{
`query FindLead($id: UUID!) { leads: { edges: { node: any }[] };
}>(
`query FindLead($id: UUID!) {
leads(filter: { id: { eq: $id } }, first: 1) { leads(filter: { id: { eq: $id } }, first: 1) {
edges { edges {
node { node {
@@ -252,18 +299,83 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ id }, { id },
authHeader, authHeader,
); );
return data.leads.edges[0]?.node ?? null; return data.leads.edges[0]?.node ?? null;
}
} }
}
// --- Server-to-server versions (for webhooks, background jobs) --- async getPatientWithToken(
patientId: string,
authHeader: string,
): Promise<any | null> {
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<LeadActivityNode[]> { async getUpcomingAppointmentsWithToken(
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( patientId: string,
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { authHeader: string,
limit = 3,
): Promise<any[]> {
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<LeadActivityNode[]> {
const data = await this.query<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
edges { edges {
node { node {
@@ -272,8 +384,8 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
); );
return data.leadActivities.edges.map(e => e.node); return data.leadActivities.edges.map((e) => e.node);
} }
} }