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 // 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, endpoint: string,
payload: any payload: any,
): Promise<string> { timeoutMs = 8 * 60 * 1000
): Promise<any> {
const resp = await axios.post( const resp = await axios.post(
`${SELENIUM_BASE_URL}${endpoint}`, `${SELENIUM_BASE_URL}${endpoint}`,
payload, payload,
{ timeout: 10_000 } { timeout: timeoutMs }
); );
const sid: string = resp.data?.session_id; const data = resp.data;
if (!sid) throw new Error(`Python service did not return a session_id from ${endpoint}`); if (data?.status === "error") {
return sid; const msg =
} typeof data.message === "string"
? data.message
/** Poll /job/<sid>/status until completed/failed or timeout. */ : data.message?.msg ?? "Selenium returned error status";
export async function pollPythonJob( throw new Error(msg);
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);
} }
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 { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import { import {
startPythonJob, callPythonSync,
pollPythonJob,
imageToPdfBuffer, imageToPdfBuffer,
} from "./_shared"; } from "./_shared";
@@ -28,14 +27,11 @@ export async function runClaimStatusProcessor(
): Promise<ClaimStatusProcessorResult> { ): Promise<ClaimStatusProcessorResult> {
const { enrichedPayload, insuranceId } = input; const { enrichedPayload, insuranceId } = input;
// 1) Start async Python job // 1) Call the Python service synchronously (BullMQ worker handles async)
const sid = await startPythonJob("/claim-status-check/async", { const result = await callPythonSync("/claim-status-check", {
data: enrichedPayload, data: enrichedPayload,
}); });
// 2) Poll for completion
const result = await pollPythonJob(sid);
const outputResult: ClaimStatusProcessorResult = {}; const outputResult: ClaimStatusProcessorResult = {};
// 3) Look up patient // 3) Look up patient

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import {
} from "@/redux/slices/seleniumTaskSlice"; } from "@/redux/slices/seleniumTaskSlice";
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner"; import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { InsertPatient, Patient } from "@repo/db/types"; import { InsertPatient, Patient } from "@repo/db/types";
import { DateInput } from "@/components/ui/dateInput"; import { DateInput } from "@/components/ui/dateInput";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
@@ -117,6 +118,8 @@ export default function InsuranceStatusPage() {
memberId, memberId,
dateOfBirth: formattedDob, dateOfBirth: formattedDob,
insuranceSiteKey: "MH", insuranceSiteKey: "MH",
firstName: firstName || undefined,
lastName: lastName || undefined,
}; };
try { try {
dispatch( dispatch(
@@ -129,11 +132,44 @@ export default function InsuranceStatusPage() {
const response = await apiRequest( const response = await apiRequest(
"POST", "POST",
"/api/insurance-status/eligibility-check", "/api/insurance-status/eligibility-check",
{ data: data }, { data: data, socketId: socket.id },
); );
// { data: JSON.stringify(data) }, const enqueueResult = await response.json();
const result = await response.json(); if (enqueueResult.error) throw new Error(enqueueResult.error);
if (result.error) throw new Error(result.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( dispatch(
setTaskStatus({ setTaskStatus({
@@ -153,12 +189,11 @@ export default function InsuranceStatusPage() {
setSelectedPatient(null); setSelectedPatient(null);
// If server returned pdfFileId: open preview modal // If worker returned pdfFileId: open preview modal
if (result.pdfFileId) { if (jobResult.pdfFileId) {
setPreviewPdfId(Number(result.pdfFileId)); setPreviewPdfId(Number(jobResult.pdfFileId));
// optional fallback name while header is parsed
setPreviewFallbackFilename( setPreviewFallbackFilename(
result.pdfFilename ?? `eligibility_${memberId}.pdf`, jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
); );
setPreviewOpen(true); setPreviewOpen(true);
} }
@@ -198,10 +233,44 @@ export default function InsuranceStatusPage() {
const response = await apiRequest( const response = await apiRequest(
"POST", "POST",
"/api/insurance-status/claim-status-check", "/api/insurance-status/claim-status-check",
{ data: JSON.stringify(data) }, { data: JSON.stringify(data), socketId: socket.id },
); );
const result = await response.json(); const enqueueResult = await response.json();
if (result.error) throw new Error(result.error); 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( dispatch(
setTaskStatus({ setTaskStatus({
@@ -221,12 +290,11 @@ export default function InsuranceStatusPage() {
setSelectedPatient(null); setSelectedPatient(null);
// If server returned pdfFileId: open preview modal // If worker returned pdfFileId: open preview modal
if (result.pdfFileId) { if (jobResult.pdfFileId) {
setPreviewPdfId(Number(result.pdfFileId)); setPreviewPdfId(Number(jobResult.pdfFileId));
// optional fallback name while header is parsed
setPreviewFallbackFilename( setPreviewFallbackFilename(
result.pdfFilename ?? `eligibility_${memberId}.pdf`, jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
); );
setPreviewOpen(true); setPreviewOpen(true);
} }

View File

@@ -169,6 +169,23 @@ export const PROCEDURE_COMBOS: Record<
codes: ["D2394"], 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 // Dentures / Partials
fu: { fu: {
id: "fu", id: "fu",
@@ -303,6 +320,7 @@ export const COMBO_CATEGORIES: Record<
"threeSurfCompBack", "threeSurfCompBack",
"fourSurfCompBack", "fourSurfCompBack",
], ],
Pedo: ["pedoSealants", "pedoPulpotomy", "pedoSSCrown"],
"Dentures / Partials (>21 price)": [ "Dentures / Partials (>21 price)": [
"fu", "fu",
"fl", "fl",

View File

@@ -162,6 +162,20 @@ class AutomationMassHealthEligibilityCheck:
print(f"Error while step1: {e}") print(f"Error while step1: {e}")
return "ERROR:STEP1" 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): def _extract_data_from_page(self):
wait = WebDriverWait(self.driver, 5) wait = WebDriverWait(self.driver, 5)
extracted = {} extracted = {}
@@ -183,19 +197,32 @@ class AutomationMassHealthEligibilityCheck:
for row in eligible_rows: for row in eligible_rows:
cells = row.find_elements(By.TAG_NAME, "td") cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) < 6: if len(cells) < 3:
continue 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: if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
full_name = cells[4].text.strip() # name is in cells[4], insurance in cells[6] (fallback to last cell)
plan_name = cells[6].text.strip() if len(cells) > 6 else cells[-1].text.strip() 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() name_parts = full_name.split()
first_name = name_parts[0] if name_parts else "" first_name = name_parts[0] if name_parts else ""
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else "" last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
print(f"[eligible] MATCHED → name='{full_name}' plan='{plan_name}'")
extracted = { extracted = {
"eligibility": "Y", "eligibility": "Y",
"firstName": first_name, "firstName": first_name,
@@ -204,7 +231,7 @@ class AutomationMassHealthEligibilityCheck:
} }
return extracted return extracted
ineligible_rows = self.driver.find_elements( ineligible_rows = self.driver.find_elements(
By.XPATH, By.XPATH,
"//h4[text()='Ineligible']/following::table[1]/tbody/tr" "//h4[text()='Ineligible']/following::table[1]/tbody/tr"
@@ -214,19 +241,29 @@ class AutomationMassHealthEligibilityCheck:
for row in ineligible_rows: for row in ineligible_rows:
cells = row.find_elements(By.TAG_NAME, "td") cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) < 5: if len(cells) < 3:
continue 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: if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
full_name = cells[4].text.strip() full_name = self._cell_text(cells[4]) if len(cells) > 4 else ""
plan_name = cells[5].text.strip() 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() name_parts = full_name.split()
first_name = name_parts[0] if name_parts else "" first_name = name_parts[0] if name_parts else ""
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else "" last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
print(f"[ineligible] MATCHED → name='{full_name}' plan='{plan_name}'")
extracted = { extracted = {
"eligibility": "N", "eligibility": "N",
"firstName": first_name, "firstName": first_name,
@@ -235,7 +272,8 @@ class AutomationMassHealthEligibilityCheck:
} }
return extracted return extracted
print(f"[extraction] No matching row found for memberId='{self.memberId}'")
return {"eligibility": None} return {"eligibility": None}
except Exception as e: except Exception as e: