Files
DentalManagement09/apps/Backend/src/ai/internal-chat-workflow.ts
T
ffandClaude Sonnet 4.6 d02e8c8dcb fix: extract tooth number and surface from #NN notation for all DDMA procedures
Added extractToothSurface() helper that parses "#NN [SURFACES]" from any
procedure input (tooth 1-32, surface letters O/M/D/B/L/F/I/V). Applied to
all lookup paths so any procedure with #14 or #29 OB carries toothNumber and
toothSurface through to the DDMA Selenium worker.

Also propagate toothNumber/toothSurface in matchedCodes and chatbot_claim_prefill
so the claim-form prefill sets these fields on the service lines before auto-submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:31:22 -04:00

731 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Internal chat workflow — deterministic orchestration after LLM classification.
*
* Steps (no LLM involved here):
* 1. Resolve patient from DB (by name or memberId)
* 2. Determine insurance siteKey from patient record OR message hint
* 3. Map procedure names → CDT codes via keyword lookup (check_and_claim only)
* 4. Return a typed ChatResponse the route handler sends to the frontend
*/
import { ChatClassification } from "./internal-chat-graph";
import { lookupCdtCodes } from "./cdt-lookup";
import insuranceAliases from "../data/insuranceAliases.json";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ResolvedPatient {
id: number;
firstName: string | null;
lastName: string | null;
insuranceId: string | null;
insuranceProvider: string | null;
dateOfBirth: string | null;
}
export interface CdtResult {
code: string | null;
description: string;
input: string;
}
export interface ChatResponse {
reply: string;
action?:
| "navigate"
| "show_patient"
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "check_and_claim_ready"
| "need_insurance_clarification"
| "appointment_created"
| "claim_only_ready"
| "need_appointment_selection"
| "need_cdt_clarification";
actionData?: Record<string, any>;
}
// ─── insuranceProvider → siteKey mapping (mirrors claims.ts batchColumnDeriveSiteKey) ─
export function deriveSiteKey(provider: string): string {
const p = (provider ?? "").toLowerCase().trim();
if (!p) return "MH";
for (const { keyword, siteKey } of insuranceAliases) {
if (p.includes(keyword.toLowerCase())) return siteKey;
}
return "MH";
}
// siteKey → autoCheck value used by the insurance-status page prefill
export function siteKeyToAutoCheck(siteKey: string): string {
switch (siteKey) {
case "CCA": return "cca";
case "DDMA": return "ddma";
case "DELTA_INS": return "delta-ins";
case "TUFTS_SCO": return "tufts-sco";
case "UNITED_SCO": return "united-sco";
default: return "mh"; // MH (caller may downgrade to "cmsp" by age)
}
}
// ─── Storage interface (duck-typed, matches the real storage object) ──────────
interface StorageLike {
searchPatients(opts: {
filters: any;
limit: number;
offset: number;
}): Promise<any[] | null>;
getPatientByInsuranceId(id: string): Promise<any | null>;
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
createAppointment(appointment: any): Promise<any>;
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
getOfficeHours(userId: number): Promise<any | null>;
getAppointmentsByPatientId(patientId: number): Promise<any[]>;
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function patientToResult(p: any): ResolvedPatient {
return {
id: p.id,
firstName: p.firstName ?? null,
lastName: p.lastName ?? null,
insuranceId: p.insuranceId ?? null,
insuranceProvider: p.insuranceProvider ?? null,
dateOfBirth: p.dateOfBirth
? (p.dateOfBirth instanceof Date
? p.dateOfBirth.toISOString().split("T")[0]
: String(p.dateOfBirth).split("T")[0])
: null,
};
}
/** Look up by memberId, preferring the insuranceId+DOB combo when DOB is available. */
async function findPatientByMemberId(
memberId: string,
dob: string | null | undefined,
storage: StorageLike
): Promise<any | null> {
const dobDate = dob ? new Date(dob) : null;
if (dobDate && !isNaN(dobDate.getTime())) {
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
if (byCombo) return byCombo;
// DOB provided but no record with that combo → this is a new family member not yet in DB
return null;
}
return storage.getPatientByInsuranceId(memberId);
}
async function findPatientByName(
name: string,
storage: StorageLike
): Promise<any | null> {
const patients = await storage.searchPatients({
filters: {
OR: [
{ firstName: { contains: name, mode: "insensitive" } },
{ lastName: { contains: name, mode: "insensitive" } },
{
AND: name.split(/\s+/).map((part: string) => ({
OR: [
{ firstName: { contains: part, mode: "insensitive" } },
{ lastName: { contains: part, mode: "insensitive" } },
],
})),
},
],
},
limit: 5,
offset: 0,
});
return patients?.[0] ?? null;
}
// ─── Workflow entry point ─────────────────────────────────────────────────────
export async function runInternalChatWorkflow(
classification: ChatClassification,
_userId: number,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[] = []
): Promise<ChatResponse> {
const { intent } = classification;
// ── Navigation ──────────────────────────────────────────────────────────────
if (intent === "navigate_claims") {
return {
reply: classification.fallbackReply,
action: "navigate",
actionData: { url: "/claims" },
};
}
if (intent === "navigate_schedule") {
return {
reply: classification.fallbackReply,
action: "navigate",
actionData: { url: "/appointments" },
};
}
// ── Find patient (record only, no eligibility) ──────────────────────────────
if (intent === "find_patient") {
const name = classification.patientName?.trim();
if (!name) {
return { reply: "Please include the patient's name in your message." };
}
const raw = await findPatientByName(name, storage);
if (!raw) {
return {
reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`,
};
}
const patient = patientToResult(raw);
const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : "";
const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : "";
return {
reply: `Found: ${patient.firstName ?? ""} ${patient.lastName ?? ""}${ins}${id}`.trim(),
action: "show_patient",
actionData: { patient },
};
}
// ── Check eligibility by patient name ──────────────────────────────────────
if (intent === "check_eligibility") {
const name = classification.patientName?.trim();
if (!name) {
return { reply: "Please include the patient's name in your message." };
}
const raw = await findPatientByName(name, storage);
if (!raw) {
return {
reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`,
};
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (!patient.insuranceId) {
return {
reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`,
action: "show_patient",
actionData: { patient },
};
}
// If patient has DOB + known insurance, route through eligibility_id_ready so the
// correct provider button (DDMA, CCA, etc.) auto-triggers instead of always MH.
const resolvedDob = patient.dateOfBirth ?? null;
const siteKey = resolveSiteKey(
patient.insuranceProvider ?? null,
classification.insuranceHint ?? null
);
if (resolvedDob && siteKey) {
return {
reply: `Found ${fullName}. Ready to check eligibility.`,
action: "eligibility_id_ready",
actionData: {
patient,
memberId: patient.insuranceId,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
},
};
}
return {
reply: `Found ${fullName}. Ready to check eligibility.`,
action: "check_eligibility_prefill",
actionData: { patient },
};
}
// ── Eligibility by explicit member ID + DOB ────────────────────────────────
if (intent === "eligibility_by_id") {
return await handleEligibilityById(classification, storage);
}
// ── Check eligibility + claim procedures ──────────────────────────────────
if (intent === "check_and_claim") {
return await handleCheckAndClaim(classification, storage, customAliases);
}
// ── Claim only (no eligibility check) ─────────────────────────────────────
if (intent === "claim_only") {
return await handleClaimOnly(classification, storage, customAliases);
}
// ── Schedule appointment ───────────────────────────────────────────────────
if (intent === "schedule_appointment") {
return await handleScheduleAppointment(classification, _userId, storage);
}
// ── General ────────────────────────────────────────────────────────────────
return { reply: classification.fallbackReply };
}
// ─── eligibility_by_id ────────────────────────────────────────────────────────
async function handleEligibilityById(
c: ChatClassification,
storage: StorageLike
): Promise<ChatResponse> {
const memberId = c.memberId?.trim();
const dob = c.dob?.trim();
if (!memberId) {
return {
reply: "Please provide a Member ID to run the eligibility check.",
};
}
// Try to resolve existing patient for name display + insurance (use DOB to pick the right family member)
const existingPatient = await findPatientByMemberId(memberId, dob, storage);
const patient: ResolvedPatient | null = existingPatient
? patientToResult(existingPatient)
: null;
// Use stored DOB if not provided in the message
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) {
return {
reply: "Please provide a Date of Birth (MM/DD/YYYY) to run the eligibility check.",
};
}
// Determine siteKey
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
);
if (!siteKey) {
const name = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Found ${name} but couldn't determine the insurance type. Which insurance should I use?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob: resolvedDob,
patient,
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
},
};
}
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Ready to check eligibility for ${label}.`,
action: "eligibility_id_ready",
actionData: {
patient,
memberId,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
},
};
}
// ─── check_and_claim ─────────────────────────────────────────────────────────
async function handleCheckAndClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[] = []
): Promise<ChatResponse> {
// 1. Resolve patient
let patient: ResolvedPatient | null = null;
let memberId = c.memberId?.trim() ?? null;
const dob = c.dob?.trim() ?? null;
if (memberId) {
const existing = await findPatientByMemberId(memberId, dob, storage);
if (existing) patient = patientToResult(existing);
} else if (c.patientName?.trim()) {
const raw = await findPatientByName(c.patientName.trim(), storage);
if (raw) {
patient = patientToResult(raw);
memberId = patient.insuranceId;
}
}
if (!memberId) {
return {
reply: "Please include either a Member ID or a patient name so I can look up their record.",
};
}
// Use stored DOB if not provided in the message
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) {
return {
reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`,
};
}
// 2. Determine siteKey
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
);
if (!siteKey) {
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Found ${label} but couldn't determine the insurance type. Which insurance should I use?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob,
patient,
procedureNames: c.procedureNames ?? [],
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
},
};
}
// 3. Map procedure names → CDT codes (custom aliases take priority)
const procedureNames = c.procedureNames ?? [];
const cdtResults: CdtResult[] = procedureNames.length > 0
? lookupCdtCodes(procedureNames, customAliases)
: [];
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
// Block if any term couldn't be mapped — ask instead of proceeding
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
return {
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
action: "need_cdt_clarification",
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
};
}
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
const reply = `Ready to check eligibility for ${label} and claim: ${
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
}.`;
return {
reply,
action: "check_and_claim_ready",
actionData: {
patient,
memberId,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
cdtResults,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })),
},
};
}
// ─── claim_only ───────────────────────────────────────────────────────────────
async function handleClaimOnly(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
// Resolve patient — use insuranceId+DOB when DOB is available to distinguish family members
let patient: ResolvedPatient | null = null;
if (c.memberId?.trim()) {
const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage);
if (existing) patient = patientToResult(existing);
} else if (c.patientName?.trim()) {
const raw = await findPatientByName(c.patientName.trim(), storage);
if (raw) patient = patientToResult(raw);
}
if (!patient) {
return {
reply: "Please include a patient name or Member ID so I can look them up.",
};
}
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
// Map procedure names → CDT codes
const procedureNames = c.procedureNames ?? [];
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
// Block if any term couldn't be mapped — ask instead of proceeding
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
return {
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
action: "need_cdt_clarification",
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
};
}
// Resolve service date: use explicit date from message, then latest appointment, then ask
let serviceDate: string | null = c.appointmentDate ?? null;
let appointmentId: number | null = null;
if (!serviceDate) {
const appts = await storage.getAppointmentsByPatientId(patient.id);
const sorted = appts.sort((a: any, b: any) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
if (sorted.length >= 2) {
const d1 = new Date(sorted[0].date).getTime();
const d2 = new Date(sorted[1].date).getTime();
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
if (diffDays < 7) {
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
const fmtUTC = (a: any) => {
const d = new Date(a.date);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
};
const isoUTC = (a: any) => {
const d = new Date(a.date);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
return {
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
action: "need_appointment_selection",
actionData: {
patient,
siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH",
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })),
options: [
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) },
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
],
},
};
}
}
if (sorted.length > 0) {
const rawDate = new Date(sorted[0].date);
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
appointmentId = sorted[0].id ?? null;
}
}
if (!serviceDate) {
// No appointment on file and no date in message — default to today
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
const [sy, sm, sd] = serviceDate.split("-");
const dateLabel = `${sm}/${sd}/${sy}`;
let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched"
}.`;
if (unmatched.length > 0) {
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
}
return {
reply,
action: "claim_only_ready",
actionData: {
patient,
siteKey,
serviceDate,
appointmentId,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })),
},
};
}
// ─── schedule_appointment ─────────────────────────────────────────────────────
const DEFAULT_STAFF_ID = 1; // Column A
const SLOT_DURATION = 30; // minutes
/** Convert "HH:MM" to total minutes since midnight */
function toMinutes(t: string): number {
const [h, m] = t.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
/** Convert total minutes since midnight to "HH:MM" */
function fromMinutes(m: number): string {
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
/**
* Build the list of valid start minutes for a day using office hours.
* Skips the lunch gap (amEnd → pmStart).
*/
function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] {
const slots: number[] = [];
const ranges = [
[toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)],
[toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)],
];
for (const [start, end] of ranges) {
for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) {
slots.push(t);
}
}
return slots;
}
async function handleScheduleAppointment(
c: ChatClassification,
userId: number,
storage: StorageLike
): Promise<ChatResponse> {
const name = c.patientName?.trim();
if (!name) {
return { reply: "Please include the patient's name so I can find them." };
}
const raw = await findPatientByName(name, storage);
if (!raw) {
return {
reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`,
};
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
// Resolve date
const today = new Date();
const localDate = c.appointmentDate
? new Date(c.appointmentDate + "T00:00:00")
: new Date(today.getFullYear(), today.getMonth(), today.getDate());
const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`;
const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
// If time was explicitly provided, use it directly
if (c.appointmentTime) {
const startTime = c.appointmentTime;
const startMin = toMinutes(startTime);
const endTime = fromMinutes(startMin + SLOT_DURATION);
await storage.createAppointment({
patientId: patient.id,
userId,
staffId: DEFAULT_STAFF_ID,
title: dateLabel,
date: localDate,
startTime,
endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
});
return {
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`,
action: "appointment_created",
actionData: { patient, date: dateStr, startTime, endTime },
};
}
// No time specified — find earliest available slot in Column A
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const dayName = dayNames[localDate.getDay()]!;
// Load office hours (fall back to 9–12 / 13–17 if not configured)
const officeHours = await storage.getOfficeHours(userId);
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true,
};
if (!dayHours.enabled) {
return { reply: `The office is closed on ${dayName}. Please choose a different day.` };
}
const allSlots = buildSlots(dayHours);
// Fetch all Column A appointments for that day
const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId))
.filter((a: any) => a.staffId === DEFAULT_STAFF_ID);
// Find first slot that doesn't overlap any existing appointment
const booked = existing.map((a: any) => ({
start: toMinutes(a.startTime),
end: toMinutes(a.endTime),
}));
const availableStart = allSlots.find((slotStart) => {
const slotEnd = slotStart + SLOT_DURATION;
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
});
if (availableStart === undefined) {
return {
reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`,
};
}
const startTime = fromMinutes(availableStart);
const endTime = fromMinutes(availableStart + SLOT_DURATION);
await storage.createAppointment({
patientId: patient.id,
userId,
staffId: DEFAULT_STAFF_ID,
title: dateLabel,
date: localDate,
startTime,
endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
});
return {
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`,
action: "appointment_created",
actionData: { patient, date: dateStr, startTime, endTime },
};
}
// ─── Insurance resolution helper ──────────────────────────────────────────────
/**
* Determine siteKey from:
* 1. Patient's stored insuranceProvider (most authoritative)
* 2. Insurance hint from the chat message
* 3. null → caller must ask for clarification
*/
function resolveSiteKey(
storedProvider: string | null,
hint: string | null
): string | null {
if (storedProvider) return deriveSiteKey(storedProvider);
if (hint) return deriveSiteKey(hint);
return null;
}