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