From 2d18110786ba0b043c7e2bf402836a82f6fcc41b Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:09:47 +0530 Subject: [PATCH] feat: suggestion rules engine + caller context evaluation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/caller/caller-context.service.ts | 50 +++++++-- src/rules-engine/suggestion-rules.ts | 152 +++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 src/rules-engine/suggestion-rules.ts diff --git a/src/caller/caller-context.service.ts b/src/caller/caller-context.service.ts index ba4f130..7f2ec32 100644 --- a/src/caller/caller-context.service.ts +++ b/src/caller/caller-context.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { SessionService } from '../auth/session.service'; +import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules'; export type CallerContext = { leadId: string; @@ -39,6 +40,8 @@ export type CallerContext = { occurredAt: string; outcome: string | null; }>; + // Rule-driven suggestion triggers + suggestionTriggers: SuggestionTrigger[]; }; const CACHE_KEY_PREFIX = 'caller:context:'; @@ -123,6 +126,26 @@ export class CallerContextService { const firstName = lead.contactName?.firstName ?? ''; const lastName = lead.contactName?.lastName ?? ''; + const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node); + const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({ + startedAt: e.node.startedAt, + direction: e.node.direction, + duration: e.node.durationSec, + disposition: e.node.disposition, + agentName: e.node.agentName, + })); + + const suggestionTriggers = evaluateSuggestionRules({ + isNew: false, + interestedService: lead.interestedService ?? null, + leadStatus: lead.status ?? null, + contactAttempts: lead.contactAttempts ?? 0, + appointments, + calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })), + utmCampaign: lead.utmCampaign ?? null, + leadSource: lead.source ?? null, + }); + return { leadId, patientId: patientId || lead.patientId || '', @@ -136,15 +159,10 @@ export class CallerContextService { contactAttempts: lead.contactAttempts ?? 0, lastContacted: lead.lastContacted ?? null, utmCampaign: lead.utmCampaign ?? null, - appointments: (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node), - calls: (callsData?.calls?.edges ?? []).map((e: any) => ({ - startedAt: e.node.startedAt, - direction: e.node.direction, - duration: e.node.durationSec, - disposition: e.node.disposition, - agentName: e.node.agentName, - })), + appointments, + calls, activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node), + suggestionTriggers, }; } catch (err: any) { this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`); @@ -152,6 +170,22 @@ export class CallerContextService { } } + renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string { + if (triggers.length === 0) return ''; + const lines = [ + '', + 'SUGGESTION RULES (from business configuration):', + 'Based on this caller\'s profile, the following suggestions should be offered.', + 'Generate a natural, conversational script for each that the agent can read aloud.', + 'Return them in the `suggestions` array of your JSON response.', + '', + ]; + triggers.forEach((t, i) => { + lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title} — ${t.reason}`); + }); + return lines.join('\n'); + } + renderForPrompt(ctx: CallerContext): string { const lines: string[] = []; lines.push(`## CURRENT CALLER: ${ctx.name}`); diff --git a/src/rules-engine/suggestion-rules.ts b/src/rules-engine/suggestion-rules.ts new file mode 100644 index 0000000..36ced4c --- /dev/null +++ b/src/rules-engine/suggestion-rules.ts @@ -0,0 +1,152 @@ +export type SuggestionType = 'upsell' | 'crosssell' | 'retention' | 'operational'; +export type SuggestionPriority = 'high' | 'medium' | 'low'; + +export type SuggestionTrigger = { + type: SuggestionType; + title: string; + reason: string; + priority: SuggestionPriority; +}; + +type CallerFacts = { + isNew: boolean; + interestedService: string | null; + leadStatus: string | null; + contactAttempts: number; + appointments: Array<{ status: string; department: string; doctorName: string; scheduledAt: string }>; + calls: Array<{ direction: string; disposition: string | null; startedAt: string }>; + utmCampaign: string | null; + leadSource: string | null; +}; + +const DEPARTMENT_PACKAGES: Record = { + CARDIOLOGY: { package: 'Cardiac Wellness Package', description: 'ECG, stress test, lipid panel' }, + ORTHOPEDICS: { package: 'Joint Care Package', description: 'X-ray, physiotherapy assessment, bone density' }, + GENERAL_MEDICINE: { package: 'Full Body Checkup', description: 'Complete health screening with blood work' }, + NEUROLOGY: { package: 'Neuro Wellness Package', description: 'EEG, nerve conduction, cognitive assessment' }, + GYNECOLOGY: { package: 'Women\'s Health Package', description: 'Pap smear, mammogram, hormone panel' }, +}; + +const CROSS_SELL_MAP: Record = { + ORTHOPEDICS: { department: 'Physiotherapy', reason: 'complement orthopedic treatment' }, + CARDIOLOGY: { department: 'Dietician', reason: 'dietary guidance for heart health' }, + GENERAL_MEDICINE: { department: 'Ophthalmology', reason: 'routine eye screening' }, +}; + +export const evaluateSuggestionRules = (facts: CallerFacts): SuggestionTrigger[] => { + const triggers: SuggestionTrigger[] = []; + + // Rule 1: Package upsell by department + for (const appt of facts.appointments) { + const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_'); + const pkg = DEPARTMENT_PACKAGES[dept]; + if (pkg && appt.status === 'SCHEDULED') { + triggers.push({ + type: 'upsell', + title: pkg.package, + reason: `Patient has ${appt.department} appointment with ${appt.doctorName}, offer ${pkg.description}`, + priority: 'high', + }); + break; + } + } + + // Rule 2: Reschedule missed/cancelled appointments + const needsReschedule = facts.appointments.find(a => + a.status === 'CANCELLED' || a.status === 'RESCHEDULED' || a.status === 'NO_SHOW' + ); + if (needsReschedule) { + triggers.push({ + type: 'retention', + title: 'Reschedule appointment', + reason: `Last ${needsReschedule.department} appointment was ${needsReschedule.status.toLowerCase()}, offer to rebook with ${needsReschedule.doctorName}`, + priority: 'medium', + }); + } + + // Rule 3: Cross-sell related department + for (const appt of facts.appointments) { + const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_'); + const cross = CROSS_SELL_MAP[dept]; + if (cross && appt.status === 'SCHEDULED') { + triggers.push({ + type: 'crosssell', + title: `${cross.department} consultation`, + reason: `${cross.reason} — patient already seeing ${appt.department}`, + priority: 'low', + }); + break; + } + } + + // Rule 4: First-visit patient — health checkup + if (facts.isNew || facts.contactAttempts === 0) { + triggers.push({ + type: 'upsell', + title: 'Welcome Health Checkup', + reason: 'First-time patient, offer introductory health screening package', + priority: 'medium', + }); + } + + // Rule 5: Returning patient with no recent appointment + if (!facts.isNew && facts.appointments.length === 0 && facts.contactAttempts > 2) { + triggers.push({ + type: 'retention', + title: 'Re-engagement', + reason: `Returning patient with ${facts.contactAttempts} prior contacts but no active appointments`, + priority: 'high', + }); + } + + return triggers.slice(0, 4); +}; + +// For display in Settings > Automations (read-only cards) +export const SUGGESTION_RULE_DEFINITIONS = [ + { + name: 'Package Upsell by Department', + category: 'upsell' as const, + description: 'Suggest department wellness package when patient has a scheduled appointment.', + trigger: 'On call connect', + condition: 'Scheduled appointment exists', + action: 'Suggest department package', + enabled: true, + }, + { + name: 'Reschedule Missed Appointment', + category: 'retention' as const, + description: 'Offer to rebook when patient has a cancelled or rescheduled appointment.', + trigger: 'On call connect', + condition: 'Cancelled/Rescheduled/No-show appointment exists', + action: 'Suggest rebooking', + enabled: true, + }, + { + name: 'Cross-sell Related Department', + category: 'crosssell' as const, + description: 'Suggest complementary department service based on current appointment.', + trigger: 'On call connect', + condition: 'Scheduled appointment in mapped department', + action: 'Suggest related service', + enabled: true, + }, + { + name: 'First Visit Health Checkup', + category: 'upsell' as const, + description: 'Suggest introductory health screening for first-time patients.', + trigger: 'On call connect', + condition: 'New patient or zero contact attempts', + action: 'Suggest health checkup package', + enabled: true, + }, + { + name: 'Returning Patient Re-engagement', + category: 'retention' as const, + description: 'Prompt re-engagement for returning patients with no active appointments.', + trigger: 'On call connect', + condition: 'Returning patient, no appointments, 3+ contacts', + action: 'Suggest booking', + enabled: true, + }, +];