""" CMS-1500 (02/12) reader that uses the PDF's own text layer. Waystar's ViewClaimPDF returns digital PDFs: every value is real text at a fixed spot on the form. Reading those words by position is exact and runs offline, so no OCR and no PHI leaves the machine. All coordinates are normalized to 0-1 (x from the left, y from the top) and taken from Waystar's CMS1500_0212 rendering. """ import re from decimal import Decimal from dataclasses import dataclass @dataclass(frozen=True) class Word: text: str x: float # horizontal mid-point, 0-1 y: float # vertical mid-point, 0-1 # ================= BOX 21 — DIAGNOSIS CODES ================= _DX_SLOT_LABEL = re.compile(r"^([A-L])\.$") _ICD10 = re.compile(r"^[A-Z]\d[0-9A-Z]{1,5}$") _DX_Y_MIN, _DX_Y_MAX = 0.590, 0.640 _DX_X_MAX = 0.615 # box 22/23 starts to the right _DX_ROW_TOL = 0.010 # codes print a little above their slot label def parse_diagnosis_codes(words): """Return [(slot_letter, code), ...] ordered A..L.""" in_box = [w for w in words if _DX_Y_MIN <= w.y <= _DX_Y_MAX and w.x <= _DX_X_MAX] labels = [(m.group(1), w) for w in in_box if (m := _DX_SLOT_LABEL.match(w.text))] found = {} for w in in_box: if not _ICD10.match(w.text): continue same_row = [(s, lw) for s, lw in labels if abs(lw.y - w.y) <= _DX_ROW_TOL and lw.x <= w.x] if not same_row: continue slot, _ = max(same_row, key=lambda sl: sl[1].x) # nearest label to the left found.setdefault(slot, w.text) return sorted(found.items()) # ================= BOX 24 — SERVICE LINES ================= @dataclass(frozen=True) class ServiceLine: line_no: int dos_from: str dos_to: str pos: str cpt: str modifiers: tuple dx_pointer: str charge: object # Decimal, or None when not printed units: str npi: str _CPT = re.compile(r"^(\d{5}|[A-V]\d{4}|\d{4}[TF])$") _LINES_Y_MIN, _LINES_Y_MAX = 0.665, 0.848 # below the box-24 header, above box 25 _LINE_ROW_TOL = 0.006 # shaded supplemental row sits ~0.009 above # Column x-ranges (on the word's mid-point), left to right. _COLS = { "from_mm": (0.035, 0.066), "from_dd": (0.066, 0.101), "from_yy": (0.101, 0.137), "to_mm": (0.137, 0.171), "to_dd": (0.171, 0.205), "to_yy": (0.205, 0.240), "pos": (0.240, 0.280), "cpt": (0.315, 0.405), "mods": (0.405, 0.545), "pointer": (0.545, 0.615), "charge": (0.615, 0.712), "units": (0.712, 0.760), "npi": (0.825, 0.960), } def _in_col(row, col): lo, hi = _COLS[col] return [w.text for w in sorted(row, key=lambda w: w.x) if lo <= w.x < hi] def _date(mm, dd, yy): if not (mm and dd and yy): return "" year = yy if len(yy) == 4 else f"20{yy}" return f"{mm.zfill(2)}/{dd.zfill(2)}/{year}" def parse_charge(text): """'9000' -> 90.00 (Waystar prints dollars and cents without a point).""" text = text.replace("$", "").replace(",", "").strip() if not re.fullmatch(r"\d+(\.\d{1,2})?", text): return None return Decimal(text).quantize(Decimal("0.01")) if "." in text else Decimal(text) / 100 def parse_service_lines(words): """Return ServiceLine for each box-24 row that has a CPT/HCPCS code, top to bottom.""" section = [w for w in words if _LINES_Y_MIN <= w.y <= _LINES_Y_MAX] lo, hi = _COLS["cpt"] anchors = sorted((w for w in section if lo <= w.x < hi and _CPT.match(w.text)), key=lambda w: w.y) lines = [] for anchor in anchors: row = [w for w in section if abs(w.y - anchor.y) <= _LINE_ROW_TOL] first = lambda col: (_in_col(row, col) or [""])[0] charge_text = "".join(_in_col(row, "charge")) lines.append(ServiceLine( line_no=len(lines) + 1, dos_from=_date(first("from_mm"), first("from_dd"), first("from_yy")), dos_to=_date(first("to_mm"), first("to_dd"), first("to_yy")), pos=first("pos"), cpt=anchor.text, modifiers=tuple(_in_col(row, "mods")), dx_pointer="".join(_in_col(row, "pointer")), charge=parse_charge(charge_text) if charge_text else None, units="".join(_in_col(row, "units")), npi=first("npi"), )) return lines # ================= HEADER BOXES (1a, 3, 26, 28) ================= _BOX = { # (x_min, x_max, y_min, y_max) "insured_id": (0.600, 0.990, 0.135, 0.155), "dob": (0.380, 0.500, 0.166, 0.182), "patient_account": (0.300, 0.470, 0.862, 0.876), "total_charge": (0.615, 0.745, 0.862, 0.876), } def _box_words(words, box): x0, x1, y0, y1 = _BOX[box] return [w.text for w in sorted(words, key=lambda w: (round(w.y, 3), w.x)) if x0 <= w.x <= x1 and y0 <= w.y <= y1] def parse_header(words): dob = [t for t in _box_words(words, "dob") if t.isdigit()] total = "".join(t for t in _box_words(words, "total_charge") if t != "$") return { "insured_id": "".join(_box_words(words, "insured_id")).upper(), "dob": _date(*dob) if len(dob) == 3 else "", "patient_account": " ".join(_box_words(words, "patient_account")), "total_charge": parse_charge(total) if total else None, } # ================= WHOLE CLAIM ================= @dataclass class Claim: insured_id: str dob: str patient_account: str total_charge: object # Decimal or None dx: list # [(slot_letter, code), ...] lines: list # [ServiceLine, ...] issues: list # human-readable review notes; empty = clean def build_claim(pages): """Build one Claim from the word lists of every page of a claim PDF.""" if not any(pages): return Claim("", "", "", None, [], [], ["No text layer on PDF (scanned?) - check by hand"]) header = parse_header(pages[0]) lines, totals = [], [] for words in pages: for ln in parse_service_lines(words): lines.append(ServiceLine(**{**ln.__dict__, "line_no": len(lines) + 1})) totals.append(parse_header(words)["total_charge"]) total = None if None in totals else sum(totals, Decimal("0.00")) claim = Claim(header["insured_id"], header["dob"], header["patient_account"], total, parse_diagnosis_codes(pages[0]), lines, []) claim.issues = _review(claim) return claim def _review(claim): issues = [] if not claim.insured_id: issues.append("No Insured ID (box 1a)") if not claim.dob: issues.append("No Date of Birth (box 3)") if not claim.dx: issues.append("No diagnosis codes (box 21)") if not claim.lines: issues.append("No service lines found") slots = {s for s, _ in claim.dx} for ln in claim.lines: for label, value in (("Date of Service", ln.dos_from), ("POS", ln.pos), ("Units", ln.units), ("Charge", ln.charge), ("Rendering NPI", ln.npi)): if not value: issues.append(f"Line {ln.line_no}: no {label}") for letter in ln.dx_pointer: if letter not in slots: issues.append(f"Line {ln.line_no}: Dx pointer {letter} has no diagnosis code") line_sum = sum((ln.charge or Decimal("0.00") for ln in claim.lines), Decimal("0.00")) if claim.total_charge is None: issues.append("No total charge (box 28)") elif line_sum != claim.total_charge: issues.append(f"Line charges {line_sum:.2f} != box 28 total {claim.total_charge:.2f}") return issues # ================= REPORT ROW ================= def _per_line(values, label): if len(values) == 1: return values[0] return "; ".join(f"{label} {i}: {v}" for i, v in enumerate(values, 1)) def to_report_row(claim, claim_id="", insurance="", patient_name="", signature_physician="", fallback_account="", fallback_charges=""): """One report row per claim: the bot's original 15 columns + Line Charges + Review Notes.""" lines = claim.lines money = lambda d: f"{d:.2f}" if d is not None else "" dx_codes = [code for _, code in claim.dx] return { "Claim ID": claim_id, "Rendering Provider #": _per_line([l.npi for l in lines], "RP #"), "Signature of Physician": signature_physician, "Patient's Account No": claim.patient_account or fallback_account, "Date of Birth": claim.dob, "Insurance Name": insurance, "Insured ID Number": claim.insured_id, "Patient's Name": patient_name, "Date of Service": _per_line([l.dos_from for l in lines], "DOS"), "Diagnosis Codes": f"{', '.join(dx_codes)} (Total: {len(dx_codes)})" if dx_codes else "", "CPT": _per_line([l.cpt for l in lines], "CPT"), "POS": _per_line([l.pos for l in lines], "POS"), "Modifier": _per_line([" ".join(l.modifiers) for l in lines], "Modifier"), "Units": _per_line([l.units for l in lines], "Units"), "Charges": money(claim.total_charge) or fallback_charges, "Line Charges": _per_line([money(l.charge) for l in lines], "Charge"), "Review Notes": "; ".join(claim.issues), } # ================= PDF ENTRY POINT ================= def read_pdf_pages(pdf_path): """Word lists (normalized positions) for every page of the PDF.""" import pdfplumber pages = [] with pdfplumber.open(str(pdf_path)) as pdf: for page in pdf.pages: width, height = float(page.width), float(page.height) pages.append([ Word(w["text"], (w["x0"] + w["x1"]) / 2 / width, (w["top"] + w["bottom"]) / 2 / height) for w in page.extract_words() ]) return pages def extract_claim(pdf_path): return build_claim(read_pdf_pages(pdf_path))