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:
2026-10-05 21:58:21 -04:00
co-authored by Claude Sonnet 5
parent d235845ea2
commit 6148cfa848
4 changed files with 92 additions and 33 deletions
+6
View File
@@ -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",
+75 -27
View File
@@ -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,
@@ -256,12 +256,12 @@ export function ChatbotButton() {
renderingProvider: string | null;
} | null>(null);
const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState<{
queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[];
queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; matchedCodes?: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[] }[];
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
renderingProvider: string | null;
} | null>(null);
const [batchClaimData, setBatchClaimData] = useState<{
queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null }[];
queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null; matchedCodes?: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[] }[];
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
renderingProvider: string | null;
} | null>(null);
@@ -1461,7 +1461,7 @@ export function ChatbotButton() {
}));
}
sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({
codes: matchedCodes,
codes: first!.matchedCodes?.length ? first!.matchedCodes : matchedCodes,
siteKey: first!.siteKey,
patientId: first!.patient?.id ?? null,
memberId: first!.memberId,
@@ -1492,6 +1492,9 @@ export function ChatbotButton() {
{batchClaimData.queue.map((item, i) => (
<p key={i} className="text-xs text-orange-600 pl-2">
{i + 1}. {item.patient.firstName} {item.patient.lastName}
{item.matchedCodes?.some((c, idx) => c.code !== batchClaimData.matchedCodes[idx]?.code) && (
<span className="text-gray-500"> — {item.matchedCodes.map((c) => c.code).join(", ")} (adjusted for age)</span>
)}
</p>
))}
{batchClaimData.matchedCodes.length > 0 && (
@@ -1520,9 +1523,10 @@ export function ChatbotButton() {
renderingProvider,
}));
}
if (first!.patient.id && matchedCodes.length > 0) {
const firstCodes = first!.matchedCodes?.length ? first!.matchedCodes : matchedCodes;
if (first!.patient.id && firstCodes.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
codes: firstCodes,
siteKey: first!.siteKey,
serviceDate: first!.serviceDate,
autoSubmit: true,
+2 -1
View File
@@ -381,8 +381,9 @@ export default function ClaimsPage() {
} else {
sessionStorage.removeItem("chatbot_check_claim_queue");
}
const codes = next.matchedCodes?.length > 0 ? next.matchedCodes : matchedCodes;
sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({
codes: matchedCodes,
codes,
siteKey: next.siteKey,
patientId: next.patient?.id ?? null,
memberId: next.memberId,