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>
This commit is contained in:
ff
2026-06-05 16:19:56 -04:00
parent ba2882957a
commit 1bbca38344
11 changed files with 693 additions and 94 deletions

View File

@@ -9,6 +9,7 @@
*/
import { ChatClassification } from "./internal-chat-graph";
import { lookupCdtCodes } from "./cdt-lookup";
import insuranceAliases from "../data/insuranceAliases.json";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -35,7 +36,10 @@ export interface ChatResponse {
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "check_and_claim_ready"
| "need_insurance_clarification";
| "need_insurance_clarification"
| "appointment_created"
| "claim_only_ready"
| "need_appointment_selection";
actionData?: Record<string, any>;
}
@@ -43,13 +47,10 @@ export interface ChatResponse {
export function deriveSiteKey(provider: string): string {
const p = (provider ?? "").toLowerCase().trim();
if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO";
if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO";
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA";
if (!p) return "MH";
for (const { keyword, siteKey } of insuranceAliases) {
if (p.includes(keyword.toLowerCase())) return siteKey;
}
return "MH";
}
@@ -74,6 +75,10 @@ interface StorageLike {
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 ───────────────────────────────────────────────────────────
@@ -210,6 +215,18 @@ export async function runInternalChatWorkflow(
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 };
}
@@ -223,9 +240,9 @@ async function handleEligibilityById(
const memberId = c.memberId?.trim();
const dob = c.dob?.trim();
if (!memberId || !dob) {
if (!memberId) {
return {
reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).",
reply: "Please provide a Member ID to run the eligibility check.",
};
}
@@ -235,6 +252,14 @@ async function handleEligibilityById(
? 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,
@@ -250,7 +275,7 @@ async function handleEligibilityById(
action: "need_insurance_clarification",
actionData: {
memberId,
dob,
dob: resolvedDob,
patient,
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
},
@@ -267,7 +292,7 @@ async function handleEligibilityById(
actionData: {
patient,
memberId,
dob,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
},
@@ -302,9 +327,12 @@ async function handleCheckAndClaim(
reply: "Please include either a Member ID or a patient name so I can look up their record.",
};
}
if (!dob) {
// 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 need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`,
reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`,
};
}
@@ -358,7 +386,7 @@ async function handleCheckAndClaim(
actionData: {
patient,
memberId,
dob,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
cdtResults,
@@ -367,6 +395,269 @@ async function handleCheckAndClaim(
};
}
// ─── 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 912 / 1317 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 ──────────────────────────────────────────────
/**