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:
2026-09-20 22:30:01 -04:00
co-authored by Claude Sonnet 5
parent ce25cdd2a2
commit 7ec82902d9
7 changed files with 366 additions and 64 deletions
@@ -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,