Initial import: eCW Charge Validation bot and results Portal

Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one
commit with no earlier history: the tracked files were de-identified
first (synthetic claim ids and patient data only; see Context.md
section 7). Credentials, run logs, screenshots, claim reports and client
workbooks are gitignored and not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-23 18:38:17 +05:30
commit ba15a574ab
205 files changed
+56772

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Portal-agnostic plumbing: browser, page helpers, logging, prompts, guard."""
+351
View File
@@ -0,0 +1,351 @@
"""
Browser launch.
One line in here is the whole reason this bot can work at all:
kwargs["proxy"] = proxy # chromium.launch(proxy=…)
The proxy is set **per browser**, not on Windows. That dissolves a deadlock
that makes logging into this account by hand impossible. Measured 2026-09-01:
| target | direct | through the proxy |
|----------------------------|-----------|-------------------|
| eCW portal | HTTP 400 | HTTP 200 |
| emailed approval link | HTTP 400 | HTTP 200 |
| webmail site (cPanel :2096)| HTTP 200 | 403, proxy refuses|
| mailbox over IMAP :993 | connects | not proxied at all|
The portal needs the proxy; the webmail site is blocked *by* it. A human would
have to toggle the Windows proxy mid-login. The bot never opens the webmail
site: this browser gets the proxy, and `auth/mailbox.py` reads the mail with
`imaplib` over a raw socket that ignores WinINET and HTTP(S)_PROXY entirely.
So the Windows system proxy must stay **off**
(`HKCU:\\...\\Internet Settings\\ProxyEnable = 0`). `tools\\check_net.py`
asserts that.
With `BROWSER_CDP_URL` set the bot does not launch Playwright's browser: it
attaches to Google Chrome on that debug port (profile on D:, 127.0.0.1
only), so the run can be watched with the Claude extension in that Chrome.
No Chrome there when the run starts → the bot opens it (`start_chrome`,
operator 2026-10-22: Start on the Portal is all it should take), and closes
it again at the end (`CDP_CLOSE_AFTER_RUN`) — never a Chrome it did not
open. The bot opens its own tab, clears eCW's cookies and storage when it
attaches and again when it is done, and closes only its own tabs.
"""
import os
import sys
import time
import subprocess
import urllib.request
from pathlib import Path
from urllib.parse import urlsplit
from .. import settings as cfg
from .page_utils import auto_dialog
# Chrome pauses a window it thinks nobody can see. The bot's review and
# per-claim boxes are Windows message boxes in front of it, and while one is
# up Chrome stopped the claim window's fade-out half-way (live run
# 2026-10-13: an HCFA window left at opacity 0.222, pointer-events none, in
# the DOM after its Cancel) — the next claim found that "form still open",
# and four Cancel clicks on it timed out (~45 s). These keep the page
# running behind a box.
_KEEP_RUNNING_BEHIND_A_BOX = [
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
]
CHROME_ARGS = ["--disable-blink-features=AutomationControlled",
"--start-maximized", *_KEEP_RUNNING_BEHIND_A_BOX]
def launch(pw, incognito: bool = False):
"""Launch a browser + context wired up with the dialog handler.
With BROWSER_CDP_URL set, attach to that Chrome instead. The incognito
retry after a failed captcha still launches a fresh Chromium of its own:
a clean, cookie-less browser is the point of that retry.
"""
if cfg.BROWSER_CDP_URL and not incognito:
return _attach(pw, cfg.BROWSER_CDP_URL)
args = list(CHROME_ARGS)
if incognito:
args.append("--incognito")
kwargs = dict(headless=cfg.HEADLESS, args=args)
proxy = cfg.proxy_settings()
if proxy:
kwargs["proxy"] = proxy
print(f"[BROWSER] Proxy {proxy['server']}")
if cfg.CHROME_PATH:
kwargs["executable_path"] = cfg.CHROME_PATH
print(f"[BROWSER] Chrome {cfg.CHROME_PATH}")
try:
cfg.DOWNLOADS_TMP.mkdir(parents=True, exist_ok=True)
kwargs["downloads_path"] = str(cfg.DOWNLOADS_TMP)
except Exception:
pass
browser = pw.chromium.launch(**kwargs)
context = browser.new_context(
no_viewport=True, # use the real maximized window
ignore_https_errors=True,
accept_downloads=True,
)
context.on("page", lambda p: p.on("dialog", auto_dialog))
page = context.new_page()
page.on("dialog", auto_dialog)
return browser, context, page
# ── Attached Chrome (BROWSER_CDP_URL) ─────────────────────────────────────────
# The extension signs in and talks to these; the eCW proxy has no business
# with them (the Windows proxy stays off, so direct works).
PROXY_BYPASS = "claude.ai;*.claude.ai;*.anthropic.com"
# Debug-port URLs of the Chromes this process opened — the only ones it
# closes when it is done.
_STARTED = set()
# Started through a Python that exits at once: Chrome's parent is then
# gone, so a Stop on the Portal (taskkill /T on the bot) never takes Chrome
# with it.
_SPAWN = ("import subprocess, sys; subprocess.Popen(sys.argv[1:], "
"creationflags=0x00000008 | 0x00000200, close_fds=True, "
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, "
"stderr=subprocess.DEVNULL)")
def find_chrome():
"""Google Chrome's chrome.exe: CHROME_PATH if it is set, else the usual
per-user and per-machine install folders."""
candidates = [cfg.CHROME_PATH] if cfg.CHROME_PATH else []
for base in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
if os.environ.get(base):
candidates.append(os.path.join(os.environ[base], "Google", "Chrome",
"Application", "chrome.exe"))
for path in candidates:
if path and Path(path).is_file():
return path
return None
def chrome_command(exe: str, port: int, profile: str, proxy) -> list:
cmd = [exe, f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check", *CHROME_ARGS]
if proxy:
cmd += [f"--proxy-server={proxy['server']}",
f"--proxy-bypass-list={PROXY_BYPASS}"]
return cmd
def _cmdline_on_port(port: int):
"""The command line of the process listening on `port`, or None."""
ps = (f"$c = Get-NetTCPConnection -LocalPort {int(port)} -State Listen "
f"-ErrorAction SilentlyContinue | Select-Object -First 1; "
f"if ($c) {{ (Get-CimInstance Win32_Process -Filter "
f"\"ProcessId=$($c.OwningProcess)\").CommandLine }}")
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True,
timeout=30).stdout.strip()
except Exception:
return None
return out or None
def chrome_proxy_problem(url: str):
"""Why the Chrome on `url` must not be used for the eCW login, or None.
eCW answers only through the proxy. A Chrome on the port without it
sends the login straight to eCW, which refuses it as "restricted" — and
the guard then locks the bot out (2026-10-22: a test opened such a
Chrome, and a Portal run attached to it). Checked on the Chrome's own
command line; when that cannot be read, the run goes on."""
proxy = cfg.proxy_settings()
if not proxy:
return None
cmdline = _cmdline_on_port(urlsplit(url).port)
if cmdline is None:
print(f"[BROWSER] Could not read the command line of the Chrome at "
f"{url} — its proxy is not checked")
return None
if f"--proxy-server={proxy['server']}" in cmdline:
return None
return (f"The Chrome at {url} was started without the eCW proxy "
f"({proxy['server']}) — eCW would refuse the login. Close that "
f"Chrome; the run opens its own")
def start_chrome(url: str, extra_args=()) -> bool:
"""Open Google Chrome on `url`'s debug port with CDP_PROFILE_DIR and the
eCW proxy, outside this process's tree. True once it answers."""
if cdp_ready(url):
return True
exe = find_chrome()
if not exe:
print("[BROWSER] Google Chrome not found — set CHROME_PATH in .env")
return False
Path(cfg.CDP_PROFILE_DIR).mkdir(parents=True, exist_ok=True)
cmd = chrome_command(exe, urlsplit(url).port, cfg.CDP_PROFILE_DIR,
cfg.proxy_settings()) + list(extra_args)
subprocess.Popen([sys.executable, "-c", _SPAWN, *cmd],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
for _ in range(60):
if cdp_ready(url):
_STARTED.add(url)
print(f"[BROWSER] Opened Chrome at {url} "
f"(profile {cfg.CDP_PROFILE_DIR})")
return True
time.sleep(0.5)
print(f"[BROWSER] Started {exe}, but nothing answers at {url} — a "
f"Chrome already open with that profile and no debug port? "
f"Close it and start again")
return False
def cdp_ready(url: str) -> bool:
"""True if a Chrome answers on its debug port at `url`."""
try:
with urllib.request.urlopen(url.rstrip("/") + "/json/version",
timeout=3) as resp:
return resp.status == 200
except Exception:
return False
def ecw_sites(urls) -> set:
"""eCW's registrable domains ("ecwcloud.com"), from its configured URLs.
eCW sets cookies on the parent domain as well as on the app host, so the
clean-up goes by the domain, not the host.
"""
sites = set()
for url in urls:
host = urlsplit(url).hostname if url else None
if host:
sites.add(".".join(host.split(".")[-2:]))
return sites
def is_ecw_host(host: str, sites) -> bool:
host = host.lstrip(".").lower()
return any(host == s or host.endswith("." + s) for s in sites)
def clear_ecw_data(context, page) -> None:
"""Remove eCW's cookies and site storage from `context`, nothing else.
Only eCW's: the same profile holds claude.ai's sign-in, which is what
keeps the extension connected between runs.
"""
sites = ecw_sites([cfg.ECW_URL, cfg.ECW_LANDING_URL])
if not sites:
return
hosts = {urlsplit(u).hostname for u in (cfg.ECW_URL, cfg.ECW_LANDING_URL) if u}
for p in context.pages:
host = urlsplit(p.url).hostname
if host and is_ecw_host(host, sites):
hosts.add(host)
for c in context.cookies():
if is_ecw_host(c["domain"], sites):
hosts.add(c["domain"].lstrip("."))
context.clear_cookies(name=c["name"], domain=c["domain"],
path=c["path"])
# localStorage, IndexedDB, cache, service workers — per origin.
cdp = context.new_cdp_session(page)
try:
for host in sorted(h for h in hosts if h):
cdp.send("Storage.clearDataForOrigin",
{"origin": f"https://{host}", "storageTypes": "all"})
finally:
cdp.detach()
class _AttachedBrowser:
"""What `launch()` returns as "browser" when attached to Chrome. Callers
only ever `close()` it; here that means clear eCW's data, close the
bot's tabs, and disconnect — and close Chrome too if this run opened it
(CDP_CLOSE_AFTER_RUN), so its debug port is not left open."""
def __init__(self, browser, context, page, theirs, url=""):
self._browser, self._context, self._page = browser, context, page
self._theirs = theirs # tabs that were open before the bot
self._url = url
def close(self) -> None:
try:
clear_ecw_data(self._context, self._page)
print("[BROWSER] eCW cookies and storage cleared")
except Exception as exc:
# Cleared again when the next run attaches.
print(f"[BROWSER] Could not clear eCW data: {exc}")
if self._url in _STARTED and cfg.CDP_CLOSE_AFTER_RUN:
try:
self._browser.new_browser_cdp_session().send("Browser.close")
print("[BROWSER] Closed the Chrome this run opened")
except Exception:
pass # the connection goes with it
_STARTED.discard(self._url)
try:
self._browser.close()
except Exception:
pass
return
try:
mine = [p for p in self._context.pages if p not in self._theirs]
if len(mine) == len(self._context.pages) and mine:
# Closing Chrome's last tab closes Chrome. Leave one, blank.
mine[0].goto("about:blank")
mine = mine[1:]
for p in mine:
p.close()
finally:
self._browser.close() # connected, not launched: disconnects
def _is_blank(url: str) -> bool:
"""A tab showing nothing: about:blank or Chrome's New Tab page."""
return (url or "about:blank").split("?")[0].rstrip("/") in (
"about:blank", "chrome://newtab", "chrome://new-tab-page",
"chrome-search://local-ntp/local-ntp.html")
def _attach(pw, url: str):
"""Attach to the operator's Chrome; same (browser, context, page) shape."""
browser = pw.chromium.connect_over_cdp(url)
print(f"[BROWSER] Attached to Chrome at {url} (its own proxy and profile)")
# Chrome's own context: the one the extension can see. It cannot take
# new_context()'s options; a real window already has no viewport.
context = browser.contexts[0]
theirs = list(context.pages)
context.on("page", lambda p: p.on("dialog", auto_dialog))
page = context.new_page()
page.on("dialog", auto_dialog)
if url in _STARTED:
# The Chrome this run opened shows eCW only (operator, 2026-10-23):
# its empty first tab goes, now that the bot's tab keeps Chrome open.
# The operator's own Chrome keeps every tab it had.
for p in [p for p in theirs if _is_blank(p.url)]:
try:
p.close()
theirs.remove(p)
except Exception as exc:
print(f"[BROWSER] Could not close the empty first tab: {exc}")
# A run killed half-way (Stop on the results page, a crash) never got to
# close(), so its session may still be here.
clear_ecw_data(context, page)
page.bring_to_front()
return (_AttachedBrowser(browser, context, page, theirs, url), context,
page)
+462
View File
@@ -0,0 +1,462 @@
r"""
The Charge Summary sheet: one row per claim in the run's report, in the
operator's own 24-column format ("Charge Summary File Format.xlsx",
2026-10-01), written to Output\Charge Summary_<date>.xlsx after every
claim of a claims run, so the sheet grows claim by claim — or Output\Charge Summary_TEST_<date>.xlsx when the claim
ids came from a TEST_CLAIM_IDS_FILE sheet (see file_name()).
Columns A-R come from the downloaded claim report (15 of them by the same
header), plus three the report does not carry:
S.No 1, 2, 3 ... in the report's order
Group Name payer_groups.json, blank when the payer is unmapped
Pmts/Refunds PMTS/ADJS - Adjustment (the format's own sample rows all
have PMTS/ADJS = Pmts/Refunds + Adjustment)
The report's CLAIMTYPE column is dropped. Columns S-X are what this run
did with the claim, and stay blank for a claim it never opened (not found,
past CLAIM_PROCESS_LIMIT, or OPEN_CLAIM_FORM off). A Dental claim is read
and run through the conditions like any other since 2026-10-12, so its row
carries the condition comments and its high risk / sliding fee findings:
Recevied date / Completed On the run date
Assigned To SUMMARY_ASSIGNED_TO (default "eCW Bot")
Comments one line per finding, then one per review
reason, "; "-joined
Current Status / Category see claim_outcome() — "Pass", "Fail", or
"Review" / "Needs manual review"
"Review" is the third Category (review items D2/St1, 2026-10-05). Before it,
anything the bot did not actually check — a condition that raised or
declined, APPLY_CORRECTIONS=0, a progress note never read, a form that
would not read — came out "Clean claims / Ready to Submit / Pass": the
sheet claimed a check that never happened.
Header spellings ("Recevied date", "Assigned To ") are the format's own,
kept exactly so the sheet lines up with the team's existing ones.
"""
from datetime import datetime
from pathlib import Path
from . import payer_groups, spreadsheet
from .text import first_line
HEADERS = [
"S.No", "CLAIMS#", "SERVICE DATE", "PVDR", "PATIENT", "PAYER",
"Group Name", "STATUS", "CHARGES", "PMTS/ADJS", "Pmts/Refunds",
"Adjustment", "WithHeld", "Balance", "Visit Type", "Account Number",
"Provider Name", "Guarantor Name", "Recevied date", "Assigned To ",
"Completed On", "Comments", "Current Status", "Category",
]
# Copied straight from the downloaded report, by header name.
_FROM_REPORT = [
"CLAIMS#", "SERVICE DATE", "PVDR", "PATIENT", "PAYER", "STATUS",
"CHARGES", "PMTS/ADJS", "Adjustment", "WithHeld", "Balance",
"Visit Type", "Account Number", "Provider Name", "Guarantor Name",
]
# Operator, 2026-10-06: the team's wording for a claim with no findings.
CLEAN = ("Clean claims (claims that have no errors, placed in Pending with "
"Errors)")
# Comment per fired condition, worded like the format's own samples.
COMMENTS = {
"depression_dx_pointer": (
"ICD pointer issue (Z13.31 - Depression Diagnosis tagged under E/M "
"Procedure, Need to Tag under G8510 only )"),
# The two telehealth conditions word their own comment from what
# actually happened (`summary_comment`); these fixed texts are only the
# fallback when that hook raises, so they claim nothing that may not
# have happened — not "G2025 added" (it can fail), not "93 or 95"
# (Medicaid is GT), not "unchecked" (the UB form only zeroes fees)
# (final review, 2026-10-05).
"telehealth": "Telehealth (POS / modifier updated — see the log)",
"medicare_telehealth": (
"Medicare Telehealth (POS / modifier and G2025 — see the log for "
"what changed)"),
"j2930_j2001_update": (
"Invalid CPT codes (J2930/J2001 - checked that should be unchecked)"),
"dummy_codes": (
"Invalid CPT codes (DCONS/Dscprit - checked that should be "
"unchecked)"),
"dental_dummy_codes": (
"Invalid CPT codes (DPIMP/DFIMP dental dummy code - checked that should "
"be unchecked)"),
# Fallback only: the condition words its own comment (codes, payers,
# whether the Claim Status was set to Patient — EEWA-27, 2026-10-21).
"dental_payer_check": (
"Dental codes on a payer other than DentaQuest / Delta Dental / "
"CHP+ Dental - Claim Status to Patient"),
"medicaid_uncheck_cpts": (
"CPT not billable to Medicaid (15851/15852/73521 - unchecked, fee 0)"),
"medicare_billing_screening": (
"Screening codes not billed to Medicare (G8510/G8431)"),
# Fallback only: the condition words its own comment (which codes).
"medicare_vaccine_a6": (
"Condition code A6 added (G0008/G0009 on Medicare)"),
# Not read today: the condition never changes the claim; its Review
# reason (which code, billed when, on which claim) reaches the sheet.
"medicare_wellness_history": (
"G0402/G0438/G0439 billed too often - check the history"),
# Fallback only: the condition words its own comment (line, ICD).
"medicaid_pregnancy_th": (
"TH modifier added (pregnancy visit on Medicaid)"),
"vaccine_admin_codes": "CPT Code Missing ( vaccine admin code added )",
# Fallback only: the condition words its own comment (96372 added or not).
"injection_admin_code": "CPT Code Missing (injection admin code 96372)",
# Fallback only: the condition words its own comment by the path it
# took (BH payer: modifier removed; any other payer: line unchecked).
"bh_h_code_modifier": "H code corrected (#12) — see the log",
"zero_fee_uncheck": "Zero billed fee CPT unchecked",
"priced_line_check": "Billed CPT checked",
}
HIGH_RISK = "High risk claim (submit through DentaQuest web portal)"
# A progress note in one of these states was never read (pages/claim_form
# .open_progress_notes): no button, a disabled one, or a click that opened
# nothing — or, "skipped", not opened at all because the form never finished
# loading (the flow, final review 2026-10-05). Telehealth and Medicare
# telehealth decide from the note's text, so on such a claim they were not
# checked at all — not checked, not clean.
_NOTE_NOT_READ = ("nothing", "absent", "disabled", "skipped")
# Leads a Review row's Comments when the claim WAS written to eCW: "Needs
# manual review" alone would let the reviewer treat it as untouched and redo
# or undo edits already on the live claim (final review, 2026-10-05).
SAVED_NOTE = "Corrections SAVED to eCW"
# The operator approved the save, but eCW never confirmed it: save_claim()
# answers False both when OK could not be clicked and when it WAS clicked
# and the form did not hide in time — so whether the claim was written is
# unknown, and "Corrections pending (not saved)" could be untrue (final
# review, 2026-10-05). The flow sets `_conditions["save_unconfirmed"]`.
SAVE_UNCONFIRMED = ("Save approved but not confirmed by eCW — check whether "
"the correction was written")
def is_dental(data: dict) -> bool:
"""
A Dental Claim screen read: the flow's `_dental_checks` results, or
(since 2026-10-12, Dental claims run the conditions) a read whose
`screen` is "dental". Public because the flow's per-claim box asks the
same question (review item D9) — one answer, not two that can drift
apart.
"""
return ("high_risk" in data or "sliding_fee" in data
or data.get("screen") == "dental")
def review_reasons(data: dict) -> list:
"""
Every reason a human must look at this claim — empty when the bot
really checked everything it was meant to. Order: why the claim could
not be evaluated first, then what the conditions reported, then Dental.
Public because the flow's per-claim message box lists these same
reasons as its "Needs review" lines (review item D9, 2026-10-05): the
box and the sheet must give the same account of one claim.
"""
if data.get("_search_error"):
# The search raised (browser gone, a timeout): the claim was never
# found or opened, which is not "not found" (final review,
# 2026-10-05).
return [f"Bot could not search the claim ({data['_search_error']})"]
if data.get("_error"):
if data.get("_conditions") is not None:
# Raised AFTER the conditions ran (the flow keeps their account
# on the error dict): the claim was read and may have been
# changed, so "could not read" would misstate it. What the
# conditions themselves reported still follows.
cond = data["_conditions"] or {}
return ([f"Bot failed after the conditions ran "
f"({data['_error']})"]
+ [f"{f.get('name')} failed: {f.get('error')}"
for f in cond.get("failed") or []]
+ [f"{r.get('name')}: {r.get('reason')}"
for r in cond.get("review") or []])
# The read itself failed: `data` is only {"claim_no", "_error"}, so
# every trigger below would just restate "nothing was read".
return [f"Bot could not read the claim ({data['_error']})"]
reasons = []
if data.get("_not_evaluated"):
reasons.append(data["_not_evaluated"])
cond = data.get("_conditions")
if not is_dental(data) or data.get("screen") == "dental":
# Every claim that was read for the conditions: UB/HCFA, and since
# 2026-10-12 a Dental claim too (`screen` "dental"). Only a bare
# Dental-checks result (high risk / sliding fee alone, no read)
# skips these — nothing here applies to it.
if cond is None and not data.get("_not_evaluated"):
# No `_conditions` on a claim that loaded = run_all never ran.
# A claim that did NOT load has none either — the flow skips the
# conditions on purpose and `_not_evaluated` already says why —
# so calling that "APPLY_CORRECTIONS=0" would be untrue.
reasons.append("Conditions not run (APPLY_CORRECTIONS=0)")
note = data.get("progress_notes")
state = None
if not isinstance(note, dict):
state = "not opened" # OPEN_PROGRESS_NOTES off, or never got there
elif note.get("error"):
state = f"error: {first_line(note['error'])}"
elif note.get("opened") in _NOTE_NOT_READ:
state = note["opened"]
if state:
reasons.append(f"Progress note not read ({state}) — telehealth "
f"not checked")
elif note.get("partial"):
# Read, but its sections never settled before the deadline
# (pages/claim_form._wait_for_note, unit C). With 0 sections only
# the header arrived — no Procedure Codes, so telehealth saw
# nothing: the D8 false Clean. With some sections the Procedure
# Codes may still be missing. Either way "clean" is unproven
# (review of unit D, 2026-10-05, finding 1).
n = note.get("partial_sections")
if n == 0:
reasons.append("Progress note partial (0 sections) — "
"telehealth not checked")
else:
# No "?" for an unknown count: these reasons are also the
# client's on-screen "Needs review" lines (review item D9).
count = ("section count unknown" if n is None
else f"{n} section(s)")
reasons.append(f"Progress note may be partial ({count}) — "
f"telehealth may not be fully checked")
if data.get("cpt") == []:
# Every CPT condition reads these rows; none of them saw one
# (review item D7: a half-loaded HCFA form read 0 and went Pass).
reasons.append("No CPT rows were read")
elif data.get("cpt") and data.get("icd") == []:
# CPT rows but not one diagnosis row: depression_dx_pointer
# (the Z13.31 pointer) reads the ICD list and saw nothing, so it
# passed without really running. A billed claim always carries
# a diagnosis; an empty list is a read that did not land — e.g.
# a partly rendered UB form (final review, 2026-10-05).
reasons.append("No ICD rows were read")
cond = cond or {}
for f in cond.get("failed") or []:
reasons.append(f"{f.get('name')} failed: {f.get('error')}")
for r in cond.get("review") or []:
reasons.append(f"{r.get('name')}: {r.get('reason')}")
if cond.get("save_unconfirmed"):
reasons.append(SAVE_UNCONFIRMED)
if "high_risk" in data and \
(data["high_risk"] or {}).get("high_risk") is None:
reasons.append("High risk: Billing Notes could not be read")
sf = data.get("sliding_fee") or {}
if (sf.get("verdict") or "").lower() == "unknown":
reasons.append(f"Sliding fee UNKNOWN ({sf.get('reason', '')})")
return reasons
def claim_outcome(found: bool, data: dict) -> dict:
"""
Columns V-X for one claim, from what `_read_claim_form` returned.
* not found / never opened -> {} (S-X left blank)
* Review -> Current Status "Needs manual review": the bot did not, or
could not, check everything (see `review_reasons`) — the search
raised (`_search_error`), the form would not read (`_error`) or
never finished loading (`_not_evaluated`);
on UB/HCFA the conditions not run, the progress note not read (or
read but marked partial), or zero CPT rows read; a condition that raised or asked for a human
(`_conditions` "failed" / "review"), or an approved save eCW did
not confirm (SAVE_UNCONFIRMED); Dental Billing Notes unreadable
or a sliding fee UNKNOWN. Comments = every finding, then every
reason, led by SAVED_NOTE when the correction was saved to eCW.
Review wins over Fail: a fired condition on a claim whose note was
never read is still a claim a human has to look at.
* Fail -> a condition changed something, HIGH RISK, or a sliding fee
EXPIRED / NOT_STARTED. A condition's comment is its own
`summary_comment()` wording when it has one, else COMMENTS[name].
Current Status "Ready to Submit" if the correction was saved, else
"Corrections pending (not saved)" — with SAVE_CORRECTIONS=0
nothing reaches eCW, and the sheet must not say otherwise
* Pass -> CLEAN, "Ready to Submit" (a Dental sliding fee NONE is
noted after it, but is not a finding)
"""
if data and data.get("_search_error"):
# Searched, and the search itself raised: Review, not the blank row
# of a claim that is really not in the queue.
return {"Comments": "; ".join(review_reasons(data)),
"Current Status": "Needs manual review", "Category": "Review"}
if not found or not data:
return {}
comments, failed = [], False
cond = data.get("_conditions") or {}
worded = cond.get("comments") or {}
for name in cond.get("fired") or []:
comments.append(worded.get(name) or COMMENTS.get(name, name))
failed = True
saved = bool(cond.get("saved"))
hr = data.get("high_risk")
if hr and hr.get("high_risk"):
comments.append(HIGH_RISK)
failed = True
sf = data.get("sliding_fee")
if sf:
verdict = (sf.get("verdict") or "").lower()
if verdict in ("expired", "not_started"):
comments.append(f"Sliding fee {verdict.upper().replace('_', ' ')}"
f" ({sf.get('reason', '')})")
failed = True
elif verdict == "none":
# Noted, not a finding. "unknown" is a review reason instead.
comments.append(f"Sliding fee NONE ({sf.get('reason', '')})")
reasons = review_reasons(data)
if reasons:
# A save that happened is never hidden behind "Needs manual review"
# (final review, 2026-10-05): it leads the Comments.
lead = [SAVED_NOTE] if saved else []
return {"Comments": "; ".join(lead + comments + reasons),
"Current Status": "Needs manual review", "Category": "Review"}
if not failed:
return {"Comments": "; ".join([CLEAN] + comments),
"Current Status": "Ready to Submit", "Category": "Pass"}
return {"Comments": "; ".join(comments),
"Current Status": ("Ready to Submit" if saved
else "Corrections pending (not saved)"),
"Category": "Fail"}
def _money(text):
try:
return float(str(text).replace(",", "").replace("$", "").strip() or 0)
except ValueError:
return None
def build_rows(report_rows: list, header_row: int, claim_col: int,
outcomes: dict, run_date: str, assigned_to: str,
mapping: dict) -> list:
"""
Header + one row per claim. `report_rows` is the report as read;
`header_row`/`claim_col` are where claim_file found the claim-id
header and column (header_row -1: the file has no header row).
`outcomes` maps claim id -> claim_outcome(). A claim listed twice in
the report (eCW does, for a multi-line claim) gets one row. A report
missing a column (a hand-made test sheet with only claim ids) gets it
blank.
"""
header = ([str(h or "").strip() for h in report_rows[header_row]]
if header_row >= 0 else [])
ix = {h: i for i, h in enumerate(header)}
out, seen = [HEADERS], set()
for row in report_rows[header_row + 1:]:
get = lambda h: (str(row[ix[h]]).strip()
if h in ix and ix[h] < len(row) else "")
claim = str(row[claim_col]).strip() if claim_col < len(row) else ""
if not claim or claim in seen:
continue
seen.add(claim)
vals = {h: get(h) for h in _FROM_REPORT}
vals["CLAIMS#"] = claim
vals["S.No"] = len(seen)
vals["Group Name"] = payer_groups.classify(vals["PAYER"], mapping) or ""
pa, adj = _money(vals["PMTS/ADJS"]), _money(vals["Adjustment"])
vals["Pmts/Refunds"] = (f"{pa - adj:,.2f}" if "PMTS/ADJS" in ix
and pa is not None and adj is not None else "")
done = outcomes.get(claim) or {}
if done:
vals.update(done)
vals["Recevied date"] = run_date
vals["Completed On"] = run_date
vals["Assigned To "] = assigned_to
out.append([vals.get(h, "") for h in HEADERS])
return out
def file_name(today: str, test_mode: bool = False) -> str:
"""
`Charge Summary_<today>.xlsx`, or `Charge Summary_TEST_<today>.xlsx` for
a run whose claim ids came from TEST_CLAIM_IDS_FILE.
Test runs get their own name (review item D1): a test sheet is a hand
list of a few claim ids, its summary is mostly blank columns, and under
the live name it pushed that day's real summary aside to `__HHMMSS` —
so the file the team opens by its usual name was the test one. One
function, so the flow's "[TEST MODE] the summary goes to ..." line names
exactly the file write() saves.
"""
return (f"Charge Summary_TEST_{today}.xlsx" if test_mode
else f"Charge Summary_{today}.xlsx")
# The last report read: (path, mtime_ns, size) -> (rows, header_row,
# claim_col). The flow builds the sheet after every claim, for the Excel
# and again for the database, from the same unchanged report — reading it
# once is enough (review, 2026-10-12). A changed file is read again.
_REPORT_CACHE = {}
def _read_report(report_path):
# claim_file's own header/column finder, so the summary reads the
# report exactly the way the claim ids were read from it.
from ..claim_file import _find_column
path = Path(report_path)
stat = path.stat()
key = (str(path.resolve()), stat.st_mtime_ns, stat.st_size)
if key not in _REPORT_CACHE:
rows = spreadsheet.read_rows(path)
_REPORT_CACHE.clear()
_REPORT_CACHE[key] = (rows, *_find_column(rows))
return _REPORT_CACHE[key]
def build(report_path, outcomes: dict, assigned_to: str) -> list:
"""
The summary sheet (header row + one row per claim) for this report and
these outcomes — what `write()` saves to Excel, and what
`core/results_db.py` saves to SQL Server.
"""
rows, header_row, claim_col = _read_report(report_path)
run_date = datetime.now().strftime("%m/%d/%Y")
return build_rows(rows, header_row, claim_col, outcomes, run_date,
assigned_to, payer_groups.load_mapping())
def write(report_path, outcomes: dict, out_dir, today: str,
assigned_to: str, *, test_mode: bool = False, into=None) -> Path:
"""
Read the report, build the sheet, save it in `out_dir` as file_name()
— `Charge Summary_<today>.xlsx`, or `Charge Summary_TEST_<today>.xlsx`
with `test_mode` (claim ids from TEST_CLAIM_IDS_FILE, not the live queue).
A same-day file is kept aside, never overwritten (operators edit it); if
it is open in Excel, this run's sheet is saved under a `__HHMMSS` name
next to it instead of being lost (spreadsheet.write_xlsx_keep_old).
Returns the path actually written — report that one, not the usual name.
`into`: the file this run's earlier claims were written to (the flow
writes after every claim, operator 2026-10-12). It is this run's own
file, so it is rewritten in place — no `__HHMMSS` copy per claim. If it
is open in Excel by now, the sheet goes to a new `__HHMMSS` name, which
the caller then writes into for the rest of the run.
"""
sheet = build(report_path, outcomes, assigned_to)
out_path = Path(out_dir) / file_name(today, test_mode)
if into is None:
return spreadsheet.write_xlsx_keep_old(out_path, sheet, "[SUMMARY]")
into = Path(into)
try:
spreadsheet.write_xlsx(into, sheet)
return into
except OSError as exc:
fallback = spreadsheet._first_free_name(
out_path, datetime.now().strftime("%H%M%S"))
spreadsheet.write_xlsx(fallback, sheet)
print(f"[SUMMARY] {into.name} is open or locked ({first_line(exc)}) "
f"— this run's summary continues in {fallback.name}")
return fallback
+67
View File
@@ -0,0 +1,67 @@
r"""
Fallback selectors (2026-10-21): the selectors a run cannot do without carry
one or two alternatives, so a renamed id does not stop the run.
Each one is a CHAIN — the primary first, then alternatives taken from
attributes seen in the live DOM (ng-click, ng-model, type / value), never
guessed. The selector constant the code uses is the chain joined as a CSS
selector list ("primary, alternative, ..."), so every existing call site —
`page.click`, `locator(...)`, `:has(...)`, the JS `querySelectorAll` ones —
gets the fallback with no change. The alternatives are plain CSS for that
reason (no Playwright-only `:has-text` / `text=`).
What a list cannot say is WHICH one matched. `report()` does: where the
primary is gone and an alternative stands in, it logs
[SELECTOR] fallback: Lookup button — #btnclaimlookup not found, matched
[ng-click="getClaimsListFromLookup('lookup')"]; update selectors/...
so the primary is fixed before the alternative breaks too. The pre-flight
check (pages/preflight.py) and the login call it, passing the chains
registered in selectors/fallbacks.py.
"""
from . import run_events
# Names already reported this run: one log line each, not one per claim.
_reported = set()
def reset() -> None:
_reported.clear()
def _shown(page, selector: str) -> bool:
return page.locator(selector).filter(visible=True).count() > 0
def which(page, chain):
"""Index of the first entry in `chain` shown on the page; None if none."""
for i, selector in enumerate(chain):
if _shown(page, selector):
return i
return None
def report(page, names, chains) -> list:
"""Log each chain in `names` whose primary is gone but an alternative
is shown; the names so reported (each once a run). Never raises.
`chains` is `selectors/fallbacks.CHAINS` (core imports no selectors)."""
out = []
for name in names:
if name in _reported:
continue
module, chain = chains[name]
try:
i = which(page, chain)
except Exception:
continue
if i:
_reported.add(name)
out.append(name)
print(f"[SELECTOR] fallback: {name} — {chain[0]} not found, "
f"matched {chain[i]}; update "
f"src/ecw_bot/selectors/{module}.py")
run_events.warning("fallback", f"{name}: {chain[0]} not found, "
f"matched {chain[i]}")
return out
+201
View File
@@ -0,0 +1,201 @@
"""
Login attempt guard.
A courtesy to the account, not a security boundary: attempts are recorded to
`Logs\\attempts.json` and refused when they come too close together or in a
burst. eCW does stop mailing approval links after a run of failed logins, and
nothing in the code otherwise knew how often it had been run. `ECW_FORCE=1`
skips every check here.
Note on the restriction page — the cause was initially misread as rate
limiting. It is an IP check (see `config.TXT_RESTRICTED`): the same link answers
400 unproxied and 200 through the proxy. So a restriction the bot walks into
means "the browser lost the proxy" far more often than "the account is locked",
which is why `record_restricted()` does not impose a long cool-off.
"""
import os
import json
import time
from datetime import datetime, timezone, timedelta
from .. import settings as cfg
STATE_PATH = cfg.LOGS / "attempts.json"
# A restriction seen on the page pauses the bot for this long. Deliberately
# short: the usual cause is a proxy problem, which no amount of waiting fixes,
# and a long lockout would just block the run that has the proxy working again.
# Long enough to stop a retry loop, short enough to stay out of the way.
RESTRICTED_LOCKOUT = timedelta(minutes=5)
def _now():
return datetime.now(timezone.utc)
def _parse(raw):
try:
return datetime.fromisoformat(raw) if raw else None
except Exception:
return None
def _read() -> dict:
try:
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
def _write(state: dict) -> None:
try:
cfg.LOGS.mkdir(parents=True, exist_ok=True)
STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8")
except Exception as exc:
print(f"[GUARD] Could not write {STATE_PATH}: {exc}")
def _forced() -> bool:
return os.environ.get("ECW_FORCE", "").strip().lower() in ("1", "true", "yes")
def check() -> str:
"""Return a reason to refuse this attempt, or "" to go ahead."""
if _forced():
print("[GUARD] ECW_FORCE set — skipping the cooldown check")
return ""
state = _read()
now = _now()
locked = _parse(state.get("locked_until"))
if locked and now < locked:
mins = (locked - now).total_seconds() / 60
return (f"eCW restricted this account at "
f"{state.get('restricted_at', '?')[:19]} UTC. "
f"Waiting another {mins:.0f} min before trying again — "
f"more attempts extend the restriction. "
f"Set ECW_FORCE=1 to override.")
last = _parse(state.get("last_attempt"))
if last:
elapsed = (now - last).total_seconds()
if elapsed < cfg.LOGIN_COOLDOWN_SECONDS:
wait = cfg.LOGIN_COOLDOWN_SECONDS - elapsed
return (f"Last login attempt was {elapsed:.0f}s ago. "
f"Waiting {wait:.0f}s more — rapid retries are what get the "
f"account restricted. Set ECW_FORCE=1 to override.")
# Recent-attempt burst check: eCW tolerates the occasional retry, not a run
# of them.
window = now - timedelta(hours=1)
recent = [d for d in (_parse(x) for x in state.get("recent", [])) if d and d > window]
if len(recent) >= cfg.LOGIN_MAX_PER_HOUR:
# Say WHEN, not just "give it a rest". The window is a rolling hour, so
# it clears when the oldest attempt ages out — which is not obvious from
# the outside, and guessing at it means re-running to find out.
clears = min(recent) + timedelta(hours=1)
mins = (clears - now).total_seconds() / 60
return (f"{len(recent)} attempts in the last hour "
f"(limit {cfg.LOGIN_MAX_PER_HOUR}). The oldest was at "
f"{min(recent).astimezone():%H:%M:%S}, so this clears at "
f"{clears.astimezone():%H:%M:%S} — {mins:.0f} min from now. "
f"Waiting is the fix: seven attempts in forty-five minutes is "
f"what got this account restricted. "
f"Run tools\\check_guard.py to see the clock without starting "
f"a run.")
return ""
def wait_if_cooling() -> None:
"""
Sleep out a plain cooldown instead of refusing the run over it.
`check()` used to be the only gate, so a run started 297s into a 300s
cooldown died with "Waiting 3s more" — five of six consecutive runs ended
that way, and re-running by hand until the clock cleared is exactly the
pattern the cooldown was meant to discourage. Sleeping honours the cooldown
just as well as exiting does and actually gets the work done.
Only a plain cooldown is waited out. A restriction lockout and the
per-hour burst limit still refuse through `check()`: those mean "stop",
not "not yet".
"""
if _forced() or cfg.COOLDOWN_MAX_WAIT_SECONDS <= 0:
return
state = _read()
now = _now()
locked = _parse(state.get("locked_until"))
if locked and now < locked:
return # a restriction — check() refuses it
window = now - timedelta(hours=1)
recent = [d for d in (_parse(x) for x in state.get("recent", []))
if d and d > window]
if len(recent) >= cfg.LOGIN_MAX_PER_HOUR:
return # a burst — check() refuses it
last = _parse(state.get("last_attempt"))
if not last:
return
remaining = cfg.LOGIN_COOLDOWN_SECONDS - (now - last).total_seconds()
if remaining <= 0:
return
if remaining > cfg.COOLDOWN_MAX_WAIT_SECONDS:
# Too long to sit on. Let check() say so and exit, so the operator can
# decide rather than watch a silent process.
return
print(f"[GUARD] Cooldown has {remaining:.0f}s left — waiting it out "
f"instead of quitting (COOLDOWN_MAX_WAIT_SECONDS="
f"{cfg.COOLDOWN_MAX_WAIT_SECONDS})")
# Measured against `_now()`, the same clock `check()` uses, plus a second of
# margin. An earlier version counted with `time.time()` while `check()`
# counted with `datetime.now()`; two clocks for one deadline is how you get
# a run that waits out its cooldown and is then refused for being a
# fraction of a second early.
target = last + timedelta(seconds=cfg.LOGIN_COOLDOWN_SECONDS + 1)
while True:
left = (target - _now()).total_seconds()
if left <= 0:
break
# Say something periodically: a silent process looks hung, which is what
# got earlier runs killed by hand.
print(f"[GUARD] {left:.0f}s to go")
time.sleep(min(30, left))
print("[GUARD] Cooldown cleared — starting")
def record_attempt() -> None:
state = _read()
now = _now()
window = now - timedelta(hours=1)
recent = [x for x in state.get("recent", []) if (_parse(x) or now) > window]
recent.append(now.isoformat())
state["last_attempt"] = now.isoformat()
state["recent"] = recent[-50:]
_write(state)
def record_restricted() -> None:
"""Called when the portal shows its restriction page."""
state = _read()
now = _now()
state["restricted_at"] = now.isoformat()
state["locked_until"] = (now + RESTRICTED_LOCKOUT).isoformat()
_write(state)
print(f"[GUARD] Restriction recorded — locked out until "
f"{(now + RESTRICTED_LOCKOUT):%H:%M:%S} UTC")
def record_success() -> None:
"""A clean login clears the burst history — the account is evidently fine."""
state = _read()
state["recent"] = []
state["locked_until"] = None
state["last_success"] = _now().isoformat()
_write(state)
+175
View File
@@ -0,0 +1,175 @@
"""
Operator prompts.
Everything here can time out. An earlier run wedged for forty minutes on a
plain MessageBoxW: the mailbox poll found nothing, the code fell back to asking
a human, and nobody was at the desk — so the process sat holding a browser open
with no log output while eCW went on rate-limiting the account. A prompt that
can block forever is a prompt that will, so `MessageBoxTimeoutW` is used
instead and callers are handed an explicit TIMEOUT.
`MessageBoxTimeoutW` is undocumented but has shipped in user32 since XP. If it
is ever missing, the plain call is used and the timeout silently does not
apply — better a hang than a crash at the moment a human is being asked for
help.
"""
import ctypes
from ctypes import wintypes
from . import watchdog
# Return values
OK = "ok"
CANCEL = "cancel"
TIMEOUT = "timeout"
_MB_OK = 0x00000000
_MB_OKCANCEL = 0x00000001
_MB_ICONINFO = 0x00000040
_MB_ICONWARN = 0x00000030
_MB_TOPMOST = 0x00040000 # otherwise it can open behind the browser window
# Second button (Cancel, on an OK/Cancel box) is the default one, so Enter or
# Space picks Cancel instead of OK. Without it, Enter on the per-claim save
# box meant OK — save the claim to live eCW (review item P1).
_MB_DEFBUTTON2 = 0x00000100
_IDOK = 1
_IDTIMEOUT = 32000
def _raw_call(text: str, caption: str, flags: int, timeout_s: int) -> int:
# A box waits on a person, not a frozen page: the claim watchdog's
# clock starts again when it opens and when it closes.
watchdog.beat("message box")
try:
return _call_box(text, caption, flags, timeout_s)
finally:
watchdog.beat("message box closed")
def _call_box(text: str, caption: str, flags: int, timeout_s: int) -> int:
user32 = ctypes.windll.user32
try:
fn = user32.MessageBoxTimeoutW
fn.argtypes = [wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR,
wintypes.UINT, wintypes.WORD, wintypes.DWORD]
fn.restype = ctypes.c_int
return fn(None, text, caption, flags, 0, int(timeout_s * 1000))
except AttributeError:
return user32.MessageBoxW(None, text, caption, flags)
def info(title: str, msg: str, timeout_s: int,
needs_person: bool = False) -> str:
"""Informational box. Returns OK or TIMEOUT.
With QUIET_BOXES on (a run the results page started), a box that does
not need a person is one log line instead, and answers OK."""
if cfg.QUIET_BOXES and not needs_person:
first = (msg or "").strip().splitlines()[0] if (msg or "").strip() \
else ""
print(f"[BOX] {titled(title)}: {first}")
return OK
try:
rc = _raw_call(msg, titled(title), _MB_OK | _MB_ICONINFO | _MB_TOPMOST, timeout_s)
except Exception:
return TIMEOUT
return TIMEOUT if rc == _IDTIMEOUT else OK
def confirm(title: str, msg: str, timeout_s: int,
default_cancel: bool = False) -> str:
"""
OK/Cancel box. Returns OK, CANCEL or TIMEOUT.
`default_cancel=True` makes Cancel the default button, so Enter/Space
answers Cancel. For boxes where OK writes to live eCW: there, a key
pressed out of habit must not count as a decision. Off by default so the
captcha and security-image prompts, where OK only lets a read continue,
behave as they always have.
"""
footer = (f"\n\n(If there's no reply, this will cancel automatically "
f"after {timeout_s}s.)")
flags = _MB_OKCANCEL | _MB_ICONWARN | _MB_TOPMOST
if default_cancel:
flags |= _MB_DEFBUTTON2
try:
rc = _raw_call(msg + footer, titled(title), flags, timeout_s)
except Exception:
# No user32 (not Windows): nothing can be asked, so do not pretend a
# human agreed.
return TIMEOUT
if rc == _IDTIMEOUT:
return TIMEOUT
return OK if rc == _IDOK else CANCEL
# =============================================================================
# Convenience wrappers that apply the configured timeout and log the timeout
# =============================================================================
# These were private helpers in auth.py. They are here because every caller
# wants the same two things — the configured timeout, and a log line when a
# prompt goes unanswered — and duplicating that per module is how one of them
# ends up silently treating a timeout as consent.
from .. import settings as cfg # noqa: E402 (kept below the ctypes core)
TEST_PREFIX = "[TEST] "
def titled(title: str) -> str:
"""
`title` as the box shows it: with `[TEST] ` in front when the claim ids
come from a TEST_CLAIM_IDS_FILE sheet (review item D9, 2026-10-05).
A test run drives the same live eCW as a real one, so a screen with a
box on it looks exactly like production to anyone watching — the client
in a demo included. The [MODE] banner says "TEST MODE" in the log only;
this puts it on every box. Applied in the raw `info()` / `confirm()`,
the two functions every box reaches `_raw_call` through, not only in
the `info_box` / `ok_cancel` wrappers: three callers use the raw ones
directly (the 2FA manual box in auth/mailbox.py, which a test run does
reach at login; "Run the Lookup?" in pages/claim_results.py; the export
report box in pages/claim_report.py). Done here rather than at each call
site so a box added later cannot forget it. The wrappers apply it too,
so their "timed out" log line names the title the box showed; that is
safe because the prefix is never doubled. Read at call time (this
module imports settings below the ctypes core, and settings can change
under a test).
"""
if cfg.TEST_CLAIM_IDS_FILE and not title.startswith(TEST_PREFIX):
return TEST_PREFIX + title
return title
def info_box(title: str, msg: str, needs_person: bool = False) -> None:
"""Informational box on the configured timeout. Never blocks the run.
`needs_person=True` for a box a person must act on (it is never
quieted by QUIET_BOXES)."""
title = titled(title)
# The keyword is passed only when set, so a test that replaces info()
# with a three-argument stub keeps working.
extra = {"needs_person": True} if needs_person else {}
if info(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS, **extra) == TIMEOUT:
print(f"[UI] '{title}' timed out with nobody at the desk — continuing")
def ok_cancel(title: str, msg: str, default_cancel: bool = False) -> bool:
"""
True only on an explicit OK. A timeout counts as 'no'.
Note the asymmetry with `claim_results.run_lookup`, which treats a timeout
as "carry on": there, proceeding only reads. Here the prompts guard captcha
and security-image steps where proceeding without a human is meaningless,
so silence must not be read as agreement.
`default_cancel=True` (see `confirm`) for any box whose OK saves to eCW:
the per-claim save box and the LIVE-WRITE start box.
"""
title = titled(title)
result = confirm(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS,
default_cancel=default_cancel)
if result == TIMEOUT:
print(f"[UI] '{title}' timed out with nobody at the desk — treating as cancel")
return result == OK
+330
View File
@@ -0,0 +1,330 @@
"""
Page helpers shared by every screen. No eCW knowledge lives here.
These earned their edge cases the hard way, which is exactly why they are
in one place instead of being reimplemented per module:
* `shot` writes into a **dated** screenshot folder and never raises — a
screenshot failing must not lose the run it was documenting.
A FAILURE shot (`is_failure_tag`) also saves the page's DOM beside
it as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE`, 2026-10-21):
when eCW changes a selector, the HTML to fix it from is already on
disk — no re-run, no hand copy from DevTools into Dom.txt. PHI, like
the screenshots: it stays in the gitignored Screenshots folder.
* `visible` is an INSTANT "is it on screen right now?" and `appears` is
"give it up to N ms to show up". They are two functions because
Playwright 1.62's `is_visible(timeout=...)` silently ignores its timeout
(review item P8, 2026-10-01): every caller that passed `timeout=10_000`
expecting a grace period was getting an instant check. Both look at
VISIBLE matches only rather than `.first` — eCW renders some ids twice
(`#jellybean-panelLink4` among them), and a hidden leftover copy first in
the DOM used to make `.first` answer False while the real one was on
screen.
* `goto_with_retry` retries `net::ERR_ABORTED` — the corporate proxy
intermittently kills the first navigation, and one try used to abort a whole
run.
* `reset_scroll` does NOT trust `window.scrollTo(0, 0)`. On a single-page app
the document itself is usually not the scroller — an inner container is —
so scrolling the window is a silent no-op on an element that was never
offset. It sweeps for whatever actually moved and names it in the log.
* `auto_dialog` answers every native dialog: alerts OK, confirms / prompts
OK only in LIVE-WRITE (Cancel in VERIFY: live eCW is read-only), and
beforeunload **dismissed**. An unhandled native dialog freezes the
renderer's JS thread, after which every Playwright call hangs with no
exception; accepting beforeunload means "yes, close this page", which has
been observed closing the working tab mid-flow.
"""
import re
from datetime import datetime
from playwright.sync_api import Page
from .. import settings as cfg
from . import run_events
# A shot taken because something went wrong — by its tag, as every caller
# names it: `claims_status_failed`, `claim_form_no_modal_<id>`,
# `login_unconfirmed`, `search_not_ready_<id>` ... Not `logged_in`,
# `landing`, `claims_status`, `progress_note`, `claims_balance_cleared`.
_FAILURE_TAG = re.compile(
r"fail|missing|not_|notfound|nodownload|(^|_)no_|unconfirmed|wrong|"
r"leftover|unreadable|timed_out|locked|disabled|lost|restricted|"
r"still_open|expired|error")
# DOM files written by this process (SAVE_DOM_LIMIT caps them per run).
_dom_saved = 0
def is_failure_tag(tag: str) -> bool:
return bool(_FAILURE_TAG.search(tag or ""))
def save_dom(page: Page, path) -> bool:
"""The page's HTML and every frame's, in one file. Never raises."""
try:
parts = [page.content()]
for n, frame in enumerate(page.frames[1:], start=1):
try:
parts.append(f"\n<!-- ===== frame {n}: {frame.name or ''} "
f"===== -->\n{frame.content()}")
except Exception:
pass
path.write_text("".join(parts), encoding="utf-8")
return True
except Exception:
return False
def shot(page: Page, tag: str) -> None:
"""Screenshot into Screenshots\\<MM-DD-YYYY>\\<HHMMSS>_<tag>.png — and,
for a failure (`is_failure_tag`), the DOM beside it as `.html`."""
global _dom_saved
ts = datetime.now().strftime("%H%M%S")
png = cfg.SCREENSHOTS / f"{ts}_{tag}.png"
try:
cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(png))
if is_failure_tag(tag):
# The Runs page links a Run's failure shots.
run_events.emit("screenshot", path=str(png), tag=tag)
except Exception:
pass
if not (cfg.SAVE_DOM_ON_FAILURE and is_failure_tag(tag)
and _dom_saved < cfg.SAVE_DOM_LIMIT):
return
try:
cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True)
except Exception:
return
path = cfg.SCREENSHOTS / f"{ts}_{tag}.html"
if save_dom(page, path):
_dom_saved += 1
print(f"[DOM] Saved the page's HTML for the failure: {path}")
def visible(page: Page, selector: str, timeout: int = 500) -> bool:
"""
Whether any element matching `selector` is visible RIGHT NOW. Instant.
`timeout` is accepted for call compatibility and **ignored** — exactly as
it always was underneath: Playwright 1.62's `locator.is_visible(timeout=)`
does not wait (verified in this project; the review's item P8). Keeping
the parameter means no caller breaks, but nothing here pretends to wait.
A caller that means "give it time to show up" must use `appears()`.
Counts visible matches instead of asking `.first`: when eCW leaves a
hidden copy of an element ahead of the live one in the DOM, `.first` is
the hidden copy and the answer came back False with the real element on
screen. Never raises — "is it there?" is a question, not an assertion.
"""
try:
return page.locator(selector).filter(visible=True).count() > 0
except Exception:
return False
def appears(page: Page, selector: str, timeout: int = 5_000) -> bool:
"""
Wait up to `timeout` ms for an element matching `selector` to be visible.
Built on `wait_for(state="visible")`, the call that actually waits —
`is_visible(timeout=...)` looks like it should and does not (Playwright
1.62 ignores the timeout and answers instantly). That gotcha is why a slow
CPT tab was read short and a slow Prog. Notes button read as "absent"
(review items P8/D11).
Filtered to visible matches before `.first`, for the same reason as
`visible()`: a hidden leftover copy earlier in the DOM must not be the
one that gets waited on. Returns at once when it is already showing, so
the cost on the happy path is nothing. Never raises.
"""
try:
page.locator(selector).filter(visible=True).first.wait_for(
state="visible", timeout=timeout)
return True
except Exception:
return False
# Marks the one match a click would really land on: the shown element whose
# own centre hit-tests to itself (nothing covering it), or, when none does —
# off screen, or covered by something else entirely — the last one in the
# document (eCW's dialogs are appended, so later is higher). Returns how
# many shown matches there were. The mark is an attribute, removed from any
# earlier match first, so the locator below finds exactly one element.
#
# A match that computes `pointer-events: none` is not counted at all: no
# click can land on it (a window eCW is still opening, or one whose fade-out
# Chrome paused — live run 2026-10-13, ~45 s of Cancel clicks timing out).
_MARK_TOPMOST_JS = r"""
(s) => {
for (const el of document.querySelectorAll('[data-ecwbot-top]'))
el.removeAttribute('data-ecwbot-top');
const shown = el => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return r.width > 0 && r.height > 0
&& cs.visibility !== 'hidden' && cs.pointerEvents !== 'none';
};
const all = [...document.querySelectorAll(s)].filter(shown);
if (!all.length) return 0;
const onTop = el => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2,
r.top + r.height / 2);
return !!hit && (hit === el || el.contains(hit));
};
const top = all.filter(onTop).pop() || all[all.length - 1];
top.setAttribute('data-ecwbot-top', '1');
return all.length;
}
"""
_TOPMOST = '[data-ecwbot-top="1"]'
def count_shown(page: Page, selector: str) -> int:
"""How many matches are on screen right now (0 on any page error)."""
try:
return int(page.evaluate(_MARK_TOPMOST_JS, selector) or 0)
except Exception:
return 0
def click_topmost(page: Page, selector: str, timeout: int = 15_000) -> bool:
"""
Click the match that is on TOP — not `.first`, which is the earliest in
the document and, with two dialogs stacked, the one underneath. Live run
2026-10-12: a second Progress Note viewer and a second copy of an HCFA
claim window sat over the first; every `.first` click (Close, Cancel)
was intercepted for its full timeout, and the dialog that stayed blocked
the next claims too.
Returns False when nothing matching is on screen. A click that fails
raises, as `.click()` does — callers already handle that.
"""
if not count_shown(page, selector):
return False
page.locator(_TOPMOST).first.click(timeout=timeout)
return True
def text_of(page: Page, selector: str) -> str:
"""
An input's current value, or "?" when it cannot be read.
"?" is deliberately not "" — an unreadable control is *unknown*, and code
that treats unknown as empty will happily skip a filter that is still live.
"""
try:
return page.locator(selector).first.input_value(timeout=5_000).strip()
except Exception:
return "?"
def is_disabled(page: Page, selector: str) -> bool:
"""
Whether a control is disabled — and **True when it cannot be read**.
That default is deliberate and load-bearing. Its one caller decides between
typing into the Balance row and clearing it through Angular's scope; the
scope path works either way, so an unreadable control has to fall to the
safe side. Returning False here would make an unreadable control look
editable and the clear would silently do nothing.
"""
try:
return not page.locator(selector).first.is_enabled(timeout=5_000)
except Exception:
return True
# Every element is walked rather than a known list of containers: which div
# scrolls is a property of the page, not something this codebase can hardcode,
# and the whole point of the sweep is that it reports the answer instead of
# requiring someone to already know it.
_RESET_SCROLL_JS = r"""() => {
const out = [];
window.scrollTo(0, 0);
document.querySelectorAll('*').forEach(el => {
if (el.scrollTop > 0) {
out.push((el.tagName || '?').toLowerCase()
+ (el.id ? '#' + el.id : '')
+ ' @' + el.scrollTop);
el.scrollTop = 0;
}
});
return out;
}"""
def reset_scroll(page: Page) -> list:
"""
Scroll everything back to the top, and return what actually moved.
`window.scrollTo(0, 0)` was tried on its own first and did nothing at all:
the page came back still scrolled, which is what proved the document is not
the scroller here. An inner container is, so the window call is a no-op on
an element that was never offset in the first place.
The return value is the point as much as the scrolling is — each entry names
a container that was genuinely offset (`div#someGrid @240`), so the next
person reading a log does not have to rediscover which element scrolls.
Never raises: this is tidying up after a step, not the step itself.
"""
try:
return page.evaluate(_RESET_SCROLL_JS) or []
except Exception as exc:
print(f"[PAGE] Could not reset the scroll: "
f"{str(exc).splitlines()[0]}")
return []
def goto_with_retry(page: Page, url: str) -> None:
"""
The first navigation through the corporate proxy intermittently dies with
net::ERR_ABORTED — a transient proxy/TLS hiccup, not a bad URL.
"""
for attempt in range(1, cfg.NAV_RETRIES + 1):
try:
page.goto(url, wait_until="domcontentloaded", timeout=cfg.NAV_TIMEOUT)
return
except Exception as exc:
if attempt == cfg.NAV_RETRIES:
raise
print(f"[NAV] Navigation {attempt}/{cfg.NAV_RETRIES} failed "
f"({str(exc).splitlines()[0]}) — retrying in 5s")
page.wait_for_timeout(5_000)
def auto_dialog(dialog) -> None:
"""
Answer every native dialog: one left open wedges the renderer, and in
tests crashed the tab outright ("Target crashed"). Until 2026-10-22 none
was answered — `dialog.message()` called a property, and the TypeError
was swallowed.
* alert → OK: it only informs.
* confirm / prompt → OK only in LIVE-WRITE. In VERIFY, Cancel: live
eCW is read-only, and a confirm's OK may be a save.
* beforeunload → dismissed: accepting means "yes, close this page".
Each one is logged and is a Warning on the Runs page, with the answer.
"""
kind = getattr(dialog, "type", "?")
message = str(getattr(dialog, "message", "") or "")
accept = kind == "alert" or (kind in ("confirm", "prompt")
and bool(cfg.SAVE_CORRECTIONS))
answer = "OK" if accept else "Cancel"
print(f"[DIALOG] {kind}: {message!r} -> {answer}")
run_events.warning("dialog", f"{kind}: {message} -> {answer}"[:300])
try:
if accept:
dialog.accept()
else:
dialog.dismiss()
except Exception:
pass # answered already by the other listener
# (core/browser.py wires context and page)
+81
View File
@@ -0,0 +1,81 @@
"""
Payer -> Group Name lookup for the categorization step.
Exact-string match against a small hand-edited table (`payer_groups.json`,
next to this package) — no fuzzy or substring matching. eCW's own PAYER
values already differ by case and spacing for what is really the same
carrier (see `tests/test_payer_groups.py`'s docstring), so "close enough"
matching has no safe way to pick which known string a new one is closest to.
An unrecognized payer must come back as None and get a human's attention —
never a guess, on billing data.
Since 2026-10-06 the table is the team's own template: the Payer Name ->
Payer Group Name pairs of their Claim Status Dashboard, imported with
`tools/import_payer_groups.py` (re-run it when the dashboard changes). Adding
a payer by hand is still a one-line edit to `payer_groups.json`.
"""
import json
from pathlib import Path
_CONFIG_PATH = Path(__file__).resolve().parent.parent / "payer_groups.json"
def load_mapping(path=None) -> dict:
"""The payer -> group table. `path` defaults to the shipped config."""
p = Path(path) if path else _CONFIG_PATH
if not p.exists():
raise FileNotFoundError(
f"{p} not found — the payer group table is missing")
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(
f"{p} does not contain a JSON object (payer -> group name) — "
f"got {type(data).__name__}")
return data
def classify(payer: str, mapping: dict) -> str:
"""The Group Name for this exact PAYER string, or None if unmapped."""
return mapping.get((payer or "").strip())
# ── Group names the CONDITIONS decide on ─────────────────────────────────────
# Since 2026-10-06 `payer_groups.json` is the team's own template — the
# "Payer Name" -> "Payer Group Name" pairs of their Claim Status Dashboard
# (tools/import_payer_groups.py) — so its group names are the dashboard's
# ("Commercial Ins", "RAE (Behavioral Health)", "Dental - CHP+/DentaQuest"...)
# and that is what the Group Name columns show. The condition modules were
# written against the earlier, shorter names, which stay the vocabulary of
# their rules; this table translates. A name not listed (including every
# earlier short name) passes through unchanged.
RULE_GROUP = {
"Commercial Ins": "Commercial",
"RAE (Behavioral Health)": "RAE payer",
"Regional Accountable Entity-RAE (Reporting Group)": "RAE payer",
"Dental - CHP+/DentaQuest": "Dentaquest",
"Self Pay": "Program", # the clinic's programs (Outreach, slides…)
"Payer Not In Group": None, # the dashboard's own "unclassified"
}
# A payer whose condition rule differs from its dashboard group. Empty since
# 2026-10-21: the Medicare Advantage plans (AARP Medicare Advantage, Aetna
# Medicare ADV) were kept on Medicare here until the operator ruled; the
# ruling is "follow the dashboard", so they are Commercial for every
# condition (#21 95/93 not #22 G2025; no #14, #19, #27-#29; #18 / #20 add).
RULE_OVERRIDES = {}
def rule_group(payer: str, mapping: dict) -> str:
"""
The group a condition's rule should use for this PAYER, or None if
unmapped: the payer's own override if it has one, else its Group Name
translated through RULE_GROUP.
"""
key = (payer or "").strip()
if key in RULE_OVERRIDES:
return RULE_OVERRIDES[key]
group = classify(key, mapping)
if group is None:
return None
return RULE_GROUP.get(group, group)
+318
View File
@@ -0,0 +1,318 @@
r"""
The Charge Summary rows, also saved to SQL Server for the results web page
(`web/`).
Operator, 2026-10-08: server MECARE331\MECARE331, database
`Charge_Validation`, table `dbo.CV_ChargeSummary` (every table of this
project starts with `CV_`, operator 2026-10-12) — the team's Charge Summary
File Format's 24 columns (`charge_summary.HEADERS`), plus `run_at`,
`run_mode` and `details_json` (what the conditions found and changed). The
table is created by the operator from `tools/sql/create_tables.sql`; the bot
only inserts.
The rows are the ones the Excel summary gets (`charge_summary.build`), so the
two always agree. Only rows the run filled in (a Category) are saved: a
claim not processed or not found is blank in the Excel too.
Off unless `RESULTS_DB=1`. Never fatal: any database error is one
`[RESULTS-DB]` log line, and the run and its Excel summary carry on.
Values are converted for their columns — amounts without `$` and commas,
dates from mm/dd/yyyy (a trailing time or an Excel serial day number
included) — and anything that does not convert is stored as NULL (the Excel
file still has the text as read). Text longer than its column is cut to the
column's width, so one long value cannot fail the insert. SQL is always
parameterized, and a database error is logged by its SQLSTATE only, never
the driver's message, which can quote a value (PHI) back.
"""
import json
import re
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from . import charge_summary
TABLE = "dbo.CV_ChargeSummary"
# Charge Summary header -> DB column. `COLUMNS` is built from HEADERS, so a
# header added to the format without a column here fails at import, not
# silently at insert time.
_COLUMN_FOR = {
"S.No": "s_no", "CLAIMS#": "claim_no", "SERVICE DATE": "service_date",
"PVDR": "pvdr", "PATIENT": "patient", "PAYER": "payer",
"Group Name": "group_name", "STATUS": "status", "CHARGES": "charges",
"PMTS/ADJS": "pmts_adjs", "Pmts/Refunds": "pmts_refunds",
"Adjustment": "adjustment", "WithHeld": "withheld", "Balance": "balance",
"Visit Type": "visit_type", "Account Number": "account_number",
"Provider Name": "provider_name", "Guarantor Name": "guarantor_name",
"Recevied date": "received_date", "Assigned To ": "assigned_to",
"Completed On": "completed_on", "Comments": "comments",
"Current Status": "current_status", "Category": "category",
}
COLUMNS = [(h, _COLUMN_FOR[h]) for h in charge_summary.HEADERS]
DB_COLUMNS = (["run_at", "run_mode"] + [c for _, c in COLUMNS]
+ ["details_json"])
# NVARCHAR widths from tools/sql/create_tables.sql. MAX columns are not
# listed.
_MAX_LEN = {
"run_mode": 20, "claim_no": 20, "pvdr": 50, "patient": 200,
"payer": 200, "group_name": 100, "status": 100, "visit_type": 100,
"account_number": 50, "provider_name": 200, "guarantor_name": 200,
"assigned_to": 100, "current_status": 200, "category": 20,
}
_MONEY = {"charges", "pmts_adjs", "pmts_refunds", "adjustment", "withheld",
"balance"}
_DATES = {"service_date", "received_date", "completed_on"}
_INTS = {"s_no"}
# DECIMAL(12,2): ten digits before the point.
_MONEY_LIMIT = Decimal(10) ** 10
_CENT = Decimal("0.01")
_US_DATE = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})(?:\s|$)")
_SERIAL = re.compile(r"^\d{5}(?:\.0+)?$")
# Excel's day 0, and the serial range that is a plausible date
# (1954-10-03 .. 2119-01-08) — not some other five-digit number.
_EXCEL_EPOCH = date(1899, 12, 30)
_SERIAL_RANGE = (20_000, 80_000)
_ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
# What details_for() copies from the claim's `_conditions`.
_CONDITION_KEYS = ("fired", "detected", "changed", "saved",
"save_unconfirmed", "failed", "review", "comments",
"changes")
# Keys dropped wherever they appear in a condition's changes: they carry
# patient text, not what the bot did — vaccine_admin_codes' age_source
# names the DOB it read; the Dental Billing Notes' match quotes the note.
_PHI_KEYS = {"age_source", "match", "dob"}
# SQLSTATE -> what it means for the operator.
_SQLSTATE_HINT = {
"08001": "server not reachable", "08S01": "connection lost",
"HYT00": "timed out", "HYT01": "timed out", "28000": "login failed",
"42S02": "table missing — run tools/sql/create_tables.sql",
"42000": "no permission, or a SQL error",
"22001": "a value is too long for its column",
"22003": "a number is too large for its column",
}
def to_money(value):
"""An amount as Decimal (2 places), or None when blank or not a number."""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float, Decimal)):
text = str(value)
else:
text = str(value).replace("$", "").replace(",", "").strip()
negative = text.startswith("(") and text.endswith(")")
if negative:
text = text[1:-1].strip()
if not text:
return None
try:
amount = Decimal(text)
except InvalidOperation:
return None
if not amount.is_finite() or abs(amount) >= _MONEY_LIMIT:
return None
amount = amount.quantize(_CENT)
# 9999999999.995 rounds up to 10^10, past DECIMAL(12,2).
if abs(amount) >= _MONEY_LIMIT:
return None
return -amount if negative else amount
def to_date(value):
"""A date from mm/dd/yyyy, yyyy-mm-dd or a date/datetime; else None."""
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
value = str(value)
text = str(value or "").strip()
try:
m = _US_DATE.match(text)
if m:
return date(int(m[3]), int(m[1]), int(m[2]))
m = _ISO_DATE.match(text)
if m:
return date(int(m[1]), int(m[2]), int(m[3]))
if _SERIAL.match(text):
days = int(float(text))
if _SERIAL_RANGE[0] <= days <= _SERIAL_RANGE[1]:
return _EXCEL_EPOCH + timedelta(days=days)
except ValueError:
return None
return None
def _to_int(value):
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _to_text(value):
if value is None:
return None
text = str(value).strip()
return text or None
def _fit(col, text):
"""Text cut to its column's width (NVARCHAR(n)); MAX columns as is."""
limit = _MAX_LEN.get(col)
if text is None or limit is None or len(text) <= limit:
return text
return text[:limit]
def to_record(row: dict, *, run_at, run_mode, details) -> dict:
"""One Charge Summary row ({header: value}) as {db column: value}."""
rec = {"run_at": run_at, "run_mode": run_mode}
for header, col in COLUMNS:
value = row.get(header)
if col in _MONEY:
rec[col] = to_money(value)
elif col in _DATES:
rec[col] = to_date(value)
elif col in _INTS:
rec[col] = _to_int(value)
else:
rec[col] = _fit(col, _to_text(value))
rec["run_mode"] = _fit("run_mode", run_mode)
rec["details_json"] = (json.dumps(details, default=str, ensure_ascii=False)
if details else None)
return rec
def details_for(data: dict) -> dict:
"""
What the run found on one claim, for `details_json`: the conditions'
account (fired / changes / review / failed / comments / saved), the
Dental checks, and why a claim could not be read. Never the claim's own
fields (patient, CPT grid...) — those are in the row's columns or not
stored at all.
"""
data = data or {}
out = {}
cond = data.get("_conditions")
if isinstance(cond, dict):
out.update({k: _without_phi(cond[k])
for k in _CONDITION_KEYS if k in cond})
# Dental: the verdicts only — not the Billing Notes text, not the
# sliding-fee dates.
hr = data.get("high_risk")
if isinstance(hr, dict):
out.setdefault("dental", {})["high_risk"] = hr.get("high_risk")
sf = data.get("sliding_fee")
if isinstance(sf, dict):
out.setdefault("dental", {})["sliding_fee"] = {
k: sf[k] for k in ("verdict", "reason", "error") if k in sf}
for key in ("_error", "_not_evaluated", "_search_error"):
if data.get(key):
out[key.lstrip("_")] = data[key]
return out
def _without_phi(value):
"""`value` with every _PHI_KEYS key dropped, at any depth."""
if isinstance(value, dict):
return {k: _without_phi(v) for k, v in value.items()
if k not in _PHI_KEYS}
if isinstance(value, (list, tuple)):
return [_without_phi(v) for v in value]
return value
def safe_error(exc) -> str:
"""
An error for the log without the driver's message: SQL Server's
"String or binary data would be truncated" (Msg 2628) quotes the value,
which can be a patient's name. pyodbc puts the SQLSTATE in args[0].
"""
state = exc.args[0] if exc.args and isinstance(exc.args[0], str) else ""
if re.fullmatch(r"[0-9A-Z]{5}", state):
hint = _SQLSTATE_HINT.get(state, "database error")
return f"{type(exc).__name__} SQLSTATE {state} ({hint})"
return type(exc).__name__
def has_column(conn, column: str) -> bool:
"""True if the results table has `column`. False when it does not — or
the check itself fails: a column the table lacks must never cost the
run its rows."""
try:
row = conn.cursor().execute("SELECT COL_LENGTH(?, ?)",
[TABLE, column]).fetchone()
return bool(row) and row[0] is not None
except Exception:
return False
def insert_rows(conn, records: list, started_by: str = "") -> int:
"""One parameterized INSERT for every record, one commit. `started_by`
(EEWA-33) goes in only once tools/sql/create_tables.sql has added its
column: until then the rows are saved without it."""
if not records:
return 0
cols = list(DB_COLUMNS)
extra = []
if started_by and has_column(conn, "started_by"):
cols.append("started_by")
extra = [started_by[:50]]
sql = (f"INSERT INTO {TABLE} ({', '.join(cols)}) "
f"VALUES ({', '.join('?' for _ in cols)})")
cur = conn.cursor()
cur.executemany(sql, [[rec[c] for c in DB_COLUMNS] + extra
for rec in records])
conn.commit()
return len(records)
def save_summary(sheet: list, details: dict, *, run_at, run_mode,
conn_str: str, connect=None, started_by: str = ""):
"""
Save the rows of `sheet` (header row + rows, as `charge_summary.build`
returns it) that the run filled in. Returns the number saved, or None
when the database could not be written — never raises.
"""
try:
header = list(sheet[0])
records = []
for values in sheet[1:]:
row = dict(zip(header, values))
if not str(row.get("Category") or "").strip():
continue
claim = str(row.get("CLAIMS#") or "").strip()
records.append(to_record(row, run_at=run_at, run_mode=run_mode,
details=(details or {}).get(claim)))
if not records:
print("[RESULTS-DB] No claim rows to save")
return 0
if connect is None:
import pyodbc
connect = pyodbc.connect
conn = connect(conn_str, timeout=15)
try:
# Query timeout: a blocked insert raises (and is logged)
# instead of holding the run up.
conn.timeout = 30
saved = insert_rows(conn, records, started_by)
finally:
conn.close()
print(f"[RESULTS-DB] {saved} row(s) saved to {TABLE}")
return saved
except Exception as exc:
print(f"[RESULTS-DB] Could not save the charge summary to the "
f"database: {safe_error(exc)} — the Excel summary is not "
f"affected")
return None
+146
View File
@@ -0,0 +1,146 @@
"""
Run events (spec docs/superpowers/specs/2026-10-22-run-tracking-and-portal-
sign-in-design.md, Stage 1; ADR docs/adr/0001-run-events-in-files.md).
The run log has no times on its lines, so how long the eCW login took, or
when a claim went wrong, could not be told afterwards. Every Run now also
writes `Logs\\events_<run>.jsonl` beside `run_<run>.log`: one JSON object a
line, each with its time to the millisecond, flushed at once so a killed Run
keeps what it wrote. The Portal's Runs page reads these files.
An event never stops a Run: anything that goes wrong writing one drops that
event and nothing else. Nothing secret goes in — no OTP code, no password,
no eCW username: the callers pass fixed fields, never a whole log line.
"""
import os
import json
import getpass
import traceback
from datetime import datetime
from pathlib import Path
_handle = None
_attempt = 0
def close() -> None:
"""End of the Run: let go of the file (Windows keeps an open one locked)."""
_reset()
def _reset() -> None:
"""Close the file and forget the attempt count (tests; a new Run)."""
global _handle, _attempt
try:
if _handle:
_handle.close()
except Exception:
pass
_handle, _attempt = None, 0
def run_id_of(log_path) -> str:
"""`run_20261022_141530.log` → `20261022_141530`."""
stem = Path(log_path).stem
return stem[len("run_"):] if stem.startswith("run_") else stem
def path_for(log_path) -> Path:
"""The events file of the Run whose log is `log_path`."""
log_path = Path(log_path)
return log_path.with_name(f"events_{run_id_of(log_path)}.jsonl")
def _windows_user() -> str:
try:
return getpass.getuser()
except Exception:
return os.environ.get("USERNAME", "")
def started_by() -> str:
"""Started By: the Portal User the Portal passed (ECW_STARTED_BY), else
the Windows user of a terminal run."""
return os.environ.get("ECW_STARTED_BY") or _windows_user()
def _now() -> str:
return datetime.now().isoformat(timespec="milliseconds")
def _line(kind: str, fields: dict) -> str:
# The time and kind last, so no field can overwrite them.
return json.dumps({**fields, "t": _now(), "kind": kind},
ensure_ascii=False, default=str)
def start(log_path, mode: str, claim_limit=None, test_sheet: str = "") -> None:
"""Open this Run's events file and write `run_start`."""
global _handle
_reset()
try:
_handle = open(path_for(log_path), "a", encoding="utf-8",
buffering=1)
except Exception:
_handle = None
return
source = os.environ.get("ECW_RUN_SOURCE", "terminal")
emit("run_start",
source="portal" if source == "web" else source,
started_by=started_by(),
mode=mode, claim_limit=claim_limit,
test_sheet=Path(test_sheet).name if test_sheet else "",
pid=os.getpid(), log=str(log_path))
def emit(kind: str, **fields) -> None:
"""Write one event. Never raises."""
if _handle is None:
return
try:
_handle.write(_line(kind, fields) + "\n")
_handle.flush()
except Exception:
pass
def append_to(log_path, kind: str, **fields) -> None:
"""Add one event to another Run's file — the Portal, after a Stop."""
try:
with open(path_for(log_path), "a", encoding="utf-8") as fh:
fh.write(_line(kind, fields) + "\n")
except Exception:
pass
def warning(code: str, message: str, **fields) -> None:
emit("warning", code=code, message=message, **fields)
def claim_error(claim: str, step: str, message: str) -> None:
emit("claim_error", claim=claim, step=step, message=message)
def run_error(step: str, message: str, exit_code: int, exc=None) -> None:
tb = ""
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc,
exc.__traceback__))
emit("run_error", step=step, exit_code=exit_code, message=message,
traceback=tb)
def login_attempt_start() -> None:
global _attempt
_attempt += 1
emit("login_attempt_start", attempt=_attempt)
def login_step(step: str, **fields) -> None:
emit("login_step", attempt=_attempt, step=step, **fields)
def login_attempt_end(result: str, message: str = "") -> None:
emit("login_attempt_end", attempt=_attempt, result=result,
message=message)
+229
View File
@@ -0,0 +1,229 @@
r"""
The run record — `Logs\bot_run.json`, the latest bot run (spec
docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md).
Written by the bot itself at the start of `main()` and finished when
`main()` returns, so the results page sees a run started in the terminal
as well as one it started. While a run is live a second `main()` refuses to
start: two runs would share one eCW login guard and one Charge Summary.
{"pid", "started_at", "log_path", "source": "web" | "terminal",
"ended_at", "exit_code", "stopped_by_operator"}
Live = no `ended_at`, the pid is a running python process, and that process
was created no later than the record's `started_at` (plus a few seconds) —
so a record left unended by a killed run, whose pid Windows later hands to
another python process, does not read as live. A killed run cannot finish
its own record; its pid is gone or reused, so it reads as ended.
"""
import os
import time
import json
import ctypes
from ctypes import wintypes
from datetime import datetime, timedelta
from .. import settings as cfg
FILE_NAME = "bot_run.json"
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_STILL_ACTIVE = 259
# started_at is written after the process exists, truncated to the second.
_CREATED_SLACK = timedelta(seconds=5)
_FILETIME_UNIX_EPOCH = 116444736000000000 # 1601-01-01 -> 1970-01-01, 100 ns
class Busy(Exception):
"""Another bot run is live."""
def path():
"""Read at call time: tests point cfg.LOGS elsewhere."""
return cfg.LOGS / FILE_NAME
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def pid_alive(pid) -> bool:
"""
A running process with this pid whose program is python.
Never `os.kill(pid, 0)`: on Windows that TERMINATES the process.
The program-name check keeps a stale record whose pid Windows gave to
some other program from reading as a live bot run.
"""
try:
pid = int(pid)
except (TypeError, ValueError):
return False
if pid <= 0:
return False
if os.name != "nt":
try:
os.kill(pid, 0)
return True
except OSError:
return False
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.OpenProcess.restype = wintypes.HANDLE
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE,
ctypes.POINTER(wintypes.DWORD)]
k32.QueryFullProcessImageNameW.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR,
ctypes.POINTER(wintypes.DWORD)]
k32.CloseHandle.argtypes = [wintypes.HANDLE]
handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
code = wintypes.DWORD()
if not k32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
if code.value != _STILL_ACTIVE:
return False
buf = ctypes.create_unicode_buffer(1024)
size = wintypes.DWORD(len(buf))
if not k32.QueryFullProcessImageNameW(handle, 0, buf,
ctypes.byref(size)):
return True
return "python" in os.path.basename(buf.value).lower()
finally:
k32.CloseHandle(handle)
def process_created(pid):
"""
When process `pid` was created, as a local naive datetime (the same
clock as the record's `started_at`); None when it cannot be read or
the process is no longer running.
"""
if os.name != "nt":
return None
try:
pid = int(pid)
except (TypeError, ValueError):
return None
if pid <= 0:
return None
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.OpenProcess.restype = wintypes.HANDLE
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE,
ctypes.POINTER(wintypes.DWORD)]
k32.GetProcessTimes.argtypes = (
[wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4)
k32.CloseHandle.argtypes = [wintypes.HANDLE]
handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return None
try:
# An ended process can still be opened while some handle to it is
# held (its parent's, say) and still has times: not a running one.
code = wintypes.DWORD()
if (not k32.GetExitCodeProcess(handle, ctypes.byref(code))
or code.value != _STILL_ACTIVE):
return None
created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
if not k32.GetProcessTimes(handle, ctypes.byref(created),
ctypes.byref(exited), ctypes.byref(kernel),
ctypes.byref(user)):
return None
ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime
if ticks <= _FILETIME_UNIX_EPOCH:
return None
return datetime.fromtimestamp(
(ticks - _FILETIME_UNIX_EPOCH) / 10_000_000)
except (OSError, OverflowError, ValueError):
return None
finally:
k32.CloseHandle(handle)
def read():
"""The record, or None when there is none or it cannot be read."""
try:
data = json.loads(path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def is_live(rec) -> bool:
if not rec or rec.get("ended_at") or not pid_alive(rec.get("pid")):
return False
# Same pid, but a younger process: the pid was reused after the run
# died without finishing its record. If either time cannot be read,
# trust the pid.
try:
started = datetime.fromisoformat(rec["started_at"])
except (KeyError, TypeError, ValueError):
return True
created = process_created(rec.get("pid"))
try:
if created is not None and created > started + _CREATED_SLACK:
return False
except TypeError:
pass # started_at carries a UTC offset: keep the pid verdict
return True
def _write(rec: dict) -> None:
target = path()
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".tmp")
tmp.write_text(json.dumps(rec, indent=2), encoding="utf-8")
# On Windows the replace is refused for a moment while another process
# (the page polling, a scanner) holds the file open.
for attempt in range(10):
try:
os.replace(tmp, target)
return
except PermissionError:
if attempt == 9:
raise
time.sleep(0.05)
def acquire(log_path, source=None) -> dict:
"""This process's record, written; raises Busy over a live one."""
rec = read()
if is_live(rec):
raise Busy(f"A bot run is already going (pid {rec.get('pid')}, "
f"started {rec.get('started_at') or '?'}) — not starting "
f"a second one")
rec = {"pid": os.getpid(), "started_at": _now(),
"log_path": str(log_path),
"source": source or os.environ.get("ECW_RUN_SOURCE") or "terminal",
# The Portal User for a Portal run (EEWA-32), else Windows.
"started_by": (os.environ.get("ECW_STARTED_BY")
or os.environ.get("USERNAME") or ""),
"ended_at": None, "exit_code": None,
"stopped_by_operator": False}
_write(rec)
return rec
def finish(exit_code) -> None:
"""End this process's own record. Never raises."""
try:
rec = read()
if not rec or rec.get("pid") != os.getpid():
return
rec.update(ended_at=_now(), exit_code=exit_code)
_write(rec)
except Exception:
pass
def mark_stopped(pid) -> None:
"""The page killed run `pid`: say so on its record."""
rec = read()
if not rec or rec.get("pid") != pid or rec.get("ended_at"):
return
rec.update(ended_at=_now(), exit_code=None, stopped_by_operator=True)
_write(rec)
+76
View File
@@ -0,0 +1,76 @@
"""
Run log.
Python buffers stdout when it is not a terminal, so a background run that hung
produced a zero-byte log and there was no way to see how far it had got — the
only evidence left was a screenshot. Every run now tees to
`Logs\\run_<timestamp>.log`, flushed per line, so a stuck or killed run still
leaves a readable trail.
"""
import sys
from datetime import datetime
from .. import settings as cfg
class _Tee:
def __init__(self, stream, handle):
self._stream = stream
self._handle = handle
def write(self, text):
try:
self._stream.write(text)
self._stream.flush()
except Exception:
pass
try:
self._handle.write(text)
self._handle.flush() # a killed run must still have its log
except Exception:
pass
return len(text)
def flush(self):
for target in (self._stream, self._handle):
try:
target.flush()
except Exception:
pass
def isatty(self):
return getattr(self._stream, "isatty", lambda: False)()
def _force_utf8_console() -> None:
"""
Make the console accept the characters this bot actually prints.
Windows hands Python a cp1252 console, which cannot encode an em dash or an
arrow — and nearly every log line here has one. The tee below swallows the
resulting UnicodeEncodeError so a run never dies of it, which is right but
means those lines were being **silently dropped from the console** while
landing correctly in the log file. Watching a run and reading its log
afterwards showed different things, which is a nasty way to debug.
`errors="replace"` as well as the encoding: a console that still cannot
represent a character should print a box, not raise.
"""
for stream in (sys.__stdout__, sys.__stderr__):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass # not a real console, or too old to care
def start() -> "object":
"""Begin teeing stdout/stderr. Returns the log path."""
_force_utf8_console()
cfg.LOGS.mkdir(parents=True, exist_ok=True)
path = cfg.LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log"
handle = open(path, "a", encoding="utf-8", buffering=1)
handle.write(f"\n{'=' * 70}\n{datetime.now():%Y-%m-%d %H:%M:%S} — run start\n"
f"{'=' * 70}\n")
sys.stdout = _Tee(sys.__stdout__, handle)
sys.stderr = _Tee(sys.__stderr__, handle)
return path
+396
View File
@@ -0,0 +1,396 @@
"""
Read rows out of a downloaded report. No eCW knowledge, no dependencies.
**Why not openpyxl.** Same reason `settings.py` parses `.env` by hand and the
tests run on plain asserts: these machines are locked down and every added
package is one more approval to chase. An `.xlsx` is a zip of XML, and the one
eCW produces is about as simple as the format gets, so `zipfile` plus
`xml.etree` from the stdlib is enough. If eCW ever ships something genuinely
complicated, that is the moment to argue for openpyxl — not now.
**Three formats, because eCW has sent more than one.** Its "EXCEL" export is a
real xlsx today (verified against the 391 KB file it produced on 2026-09-07),
but the same menu on other builds serves an HTML table with an `.xls` name, and
the CSV item next to it is one keystroke away. Sniffing the file's own bytes
rather than trusting its extension is what keeps a format change from reading as
a parsing bug.
What the eCW xlsx actually looks like, since it shapes the parser:
<row r="1">
<c r="A1" t="str"><v></v></c>
<c r="C1" t="str"><v>CLAIMS#</v></c>
...
Every value is an inline `t="str"`, and there is **no `sharedStrings.xml`** — so
no string table to resolve. The shared-string path below is still implemented,
because "this build does not use it" is not the same as "no build will".
"""
import csv
import re
import zipfile
from datetime import datetime
from html.parser import HTMLParser
from pathlib import Path
from xml.etree import ElementTree
from xml.sax.saxutils import escape
from .text import first_line
def _tag(name: str) -> str:
"""Local tag name, with the OOXML namespace stripped."""
return name.rsplit("}", 1)[-1]
def _col_index(ref: str) -> int:
"""`"D2"` → 3. Column letters are base-26 with no zero digit."""
letters = re.match(r"[A-Z]+", ref or "")
if not letters:
return 0
n = 0
for ch in letters.group(0):
n = n * 26 + (ord(ch) - 64)
return n - 1
def _shared_strings(zf: zipfile.ZipFile) -> list:
if "xl/sharedStrings.xml" not in zf.namelist():
return []
out = []
root = ElementTree.fromstring(zf.read("xl/sharedStrings.xml"))
for si in root:
# A shared string can be split across several <r><t> runs.
out.append("".join(t.text or "" for t in si.iter()
if _tag(t.tag) == "t"))
return out
def _read_xlsx(path: Path) -> list:
with zipfile.ZipFile(path) as zf:
sheets = [n for n in zf.namelist()
if n.startswith("xl/worksheets/sheet")]
if not sheets:
raise ValueError(f"{path.name} is a zip but has no worksheet")
shared = _shared_strings(zf)
root = ElementTree.fromstring(zf.read(sorted(sheets)[0]))
rows = []
for row in root.iter():
if _tag(row.tag) != "row":
continue
cells = {}
for c in row:
if _tag(c.tag) != "c":
continue
# Addressed by column letter, never by position: a cell with no
# value can be omitted entirely, and reading positionally would
# then shift every column after it.
idx = _col_index(c.get("r", ""))
kind = c.get("t")
text = ""
if kind == "s": # shared-string index
v = c.find("./{*}v")
if v is not None and (v.text or "").isdigit():
i = int(v.text)
text = shared[i] if i < len(shared) else ""
elif kind == "inlineStr":
text = "".join(t.text or "" for t in c.iter()
if _tag(t.tag) == "t")
else: # str, n, b, or untyped
v = c.find("./{*}v")
text = (v.text or "") if v is not None else ""
cells[idx] = text.strip()
width = max(cells) + 1 if cells else 0
rows.append([cells.get(i, "") for i in range(width)])
return rows
class _TableParser(HTMLParser):
"""Pull the first table's rows out of an HTML-as-.xls export."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.rows, self._row, self._cell, self._in_cell = [], None, [], False
def handle_starttag(self, tag, attrs):
if tag == "tr":
self._row = []
elif tag in ("td", "th"):
self._in_cell, self._cell = True, []
def handle_endtag(self, tag):
if tag in ("td", "th") and self._row is not None:
self._row.append("".join(self._cell).strip())
self._in_cell = False
elif tag == "tr" and self._row is not None:
if any(self._row):
self.rows.append(self._row)
self._row = None
def handle_data(self, data):
if self._in_cell:
self._cell.append(data)
def _read_html(path: Path) -> list:
p = _TableParser()
p.feed(path.read_text(encoding="utf-8", errors="replace"))
return p.rows
def _read_csv(path: Path) -> list:
text = path.read_text(encoding="utf-8-sig", errors="replace")
# Sniff on the header line only: a patient name with a comma in it would
# otherwise outvote the real delimiter.
first = text.splitlines()[0] if text else ""
delim = "\t" if first.count("\t") > first.count(",") else ","
return [[c.strip() for c in row]
for row in csv.reader(text.splitlines(), delimiter=delim)
if any(c.strip() for c in row)]
def read_rows(path) -> list:
"""
Every row of the report as a list of strings, blank rows dropped.
The format is decided by the file's first bytes, not its extension.
"""
path = Path(path)
head = path.open("rb").read(8)
if head.startswith(b"PK"):
return _read_xlsx(path)
if head.lstrip()[:1] == b"<":
return _read_html(path)
if head.startswith(b"\xd0\xcf\x11\xe0"):
# Legacy OLE2 .xls. Not a zip and not XML — a real binary format that
# would need a parser of its own. eCW has not sent one, so this says so
# instead of silently returning nothing.
raise ValueError(
f"{path.name} is a legacy binary .xls (OLE2). Nothing here can "
f"read that. Set CLAIM_REPORT_FORMAT=CSV, which eCW offers in the "
f"same menu, or add a parser for it.")
return _read_csv(path)
# =============================================================================
# Writing (the categorization step's output)
# =============================================================================
# The read side above has no dependency on openpyxl; the write side keeps that
# constraint too. What follows is the minimal set of OOXML parts real Excel
# actually requires to open a file without a "repair" prompt — more than the
# reader above needs (it only ever looks at xl/worksheets/sheet*.xml), because
# this file is meant to be opened by a person, not just this codebase.
_CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'</Types>'
)
_ROOT_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.xml"/>'
'</Relationships>'
)
_WORKBOOK = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
'<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>'
'</workbook>'
)
_WORKBOOK_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
'Target="worksheets/sheet1.xml"/>'
'</Relationships>'
)
# XML 1.0 does not allow these control characters anywhere in a document,
# escaped or not. `escape()` only handles &, <, > — a cell value carrying one
# of these would still produce a file that "wrote successfully" but is
# malformed XML, silently corrupt rather than caught. Stripped, not escaped:
# there is no valid XML representation for them at all.
_XML_ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
def _col_letter(idx: int) -> str:
"""0 -> 'A', 25 -> 'Z', 26 -> 'AA'. Inverse of `_col_index` above."""
letters = ""
idx += 1
while idx > 0:
idx, rem = divmod(idx - 1, 26)
letters = chr(65 + rem) + letters
return letters
def write_xlsx(path, rows: list) -> None:
"""
Write `rows` (a header row plus data rows, each a list of values) as a
minimal single-sheet xlsx.
Inline `t="str"` cells, no sharedStrings — the same shape `_read_xlsx`
above already parses, and what eCW's own export already sends (see the
module docstring). Every value is written through `str()`, so callers can
pass numbers or None without pre-formatting.
"""
path = Path(path)
body = []
for r, row in enumerate(rows, start=1):
cells = []
for c, value in enumerate(row):
text = escape(_XML_ILLEGAL.sub("", str(value)))
cells.append(f'<c r="{_col_letter(c)}{r}" t="str"><v>{text}</v></c>')
body.append(f'<row r="{r}">{"".join(cells)}</row>')
sheet = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(body)}</sheetData></worksheet>'
)
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", _CONTENT_TYPES)
zf.writestr("_rels/.rels", _ROOT_RELS)
zf.writestr("xl/workbook.xml", _WORKBOOK)
zf.writestr("xl/_rels/workbook.xml.rels", _WORKBOOK_RELS)
zf.writestr("xl/worksheets/sheet1.xml", sheet)
# =============================================================================
# Writing next to a file the operator may have open
# =============================================================================
# Both dated outputs (Output\Medcor Ready_<date>.xlsx and Output\Charge
# Summary_<date>.xlsx) are files people work in: the operator hand-fills blank
# Group Names in one and reviews claims in the other, and a same-day re-run
# must neither overwrite that work nor lose its own rows over it.
#
# Until 2026-10-05 each caller had its own copy of "rename the old file aside,
# then write the new one". On Windows that rename FAILS while the old file is
# open in Excel ([WinError 32] "being used by another process"), the caller
# caught the error, and the whole run's sheet was thrown away (review item
# D10) — on exactly the second run of a day, when the first run's sheet is the
# one most likely to be open. This one helper replaces both copies, and its
# answer to a locked file is to write the new rows under a free name next to
# it instead.
def _first_free_name(out_path: Path, stamp: str) -> Path:
"""
`<stem>__<HHMMSS>.xlsx`, else `<stem>__<HHMMSS>_2.xlsx`, `_3` ... — the
first one not already on disk. The `_<n>` suffix exists because two
writes inside the same wall-clock second do happen (back-to-back in this
project's own tests, and a fast manual re-run), and Windows refuses to
rename or write onto a path that is already taken.
"""
candidate = out_path.with_stem(f"{out_path.stem}__{stamp}")
n = 2
while candidate.exists():
candidate = out_path.with_stem(f"{out_path.stem}__{stamp}_{n}")
n += 1
return candidate
def _move_back(out_path: Path, backup: Path, log_tag: str) -> None:
"""
The old file was renamed aside but the new one could not be written in
its place: put the old one back under the name the operator knows it by,
so that — when the move back succeeds — a failed write leaves the folder
as it was found.
Whatever is at `out_path` now is the failed write's own half-file — the
old file was moved off that name a moment ago — so it is removed first;
a half-written zip under the real name would open as a "corrupt file"
error instead of the operator's sheet. If the removal or the move back
fails (a virus scanner holding the half-file, say), the old file is
still safe under `backup`, but the usual name may now hold that
incomplete file — so the log says both: which name not to trust, and
where the operator's sheet really is.
"""
try:
out_path.unlink(missing_ok=True)
backup.rename(out_path)
except OSError as exc:
print(f"{log_tag} {out_path.name} may be an incomplete file — your "
f"earlier sheet is {backup.name} (could not move it back: "
f"{first_line(exc)})")
def write_xlsx_keep_old(out_path, rows: list, log_tag: str) -> Path:
"""
`write_xlsx(out_path, rows)`, without ever overwriting a file already at
`out_path` and without ever losing `rows` because of one. Returns the
path actually written, which callers must report — it is not always
`out_path`.
* nothing at `out_path` -> written there.
* a file already there -> renamed aside to the first free
`<stem>__<HHMMSS>[_<n>].xlsx` (kept, not deleted: it may hold the
operator's own edits), then `rows` written to `out_path`. Logged as
"<tag> <name> already existed — kept as <backup>".
* that rename, or the write after it, fails with OSError (the file is
open in Excel, or locked by a sync/virus scanner) -> `rows` go to the
first free `<stem>__<HHMMSS>[_<n>].xlsx` instead. Logged as "<tag>
<name> is open or locked (<error>) — saved this run as <fallback>
instead". The old file: when the rename itself failed, it never
moved; when the rename went through and the write after it failed,
it is put back under its usual name (`_move_back`) when possible —
and if that fails too, it stays at the `__HHMMSS` backup and the log
names both files, because the usual name may then hold the
incomplete write (final review, 2026-10-05: this bullet used to say
the old file was always "left where and as it was").
If the fallback write fails as well, that error propagates: both callers
already catch it and tell the operator, and there is nowhere left to put
the rows that would not be a guess.
The fallback writes directly under its final name rather than to a temp
file moved into place (as the D10 review suggested): virus scanners
commonly open a freshly closed file for a moment, so an extra rename
would be one more step that can hit the same WinError 32 this exists to
get around.
"""
out_path = Path(out_path)
if not out_path.exists():
write_xlsx(out_path, rows)
return out_path
stamp = datetime.now().strftime("%H%M%S")
backup = _first_free_name(out_path, stamp)
moved = False
try:
out_path.rename(backup)
moved = True
write_xlsx(out_path, rows)
except Exception as exc:
# Any failure after the old file moved puts it back first — even one
# that is a bug rather than a lock — so nothing is left renamed
# behind a half-written file.
if moved:
_move_back(out_path, backup, log_tag)
if not isinstance(exc, OSError):
raise
fallback = _first_free_name(out_path, stamp)
write_xlsx(fallback, rows)
print(f"{log_tag} {out_path.name} is open or locked "
f"({first_line(exc)}) — saved this run as {fallback.name} "
f"instead")
return fallback
print(f"{log_tag} {out_path.name} already existed — kept as "
f"{backup.name}")
return out_path
+23
View File
@@ -0,0 +1,23 @@
"""
core/text.py — small text helpers shared by core/, conditions/ and flows/.
Lives in core/ because core/ must not import from conditions/, pages/ or
flows/, and all three need it (review of unit D, 2026-10-05: the condition
runner, the Charge Summary and the flow each had their own copy).
"""
def first_line(value) -> str:
"""
The first non-blank line of `value`'s text, stripped.
For an exception: Playwright's messages carry a whole call log after the
first line, which is all a log line or a summary cell wants. An exception
with an empty message gives its class name instead — a bare
`str(exc).splitlines()[0]` raises IndexError on one. Any other empty
value (an error string that came back blank) gives "".
"""
lines = str(value).strip().splitlines()
if lines:
return lines[0].strip()
return type(value).__name__ if isinstance(value, BaseException) else ""
+148
View File
@@ -0,0 +1,148 @@
"""
Claim watchdog (2026-10-22).
A frozen page hangs the bot for good: Playwright's `evaluate` has no
timeout, and on 2026-10-21 / 22 a run sat 28 minutes on one claim after
its form opened — no error, no end, the Runs page frozen on its last event.
While the claims are checked (`watching()`), the flow beats at every step
of a claim, and every message box beats as it opens and closes. When no
beat comes for the limit (`WATCHDOG_MINUTES`, never under a box's timeout
plus two minutes), a thread here:
1. closes this process's Playwright driver (`node.exe`, a child of this
process). The frozen call then returns with an error, so the claim
fails like any other and the flow carries on to its next step. With
BROWSER_CDP_URL only the driver goes: the operator's Chrome stays.
2. marks the watchdog fired. The flow's next `check()` — at the top of
the next claim — raises `Stalled`, and the Run ends with exit 8, a Run
Error naming where it stopped, and the rest of the queue Not Reached.
3. if the main thread still does not come back (GRACE_S), writes the Run
Error and the end itself and exits the process with code 8 — a hung
bot never again runs without an end.
"""
import os
import time
import threading
import subprocess
from contextlib import contextmanager
from .. import settings as cfg
from . import run_events, run_lock
EXIT_CODE = 8
POLL_S = 5.0 # how often the thread looks
GRACE_S = 120.0 # after the kill, how long the main thread gets
_lock = threading.Lock()
_state = {"armed": False, "last": 0.0, "where": "", "limit": 0.0,
"fired": False, "answered": False, "message": ""}
_thread = None
class Stalled(RuntimeError):
"""No progress for the limit: the Run stops (exit 8)."""
def limit_seconds() -> float:
"""WATCHDOG_MINUTES in seconds, never under a message box's timeout
plus two minutes (a box left open is not a hang); 0 = off."""
if not cfg.WATCHDOG_MINUTES:
return 0
return max(cfg.WATCHDOG_MINUTES * 60, cfg.MSGBOX_TIMEOUT_SECONDS + 120)
def beat(where: str = "") -> None:
"""Progress: the clock starts again, and `where` names the step."""
with _lock:
_state["last"] = time.monotonic()
if where:
_state["where"] = where
def has_fired() -> bool:
return _state["fired"]
def message() -> str:
return _state["message"]
def check() -> None:
"""Raise `Stalled` once the watchdog has fired."""
if _state["fired"]:
_state["answered"] = True
raise Stalled(_state["message"])
@contextmanager
def watching(limit_s: float):
"""Watch the block; `limit_s` 0 or less = not at all."""
global _thread
with _lock:
_state.update(armed=limit_s > 0, last=time.monotonic(), where="",
limit=float(limit_s), fired=False, answered=False,
message="")
if limit_s > 0 and (_thread is None or not _thread.is_alive()):
_thread = threading.Thread(target=_watch, name="claim-watchdog",
daemon=True)
_thread.start()
try:
yield
finally:
with _lock:
_state["armed"] = False
def _watch() -> None:
while True:
time.sleep(POLL_S)
with _lock:
due = (_state["armed"] and not _state["fired"]
and time.monotonic() - _state["last"] >= _state["limit"])
if due:
minutes = _state["limit"] / 60
_state["fired"] = True
_state["message"] = (
f"No progress for {minutes:g} min at "
f"{_state['where'] or 'the claims'} — the page froze; "
f"the browser was closed and the run stopped")
if not due:
continue
print(f"[WATCHDOG] {_state['message']}")
run_events.warning("watchdog", _state["message"])
try:
_kill()
except Exception as exc:
print(f"[WATCHDOG] Could not close the driver: {exc}")
end = time.monotonic() + GRACE_S
while time.monotonic() < end and not _state["answered"]:
if not _state["armed"]: # the claims ended another way
break
time.sleep(POLL_S / 5)
if not _state["answered"] and _state["armed"]:
print("[WATCHDOG] The run did not come back — ending it")
run_events.run_error("stalled", _state["message"], EXIT_CODE)
run_events.emit("run_end", exit_code=EXIT_CODE)
run_events.close()
run_lock.finish(EXIT_CODE)
_exit(EXIT_CODE)
def _kill() -> None:
"""Close this process's Playwright driver (node.exe) and what it
started. Nothing else of this process is touched."""
ps = (f"Get-CimInstance Win32_Process -Filter "
f"'ParentProcessId={os.getpid()} and Name=\"node.exe\"' | "
f"ForEach-Object {{ $_.ProcessId }}")
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=30).stdout
for pid in out.split():
if pid.isdigit():
subprocess.run(["taskkill", "/PID", pid, "/T", "/F"],
capture_output=True, timeout=30)
def _exit(code: int) -> None:
os._exit(code)