Files
Potiential-Automation/src/cms1500_text.py
T
VasanthandClaude Opus 5.5 902ff936cb Initial commit: Charge Billed Automation bot
Waystar Charge Billed Report bot. Reads CMS-1500 claim PDFs from the
text layer (src/cms1500_text.py) instead of Google Document AI.
Dependencies managed with uv (Python 3.12). Secrets, PHI and build
output are git-ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:12:27 +05:30

262 lines
9.8 KiB
Python

"""
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))