fix: MH eligibility selenium integration and patient data extraction

This commit is contained in:
ff
2026-04-14 22:10:19 -04:00
parent f415100cdb
commit 714029df24
12 changed files with 6677 additions and 98 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,50 +18,30 @@ const SELENIUM_BASE_URL =
// Python service helpers
// ---------------------------------------------------------------------------
/** Start an async job on the Python service and return the session ID. */
export async function startPythonJob(
/**
* Call a synchronous Python service endpoint and return its result.
* The Python service runs the selenium job inline and returns the result directly.
* Timeout is long (8 min) to accommodate slow selenium flows.
*/
export async function callPythonSync(
endpoint: string,
payload: any
): Promise<string> {
payload: any,
timeoutMs = 8 * 60 * 1000
): Promise<any> {
const resp = await axios.post(
`${SELENIUM_BASE_URL}${endpoint}`,
payload,
{ timeout: 10_000 }
{ timeout: timeoutMs }
);
const sid: string = resp.data?.session_id;
if (!sid) throw new Error(`Python service did not return a session_id from ${endpoint}`);
return sid;
}
/** Poll /job/<sid>/status until completed/failed or timeout. */
export async function pollPythonJob(
sid: string,
timeoutMs = 5 * 60 * 1000,
intervalMs = 2_000
): Promise<any> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await axios.get(
`${SELENIUM_BASE_URL}/job/${sid}/status`,
{ timeout: 5_000 }
);
const s = resp.data;
if (s.status === "completed") {
if (s.result?.status === "error") {
const msg =
typeof s.result.message === "string"
? s.result.message
: s.result.message?.msg ?? "Selenium returned error status";
throw new Error(msg);
}
return s.result;
}
if (s.status === "failed") {
throw new Error(s.error || "Python job failed");
}
await sleep(intervalMs);
const data = resp.data;
if (data?.status === "error") {
const msg =
typeof data.message === "string"
? data.message
: data.message?.msg ?? "Selenium returned error status";
throw new Error(msg);
}
throw new Error("Selenium job timed out after polling");
return data;
}
// ---------------------------------------------------------------------------

View File

@@ -8,8 +8,7 @@ import path from "path";
import { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import {
startPythonJob,
pollPythonJob,
callPythonSync,
imageToPdfBuffer,
} from "./_shared";
@@ -28,14 +27,11 @@ export async function runClaimStatusProcessor(
): Promise<ClaimStatusProcessorResult> {
const { enrichedPayload, insuranceId } = input;
// 1) Start async Python job
const sid = await startPythonJob("/claim-status-check/async", {
// 1) Call the Python service synchronously (BullMQ worker handles async)
const result = await callPythonSync("/claim-status-check", {
data: enrichedPayload,
});
// 2) Poll for completion
const result = await pollPythonJob(sid);
const outputResult: ClaimStatusProcessorResult = {};
// 3) Look up patient

View File

@@ -3,7 +3,7 @@
* Mirrors routes/claims.ts /selenium-claim and /selenium-claim-pre-auth
*/
import { storage } from "../../storage";
import { startPythonJob, pollPythonJob } from "./_shared";
import { callPythonSync } from "./_shared";
export interface ClaimSubmitProcessorInput {
enrichedPayload: any;
@@ -37,13 +37,10 @@ export async function runClaimSubmitProcessor(
const payload = { claim: enrichedPayload, pdfs, images };
const endpoint =
input.variant === "claim-pre-auth" ? "/claim-pre-auth/async" : "/claimsubmit/async";
input.variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
// 1) Start async Python job
const sid = await startPythonJob(endpoint, payload);
// 2) Poll for result
const result = await pollPythonJob(sid, 10 * 60 * 1000); // claim submit can take up to 10 min
// 1) Call the Python service synchronously (BullMQ worker handles async)
const result = await callPythonSync(endpoint, payload, 10 * 60 * 1000);
// 3) Persist claimNumber if returned
if (result?.claimNumber && claimId) {

View File

@@ -11,8 +11,7 @@ import { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import forwardToPatientDataExtractorService from "../../services/patientDataExtractorService";
import {
startPythonJob,
pollPythonJob,
callPythonSync,
splitName,
createOrUpdatePatientByInsuranceId,
} from "./_shared";
@@ -45,26 +44,29 @@ export async function runEligibilityProcessor(
formDob,
} = input;
// 1) Fire the async Python job
const sid = await startPythonJob("/eligibility-check/async", {
// 1) Call the Python service synchronously (BullMQ worker handles async)
const seleniumResult = await callPythonSync("/eligibility-check", {
data: enrichedPayload,
});
// 2) Wait for completion
const seleniumResult = await pollPythonJob(sid);
const outputResult: EligibilityProcessorResult = {};
// 3) Extract name: prefer selenium extraction → PDF extractor → form input
// Normalize: treat empty strings from Python the same as null
const seleniumFirst = seleniumResult.firstName?.trim() || null;
const seleniumLast = seleniumResult.lastName?.trim() || null;
const seleniumName = seleniumResult.name?.trim() || null;
const seleniumInsurance = seleniumResult.insurance?.trim() || null;
const extracted: { firstName?: string | null; lastName?: string | null } = {};
if (seleniumResult.firstName || seleniumResult.lastName) {
extracted.firstName = seleniumResult.firstName ?? null;
extracted.lastName = seleniumResult.lastName ?? null;
} else if (seleniumResult.name) {
const parts = splitName(seleniumResult.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
if (seleniumFirst || seleniumLast) {
extracted.firstName = seleniumFirst;
extracted.lastName = seleniumLast;
} else if (seleniumName) {
const parts = splitName(seleniumName);
extracted.firstName = parts.firstName || null;
extracted.lastName = parts.lastName || null;
}
if (
@@ -81,14 +83,15 @@ export async function runEligibilityProcessor(
} as any);
if (extraction.name) {
const parts = splitName(extraction.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
extracted.firstName = parts.firstName || null;
extracted.lastName = parts.lastName || null;
}
} catch (e) {
console.error("[eligibilityProcessor] PDF name extraction failed:", e);
}
}
// Final fallback: use form data sent by the frontend
const preferFirst = extracted.firstName || formFirstName || null;
const preferLast = extracted.lastName || formLastName || null;
@@ -113,7 +116,7 @@ export async function runEligibilityProcessor(
else if (seleniumResult.eligibility === "N") newStatus = "INACTIVE";
const updates: any = { status: newStatus };
if (seleniumResult.insurance) updates.insuranceProvider = seleniumResult.insurance;
if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance;
await storage.updatePatient(patient.id, updates);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;

View File

@@ -22,6 +22,7 @@ import {
} from "@/redux/slices/seleniumTaskSlice";
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { InsertPatient, Patient } from "@repo/db/types";
import { DateInput } from "@/components/ui/dateInput";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
@@ -117,6 +118,8 @@ export default function InsuranceStatusPage() {
memberId,
dateOfBirth: formattedDob,
insuranceSiteKey: "MH",
firstName: firstName || undefined,
lastName: lastName || undefined,
};
try {
dispatch(
@@ -129,11 +132,44 @@ export default function InsuranceStatusPage() {
const response = await apiRequest(
"POST",
"/api/insurance-status/eligibility-check",
{ data: data },
{ data: data, socketId: socket.id },
);
// { data: JSON.stringify(data) },
const result = await response.json();
if (result.error) throw new Error(result.error);
const enqueueResult = await response.json();
if (enqueueResult.error) throw new Error(enqueueResult.error);
const jobId = enqueueResult.jobId;
if (!jobId) throw new Error("No jobId returned from server");
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Selenium browser starting...",
}),
);
// Wait for the BullMQ worker to emit job:update on this socket
const jobResult = await new Promise<any>((resolve, reject) => {
const handler = (payload: any) => {
if (String(payload.jobId) !== String(jobId)) return;
if (payload.status === "active") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: payload.message ?? "Selenium running...",
}),
);
} else if (payload.status === "completed") {
socket.off("job:update", handler);
resolve(payload.result ?? {});
} else if (payload.status === "failed") {
socket.off("job:update", handler);
reject(new Error(payload.error ?? "Selenium job failed"));
}
};
socket.on("job:update", handler);
});
dispatch(
setTaskStatus({
@@ -153,12 +189,11 @@ export default function InsuranceStatusPage() {
setSelectedPatient(null);
// If server returned pdfFileId: open preview modal
if (result.pdfFileId) {
setPreviewPdfId(Number(result.pdfFileId));
// optional fallback name while header is parsed
// If worker returned pdfFileId: open preview modal
if (jobResult.pdfFileId) {
setPreviewPdfId(Number(jobResult.pdfFileId));
setPreviewFallbackFilename(
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
);
setPreviewOpen(true);
}
@@ -198,10 +233,44 @@ export default function InsuranceStatusPage() {
const response = await apiRequest(
"POST",
"/api/insurance-status/claim-status-check",
{ data: JSON.stringify(data) },
{ data: JSON.stringify(data), socketId: socket.id },
);
const result = await response.json();
if (result.error) throw new Error(result.error);
const enqueueResult = await response.json();
if (enqueueResult.error) throw new Error(enqueueResult.error);
const jobId = enqueueResult.jobId;
if (!jobId) throw new Error("No jobId returned from server");
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Selenium browser starting...",
}),
);
// Wait for the BullMQ worker to emit job:update on this socket
const jobResult = await new Promise<any>((resolve, reject) => {
const handler = (payload: any) => {
if (String(payload.jobId) !== String(jobId)) return;
if (payload.status === "active") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: payload.message ?? "Selenium running...",
}),
);
} else if (payload.status === "completed") {
socket.off("job:update", handler);
resolve(payload.result ?? {});
} else if (payload.status === "failed") {
socket.off("job:update", handler);
reject(new Error(payload.error ?? "Selenium job failed"));
}
};
socket.on("job:update", handler);
});
dispatch(
setTaskStatus({
@@ -221,12 +290,11 @@ export default function InsuranceStatusPage() {
setSelectedPatient(null);
// If server returned pdfFileId: open preview modal
if (result.pdfFileId) {
setPreviewPdfId(Number(result.pdfFileId));
// optional fallback name while header is parsed
// If worker returned pdfFileId: open preview modal
if (jobResult.pdfFileId) {
setPreviewPdfId(Number(jobResult.pdfFileId));
setPreviewFallbackFilename(
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
);
setPreviewOpen(true);
}

View File

@@ -169,6 +169,23 @@ export const PROCEDURE_COMBOS: Record<
codes: ["D2394"],
},
// Pedo
pedoSealants: {
id: "pedoSealants",
label: "Sealants",
codes: ["D1351"],
},
pedoPulpotomy: {
id: "pedoPulpotomy",
label: "Pulpotomy",
codes: ["D3220"],
},
pedoSSCrown: {
id: "pedoSSCrown",
label: "Stainless Steel Crown",
codes: ["D2930"],
},
// Dentures / Partials
fu: {
id: "fu",
@@ -303,6 +320,7 @@ export const COMBO_CATEGORIES: Record<
"threeSurfCompBack",
"fourSurfCompBack",
],
Pedo: ["pedoSealants", "pedoPulpotomy", "pedoSSCrown"],
"Dentures / Partials (>21 price)": [
"fu",
"fl",

View File

@@ -162,6 +162,20 @@ class AutomationMassHealthEligibilityCheck:
print(f"Error while step1: {e}")
return "ERROR:STEP1"
def _cell_text(self, cell):
"""Get text from a cell, falling back to JS innerText if .text is empty."""
text = cell.text.strip()
if not text:
try:
text = (self.driver.execute_script("return arguments[0].innerText;", cell) or "").strip()
except Exception:
pass
return text
def _normalize_id(self, s):
"""Strip all non-alphanumeric characters and lowercase for robust ID matching."""
return ''.join(c for c in str(s) if c.isalnum()).lower()
def _extract_data_from_page(self):
wait = WebDriverWait(self.driver, 5)
extracted = {}
@@ -183,19 +197,32 @@ class AutomationMassHealthEligibilityCheck:
for row in eligible_rows:
cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) < 6:
if len(cells) < 3:
continue
member_number = cells[2].text.strip()
member_number = self._cell_text(cells[2])
norm_cell = self._normalize_id(member_number)
norm_self = self._normalize_id(self.memberId)
print(f"[eligible] cells count={len(cells)}, memberId check: '{norm_self}' vs '{norm_cell}' (raw: '{member_number}')")
if len(cells) >= 5:
print(f" cells[3]='{self._cell_text(cells[3])}' cells[4]='{self._cell_text(cells[4])}'", end="")
if len(cells) > 5:
print(f" cells[5]='{self._cell_text(cells[5])}'", end="")
if len(cells) > 6:
print(f" cells[6]='{self._cell_text(cells[6])}'", end="")
print()
if str(self.memberId) in member_number:
full_name = cells[4].text.strip()
plan_name = cells[6].text.strip() if len(cells) > 6 else cells[-1].text.strip()
if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
# name is in cells[4], insurance in cells[6] (fallback to last cell)
full_name = self._cell_text(cells[4]) if len(cells) > 4 else ""
plan_name = self._cell_text(cells[6]) if len(cells) > 6 else (self._cell_text(cells[-1]) if len(cells) > 4 else "")
name_parts = full_name.split()
first_name = name_parts[0] if name_parts else ""
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
print(f"[eligible] MATCHED → name='{full_name}' plan='{plan_name}'")
extracted = {
"eligibility": "Y",
"firstName": first_name,
@@ -214,19 +241,29 @@ class AutomationMassHealthEligibilityCheck:
for row in ineligible_rows:
cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) < 5:
if len(cells) < 3:
continue
member_number = cells[2].text.strip()
member_number = self._cell_text(cells[2])
norm_cell = self._normalize_id(member_number)
norm_self = self._normalize_id(self.memberId)
print(f"[ineligible] cells count={len(cells)}, memberId check: '{norm_self}' vs '{norm_cell}' (raw: '{member_number}')")
if len(cells) >= 5:
print(f" cells[3]='{self._cell_text(cells[3])}' cells[4]='{self._cell_text(cells[4])}'", end="")
if len(cells) > 5:
print(f" cells[5]='{self._cell_text(cells[5])}'", end="")
print()
if str(self.memberId) in member_number:
full_name = cells[4].text.strip()
plan_name = cells[5].text.strip()
if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
full_name = self._cell_text(cells[4]) if len(cells) > 4 else ""
plan_name = self._cell_text(cells[5]) if len(cells) > 5 else (self._cell_text(cells[-1]) if len(cells) > 4 else "")
name_parts = full_name.split()
first_name = name_parts[0] if name_parts else ""
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
print(f"[ineligible] MATCHED → name='{full_name}' plan='{plan_name}'")
extracted = {
"eligibility": "N",
"firstName": first_name,
@@ -236,6 +273,7 @@ class AutomationMassHealthEligibilityCheck:
return extracted
print(f"[extraction] No matching row found for memberId='{self.memberId}'")
return {"eligibility": None}
except Exception as e: