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>
This commit is contained in:
2026-06-29 01:08:41 -04:00
parent cb49298b66
commit cdda91f2b4
5 changed files with 324 additions and 96 deletions

View File

@@ -13,6 +13,7 @@ export type InternalChatIntent =
| "find_patient" // look up patient record only
| "schedule_appointment" // add patient to today's (or specified) schedule
| "claim_only" // submit claim for procedures (no eligibility check)
| "multi_claim" // different procedures for different patients by name
| "preauth" // submit pre-authorization for procedures
| "navigate_claims"
| "navigate_schedule"
@@ -35,6 +36,8 @@ export interface ChatClassification {
renderingProvider?: string; // raw name, e.g. "Kai Gao", "Dr. Smith"
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
// --- multi_claim (different procedures per patient) ---
claimGroups?: { patientName: string; procedureNames: string[] }[];
// --- scheduling ---
appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today)
appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00)
@@ -61,6 +64,7 @@ Respond ONLY with valid JSON (no markdown fences):
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"renderingProvider": "<provider/doctor name only if explicitly stated, e.g. 'Kai Gao', 'Dr. Smith' — omit if not mentioned>",
"procedureNames": ["<raw procedure name>", ...],
"claimGroups": [{"patientName": "<name>", "procedureNames": ["<procedure>", ...]}, ...],
"appointmentDate": "<YYYY-MM-DD; use today's date (${today}) if user says 'today'; omit only if no date is mentioned at all>",
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
"fallbackReply": "<1-2 sentence reply to show the user>"
@@ -94,6 +98,13 @@ Intents:
e.g. "perio exam, adult cleaning for Maria and John"
Use this ONLY when procedures AND two or more patient names are given.
Put each patient name into the "patientNames" array. Put procedure names in "procedureNames".
- multi_claim : user wants to claim DIFFERENT procedures for DIFFERENT patients identified by NAME
e.g. "claim #13 crown for flor and claim d5212 for bian"
e.g. "claim perio exam for Maria and claim adult prophy for John"
e.g. "claim D0120 for Jane and D1110 for Bob"
Use this when each patient has their OWN distinct set of procedures.
Put each patient+procedures group into the "claimGroups" array.
Do NOT use batch_claim for this — batch_claim is ONLY for the SAME procedures applied to ALL patients.
- batch_check_and_claim : user provides MULTIPLE member IDs with DOBs AND wants to claim PROCEDURES for all of them
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000, and claim perio exam and adult prophy"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000 and claim D0120 D1110 for them"

View File

@@ -47,6 +47,7 @@ export interface ChatResponse {
| "eligibility_id_ready"
| "batch_eligibility_ready"
| "batch_claim_ready"
| "multi_claim_ready"
| "batch_check_and_claim_ready"
| "check_and_claim_ready"
| "need_insurance_clarification"
@@ -345,6 +346,12 @@ export async function runInternalChatWorkflow(
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") {
@@ -810,6 +817,112 @@ async function handleBatchClaim(
};
}
// ─── 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(