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

View File

@@ -39,7 +39,8 @@ export interface ChatResponse {
| "need_insurance_clarification"
| "appointment_created"
| "claim_only_ready"
| "need_appointment_selection";
| "need_appointment_selection"
| "need_cdt_clarification";
actionData?: Record<string, any>;
}
@@ -75,6 +76,7 @@ interface StorageLike {
offset: number;
}): Promise<any[] | null>;
getPatientByInsuranceId(id: string): Promise<any | null>;
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
createAppointment(appointment: any): Promise<any>;
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
getOfficeHours(userId: number): Promise<any | null>;
@@ -98,6 +100,22 @@ function patientToResult(p: any): ResolvedPatient {
};
}
/** Look up by memberId, preferring the insuranceId+DOB combo when DOB is available. */
async function findPatientByMemberId(
memberId: string,
dob: string | null | undefined,
storage: StorageLike
): Promise<any | null> {
const dobDate = dob ? new Date(dob) : null;
if (dobDate && !isNaN(dobDate.getTime())) {
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
if (byCombo) return byCombo;
// DOB provided but no record with that combo → this is a new family member not yet in DB
return null;
}
return storage.getPatientByInsuranceId(memberId);
}
async function findPatientByName(
name: string,
storage: StorageLike
@@ -196,6 +214,28 @@ export async function runInternalChatWorkflow(
actionData: { patient },
};
}
// If patient has DOB + known insurance, route through eligibility_id_ready so the
// correct provider button (DDMA, CCA, etc.) auto-triggers instead of always MH.
const resolvedDob = patient.dateOfBirth ?? null;
const siteKey = resolveSiteKey(
patient.insuranceProvider ?? null,
classification.insuranceHint ?? null
);
if (resolvedDob && siteKey) {
return {
reply: `Found ${fullName}. Ready to check eligibility.`,
action: "eligibility_id_ready",
actionData: {
patient,
memberId: patient.insuranceId,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
},
};
}
return {
reply: `Found ${fullName}. Ready to check eligibility.`,
action: "check_eligibility_prefill",
@@ -246,8 +286,8 @@ async function handleEligibilityById(
};
}
// Try to resolve existing patient for name display + insurance
const existingPatient = await storage.getPatientByInsuranceId(memberId);
// Try to resolve existing patient for name display + insurance (use DOB to pick the right family member)
const existingPatient = await findPatientByMemberId(memberId, dob, storage);
const patient: ResolvedPatient | null = existingPatient
? patientToResult(existingPatient)
: null;
@@ -312,7 +352,7 @@ async function handleCheckAndClaim(
const dob = c.dob?.trim() ?? null;
if (memberId) {
const existing = await storage.getPatientByInsuranceId(memberId);
const existing = await findPatientByMemberId(memberId, dob, storage);
if (existing) patient = patientToResult(existing);
} else if (c.patientName?.trim()) {
const raw = await findPatientByName(c.patientName.trim(), storage);
@@ -368,18 +408,24 @@ async function handleCheckAndClaim(
const matched = 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
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
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 })) },
};
}
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
let reply = `Ready to check eligibility for ${label} and claim: ${
const reply = `Ready to check eligibility for ${label} and claim: ${
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
}.`;
if (unmatched.length > 0) {
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — please verify these codes manually.`;
}
return {
reply,
action: "check_and_claim_ready",
@@ -402,11 +448,11 @@ async function handleClaimOnly(
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
// Resolve patient
// Resolve patient — use insuranceId+DOB when DOB is available to distinguish family members
let patient: ResolvedPatient | null = null;
if (c.memberId?.trim()) {
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage);
if (existing) patient = patientToResult(existing);
} else if (c.patientName?.trim()) {
const raw = await findPatientByName(c.patientName.trim(), storage);
@@ -431,6 +477,16 @@ async function handleClaimOnly(
const matched = 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
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
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 })) },
};
}
// Resolve service date: use explicit date from message, then latest appointment, then ask
let serviceDate: string | null = c.appointmentDate ?? null;
let appointmentId: number | null = null;
@@ -480,11 +536,9 @@ async function handleClaimOnly(
}
if (!serviceDate) {
// No appointment on file — ask for the service date
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
return {
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
};
// No appointment on file and no date in message — default to today
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";