feat: AI chat claim confirmation, CDT alias learning, and eligibility auto-trigger fixes

- Claim flow: show green confirm card (patient, CDT codes, service date) before Selenium starts
- CDT lookup: add DIRECT_CODE_MAP + ALIAS_MAP with 60+ dental abbreviations from office fee schedule
  (2BW→D0272, 4BW→D0274, PA→D0220, FL→D1208, RCT codes, composite tooth#/surface parser, etc.)
- Composite filling parser: auto-map "#29 OB" → D2392 based on tooth# (front/back) and surface count
- Ask-and-learn: unknown CDT terms block claim and ask user; answer saved to DB alias map for future use
- Cancel on confirm card returns to chat (not full reset) so user can correct and retry
- Eligibility auto-trigger fix: reset autoTriggeredRef when autoTrigger resets to false so second
  chatbot eligibility check on same page visit fires correctly (all 5 provider buttons fixed)
- check_eligibility by name now returns eligibility_id_ready with correct siteKey for non-MH patients
- DDMA/CCA/United/Tufts fee schedules updated with office prices (single Price field, no age split)
- getCodeMap case-insensitive matching fix (ddma/cca/etc. now correctly selected)
- Family plan member disambiguation: insuranceId+DOB composite lookup prevents overwriting siblings
- AI chat date fix: send clientDate from browser to avoid UTC midnight rollover (EST→wrong day)
- AI prompt: appointmentDate extracted for claim_only intent when user says "today" or a date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-06 21:11:58 -04:00
parent 4899ab8368
commit 86cf55aa4d
16 changed files with 1405 additions and 4913 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -187,7 +187,8 @@ export function CCAEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -393,7 +393,8 @@ export function DdmaEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleDdmaStart();

View File

@@ -327,7 +327,8 @@ export function DeltaInsEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -324,7 +324,8 @@ export function TuftsSCOEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -324,7 +324,8 @@ export function UnitedSCOEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -26,7 +26,9 @@ type Step =
| "eligibility-id-ready"
| "check-and-claim-ready"
| "need-insurance-clarification"
| "need-appointment-selection";
| "need-appointment-selection"
| "need-cdt-clarification"
| "claim-ready";
interface Message {
id: number;
@@ -126,6 +128,18 @@ export function ChatbotButton() {
matchedCodes: { code: string; description: string }[];
options: { label: string; appointmentId: number; serviceDate: string }[];
} | null>(null);
const [cdtClarificationData, setCdtClarificationData] = useState<{
unknownPhrases: string[];
codeInputs: Record<string, string>;
originalMessage: string;
} | null>(null);
const [claimReadyData, setClaimReadyData] = useState<{
patient: PatientResult | null;
matchedCodes: { code: string; description: string }[];
siteKey: string;
serviceDate: string;
appointmentId: number | null;
} | null>(null);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -175,6 +189,8 @@ export function ChatbotButton() {
setCheckAndClaimData(null);
setClarificationData(null);
setApptSelectionData(null);
setCdtClarificationData(null);
setClaimReadyData(null);
};
// Full reset including message history and stored session
@@ -294,7 +310,9 @@ export function ChatbotButton() {
.filter((m) => !m.isLoading)
.slice(-15)
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history });
const d = new Date();
const clientDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate });
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
@@ -366,22 +384,25 @@ export function ChatbotButton() {
return;
}
if (data.action === "need_cdt_clarification" && data.actionData) {
const phrases: string[] = data.actionData.unknownPhrases ?? [];
const inputs: Record<string, string> = {};
for (const p of phrases) inputs[p] = "";
setCdtClarificationData({ unknownPhrases: phrases, codeInputs: inputs, originalMessage: text });
setStep("need-cdt-clarification");
return;
}
if (data.action === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
);
}
const url = appointmentId
? `/claims?appointmentId=${appointmentId}`
: `/claims?newPatient=${patient?.id}`;
setTimeout(() => {
setLocation(url);
setOpen(false);
resetStep();
}, 600);
setClaimReadyData({
patient: patient ?? null,
matchedCodes: matchedCodes ?? [],
siteKey,
serviceDate,
appointmentId: appointmentId ?? null,
});
setStep("claim-ready");
return;
}
@@ -672,6 +693,7 @@ export function ChatbotButton() {
setStep("ai-loading");
apiRequest("POST", "/api/ai/internal-chat", {
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
clientDate: (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; })(),
})
.then((r) => r.json())
.then((data) => {
@@ -753,6 +775,124 @@ export function ChatbotButton() {
</div>
)}
{/* Claim ready — confirm before submitting */}
{step === "claim-ready" && claimReadyData && (() => {
const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-");
const dateLabel = sy ? `${sm}/${sd}/${sy}` : claimReadyData.serviceDate;
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-green-800">Confirm Claim</p>
{claimReadyData.patient && (
<p className="text-xs text-green-700 font-medium">
{claimReadyData.patient.firstName} {claimReadyData.patient.lastName}
</p>
)}
<p className="text-xs text-gray-500">Service date: {dateLabel}</p>
{claimReadyData.matchedCodes.length > 0 && (
<div className="space-y-0.5 pt-0.5">
{claimReadyData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs text-gray-700 pl-1">
<span className="font-medium">{c.code}</span> {c.description}
</p>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-green-600 hover:bg-green-700 text-white"
onClick={() => {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = claimReadyData;
addMsg("user", "Confirm & submit claim");
addMsg("bot", "Opening claim...");
if (patient?.id && matchedCodes.length > 0) {
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
);
}
const url = appointmentId
? `/claims?appointmentId=${appointmentId}`
: `/claims?newPatient=${patient?.id}`;
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
}}
>
<FileText className="h-3 w-3 mr-1" />
Confirm &amp; Submit
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
</div>
</div>
);
})()}
{/* CDT clarification — unknown procedure terms */}
{step === "need-cdt-clarification" && cdtClarificationData && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-amber-800">Unknown procedure term{cdtClarificationData.unknownPhrases.length > 1 ? "s" : ""}</p>
<div className="space-y-2">
{cdtClarificationData.unknownPhrases.map((phrase) => (
<div key={phrase} className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium shrink-0">"{phrase}"</span>
<input
type="text"
placeholder="D0272"
value={cdtClarificationData.codeInputs[phrase] ?? ""}
onChange={(e) => setCdtClarificationData((prev) => prev ? {
...prev,
codeInputs: { ...prev.codeInputs, [phrase]: e.target.value.toUpperCase() },
} : prev)}
className="flex-1 rounded border border-amber-300 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-amber-600 hover:bg-amber-700 text-white"
disabled={Object.values(cdtClarificationData.codeInputs).some((v) => !v.trim())}
onClick={async () => {
const entries = Object.entries(cdtClarificationData.codeInputs);
for (const [phrase, code] of entries) {
await apiRequest("POST", "/api/ai/cdt-aliases/add", { phrase, cdtCode: code.trim() });
}
const savedPairs = entries.map(([p, c]) => `"${p}" = ${c.trim()}`).join(", ");
addMsg("user", entries.map(([p, c]) => `${p} = ${c.trim()}`).join(", "));
addMsg("bot", `Got it! Saved ${savedPairs}. Retrying...`, true);
setStep("ai-loading");
setCdtClarificationData(null);
const origMsg = cdtClarificationData.originalMessage;
try {
const history = messages.filter((m) => !m.isLoading).slice(-15).map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
const _d = new Date();
const _cd = `${_d.getFullYear()}-${String(_d.getMonth()+1).padStart(2,"0")}-${String(_d.getDate()).padStart(2,"0")}`;
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: origMsg, history, clientDate: _cd });
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
if (data.action === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }));
}
const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`;
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
} else {
setStep("menu");
}
} catch {
replaceLastMsg("Sorry, something went wrong. Please try again.");
setStep("menu");
}
}}
>
Save &amp; Retry
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>

View File

@@ -92,10 +92,11 @@ const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
/** Return the correct fee-schedule map for the given insurance type. */
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
if (insuranceSiteKey === "UNITED_SCO" || insuranceSiteKey === "UnitedSCO" || insuranceSiteKey === "UNITEDDH") return UNITEDDH_CODE_MAP;
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
const k = (insuranceSiteKey ?? "").replace(/_/g, "").toLowerCase();
if (k === "cca") return CCA_CODE_MAP;
if (k === "ddma") return DDMA_CODE_MAP;
if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP;
if (k === "tuftssco" || k === "tufts") return TUFTSSCO_CODE_MAP;
return CODE_MAP; // default: MassHealth
}
@@ -175,43 +176,28 @@ export function pickPriceForRowByAge(
): Decimal {
// Special-case rules (add more codes here if needed)
if (normalizedCode) {
// D1110: only valid for age >=14 (14..21 => PriceLTEQ21, >21 => PriceGT21)
// D1110: only valid for age >=14
if (normalizedCode === "D1110") {
if (age < 14) {
// D1110 not applicable to children <14 (those belong to D1120)
return new Decimal(0);
}
if (age >= 14 && age <= 21) {
// use PriceLTEQ21 only if present
if (!isBlankPrice(row.PriceLTEQ21))
return toDecimalOrZero(row.PriceLTEQ21);
return new Decimal(0);
}
// age > 21
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
if (age < 14) return new Decimal(0); // D1110 not for children <14
// age >= 14: use age-split if present, then flat Price
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}
// D1120: child 0-13 => PriceLTEQ21, otherwise no price (NC)
// D1120: valid for child 0-13 only
if (normalizedCode === "D1120") {
if (age < 14) {
if (!isBlankPrice(row.PriceLTEQ21))
return toDecimalOrZero(row.PriceLTEQ21);
return new Decimal(0);
}
// age >= 14 => NC / no price
if (age >= 14) return new Decimal(0); // NC for adults
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}
}
// Generic/default behavior (unchanged)
if (age <= 21) {
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
} else {
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
}
// Fallback to Price if tiered not available/blank
// Generic/default: age-split first, flat Price as fallback
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}