feat: United Ucard name+DOB eligibility search via chat, duplicate-record handling, Product ID as member ID
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { getLlm, type AiProvider } from "./llm-factory";
|
||||
export type InternalChatIntent =
|
||||
| "check_eligibility" // by patient name → look up in DB
|
||||
| "eligibility_by_id" // by explicit memberId + dob (no name)
|
||||
| "eligibility_by_name_dob" // by explicit first name + last name + dob (no memberId) — United Ucard name search
|
||||
| "batch_eligibility" // multiple patients by memberId + dob
|
||||
| "batch_eligibility_by_name" // multiple patients by name (no memberId)
|
||||
| "batch_claim" // claim same procedures for multiple patients by name
|
||||
@@ -26,6 +27,8 @@ export interface ChatClassification {
|
||||
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
|
||||
memberId?: string; // for eligibility_by_id / check_and_claim
|
||||
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
|
||||
firstName?: string; // for eligibility_by_name_dob
|
||||
lastName?: string; // for eligibility_by_name_dob
|
||||
// --- batch eligibility (multiple patients) ---
|
||||
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
|
||||
// --- batch claim (same procedures for multiple patients by name) ---
|
||||
@@ -58,6 +61,8 @@ Respond ONLY with valid JSON (no markdown fences):
|
||||
"intent": "<intent>",
|
||||
"patientName": "<full name if mentioned by name>",
|
||||
"memberId": "<member/insurance ID if given explicitly or found in history>",
|
||||
"firstName": "<first name, only for eligibility_by_name_dob>",
|
||||
"lastName": "<last name, only for eligibility_by_name_dob>",
|
||||
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
|
||||
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
|
||||
"patientNames": ["<name1>", "<name2>", ...],
|
||||
@@ -86,6 +91,10 @@ Intents:
|
||||
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"
|
||||
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
|
||||
- eligibility_by_name_dob : user provides a first name, last name AND date of birth but NO member ID
|
||||
e.g. "check ucard Kai Gao 08/30/1997", "Kai Gao, 8/30/1997"
|
||||
e.g. "canin gomez, 08/30/1997, ucard" → firstName "canin", lastName "gomez" (ALWAYS: first word = first name, all remaining words = last name, e.g. "min ping wang" → firstName "min", lastName "ping wang")
|
||||
Set firstName, lastName and dob (MM/DD/YYYY). Set insuranceHint if stated (e.g. "ucard").
|
||||
- batch_eligibility : user provides MULTIPLE member IDs with dates of birth in one message
|
||||
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
|
||||
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000"
|
||||
@@ -236,6 +245,19 @@ function parseBareIdDob(message: string): { memberId: string; dob: string } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deterministic fast-path for a bare "First Last DOB" (or "DOB First Last") message,
|
||||
// e.g. "Kai Gao 08/30/1997" or "Kai Gao, 8/30/1997". Two or more alphabetic words: first word = first name, the rest = last name.
|
||||
function parseBareNameDob(message: string): { firstName: string; lastName: string; dob: string } | null {
|
||||
const text = message.trim();
|
||||
const dob = "(\\d{1,2}[\\/\\-]\\d{1,2}[\\/\\-]\\d{4})";
|
||||
const name = "([A-Za-z][A-Za-z'\\-]*)\\s+([A-Za-z][A-Za-z'\\-]*(?:\\s+[A-Za-z][A-Za-z'\\-]*)*)";
|
||||
const nameThenDob = text.match(new RegExp(`^${name}[\\s,]+${dob}$`));
|
||||
if (nameThenDob) return { firstName: nameThenDob[1]!, lastName: nameThenDob[2]!, dob: nameThenDob[3]!.replace(/-/g, "/") };
|
||||
const dobThenName = text.match(new RegExp(`^${dob}[\\s,]+${name}$`));
|
||||
if (dobThenName) return { firstName: dobThenName[2]!, lastName: dobThenName[3]!, dob: dobThenName[1]!.replace(/-/g, "/") };
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function classifyInternalChat(
|
||||
message: string,
|
||||
apiKey: string,
|
||||
@@ -261,6 +283,17 @@ export async function classifyInternalChat(
|
||||
};
|
||||
}
|
||||
|
||||
const bareName = parseBareNameDob(message);
|
||||
if (bareName) {
|
||||
return {
|
||||
intent: "eligibility_by_name_dob",
|
||||
firstName: bareName.firstName,
|
||||
lastName: bareName.lastName,
|
||||
dob: bareName.dob,
|
||||
fallbackReply: `Checking eligibility for ${bareName.firstName} ${bareName.lastName}...`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) return fallback;
|
||||
|
||||
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
|
||||
|
||||
@@ -324,6 +324,12 @@ export async function runInternalChatWorkflow(
|
||||
return await handleEligibilityById(classification, storage);
|
||||
}
|
||||
|
||||
// ── Eligibility by first name + last name + DOB (United Ucard name search) ─
|
||||
|
||||
if (intent === "eligibility_by_name_dob") {
|
||||
return handleEligibilityByNameDob(classification);
|
||||
}
|
||||
|
||||
// ── Batch eligibility (multiple patients) ─────────────────────────────────
|
||||
|
||||
if (intent === "batch_eligibility") {
|
||||
@@ -458,6 +464,44 @@ async function handleEligibilityById(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── eligibility_by_name_dob ──────────────────────────────────────────────────
|
||||
|
||||
// Only United Ucard supports searching by name + DOB (no member ID).
|
||||
function handleEligibilityByNameDob(c: ChatClassification): ChatResponse {
|
||||
// Rule: first word = first name, everything after = last name ("min ping wang" → "min" / "ping wang").
|
||||
// Re-split regardless of how the classifier divided it.
|
||||
const fullName = (c.patientName?.trim() || `${c.firstName ?? ""} ${c.lastName ?? ""}`.trim());
|
||||
const parts = fullName.split(/\s+/).filter(Boolean);
|
||||
const firstName = parts[0];
|
||||
const lastName = parts.length >= 2 ? parts.slice(1).join(" ") : undefined;
|
||||
const dob = c.dob?.trim();
|
||||
if (!firstName || !lastName || !dob) {
|
||||
return { reply: "Please provide the first name, last name and Date of Birth (MM/DD/YYYY)." };
|
||||
}
|
||||
|
||||
const hint = (c.insuranceHint ?? "").toLowerCase();
|
||||
if (hint && !hint.includes("ucard") && !hint.includes("united")) {
|
||||
return {
|
||||
reply: "Searching by name + DOB is only supported for United Ucard. Please provide a Member ID for other plans.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `Ready to check United Ucard eligibility for ${firstName} ${lastName} by name.`,
|
||||
action: "eligibility_id_ready",
|
||||
actionData: {
|
||||
patient: null,
|
||||
memberId: "",
|
||||
firstName,
|
||||
lastName,
|
||||
dob,
|
||||
siteKey: "UNITED_UCARD",
|
||||
autoCheck: siteKeyToAutoCheck("UNITED_UCARD"),
|
||||
appointmentDate: c.appointmentDate ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── batch_eligibility ───────────────────────────────────────────────────────
|
||||
|
||||
async function handleBatchEligibility(
|
||||
|
||||
@@ -66,12 +66,16 @@ export interface UnitedUcardEligibilityProcessorResult {
|
||||
|
||||
async function processUnitedUcardResult(
|
||||
userId: number,
|
||||
insuranceId: string,
|
||||
searchedInsuranceId: string,
|
||||
formFirstName: string | undefined,
|
||||
formLastName: string | undefined,
|
||||
formDob: string | undefined,
|
||||
seleniumResult: any
|
||||
): Promise<UnitedUcardEligibilityProcessorResult> {
|
||||
// Name-search jobs have no member ID up front; the worker returns the page's Product ID instead.
|
||||
const scrapedId =
|
||||
typeof seleniumResult?.memberId === "string" ? seleniumResult.memberId.replace(/\s+/g, "") : "";
|
||||
const insuranceId = searchedInsuranceId || scrapedId;
|
||||
const output: UnitedUcardEligibilityProcessorResult = {};
|
||||
let createdPdfFileId: number | string | null = null;
|
||||
|
||||
@@ -82,21 +86,50 @@ async function processUnitedUcardResult(
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = rawName
|
||||
// Name-search jobs (no member ID) always use the names the user typed.
|
||||
const { firstName, lastName } = rawName && searchedInsuranceId
|
||||
? splitName(rawName)
|
||||
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||
|
||||
// 2) Create / update patient
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: formDob,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 3) Fetch patient
|
||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
// 2-3) Resolve the patient record.
|
||||
// Name + DOB searches have no member ID (the portal masks it), so we can only
|
||||
// update a patient that is already on file — match on first/last name + DOB.
|
||||
let patient: any = null;
|
||||
if (insuranceId) {
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: formDob,
|
||||
userId,
|
||||
});
|
||||
patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
} else {
|
||||
const dobDate = formDob ? new Date(formDob) : null;
|
||||
const matches = await storage.searchPatients({
|
||||
filters: {
|
||||
firstName: { equals: firstName, mode: "insensitive" },
|
||||
lastName: { equals: lastName, mode: "insensitive" },
|
||||
...(dobDate && !isNaN(dobDate.getTime()) ? { dateOfBirth: dobDate } : {}),
|
||||
},
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
patient = matches?.[0] ?? null;
|
||||
if (!patient) {
|
||||
// The portal masks the subscriber ID, so create the patient from the typed name + DOB
|
||||
// (no insuranceId); the Member ID can be added later.
|
||||
patient = await storage.createPatient({
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: dobDate && !isNaN(dobDate.getTime()) ? dobDate : null,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
} as any);
|
||||
log("uniteducard-processor", "created patient from name search", { patientId: patient?.id });
|
||||
}
|
||||
}
|
||||
if (!patient?.id) {
|
||||
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||
return output;
|
||||
@@ -115,7 +148,7 @@ async function processUnitedUcardResult(
|
||||
typeof seleniumResult?.memberId === "string"
|
||||
? seleniumResult.memberId.replace(/\s+/g, "")
|
||||
: null;
|
||||
if (scrapedMemberId && scrapedMemberId !== insuranceId) {
|
||||
if (insuranceId && scrapedMemberId && scrapedMemberId !== insuranceId) {
|
||||
statusUpdates.insuranceId = scrapedMemberId;
|
||||
log("uniteducard-processor", "member ID changed, overwriting on patient record", {
|
||||
patientId: patient.id,
|
||||
|
||||
Reference in New Issue
Block a user