Files
DentalManagementMH06/apps/Backend/src/ai/internal-chat-workflow.ts
Gitead cdda91f2b4 feat: add multi_claim intent for different procedures per patient + fix batch claim PDF race
- Add multi_claim intent so AI correctly handles "claim X for patient A and Y for patient B"
  instead of applying all procedures to all patients (batch_claim)
- Each patient carries their own matchedCodes in the queue
- Fix batch claim PDF race condition: chatbot queue no longer advances in closeClaim(),
  instead advances after selenium PDF is downloaded (matching column claim behavior)
- Align United SCO eligibility worker with claim worker: only fill subscriber ID + DOB,
  use treatmentLocation by ID instead of arrow-wrapper click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 01:08:41 -04:00

1469 lines
52 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";
// Phrases the user may write to mean "attach the uploaded file" — not CDT procedures
const ATTACHMENT_PHRASES = /^(with\s+)?(the\s+)?(x[\s-]?ray[s]?|xray[s]?|radiograph[s]?|image[s]?|film[s]?|attachment[s]?|attach|file[s]?|photo[s]?|picture[s]?|scan[s]?|doc(ument)?[s]?)$/i;
function stripAttachmentRefs(names: string[]): string[] {
return names.filter((n) => !ATTACHMENT_PHRASES.test(n.trim()));
}
// ─── 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;
toothNumber?: string;
toothSurface?: string;
quad?: string;
}
export interface ChatResponse {
reply: string;
action?:
| "navigate"
| "show_patient"
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "batch_eligibility_ready"
| "batch_claim_ready"
| "multi_claim_ready"
| "batch_check_and_claim_ready"
| "check_and_claim_ready"
| "need_insurance_clarification"
| "appointment_created"
| "claim_only_ready"
| "preauth_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";
}
// Returns true when the user said "united" without a specific qualifier
// (e.g. "united sco", "united healthcare") — needs clarification.
function isAmbiguousUnited(hint: string | null | undefined): boolean {
const h = (hint ?? "").toLowerCase().trim();
return h === "united";
}
// siteKey → autoCheck value used by the insurance-status page prefill.
// For MH, pass the patient's DOB so under-21 patients route to CMSP.
export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): 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: {
// MassHealth: patients under 21 use the CMSP button
if (dob) {
try {
const birth = dob instanceof Date ? dob : new Date(dob);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
if (age < 21) return "cmsp";
} catch {}
}
return "mh";
}
}
}
// ─── 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 calcAge(dob: string | null | undefined, on?: string | null): number | null {
if (!dob) return null;
try {
const birth = new Date(dob);
const ref = on ? new Date(on) : new Date();
let age = ref.getFullYear() - birth.getFullYear();
const m = ref.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && ref.getDate() < birth.getDate())) age--;
return age;
} catch { return null; }
}
function checkD1120Age(
matched: CdtResult[],
patientName: string,
dob: string | null | undefined,
serviceDate?: string | null
): ChatResponse | null {
if (!matched.some((r) => r.code === "D1120")) return null;
const age = calcAge(dob, serviceDate);
if (age === null || age < 14) return null;
return {
reply: `${patientName} is ${age} years old. D1120 (child prophy) is only for patients under 14 — I recommend using D1110 (adult prophy) instead. If you still need D1120, please claim manually for this patient.`,
};
}
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" },
};
}
if (intent === "navigate_eligibility") {
return {
reply: classification.fallbackReply ?? "Opening the eligibility page...",
action: "navigate",
actionData: { url: "/insurance-status" },
};
}
// ── 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, resolvedDob),
},
};
}
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);
}
// ── Batch eligibility (multiple patients) ─────────────────────────────────
if (intent === "batch_eligibility") {
return await handleBatchEligibility(classification, storage);
}
if (intent === "batch_eligibility_by_name") {
return await handleBatchEligibilityByName(classification, storage);
}
// ── Check eligibility + claim procedures ──────────────────────────────────
if (intent === "check_and_claim") {
return await handleCheckAndClaim(classification, storage, customAliases);
}
// ── Batch check & claim (eligibility + claim for multiple patients) ────────
if (intent === "batch_check_and_claim") {
return await handleBatchCheckAndClaim(classification, storage, customAliases);
}
// ── Batch claim (same procedures for multiple patients) ───────────────────
if (intent === "batch_claim") {
return await handleBatchClaim(classification, storage, customAliases);
}
// ── Multi claim (different procedures for different patients) ─────────────
if (intent === "multi_claim") {
return await handleMultiClaim(classification, storage, customAliases);
}
// ── Claim only (no eligibility check) ─────────────────────────────────────
if (intent === "claim_only") {
return await handleClaimOnly(classification, storage, customAliases);
}
// ── Pre-authorization ──────────────────────────────────────────────────────
if (intent === "preauth") {
return await handlePreauth(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.",
};
}
// User explicitly chose "Other United" — it's not in the app
if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) {
return {
reply: "That United plan is not currently supported in the app. Please check it manually.",
};
}
// 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.",
};
}
// "united" alone is ambiguous — there's United SCO (Dental Hub) in the app
// and other United plans that are not supported.
if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) {
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Which United plan is ${label}?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob: resolvedDob,
patient,
options: ["United SCO / Dental Hub", "Other United (not in app)"],
},
};
}
// 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 SCO / Dental Hub"],
},
};
}
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),
appointmentDate: c.appointmentDate ?? null,
},
};
}
// ─── batch_eligibility ───────────────────────────────────────────────────────
async function handleBatchEligibility(
c: ChatClassification,
storage: StorageLike
): Promise<ChatResponse> {
const pairs = c.patients ?? [];
if (pairs.length < 2) {
// Fallback to single if somehow only 1 pair
if (pairs.length === 1) {
return await handleEligibilityById(
{ ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "eligibility_by_id" },
storage
);
}
return { reply: "Please provide at least two member ID + DOB pairs to batch-check." };
}
const resolved: {
memberId: string;
dob: string;
siteKey: string;
autoCheck: string;
patient: ResolvedPatient | null;
}[] = [];
for (const { memberId, dob } of pairs) {
const id = memberId?.trim();
const d = dob?.trim();
if (!id || !d) continue;
const existing = await findPatientByMemberId(id, d, storage);
const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null;
const resolvedDob = d ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) continue;
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
) ?? "MH";
resolved.push({
memberId: id,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
patient,
});
}
if (resolved.length === 0) {
return { reply: "Could not parse any valid member ID + DOB pairs." };
}
const labels = resolved.map((r) => {
const name = r.patient
? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
: `ID ${r.memberId}`;
return name;
});
return {
reply: `Ready to check eligibility for ${resolved.length} patients: ${labels.join(", ")}.`,
action: "batch_eligibility_ready",
actionData: { queue: resolved },
};
}
// ─── batch_eligibility_by_name ───────────────────────────────────────────────
async function handleBatchEligibilityByName(
c: ChatClassification,
storage: StorageLike
): Promise<ChatResponse> {
const names = c.patientNames ?? [];
if (names.length < 2) {
return { reply: "Please include at least two patient names to batch-check eligibility." };
}
const resolved: {
memberId: string;
dob: string;
siteKey: string;
autoCheck: string;
patient: ResolvedPatient | null;
}[] = [];
const notFound: string[] = [];
const noInsurance: string[] = [];
for (const name of names) {
const trimmed = name.trim();
if (!trimmed) continue;
const raw = await findPatientByName(trimmed, storage);
if (!raw) {
notFound.push(trimmed);
continue;
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (!patient.insuranceId) {
noInsurance.push(fullName);
continue;
}
const resolvedDob = patient.dateOfBirth ?? null;
if (!resolvedDob) {
noInsurance.push(fullName);
continue;
}
const siteKey = resolveSiteKey(
patient.insuranceProvider ?? null,
c.insuranceHint ?? null
) ?? "MH";
resolved.push({
memberId: patient.insuranceId,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
patient,
});
}
if (resolved.length === 0) {
if (notFound.length > 0) {
return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` };
}
if (noInsurance.length > 0) {
return { reply: `Found ${noInsurance.join(", ")} but they have no Member ID or DOB on file. Please add their insurance info first.` };
}
return { reply: "Could not resolve any patients for eligibility check." };
}
const labels = resolved.map((r) => {
const name = r.patient
? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
: `ID ${r.memberId}`;
return name;
});
let reply = `Ready to check eligibility for ${resolved.length} patients: ${labels.join(", ")}.`;
if (notFound.length > 0) {
reply += ` Could not find: ${notFound.join(", ")}.`;
}
if (noInsurance.length > 0) {
reply += ` Missing insurance info: ${noInsurance.join(", ")}.`;
}
return {
reply,
action: "batch_eligibility_ready",
actionData: { queue: resolved },
};
}
// ─── batch_check_and_claim ────────────────────────────────────────────────────
async function handleBatchCheckAndClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
const pairs = c.patients ?? [];
if (pairs.length < 2) {
if (pairs.length === 1) {
return await handleCheckAndClaim(
{ ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "check_and_claim" },
storage,
customAliases
);
}
return { reply: "Please provide at least two member ID + DOB pairs." };
}
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
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 resolved: {
memberId: string;
dob: string;
siteKey: string;
autoCheck: string;
patient: ResolvedPatient | null;
}[] = [];
for (const { memberId, dob } of pairs) {
const id = memberId?.trim();
const d = dob?.trim();
if (!id || !d) continue;
const existing = await findPatientByMemberId(id, d, storage);
const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null;
const resolvedDob = d ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) continue;
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
) ?? "MH";
resolved.push({
memberId: id,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
patient,
});
}
if (resolved.length === 0) {
return { reply: "Could not parse any valid member ID + DOB pairs." };
}
const labels = resolved.map((r) => {
const name = r.patient
? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
: `ID ${r.memberId}`;
return name;
});
const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", ");
return {
reply: `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`,
action: "batch_check_and_claim_ready",
actionData: {
queue: resolved,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── batch_claim ─────────────────────────────────────────────────────────────
async function handleBatchClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
const names = c.patientNames ?? [];
if (names.length < 2) {
if (names.length === 1) {
return await handleClaimOnly(
{ ...c, patientName: names[0], intent: "claim_only" },
storage,
customAliases
);
}
return { reply: "Please include at least two patient names to batch-claim." };
}
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
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 resolved: {
patient: ResolvedPatient;
siteKey: string;
serviceDate: string | null;
appointmentId: number | null;
}[] = [];
const notFound: string[] = [];
for (const name of names) {
const trimmed = name.trim();
if (!trimmed) continue;
const raw = await findPatientByName(trimmed, storage);
if (!raw) {
notFound.push(trimmed);
continue;
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate);
if (d1120Warning) return d1120Warning;
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
let serviceDate: string | null = c.appointmentDate ?? null;
let appointmentId: number | null = null;
if (!serviceDate) {
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
resolved.push({ patient, siteKey, serviceDate, appointmentId });
}
if (notFound.length > 0 && resolved.length === 0) {
return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` };
}
const labels = resolved.map((r) =>
`${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
);
const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", ");
let reply = `Ready to claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`;
if (notFound.length > 0) {
reply += ` Could not find: ${notFound.join(", ")}.`;
}
return {
reply,
action: "batch_claim_ready",
actionData: {
queue: resolved.map((r) => ({
patient: r.patient,
siteKey: r.siteKey,
serviceDate: r.serviceDate,
appointmentId: r.appointmentId,
})),
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── multi_claim (different procedures per patient) ─────────────────────────
async function handleMultiClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
const groups = c.claimGroups ?? [];
if (groups.length < 2) {
if (groups.length === 1) {
return await handleClaimOnly(
{ ...c, patientName: groups[0]!.patientName, procedureNames: groups[0]!.procedureNames, intent: "claim_only" },
storage,
customAliases
);
}
return { reply: "Please specify at least two patients with their procedures." };
}
const queue: {
patient: ResolvedPatient;
siteKey: string;
serviceDate: string | null;
appointmentId: number | null;
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
}[] = [];
const notFound: string[] = [];
for (const group of groups) {
const name = group.patientName?.trim();
if (!name) continue;
const procedureNames = stripAttachmentRefs(group.procedureNames ?? []);
if (procedureNames.length === 0) continue;
const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
return {
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")} for ${name}. 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 raw = await findPatientByName(name, storage);
if (!raw) {
notFound.push(name);
continue;
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate);
if (d1120Warning) return d1120Warning;
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
let serviceDate: string | null = c.appointmentDate ?? null;
if (!serviceDate) {
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
queue.push({
patient,
siteKey,
serviceDate,
appointmentId: null,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
});
}
if (notFound.length > 0 && queue.length === 0) {
return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` };
}
const labels = queue.map((r) => {
const name = `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim();
const codes = r.matchedCodes.map((c) => `${c.code}`).join(", ");
return `${name} (${codes})`;
});
let reply = `Ready to claim for ${queue.length} patients: ${labels.join("; ")}.`;
if (notFound.length > 0) {
reply += ` Could not find: ${notFound.join(", ")}.`;
}
return {
reply,
action: "multi_claim_ready",
actionData: {
queue: queue.map((r) => ({
patient: r.patient,
siteKey: r.siteKey,
serviceDate: r.serviceDate,
appointmentId: r.appointmentId,
matchedCodes: r.matchedCodes,
})),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── check_and_claim ─────────────────────────────────────────────────────────
async function handleCheckAndClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[] = []
): Promise<ChatResponse> {
// User explicitly chose "Other United" — it's not in the app
if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) {
return {
reply: "That United plan is not currently supported in the app. Please check it manually.",
};
}
// 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).`,
};
}
// "united" alone is ambiguous — clarify before proceeding
if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) {
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Which United plan is ${label}?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob: resolvedDob,
patient,
procedureNames: c.procedureNames ?? [],
options: ["United SCO / Dental Hub", "Other United (not in app)"],
},
};
}
// 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 SCO / Dental Hub"],
},
};
}
// 3. Map procedure names → CDT codes (custom aliases take priority)
const procedureNames = stripAttachmentRefs(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}`;
// D1120 age check — recommend D1110 for patients 14+
const d1120Warning = checkD1120Age(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate);
if (d1120Warning) return d1120Warning;
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, quad: r.quad })),
serviceDate: c.appointmentDate ?? null,
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── 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 = stripAttachmentRefs(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 })) },
};
}
// D1120 age check — recommend D1110 for patients 14+
const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate);
if (d1120Warning) return d1120Warning;
// 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 now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
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 > 0) {
const rawDate = new Date(sorted[0].date);
const latestStr = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
if (latestStr === todayStr) {
serviceDate = todayStr;
appointmentId = sorted[0].id ?? null;
} else {
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 todayLabel = `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}/${now.getFullYear()}`;
return {
reply: `Which service date for ${fullName}?`,
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, quad: r.quad })),
options: [
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: latestStr },
{ label: `${todayLabel} (Today)`, appointmentId: null, serviceDate: todayStr },
],
},
};
}
} else {
serviceDate = todayStr;
}
}
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, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── preauth ──────────────────────────────────────────────────────────────────
async function handlePreauth(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
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 procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to pre-authorize (e.g. rct, post, crown)." };
}
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
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. D3320)`,
action: "need_cdt_clarification",
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
};
}
// Use explicit date from message; otherwise today+3 (pre-auth is for a future appointment)
let serviceDate: string = c.appointmentDate ?? (() => {
const d = new Date();
d.setDate(d.getDate() + 3);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
})();
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
const [sy, sm, sd] = serviceDate.split("-");
const dateLabel = `${sm}/${sd}/${sy}`;
return {
reply: `Opening pre-auth for ${fullName} (tentative date ${dateLabel}): ${matched.map((r) => `${r.code} (${r.description})`).join(", ")}.`,
action: "preauth_ready",
actionData: {
patient,
siteKey,
serviceDate,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── schedule_appointment ─────────────────────────────────────────────────────
const DEFAULT_STAFF_ID = 1; // Column A
const SLOT_DURATION = 15; // 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 },
};
}
// ─── Standalone helper: create today's appointment for a known patientId ─────
export async function createAppointmentToday(
patientId: number,
userId: number,
storage: StorageLike,
targetDate?: string // YYYY-MM-DD; defaults to today
): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> {
const base = targetDate ? new Date(targetDate + "T00:00:00") : new Date();
const dateStr = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, "0")}-${String(base.getDate()).padStart(2, "0")}`;
const dateLabel = base.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
const localDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const dayName = dayNames[base.getDay()]!;
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 { error: `The office is closed on ${dayName} (${dateLabel}). Cannot create appointment.` };
}
const allSlots = buildSlots(dayHours);
const allAppointments = await storage.getAppointmentsByDateForUser(dateStr, userId);
for (const { staffId, label } of [
{ staffId: DEFAULT_STAFF_ID, label: "Column A" },
{ staffId: 2, label: "Column B" },
]) {
const booked = allAppointments
.filter((a: any) => a.staffId === staffId)
.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) {
const startTime = fromMinutes(availableStart);
const endTime = fromMinutes(availableStart + SLOT_DURATION);
await storage.createAppointment({
patientId,
userId,
staffId,
title: dateLabel,
date: localDate,
startTime,
endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
});
return { startTime, endTime, dateStr, dateLabel, column: label };
}
}
return { error: `Both Column A and Column B are fully booked today. Please add the appointment manually.` };
}
// ─── 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;
}