fix: match PDF-imported patients by member ID when DOB is missing on record
MassHealth eligibility lookups via the AI chatbot only matched existing patients on memberId+DOB, so patients imported from PDF extraction (which have a member ID but no stored birthday) were treated as new/unknown even though they already exist in the system. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"is_task_list_visible": true,
|
"is_task_list_visible": true,
|
||||||
"active_task": "backend#dev"
|
"active_task": "frontend#dev"
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,9 @@ Intents:
|
|||||||
Insurance abbreviations are: mh, masshealth, bcbs, cca, dentaquest — anything else is a patient name
|
Insurance abbreviations are: mh, masshealth, bcbs, cca, dentaquest — anything else is a patient name
|
||||||
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
|
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
|
||||||
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
||||||
|
e.g. "100xxxx 10/10/1988" or "100xxxx, 10/10/1988" — a bare member ID + DOB with
|
||||||
|
NO verb and NO insurance keyword is STILL eligibility_by_id (default to MassHealth,
|
||||||
|
omit insuranceHint). Do NOT classify this as "general".
|
||||||
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
|
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
|
||||||
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
|
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
|
||||||
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
|
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
|
||||||
@@ -219,6 +222,20 @@ Rules:
|
|||||||
|
|
||||||
// ─── Classifier ───────────────────────────────────────────────────────────────
|
// ─── Classifier ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Deterministic fast-path for a bare "memberId DOB" (or "DOB memberId") message with
|
||||||
|
// nothing else in it — e.g. "100232102531 4/27/1999" or "100232102531, 4/27/1999".
|
||||||
|
// Bypasses the LLM entirely so this always resolves to eligibility_by_id, since some
|
||||||
|
// providers hedge/refuse structured output on a bare digit string that looks like an SSN.
|
||||||
|
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
|
||||||
|
const text = message.trim();
|
||||||
|
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
|
||||||
|
const idThenDob = text.match(new RegExp(`^([A-Za-z0-9]{3,})[\\s,]+${dobPattern.source}$`));
|
||||||
|
if (idThenDob) return { memberId: idThenDob[1]!, dob: idThenDob[2]!.replace(/-/g, "/") };
|
||||||
|
const dobThenId = text.match(new RegExp(`^${dobPattern.source}[\\s,]+([A-Za-z0-9]{3,})$`));
|
||||||
|
if (dobThenId) return { memberId: dobThenId[2]!, dob: dobThenId[1]!.replace(/-/g, "/") };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function classifyInternalChat(
|
export async function classifyInternalChat(
|
||||||
message: string,
|
message: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -234,6 +251,16 @@ export async function classifyInternalChat(
|
|||||||
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
|
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bare = parseBareIdDob(message);
|
||||||
|
if (bare) {
|
||||||
|
return {
|
||||||
|
intent: "eligibility_by_id",
|
||||||
|
memberId: bare.memberId,
|
||||||
|
dob: bare.dob,
|
||||||
|
fallbackReply: `Checking eligibility for Member ID ${bare.memberId}...`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!apiKey) return fallback;
|
if (!apiKey) return fallback;
|
||||||
|
|
||||||
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
|
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ interface StorageLike {
|
|||||||
}): Promise<any[] | null>;
|
}): Promise<any[] | null>;
|
||||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||||
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
|
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
|
||||||
|
getPatientByInsuranceIdNoDob(id: string): Promise<any | null>;
|
||||||
createAppointment(appointment: any): Promise<any>;
|
createAppointment(appointment: any): Promise<any>;
|
||||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||||
getOfficeHours(userId: number): Promise<any | null>;
|
getOfficeHours(userId: number): Promise<any | null>;
|
||||||
@@ -172,7 +173,11 @@ async function findPatientByMemberId(
|
|||||||
if (dobDate && !isNaN(dobDate.getTime())) {
|
if (dobDate && !isNaN(dobDate.getTime())) {
|
||||||
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
|
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
|
||||||
if (byCombo) return byCombo;
|
if (byCombo) return byCombo;
|
||||||
// DOB provided but no record with that combo → this is a new family member not yet in DB
|
// No exact DOB match — check for an existing patient with this memberId but no DOB on
|
||||||
|
// file (e.g. imported from a PDF extraction that didn't capture birthdates) before
|
||||||
|
// concluding this is a brand-new family member not yet in DB.
|
||||||
|
const byIdNoDob = await storage.getPatientByInsuranceIdNoDob(memberId);
|
||||||
|
if (byIdNoDob) return byIdNoDob;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return storage.getPatientByInsuranceId(memberId);
|
return storage.getPatientByInsuranceId(memberId);
|
||||||
@@ -428,28 +433,12 @@ async function handleEligibilityById(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine siteKey
|
// Determine siteKey (defaults to MassHealth when no payer is specified)
|
||||||
const siteKey = resolveSiteKey(
|
const siteKey = resolveSiteKey(
|
||||||
patient?.insuranceProvider ?? null,
|
patient?.insuranceProvider ?? null,
|
||||||
c.insuranceHint ?? null
|
c.insuranceHint ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!siteKey) {
|
|
||||||
const name = patient
|
|
||||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
|
||||||
: `Member ID ${memberId}`;
|
|
||||||
return {
|
|
||||||
reply: `Found ${name} but couldn't determine the insurance type. Which insurance should I use?`,
|
|
||||||
action: "need_insurance_clarification",
|
|
||||||
actionData: {
|
|
||||||
memberId,
|
|
||||||
dob: resolvedDob,
|
|
||||||
patient,
|
|
||||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = patient
|
const label = patient
|
||||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||||
: `Member ID ${memberId}`;
|
: `Member ID ${memberId}`;
|
||||||
@@ -985,29 +974,12 @@ async function handleCheckAndClaim(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Determine siteKey
|
// 2. Determine siteKey (defaults to MassHealth when no payer is specified)
|
||||||
const siteKey = resolveSiteKey(
|
const siteKey = resolveSiteKey(
|
||||||
patient?.insuranceProvider ?? null,
|
patient?.insuranceProvider ?? null,
|
||||||
c.insuranceHint ?? null
|
c.insuranceHint ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!siteKey) {
|
|
||||||
const label = patient
|
|
||||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
|
||||||
: `Member ID ${memberId}`;
|
|
||||||
return {
|
|
||||||
reply: `Found ${label} but couldn't determine the insurance type. Which insurance should I use?`,
|
|
||||||
action: "need_insurance_clarification",
|
|
||||||
actionData: {
|
|
||||||
memberId,
|
|
||||||
dob,
|
|
||||||
patient,
|
|
||||||
procedureNames: c.procedureNames ?? [],
|
|
||||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Map procedure names → CDT codes (custom aliases take priority)
|
// 3. Map procedure names → CDT codes (custom aliases take priority)
|
||||||
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
|
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
|
||||||
const cdtResults: CdtResult[] = procedureNames.length > 0
|
const cdtResults: CdtResult[] = procedureNames.length > 0
|
||||||
@@ -1456,13 +1428,13 @@ export async function createAppointmentToday(
|
|||||||
* Determine siteKey from:
|
* Determine siteKey from:
|
||||||
* 1. Patient's stored insuranceProvider (most authoritative)
|
* 1. Patient's stored insuranceProvider (most authoritative)
|
||||||
* 2. Insurance hint from the chat message
|
* 2. Insurance hint from the chat message
|
||||||
* 3. null → caller must ask for clarification
|
* 3. No payer specified → default to MassHealth ("MH")
|
||||||
*/
|
*/
|
||||||
function resolveSiteKey(
|
function resolveSiteKey(
|
||||||
storedProvider: string | null,
|
storedProvider: string | null,
|
||||||
hint: string | null
|
hint: string | null
|
||||||
): string | null {
|
): string {
|
||||||
if (storedProvider) return deriveSiteKey(storedProvider);
|
if (storedProvider) return deriveSiteKey(storedProvider);
|
||||||
if (hint) return deriveSiteKey(hint);
|
if (hint) return deriveSiteKey(hint);
|
||||||
return null;
|
return "MH";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface IStorage {
|
|||||||
getPatient(id: number): Promise<Patient | undefined>;
|
getPatient(id: number): Promise<Patient | undefined>;
|
||||||
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
|
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
|
||||||
getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>;
|
getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>;
|
||||||
|
getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null>;
|
||||||
getAllPatients(): Promise<Patient[]>;
|
getAllPatients(): Promise<Patient[]>;
|
||||||
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
|
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
|
||||||
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
||||||
@@ -74,6 +75,12 @@ export const patientsStorage: IStorage = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null> {
|
||||||
|
return db.patient.findFirst({
|
||||||
|
where: { insuranceId, dateOfBirth: null },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
|
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
|
||||||
return db.patient.findMany({
|
return db.patient.findMany({
|
||||||
skip: offset,
|
skip: offset,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user