""" Salud eCW Charge Entry Bot — settings. Single-client build. Everything that changes between environments lives in `.env` at the project root (never committed). No credentials in code, and no credentials in a shared spreadsheet the way the multi-client NAHMS bot did it. Read order for every setting: 1. real process environment variable (wins — good for CI / scheduled runs) 2. .env file at the project root 3. the default in this file **Selectors do not live here.** They used to, and a 442-line module mixing DOM ids with operational settings meant changing a date range and changing a selector were edits to the same file. They are in `selectors\\` now; this module holds only values a person would want to change without touching code. """ import os import re import sys from pathlib import Path from datetime import datetime from .selectors.claim_lookup import DATE_TYPE_CLAIM # ── Project roots ───────────────────────────────────────────────────────────── # Three parents, not one: this file is src\ecw_bot\settings.py, so # settings.py → ecw_bot → src → the project root that holds .env and Logs\. if getattr(sys, "frozen", False): ROOT = Path(sys.executable).parent else: ROOT = Path(__file__).resolve().parent.parent.parent ENV_PATH = ROOT / ".env" TODAY = datetime.now().strftime("%m-%d-%Y") SCREENSHOTS = ROOT / "Screenshots" / TODAY LOGS = ROOT / "Logs" DOWNLOADS_TMP = ROOT / "Input" / "_pw_downloads" # ── .env loader ─────────────────────────────────────────────────────────────── # Deliberately dependency-free: these machines are locked down and adding # python-dotenv is one more thing to get approved. The format is the usual # KEY=value, one per line, '#' comments, optional surrounding quotes. # # Inline comments: an UNQUOTED value ends at the first whitespace followed by # '#', so `SAVE_CORRECTIONS=0 # verification` reads as "0". Before 2026-10-05 # the whole tail was kept, "0 # verification" was not a recognised yes/no # word, and the setting fell back to its code default — which for # SAVE_CORRECTIONS was ON. A comment meant to document "saving is off" turned # saving on, silently (review item P1). `CLAIM_PROCESS_LIMIT=5 # demo` read as # the default 1 the same way. # # A '#' with NO whitespace before it stays part of the value: real values here # contain one — the landing URL's "#/..." fragment, and a mailbox password — # and neither has a space in front of it. # # **Quote a value that needs ` #`** (space then hash) inside it: # `KEY="a # b"` reads as `a # b`. For a quoted value the text between the # quotes is the value and anything after the closing quote (` # note`) is # dropped. A value that is quoted end to end with nothing after it reads # exactly as it always did, inner quotes included (`"pa"ss"` → `pa"ss`, # `"Ab"#12"` → `Ab"#12`): after a closing quote, as everywhere else, a '#' # starts a comment only when whitespace comes before it. The one shape that # cannot be told apart from a trailing comment is an inner quote of the SAME # kind followed by whitespace and '#' (`"a" #b"` reads as `a`); write such a # value in the other kind of quotes (`'a" #b'` reads as `a" #b`). # # Text after the closing quote that is NOT a comment (`"1"0`, `'1'x`) reads # as the quoted part for a text setting, but stops the run (exit 2, before # login) for a yes/no or whole-number one — see _QUOTE_TRAILING. # Opening quote, the shortest text that still lets the rest of the line be # nothing but an optional ` #` comment, then the same quote. Shortest, so a # quote inside the comment cannot pull the comment into the value; "still lets # the rest be a comment", so a quote inside the value does not cut it short. # The comment needs whitespace before its '#' — the same rule as an unquoted # value — so `"Ab"#12"` is one value, not `Ab` plus a comment (fix round 1 of # the unit-A review: the first version cut a password there, silently). _QUOTED_VALUE = re.compile(r"""^(["'])(.*?)\1(?:\s+#.*)?$""") _INLINE_COMMENT = re.compile(r"\s+#") # Keys whose .env value was quoted and then followed by text that is NOT a # comment (`SAVE_CORRECTIONS="1"0`), as {key: the text after the '='}. Such # a value still reads as the quoted part — a password written that way keeps # working as it always did — but a yes/no or whole-number setting must never # be guessed at, so `_env_bool` / `_env_int` record it as a bad value (exit 2 # before login) instead of reading `"1"0` as 1 = LIVE-WRITE. The same text # unquoted (`10`, `1x`) was already refused (final review, 2026-10-05). _QUOTE_TRAILING: dict = {} def _parse_env_value(raw: str) -> tuple: """ `(value, trailing)` for one `KEY=value` line's text after the '='. `trailing` is True when a quoted value has non-comment text after its closing quote — read as the quoted part, but not a clean value. """ text = raw.strip() if text[:1] in ("\"", "'"): m = _QUOTED_VALUE.match(text) if m: return m.group(2), False # Text after the closing quote that is not a comment (`"abc"xyz`): # the quotes still say where the value ends. end = text.find(text[0], 1) if end != -1: return text[1:end], True # No closing quote at all: not a quoted value, so the quote character # is just part of an unquoted one — what the loader always did. # Cut on the raw text, not the stripped one, so `KEY= # nothing yet` # (whitespace right after the '=') reads as blank rather than "# nothing". return _INLINE_COMMENT.split(raw, maxsplit=1)[0].strip(), False def _env_file_value(raw: str) -> str: """The value part of one `KEY=value` line, as written after the '='.""" return _parse_env_value(raw)[0] # The keys this process took from .env, not from the real environment. A # child process started from here — the results page's bot run, # web/bot_runner.py — is given the environment WITHOUT them, so its own # settings.py reads .env afresh: an .env edited after the web server started # is seen, as a terminal run sees it (review, 2026-10-14). Inherited, the # server's copy would win over the file through the setdefault below. ENV_FILE_KEYS = set() def _load_env_file(path: Path) -> None: if not path.exists(): return for line in path.read_text(encoding="utf-8-sig").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, raw = line.partition("=") key = key.strip() val, trailing = _parse_env_value(raw) if trailing and key not in os.environ: # Only when the file's value is the one used (real env wins). _QUOTE_TRAILING[key] = raw.strip() # Real environment wins over the file. if key not in os.environ: # As os.environ lists it: Windows stores keys upper-cased, and # the child-env filter walks os.environ. ENV_FILE_KEYS.add(key.upper() if os.name == "nt" else key) os.environ.setdefault(key, val) _load_env_file(ENV_PATH) def _env(key: str, default: str = "") -> str: return str(os.environ.get(key, default) or "").strip() # Every yes/no or whole-number setting whose value could not be read, as # (key, raw value, what it should have been). validate() turns each into a # problem, so the run stops with exit 2 BEFORE login — where stopping costs # nothing — instead of quietly running on the code default. # # Strict on purpose (P1, 2026-10-01 review): the old parsers fell back to the # default for anything they did not recognise, so `SAVE_CORRECTIONS=O` (the # letter O), a stray comment, or a typo all read as the default — which for # SAVE_CORRECTIONS was "save to live eCW". A setting that controls writes must # never be guessed at. Blank or unset is NOT recorded: that genuinely means # "use the default". _BAD_VALUES: list = [] def _record_bad(key: str, raw: str, expected: str) -> None: if (key, raw, expected) not in _BAD_VALUES: _BAD_VALUES.append((key, raw, expected)) def _env_int(key: str, default: int) -> int: if key in _QUOTE_TRAILING: _record_bad(key, _QUOTE_TRAILING[key], "a whole number") return default raw = _env(key) if not raw: return default # isascii() too: "²" is a str.isdigit() digit that int() refuses, and a # crash at import would hide the clear validate() message below. if raw.isascii() and raw.isdigit(): return int(raw) _record_bad(key, raw, "a whole number") return default def _env_bool(key: str, default: bool = False) -> bool: if key in _QUOTE_TRAILING: _record_bad(key, _QUOTE_TRAILING[key], "a valid yes/no value") return default raw = _env(key) word = raw.lower() if word in ("1", "true", "yes", "y", "on"): return True if word in ("0", "false", "no", "n", "off"): return False if raw: _record_bad(key, raw, "a valid yes/no value") return default # ── eCW portal ──────────────────────────────────────────────────────────────── ECW_URL = _env("ECW_URL") ECW_USERNAME = _env("ECW_USERNAME") ECW_PASSWORD = _env("ECW_PASSWORD") # Blank for this client — kept so a TOTP-enabled login needs no code change. ECW_SECRET_KEY = _env("ECW_SECRET_KEY") # Optional deep link to open once login is confirmed (the provider schedule # page). eCW drops the URL fragment during the login redirect, so navigating # again after login is the only reliable way to land on it. ECW_LANDING_URL = _env("ECW_LANDING_URL", ECW_URL) # ── OTP / two-factor ────────────────────────────────────────────────────────── # auto — TOTP if ECW_SECRET_KEY is set, else mail if configured, else manual # totp — authenticator secret only # mail — read the challenge out of the mailbox. eCW sends either a 6-digit # code or a "Push Notification" approval link; auth/mailbox.py detects # which, so one mode covers both. (`imap` is an accepted old alias.) # manual — pause with a message box and let a human handle it OTP_MODE = _env("OTP_MODE", "auto").lower() IMAP_HOST = _env("IMAP_HOST") IMAP_PORT = _env_int("IMAP_PORT", 993) IMAP_USERNAME = _env("IMAP_USERNAME") IMAP_PASSWORD = _env("IMAP_PASSWORD") IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX") IMAP_SSL = _env_bool("IMAP_SSL", True) # The agent's email to the operator (core/notify.py, operator 2026-10-30): # sent from the 2FA mailbox's own account over SMTP, so no new credential. # Blank NOTIFY_EMAIL_TO = no mail at all. NOTIFY_EMAIL_TO = _env("NOTIFY_EMAIL_TO") SMTP_HOST = _env("SMTP_HOST") or IMAP_HOST SMTP_PORT = _env_int("SMTP_PORT", 465) SMTP_USERNAME = _env("SMTP_USERNAME") or IMAP_USERNAME SMTP_PASSWORD = _env("SMTP_PASSWORD") or IMAP_PASSWORD # How long to keep polling the mailbox for the OTP mail, and how often. # # Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery # order on 2026-09-02: the message eCW stamped 11:19:10 arrived in the mailbox # *after* the one stamped 11:26:10, so roughly seven minutes late, and ten has # been seen. A short window gives up while the mail is still in flight and the # run then blames the mailbox. Quitting early is what costs a login attempt. OTP_WAIT_SECONDS = _env_int("OTP_WAIT_SECONDS", 600) OTP_POLL_SECONDS = _env_int("OTP_POLL_SECONDS", 5) # A challenge older than this gets a warning in the log — it is NOT skipped. # # eCW's JWT is good for about ten minutes and the mail itself sometimes takes # ten to arrive, so a link can land already near expiry. Refusing to click it # then guarantees failure, while clicking it costs nothing that has not already # been spent. So this only marks the age, giving the log a reason to point at # when eCW answers "this verification link has expired" — which otherwise reads # exactly like a wrong password. OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540) # eCW's push notification screen offers a "Resend OTP" button, and pressing it # beats handing the step to a human. # # But resend LATE, not early — a full ten minutes of waiting first. # # Ten because that is the longest delivery delay actually observed: the mail is # late rather than missing, so before ten minutes have passed "still in flight" # is the likeliest explanation, and a second message would only join the same # slow queue behind the first. # # One resend, so the schedule is 600s → Resend → 600s = 20 minutes before a # human is asked. Raise OTP_RESEND_ATTEMPTS for more rounds, but note the two # ceilings that are NOT ours to raise: eCW's JWT is good for about ten minutes, # and its login page may expire before the wait does — auth/twofactor.py checks # for that and stops with it as the stated reason. OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1) OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600) # A link approved but never picked up by the login page (live 2026-10-29: mail # 6 to 8.5 minutes late, page still on "sent a notification", Resend still # there): wait this long for the page to move on, then Resend and approve the # new mail, up to OTP_PICKUP_RESENDS times. eCW allows three resends a login. OTP_PICKUP_SECONDS = _env_int("OTP_PICKUP_SECONDS", 60) OTP_PICKUP_RESENDS = _env_int("OTP_PICKUP_RESENDS", 2) # Ignore anything already sitting in the inbox from before the login attempt. OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300) # ── Proxy ───────────────────────────────────────────────────────────────────── PROXY_IP = _env("PROXY_IP") PROXY_PORT = _env("PROXY_PORT") def proxy_settings(): """Playwright proxy dict, or None when no usable proxy is configured.""" import re if not (PROXY_IP and PROXY_PORT): return None ip = re.sub(r"^https?://", "", PROXY_IP).strip().rstrip("/") port = PROXY_PORT.strip() if not ip or not port.isdigit(): return None return {"server": f"http://{ip}:{port}"} # ── Attempt guard ───────────────────────────────────────────────────────────── # eCW restricts an account that is retried too hard — seven attempts in # forty-five minutes was enough, and afterwards it stopped mailing approval # links while the page still claimed it had sent one. core/guard.py enforces # these. LOGIN_COOLDOWN_SECONDS = _env_int("LOGIN_COOLDOWN_SECONDS", 300) # 5 min LOGIN_MAX_PER_HOUR = _env_int("LOGIN_MAX_PER_HOUR", 4) # If a plain cooldown has no more than this left to run, the bot waits it out # rather than exiting 3. Refusing a run over the last few seconds of a cooldown # just moves the retrying into the operator's hands, which is worse: five of six # consecutive runs died on this, one of them with 3s left. A restriction lockout # and the per-hour burst limit are never waited out — those mean stop. # 0 disables waiting and restores the old exit-immediately behaviour. COOLDOWN_MAX_WAIT_SECONDS = _env_int("COOLDOWN_MAX_WAIT_SECONDS", LOGIN_COOLDOWN_SECONDS) # How many times to start the login over when eCW's 2FA screen times out. # # "Your log in request has timed out. Please try again" is a dead end: the page # has stopped polling for the approval and the emailed link is spent, so only a # fresh login helps. The browser is closed and the whole login runs again. # # Each retry goes through the guard above — cooldown waited out, burst limit # still enforced — because an unguarded retry loop against this portal is # exactly what earned the account its restriction. With the defaults that means # a retry costs a 5-minute wait and one of the four hourly attempts, so 1 is # deliberately modest: two shots at a factor that is delayed rather than # broken. 0 turns the retry off and the run stops with exit 6. LOGIN_RETRY_ON_TIMEOUT = _env_int("LOGIN_RETRY_ON_TIMEOUT", 1) # ── Operator prompts ────────────────────────────────────────────────────────── # Every message box times out, so an unattended run fails instead of holding a # browser open indefinitely. MSGBOX_TIMEOUT_SECONDS = _env_int("MSGBOX_TIMEOUT_SECONDS", 180) # ── Disk space ──────────────────────────────────────────────────────────────── # Checked on the system drive before login (2026-10-22): with C: at 0 MB the # browser died mid-login twice on 2026-10-21. Under WARN = a Warning on the # Runs page; under STOP = the Run stops with exit 2, no login attempt spent. DISK_WARN_MB = _env_int("DISK_WARN_MB", 2048) DISK_STOP_MB = _env_int("DISK_STOP_MB", 500) # ── Claim watchdog ──────────────────────────────────────────────────────────── # No progress on a claim for this many minutes = the page froze (2026-10-22: # a run sat 28 minutes on one claim). The driver is closed and the run stops # with exit 8, recorded. Never under a message box's timeout + 2 minutes. # 0 = off. core/watchdog.py. WATCHDOG_MINUTES = _env_int("WATCHDOG_MINUTES", 10) # ── Browser ─────────────────────────────────────────────────────────────────── HEADLESS = _env_bool("HEADLESS", False) CHROME_PATH = _env("CHROME_PATH") # blank → Playwright's bundled chromium # Attach to a Chrome the operator started with start_ecw_chrome.bat instead of # launching one, so the run can be watched live (Claude extension in that # Chrome). Blank → launch as before. Loopback only: whatever reaches this port # can drive a browser logged in to eCW. In this mode the proxy is Chrome's own # (the .bat passes it), and eCW's cookies are cleared at the start and end of # every run. BROWSER_CDP_URL = _env("BROWSER_CDP_URL") # The profile that Chrome uses — on D:, because C: running out of space killed # the browser mid-login (2026-10-21). CDP_PROFILE_DIR = _env("CDP_PROFILE_DIR", r"D:\EcwChrome") # A Chrome the bot opened itself (none on the port at Start) is closed again # at the end, so its debug port is not left open. A Chrome the operator # opened is never closed. 0 = leave it open. CDP_CLOSE_AFTER_RUN = _env_bool("CDP_CLOSE_AFTER_RUN", True) def cdp_url_problem(url: str): """Why `url` is not a usable BROWSER_CDP_URL, or None if it is.""" from urllib.parse import urlsplit try: parts = urlsplit(url) port = parts.port except ValueError: return f"BROWSER_CDP_URL={url!r} is not a URL" if parts.scheme != "http" or not port: return f"BROWSER_CDP_URL={url!r} must look like http://127.0.0.1:9222" if parts.hostname not in ("127.0.0.1", "localhost"): return (f"BROWSER_CDP_URL={url!r} must be on 127.0.0.1 — anything that " f"reaches the port can drive the logged-in browser") return None # ── Timeouts (ms — Playwright's unit) ───────────────────────────────────────── LOGIN_TIMEOUT = _env_int("LOGIN_TIMEOUT_MS", 120_000) # 2 min NAV_TIMEOUT = _env_int("NAV_TIMEOUT_MS", 300_000) # 5 min NAV_RETRIES = _env_int("NAV_RETRIES", 3) # proxy ERR_ABORTED retries # Captcha is solved by a human via message box; two tries, then a fresh # incognito browser, then one last try. Same escalation the NAHMS bot settled # on after the OCR route proved unreliable. CAPTCHA_ATTEMPTS = _env_int("CAPTCHA_ATTEMPTS", 2) # ── Claim lookup: what to search for ────────────────────────────────────────── # Selected by VALUE, with the label checked afterwards. The value is what eCW # submits and the only unambiguous handle — the list holds "Medcor Ready" # (108ME) and "Medcor Testing" (113ME), so a loose text match could pick the # wrong queue and the search would still succeed. The label check then catches # the opposite risk: eCW reassigning a code to a different status. CLAIM_STATUS = _env("CLAIM_STATUS", "108ME") CLAIM_STATUS_LABEL = _env("CLAIM_STATUS_LABEL", "Medcor Ready") # Which date the search filters on. The process asks for Claim Dt(s). # # Be aware of what it pairs with: Claim Status "Encounters Without Claims" # returns rows that have no claim, so they have no claim date and this returns # nothing. Measured 2026-09-02, same 06/01/2020 → today range — 936 rows over # 47 pages on Service Dt(s), 0 on Claim Dt(s). claim_filters._warn_if_dateless() # says so in the log rather than leaving an empty grid to be puzzled over. CLAIM_DATE_TYPE = _env("CLAIM_DATE_TYPE", DATE_TYPE_CLAIM) # From is fixed by the process; To is "today" unless overridden. MM/DD/YYYY, # matching the inputs' own placeholder and their idt-maxlength="10". CLAIM_FROM_DATE = _env("CLAIM_FROM_DATE", "06/01/2020") CLAIM_TO_DATE = _env("CLAIM_TO_DATE") # blank → today # The Balance row arrives holding "> 0.0" with every control disabled, and eCW # queries on the Angular model rather than the inputs — so the filter is live # and unclickable. 1 clears it through the scope; 0 leaves it alone. # # The ng-scope / ng-binding classes in the DOM confirm Angular's debugInfo is # enabled, which is what makes angular.element(el).scope() available. If eCW # ever ships with it disabled this stops working, and it says so rather than # reporting a clear that did not happen. FORCE_CLEAR_BALANCE = _env_bool("FORCE_CLEAR_BALANCE", True) # How long to wait for the results grid after Lookup is pressed. # NOT wait_for_load_state("networkidle"): eCW's Angular shell keeps a heartbeat # running, so the network never goes idle and that call sat silent for its whole # timeout while the run looked frozen — every run was killed there, before the # export step was ever reached. claim_results.py polls the grid's own # "Total Counts" and says what it sees, so a slow query reads as slow. LOOKUP_TIMEOUT = _env_int("LOOKUP_TIMEOUT_MS", 180_000) # 3 min LOOKUP_POLL_SECONDS = _env_int("LOOKUP_POLL_SECONDS", 3) # ── Export ──────────────────────────────────────────────────────────────────── CLAIM_REPORT_FORMAT = _env("CLAIM_REPORT_FORMAT", "EXCEL") # EXCEL | CSV | TEXT # Where the exported report is saved: `Input\`, not `Output\`. # # It reads like output — the bot downloaded it — but it is the **input to the # charge-entry phase**: the next step walks its claim ids one at a time. Naming # it Output would invite someone to treat it as a finished deliverable and # clear it out between runs, which would strand the phase that consumes it. # # Gitignored, like Output — it is a claims report, so it holds patient data. INPUTS = ROOT / "Input" # The saved file is `_.`, e.g. # `Claim Report_09-07-2026.xlsx`. A fixed, predictable name because the next # phase has to find it without being told; the date because the queue is a # daily one. The extension is whatever eCW actually sent — its "EXCEL" export # has been both a real xlsx and an HTML table, and renaming HTML to .xlsx makes # Excel refuse to open it. CLAIM_REPORT_NAME = _env("CLAIM_REPORT_NAME", "Claim Report") # Where the previous run's report goes when a new one is downloaded. # # Input\ ends up holding EXACTLY ONE report, always the current one. Six of # them accumulated over three days of testing, and once more than one is # sitting there "which file is current?" stops being obvious - to a person # reading the folder, and to any code that has to find it without being told. # Moving the old ones out is what makes the answer unambiguous instead of a # rule someone has to remember. # # Moved, not deleted: they are patient data and the earlier run's evidence. INPUTS_ARCHIVE = INPUTS / "Archive" # 0 leaves old reports in Input\ alongside the new one. Only useful if # something outside this bot is watching that folder for every file. ARCHIVE_OLD_INPUTS = _env_bool("ARCHIVE_OLD_INPUTS", True) # After the export, read the claim ids back out of the saved file and show them # in a message box — count, the first and last few, and any duplicates or blank # rows skipped. The full list always goes to the log regardless of this. # # Worth doing as its own step: it proves the file the next phase will consume is # actually readable and holds what it should, at the point where the browser is # still open and a problem is cheap to look at. Set 0 for unattended runs. SHOW_CLAIM_IDS = _env_bool("SHOW_CLAIM_IDS", True) # After categorizing by payer, tell the operator about any PAYER value with # no entry in payer_groups.json — those rows are saved with a blank Group # Name rather than a guess. Informational only, same reasoning as # NOTIFY_AFTER_EXPORT above. Set 0 for unattended runs; the full list still # goes to the log either way. NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True) # TEMPORARY testing override. Blank (the default) leaves the live search + # export flow above untouched. Set to a file path and `_claims_phase` skips # the live bulk lookup and export entirely, reading claim ids straight from # that file instead — the claim-status/date/balance filters are still set # and every id is still searched and opened exactly the way a live-queue # claim is, only the *source of the id list* changes. # # Built for a hand-maintained "standard" sheet listing one known claim per # implemented condition: a run against it exercises every condition against # a real claim in one short pass, so a new condition (or a regression in an # old one) shows up in minutes instead of after a full live-queue run. # `claim_file.claim_ids()` already reads any file with a recognizable # claim-id header or column — this just points it at a fixed file instead # of the report the export step just saved. # # Set, it also marks the run as a TEST run (review item D1, 2026-10-05): # `[MODE]`/`[TEST MODE]` log lines say so, the Charge Summary goes to # Charge Summary_TEST_.xlsx (never the live summary's name), a path # that does not exist stops the run before login (exit 2), and a sheet that # exists but cannot be read stops it with exit 5 instead of a quiet exit 0. # The exit-2 check lives in validate(), so it also stops # tools/check_claims_from_excel.py, which does not use this setting: a stale # path left here must be cleared (or fixed) before that tool will run either. # Accepted on purpose — a path in .env that points at nothing is a config # mistake whichever program reads the file (unit B fix round 1, 2026-10-05). TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "") # Set by the agent (flows/supervisor.py), not by hand: the claims of today's # live queue still pending after an earlier pass stopped. Run like the live # queue — no TEST label, the live Charge Summary — but from this list, with # no fresh export. RESUME_CLAIM_IDS_FILE = _env("RESUME_CLAIM_IDS_FILE", "") # ── Per-claim processing ────────────────────────────────────────────────────── # How many claim ids to search, one at a time, after the export. 0 = all of # them. # # **The default is 1 on purpose.** The report holds 541 ids and each one is a # full server-side lookup, so "all" is a run measured in hours — not something # to discover by accident while testing a new step. Raise it deliberately once # the per-claim work actually does something. CLAIM_PROCESS_LIMIT = _env_int("CLAIM_PROCESS_LIMIT", 1) # A message box after each claim. Fine at a limit of 1 or 2; at 50 it is 50 # dialogs, so raise CLAIM_PROCESS_LIMIT and set this to 0 together. The # end-of-run summary box appears either way, and every claim is logged. # # At most one box per claim (review item D9, 2026-10-05): a claim whose # REVIEW_BEFORE_CLOSE box was shown (a condition changed it, saving off) has # had its one box, with this box's header in it, and gets no second one. # Dental claims show their HIGH RISK / sliding fee results here too — so 0 # also turns off the Dental box, which used to appear whatever was set (P6). SHOW_EACH_CLAIM = _env_bool("SHOW_EACH_CLAIM", True) # Information boxes go to the log as one "[BOX] title: first line" line # instead of the screen. The results page sets it for the runs it starts # (2026-10-14). Boxes a person must act on — CAPTCHA, 2FA manual step, # security image, LIVE-WRITE confirm — are never quieted. QUIET_BOXES = _env_bool("QUIET_BOXES", False) # A failure screenshot (`page_utils.shot` with a failure tag) also saves the # page's DOM beside it as .html (2026-10-21): the HTML to fix a changed # selector from, without a re-run or a hand copy into Dom.txt. PHI, like the # screenshots — it never leaves the Screenshots folder. SAVE_DOM_LIMIT caps # the files per run (an eCW page is ~1 MB with its frames). SAVE_DOM_ON_FAILURE = _env_bool("SAVE_DOM_ON_FAILURE", True) SAVE_DOM_LIMIT = _env_int("SAVE_DOM_LIMIT", 30) # Pre-flight selector check (pages/preflight.py, 2026-10-21): before the # first claim the claims search page's selectors, and on the first claim of # each screen type (UB / HCFA / Dental) the claim form's, are checked. A # missing one is named in the log, its DOM saved, and — PREFLIGHT_STOP on — # the run stops (exit 7) instead of sending every claim after it to Review. PREFLIGHT = _env_bool("PREFLIGHT", True) PREFLIGHT_STOP = _env_bool("PREFLIGHT_STOP", True) # For each claim, open the UB Claim form, read it, and close it again. # # Closed with Cancel (#UBClaimBtn17) unless SAVE_CORRECTIONS=1 and the # operator clicks OK on that claim's save box (shown only when a condition # fired), in which case that one claim closes with OK instead (#UBClaimBtn18, # saveAllData(true)). See SAVE_CORRECTIONS for the guard. OPEN_CLAIM_FORM = _env_bool("OPEN_CLAIM_FORM", True) # Apply the write-path corrections in pages/claim_form.py (telehealth # POS/modifier is the first one) once their trigger condition is detected. # # This gates whether the conditions RUN at all — detect, then fill the open # form in. It does not click OK: whether a filled-in form is ever saved is # SAVE_CORRECTIONS below, and with that off the form is closed with Cancel. # 0 restores the pre-correction behaviour: read, extract, always close with # Cancel, check nothing. # # Default stays ON (operator ruling, 2026-10-05), unlike SAVE_CORRECTIONS. # Applying only fills in a form that is then closed with Cancel while saving # is off. Review item P16 is answered (audit log, 2026-10-16): typed edits # (POS, modifiers, fees, pointers) are discarded by Cancel, but ticking a # bill-to checkbox (updateBillToIns()) and adding a CPT row # (addClaimFSCPTCode('save')) save on the server at once. So with # SAVE_CORRECTIONS off neither is done — see SAVE_CORRECTIONS below. The demo # .env does not set this, so defaulting it off would make every such run # check nothing at all. APPLY_CORRECTIONS = _env_bool("APPLY_CORRECTIONS", True) # The actual write gate, one level down from APPLY_CORRECTIONS — asked # through `pages/claim_form.writes_allowed()`. With this at 0 (VERIFY) a # condition still fires and fills the form ON SCREEN — so it stays visible # and in the per-claim summary box, for review — but nothing reaches eCW: # a bill-to box is set on screen with no click (the click saves at once), no # CPT row is added (the picker's OK saves at once; the claim is Review, "add # by hand"), the confirmation box never appears and the claim is always # closed with Cancel. At 1 (LIVE-WRITE) box clicks and row adds save in eCW # the moment they are made, before the per-claim OK, and Cancel does not # undo them. Dental claims are never written in either mode. # # 2026-09-17: turned off on purpose in .env. A real claim (9103000) got saved # for real during a test run because someone answered the confirmation box — # this is the change that makes that impossible while conditions are still # being verified against live claims. # # 2026-10-05: the CODE default is off too (review item P1). Until then only # the .env line kept saving off, and the default behind it was ON — so a # missing line, a blank value, a typo, or an inline comment the loader did not # strip all turned writes back on without a word. Now a missing or blank line # can never turn saving (the OK click) on; only an explicit SAVE_CORRECTIONS=1 # can, and the run then asks for an OK at start (the LIVE-WRITE box in # flows/export_medcor_ready.py) and before every save. P16 is answered (see # APPLY_CORRECTIONS above): while this is off nothing is written (option C, # 2026-10-16). Turn it on only once a condition has been confirmed correct # and the client is ready for it to write. SAVE_CORRECTIONS = _env_bool("SAVE_CORRECTIONS", False) # Pause on a message box before the claim form is closed, so the corrections a # condition just filled in can be checked on screen while they are still there. # # Only reached when SAVE_CORRECTIONS is off. With it on, the save confirmation # box is already the pause before closing — this is the one that was missing # underneath it. Without this, SAVE_CORRECTIONS=0 fills the form in and then # clicks Cancel in the same breath, so "applied for review" was only ever # reviewable in the log and the screenshot, never on the actual screen. # # The box is informational: nothing is saved either way, and the claim still # closes with Cancel the moment it is answered. It is app-modal to the bot's # own thread, not to Chrome, so the form behind it stays scrollable and # inspectable while it is up. Times out on MSGBOX_TIMEOUT_SECONDS like every # other prompt — raise that if a claim needs longer than 180s to check. Set 0 # for unattended runs, or it adds up to that per corrected claim. # # It is the corrected claim's ONE box (review item D9, 2026-10-05): it carries # the SHOW_EACH_CLAIM header and Summary line, and the per-claim box after # close is then skipped. The "N condition(s) matched — about to apply" box # that used to come first is now a log line only. REVIEW_BEFORE_CLOSE = _env_bool("REVIEW_BEFORE_CLOSE", True) # Also click "Prog. Notes" and capture whatever screen it opens. # # Separate from OPEN_CLAIM_FORM because it is the one part whose destination # has never been seen: the button carries ng-disabled="!FormData. # EnableProgressNote", so on some claims it does nothing at all, and what it # opens (a modal or a new tab) is discovered at run time rather than assumed. OPEN_PROGRESS_NOTES = _env_bool("OPEN_PROGRESS_NOTES", True) # How long, after clicking Prog. Notes, to keep polling (every 250 ms) for the # note document before reporting "nothing". Replaces a fixed 3s sleep and one # probe, which missed 3 of ~115 notes in the logs — every one a viewer that had # opened on a document still loading, so the claim went out Clean with # telehealth never checked (review item D8, 2026-10-01). A note that loads # normally is accepted in well under a second; only a slow or missing one uses # the full wait. 20s sits in the review's suggested 15-25s. PROGRESS_NOTE_WAIT_MS = _env_int("PROGRESS_NOTE_WAIT_MS", 20_000) # Dental claims only (condition #8, "All Dental Payer"): open Info -> # Sliding Fee Schedule and check the patient's sliding fee is still valid on the claim's DOS (active / expired / not_started / none). # Read-only: Patient Information closes with Cancel, never Save. A temporary # process for now (2026-09-30); the Excel output column comes next. CHECK_SLIDING_FEE = _env_bool("CHECK_SLIDING_FEE", True) # Dental claims only (condition #15): report whether the Billing Notes say # "HIGH RISK" — such claims go through the DentaQuest web portal. Read-only, # shown with the sliding fee in the claim's SHOW_EACH_CLAIM box. CHECK_HIGH_RISK = _env_bool("CHECK_HIGH_RISK", True) # At the end of every claims run, write Output\Charge Summary_.xlsx in # the operator's 24-column format: the run's report rows (A-R) plus what the # run found per claim (S-X). See core/charge_summary.py. A TEST_CLAIM_IDS_FILE # run writes Charge Summary_TEST_.xlsx instead. If that day's file is # open in Excel, the new sheet is saved as __.xlsx next to it. WRITE_CHARGE_SUMMARY = _env_bool("WRITE_CHARGE_SUMMARY", True) # "Assigned To" on every claim the run read. SUMMARY_ASSIGNED_TO = _env("SUMMARY_ASSIGNED_TO", "eCW Bot") # ── Results database (SQL Server) ───────────────────────────────────────────── # Also save the Charge Summary rows to SQL Server (core/results_db.py) for the # results web page (web/). Off by default; a database error is a log line # only — the run and its Excel summary carry on. The default connection is # the Windows login (no password) to the operator's Charge_Validation DB. RESULTS_DB = _env_bool("RESULTS_DB", False) RESULTS_DB_CONN = _env( "RESULTS_DB_CONN", "DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\\MECARE331;" "DATABASE=Charge_Validation;Trusted_Connection=yes;" "TrustServerCertificate=yes;") # The Portal on a server (Dokploy, 2026-10-27). PORTAL_URLS: the address(es) # people type, comma separated (e.g. https://cv.example.com); empty = this # PC only (127.0.0.1:8000). PORTAL_SERVER=1: no Start / Stop — the bot runs # only on the Windows PC. PORTAL_URLS = _env("PORTAL_URLS") PORTAL_SERVER = _env_bool("PORTAL_SERVER", False) # The Ecare office sign-in service (2026-10-27), e.g. # https://auth-wfm.ecareindia.com/api/login/token. Empty: Portal passwords # only. AUTH_TOKEN_API_URL = _env("AUTH_TOKEN_API_URL") # eCW builds the file server-side over a wide date range, so this is generous. DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min # ── Operator checkpoints ────────────────────────────────────────────────────── # Show the operator the filters and wait for an OK before running the search. # Cancel aborts; a TIMEOUT carries on, because aborting there threw away a # completed login and a mailed 2FA approval link when nobody was at the desk, # and the lookup only reads. 0 skips the box entirely. CONFIRM_BEFORE_LOOKUP = _env_bool("CONFIRM_BEFORE_LOOKUP", True) # Tell the operator how the export finished — row count, file name, size, and # where it landed. Informational only, so it never blocks the result: the file # is already saved by the time it appears. Set 0 for unattended runs, or it adds # up to MSGBOX_TIMEOUT_SECONDS to every run with nobody there to click OK. NOTIFY_AFTER_EXPORT = _env_bool("NOTIFY_AFTER_EXPORT", True) def validate() -> list: """Return a list of human-readable problems with the current config.""" problems = [] if not ENV_PATH.exists(): problems.append(f".env not found at {ENV_PATH} — copy .env.example and fill it in") for key in ("ECW_URL", "ECW_USERNAME", "ECW_PASSWORD"): if not globals()[key]: problems.append(f"{key} is not set") if OTP_MODE in ("mail", "imap") and not (IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD): problems.append(f"OTP_MODE={OTP_MODE} but IMAP_HOST / IMAP_USERNAME / IMAP_PASSWORD are incomplete") if OTP_MODE == "totp" and not ECW_SECRET_KEY: problems.append("OTP_MODE=totp but ECW_SECRET_KEY is not set") if OTP_MODE not in ("auto", "totp", "mail", "imap", "manual"): problems.append(f"OTP_MODE={OTP_MODE!r} is not one of auto/totp/mail/manual") # Checked here, before login, because the run cannot do anything useful # without it: the test path skips the live export, so a missing sheet # used to cost a login and then exit 0 having processed nothing (review # item D1). Resolved exactly as the flow resolves it (Path(...) as # written, a relative path against the working folder), so this checks # the same file the run would open. A file that exists but cannot be # read still fails later, with exit 5. if TEST_CLAIM_IDS_FILE and not Path(TEST_CLAIM_IDS_FILE).exists(): problems.append(f"TEST_CLAIM_IDS_FILE={TEST_CLAIM_IDS_FILE} does not exist") if BROWSER_CDP_URL and cdp_url_problem(BROWSER_CDP_URL): problems.append(cdp_url_problem(BROWSER_CDP_URL)) # Values the strict parsers could not read (see _BAD_VALUES). Every one # stops the run here, before login: running on a guessed default is how # SAVE_CORRECTIONS=O used to mean "save". for key, raw, expected in _BAD_VALUES: hint = (" (use 1/0, true/false, yes/no or on/off)" if expected == "a valid yes/no value" else " (digits only)") # This list is printed to the console and the run log. No PASSWORD/ # SECRET/KEY setting is a yes/no or a number today, but a typo there # must still never put the value itself on screen. if any(s in key.upper() for s in ("PASSWORD", "SECRET", "KEY")): problems.append(f"{key} (value hidden) is not {expected}{hint}") else: problems.append(f"{key}={raw!r} is not {expected}{hint}") return problems def resolved_otp_mode() -> str: """Turn OTP_MODE=auto into the concrete mode that will actually be used.""" if OTP_MODE == "imap": # pre-push-link name for the same thing return "mail" if OTP_MODE != "auto": return OTP_MODE if ECW_SECRET_KEY: return "totp" if IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD: return "mail" return "manual"