mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: suggestion rules engine + caller context evaluation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
|
||||
152
src/rules-engine/suggestion-rules.ts
Normal file
152
src/rules-engine/suggestion-rules.ts
Normal file
@@ -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<string, { package: string; description: string }> = {
|
||||
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<string, { department: string; reason: string }> = {
|
||||
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,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user