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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user