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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { SessionService } from '../auth/session.service';
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules';
|
||||||
|
|
||||||
export type CallerContext = {
|
export type CallerContext = {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
@@ -39,6 +40,8 @@ export type CallerContext = {
|
|||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
// Rule-driven suggestion triggers
|
||||||
|
suggestionTriggers: SuggestionTrigger[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_KEY_PREFIX = 'caller:context:';
|
const CACHE_KEY_PREFIX = 'caller:context:';
|
||||||
@@ -123,6 +126,26 @@ export class CallerContextService {
|
|||||||
const firstName = lead.contactName?.firstName ?? '';
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
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 {
|
return {
|
||||||
leadId,
|
leadId,
|
||||||
patientId: patientId || lead.patientId || '',
|
patientId: patientId || lead.patientId || '',
|
||||||
@@ -136,15 +159,10 @@ export class CallerContextService {
|
|||||||
contactAttempts: lead.contactAttempts ?? 0,
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
lastContacted: lead.lastContacted ?? null,
|
lastContacted: lead.lastContacted ?? null,
|
||||||
utmCampaign: lead.utmCampaign ?? null,
|
utmCampaign: lead.utmCampaign ?? null,
|
||||||
appointments: (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node),
|
appointments,
|
||||||
calls: (callsData?.calls?.edges ?? []).map((e: any) => ({
|
calls,
|
||||||
startedAt: e.node.startedAt,
|
|
||||||
direction: e.node.direction,
|
|
||||||
duration: e.node.durationSec,
|
|
||||||
disposition: e.node.disposition,
|
|
||||||
agentName: e.node.agentName,
|
|
||||||
})),
|
|
||||||
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
|
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
|
||||||
|
suggestionTriggers,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
|
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 {
|
renderForPrompt(ctx: CallerContext): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`## CURRENT CALLER: ${ctx.name}`);
|
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