Files
Wellspringmanagement01/apps/Backend/src/ai/internal-chat-workflow.ts
T
ffandClaude Sonnet 4.6 1bbca38344 feat: AI chat scheduling, claim automation, and session improvements
- Internal AI chat: schedule_appointment intent books earliest available
  slot in Column A using office hours; claim_only intent looks up latest
  past appointment for service date, asks user when two appointments are
  within 7 days, auto-triggers correct Selenium worker with mapped prices
- Gemini model updated to gemini-flash-latest; conversation history (15
  messages) passed for pronoun/reference resolution; history trimmed to
  start with user turn so Gemini doesn't reject the context
- Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey
  matching; "tufs" now resolves to TUFTS_SCO
- DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate;
  autoCheck now fires for all insurance types, not just MH/CMSP
- Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts)
  accept formToUse and receive fee-schedule-priced form; prefillDone set
  after chatbot code prefill so autoSubmit gate opens correctly
- Chatbot: chat history persisted in sessionStorage, cleared on logout
  and auto-logout; Clear button writes fresh state synchronously; message
  history window increased to 15
- DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before
  sign-in to persist OTP trust cookie across sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:19:56 -04:00

677 lines
23 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";
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>;
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,
};
}
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 },
};
}
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
const existingPatient = await storage.getPatientByInsuranceId(memberId);
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 storage.getPatientByInsuranceId(memberId);
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);
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
let reply = `Ready to check eligibility for ${label} and claim: ${
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
}.`;
if (unmatched.length > 0) {
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — please verify these codes manually.`;
}
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 })),
},
};
}
// ─── claim_only ───────────────────────────────────────────────────────────────
async function handleClaimOnly(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
// Resolve patient
let patient: ResolvedPatient | null = null;
if (c.memberId?.trim()) {
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
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);
// 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 })),
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 — ask for the service date
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
return {
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
};
}
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 })),
},
};
}
// ─── 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;
}