feat: age overrides notes text for prophy code (D1110/D1120)
Patient's actual age now decides adult vs child prophylaxis instead of trusting literal "adult prophy"/"child prophy" wording in notes. Applies bidirectionally (corrects both D1110-on-a-child and D1120-on-an-adult) and also resolves ambiguous bare "prophy"/"cleaning" input purely from age. Wired into all single- and batch-claim chat flows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,12 @@ const ALIAS_MAP: Record<string, string> = {
|
||||
"child prophy": "prophylaxis child",
|
||||
"pedo cleaning": "prophylaxis child",
|
||||
"kid prophy": "prophylaxis child",
|
||||
// No adult/child qualifier given — default to adult here; the workflow layer
|
||||
// (internal-chat-workflow.ts: correctProphyForAge) looks up the patient's actual
|
||||
// age and swaps this to D1120 when they're under 14, so age always wins.
|
||||
"prophy": "prophylaxis adult",
|
||||
"prophylaxis": "prophylaxis adult",
|
||||
"cleaning": "prophylaxis adult",
|
||||
// X-rays
|
||||
"full mouth xray": "intraoral complete series",
|
||||
"fmx": "intraoral complete series",
|
||||
|
||||
@@ -136,18 +136,35 @@ function calcAge(dob: string | null | undefined, on?: string | null): number | n
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function checkD1120Age(
|
||||
matched: CdtResult[],
|
||||
/**
|
||||
* The patient's actual age is authoritative over whatever the user typed in notes
|
||||
* ("adult prophy" / "child prophy") — D1110 is for 14+, D1120 is for under 14.
|
||||
* Silently rewrites the matched code/description to match age and returns an
|
||||
* informational note when a correction was made, so the user is told why what
|
||||
* they typed differs from what's being claimed.
|
||||
*/
|
||||
function correctProphyForAge(
|
||||
results: CdtResult[],
|
||||
patientName: string,
|
||||
dob: string | null | undefined,
|
||||
serviceDate?: string | null
|
||||
): ChatResponse | null {
|
||||
if (!matched.some((r) => r.code === "D1120")) return null;
|
||||
): { results: CdtResult[]; note: string | 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.`,
|
||||
};
|
||||
if (age === null) return { results, note: null };
|
||||
|
||||
let note: string | null = null;
|
||||
const corrected = results.map((r) => {
|
||||
if (r.code === "D1110" && age < 14) {
|
||||
note = `Note: ${patientName} is ${age}, so I used D1120 (child prophy) instead of D1110 — age overrides what was written.`;
|
||||
return { ...r, code: "D1120", description: "Prophylaxis – child, 0-13 yo" };
|
||||
}
|
||||
if (r.code === "D1120" && age >= 14) {
|
||||
note = `Note: ${patientName} is ${age}, so I used D1110 (adult prophy) instead of D1120 — age overrides what was written.`;
|
||||
return { ...r, code: "D1110", description: "Prophylaxis – adult, 14 yo or older" };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
return { results: corrected, note };
|
||||
}
|
||||
|
||||
function patientToResult(p: any): ResolvedPatient {
|
||||
@@ -703,7 +720,9 @@ async function handleBatchCheckAndClaim(
|
||||
siteKey: string;
|
||||
autoCheck: string;
|
||||
patient: ResolvedPatient | null;
|
||||
matchedCodes: CdtResult[];
|
||||
}[] = [];
|
||||
const ageNotes: string[] = [];
|
||||
|
||||
for (const { memberId, dob } of pairs) {
|
||||
const id = memberId?.trim();
|
||||
@@ -720,12 +739,19 @@ async function handleBatchCheckAndClaim(
|
||||
c.insuranceHint ?? null
|
||||
) ?? "MH";
|
||||
|
||||
const label = patient
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||
: `ID ${id}`;
|
||||
const { results: patientMatched, note } = correctProphyForAge(matched, label, resolvedDob, c.appointmentDate);
|
||||
if (note) ageNotes.push(note);
|
||||
|
||||
resolved.push({
|
||||
memberId: id,
|
||||
dob: resolvedDob,
|
||||
siteKey,
|
||||
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
|
||||
patient,
|
||||
matchedCodes: patientMatched,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -741,11 +767,23 @@ async function handleBatchCheckAndClaim(
|
||||
});
|
||||
const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", ");
|
||||
|
||||
let reply = `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`;
|
||||
if (ageNotes.length > 0) {
|
||||
reply += ` ${ageNotes.join(" ")}`;
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`,
|
||||
reply,
|
||||
action: "batch_check_and_claim_ready",
|
||||
actionData: {
|
||||
queue: resolved,
|
||||
queue: resolved.map((r) => ({
|
||||
memberId: r.memberId,
|
||||
dob: r.dob,
|
||||
siteKey: r.siteKey,
|
||||
autoCheck: r.autoCheck,
|
||||
patient: r.patient,
|
||||
matchedCodes: r.matchedCodes.map((m) => ({ code: m.code!, description: m.description, toothNumber: m.toothNumber, toothSurface: m.toothSurface, quad: m.quad })),
|
||||
})),
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
|
||||
renderingProvider: c.renderingProvider ?? null,
|
||||
},
|
||||
@@ -794,8 +832,10 @@ async function handleBatchClaim(
|
||||
siteKey: string;
|
||||
serviceDate: string | null;
|
||||
appointmentId: number | null;
|
||||
matchedCodes: CdtResult[];
|
||||
}[] = [];
|
||||
const notFound: string[] = [];
|
||||
const ageNotes: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
const trimmed = name.trim();
|
||||
@@ -808,8 +848,8 @@ async function handleBatchClaim(
|
||||
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 { results: patientMatched, note } = correctProphyForAge(matched, fullName, patient.dateOfBirth, c.appointmentDate);
|
||||
if (note) ageNotes.push(note);
|
||||
|
||||
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||||
|
||||
@@ -820,7 +860,7 @@ async function handleBatchClaim(
|
||||
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
resolved.push({ patient, siteKey, serviceDate, appointmentId });
|
||||
resolved.push({ patient, siteKey, serviceDate, appointmentId, matchedCodes: patientMatched });
|
||||
}
|
||||
|
||||
if (notFound.length > 0 && resolved.length === 0) {
|
||||
@@ -835,6 +875,9 @@ async function handleBatchClaim(
|
||||
if (notFound.length > 0) {
|
||||
reply += ` Could not find: ${notFound.join(", ")}.`;
|
||||
}
|
||||
if (ageNotes.length > 0) {
|
||||
reply += ` ${ageNotes.join(" ")}`;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
@@ -845,6 +888,7 @@ async function handleBatchClaim(
|
||||
siteKey: r.siteKey,
|
||||
serviceDate: r.serviceDate,
|
||||
appointmentId: r.appointmentId,
|
||||
matchedCodes: r.matchedCodes.map((m) => ({ code: m.code!, description: m.description, toothNumber: m.toothNumber, toothSurface: m.toothSurface, quad: m.quad })),
|
||||
})),
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
|
||||
renderingProvider: c.renderingProvider ?? null,
|
||||
@@ -879,6 +923,7 @@ async function handleMultiClaim(
|
||||
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
|
||||
}[] = [];
|
||||
const notFound: string[] = [];
|
||||
const ageNotes: string[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const name = group.patientName?.trim();
|
||||
@@ -908,8 +953,8 @@ async function handleMultiClaim(
|
||||
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 { results: correctedMatched, note } = correctProphyForAge(matched, fullName, patient.dateOfBirth, c.appointmentDate);
|
||||
if (note) ageNotes.push(note);
|
||||
|
||||
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||||
|
||||
@@ -924,7 +969,7 @@ async function handleMultiClaim(
|
||||
siteKey,
|
||||
serviceDate,
|
||||
appointmentId: null,
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
|
||||
matchedCodes: correctedMatched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -941,6 +986,9 @@ async function handleMultiClaim(
|
||||
if (notFound.length > 0) {
|
||||
reply += ` Could not find: ${notFound.join(", ")}.`;
|
||||
}
|
||||
if (ageNotes.length > 0) {
|
||||
reply += ` ${ageNotes.join(" ")}`;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
@@ -1049,13 +1097,13 @@ async function handleCheckAndClaim(
|
||||
? `${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;
|
||||
// Patient's age is authoritative over notes text for prophy code (D1110 vs D1120)
|
||||
const { results: correctedMatched, note: ageNote } = correctProphyForAge(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate);
|
||||
|
||||
const reply = `Ready to check eligibility for ${label} and claim: ${
|
||||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
||||
let reply = `Ready to check eligibility for ${label} and claim: ${
|
||||
correctedMatched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
||||
}.`;
|
||||
if (ageNote) reply += ` ${ageNote}`;
|
||||
|
||||
return {
|
||||
reply,
|
||||
@@ -1067,7 +1115,7 @@ async function handleCheckAndClaim(
|
||||
siteKey,
|
||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||
cdtResults,
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
|
||||
matchedCodes: correctedMatched.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,
|
||||
},
|
||||
@@ -1107,7 +1155,7 @@ async function handleClaimOnly(
|
||||
}
|
||||
|
||||
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
|
||||
const matched = cdtResults.filter((r) => r.code !== null);
|
||||
const rawMatched = 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
|
||||
@@ -1116,13 +1164,12 @@ async function handleClaimOnly(
|
||||
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 })) },
|
||||
actionData: { unknownPhrases: phrases, matchedSoFar: rawMatched.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;
|
||||
// Patient's age is authoritative over notes text for prophy code (D1110 vs D1120)
|
||||
const { results: matched, note: ageNote } = correctProphyForAge(rawMatched, fullName, patient.dateOfBirth, c.appointmentDate);
|
||||
|
||||
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||
@@ -1180,6 +1227,7 @@ async function handleClaimOnly(
|
||||
if (unmatched.length > 0) {
|
||||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
|
||||
}
|
||||
if (ageNote) reply += ` ${ageNote}`;
|
||||
|
||||
return {
|
||||
reply,
|
||||
|
||||
Reference in New Issue
Block a user