# Project Context — eCW Charge Validation Bot (Salud) _Last updated: 2026-10-23. A one-file briefing for anyone (person or AI session) picking this project up. No credentials or patient data belong in this file._ --- ## 1. What this is A Python + Playwright bot that drives the **eClinicalWorks (eCW) web UI** for the client **Salud** and does the "Charge Validation" step of the team's Medcor Ready workflow: 1. Logs into eCW (username/password from `.env`, 2FA approval link read from the mailbox automatically). 2. Filters claims to status **108ME "Medcor Ready"** and exports the Claim Report to `Input\Claim Report_.xlsx`. 3. Adds a payer **Group Name** → `Output\Medcor Ready_.xlsx`, using the team's own template (Claim Status Dashboard pairs, see §5). 4. For each claim: looks it up, opens the claim form (UB / HCFA / Dental screen), waits for it to finish loading, reads it, opens the Progress Note, runs the **conditions** (rules from the client's *Charge Condition Document*), shows **one** review box per claim, and closes the claim with **Cancel** (or Save, only if enabled and confirmed). 5. Writes `Output\Charge Summary_.xlsx` in the team's 24-column format (`Charge Summary_TEST_.xlsx` for a `TEST_CLAIM_IDS_FILE` run), **after every claim** (since 2026-10-12): the sheet grows claim by claim, so a run that stops halfway keeps what it did. A same-day file is kept as `__HHMMSS`; if it is open in Excel, the new sheet is saved under a `__HHMMSS` name instead and the log and a box say which. 6. With `RESULTS_DB=1`, saves each claim's summary row to SQL Server (`Charge_Validation.dbo.CV_ChargeSummary`) as soon as that claim is done; `run_web.bat` shows them on a local page (§5). Repo: `D:\Ecw Process`, git branch `master`, **no remote** (never pushed). --- ## 2. How to run ```bash python run.py # the bot (logs into LIVE eCW) python agent.py # the agent: the whole day's queue, self-healing (below) python tests/test_.py # one test file, prints "N/M passed" run_web.bat # results portal, http://127.0.0.1:8000 start_ecw_chrome.bat # Chrome the bot attaches to (BROWSER_CDP_URL) ``` **The agent (2026-10-30, operator: "we are an agent, not a bot — observe, fix, continue, and mail me what happened").** `python agent.py` (`flows/supervisor.py`) runs the bot as a child process, pass after pass, until every claim in the day's Medcor Ready queue has a result. Pass 1 downloads the live queue; a later pass takes only the claims still pending — the queue file minus every `CV_ChargeSummary` row since the agent started, a "Bot could not ..." Review not counting — through `RESUME_CLAIM_IDS_FILE` (live mode, live Charge Summary, no TEST label, no second export). After each pass, by exit code (`next_action`): login failure (1, 6) → retry after 10 then 20 min, the 3rd in a row stops the agent (the client's account must not be restricted); restricted (4), screen changed (7), config (2) → stop at once; stuck page (9), stall (8), claims error (5) → fresh Chrome + login in 1 min; guard wait (3) → wait; a clean pass leaving the same claims pending → stop ("left for a person"); 15 passes a day at most. VERIFY only: refuses to start with `SAVE_CORRECTIONS=1`; forces `QUIET_BOXES=1`, `SHOW_EACH_CLAIM=0`, `CLAIM_PROCESS_LIMIT=0`. Every failure and the end are mailed (`core/notify.py`, from the 2FA mailbox's account over SMTP — `SMTP_*` default to `IMAP_*`, port 465; **`NOTIFY_EMAIL_TO` blank = no mail, and it is blank on this PC until the operator gives the address**). A mail carries no claim number (6+ digit runs masked) and only login / error page PNGs (`notify._EMAILABLE`) — never a claim screen or page HTML. Inside a pass, `flows/recovery.py`: a claim the bot could not search or read → login page on screen = session over (exit 9); else Escape, close Patient Information, clear the screen, reopen the claim search if still blocked, and try the claim once more; 2 claims in a row still failing after recovery → exit 9 (live 2026-10-29: a Dental claim's sliding fee check left Patient Information open and 18 claims went "Bot could not search" one after another). Login: the security image page's Continue is clicked by the bot (`login.continue_security_image`, a person only when no Continue/OK is found; the page is saved as `security_image_page` the first time); a 2FA link approved but not picked up in `OTP_PICKUP_SECONDS` (60) → Resend OTP + approve the new mail, `OTP_PICKUP_RESENDS` (2) times (live 2026-10-29 night: three logins lost to mail 6 to 8.5 min late with Resend still on screen). The "Chrome open" and security image boxes are quiet with `QUIET_BOXES=1` (a `[BOX]` log line); CAPTCHA and manual 2FA boxes still show — they need a person. **None of this has run live yet.** **Watching a run live (2026-10-22, `8af5c14`):** `start_ecw_chrome.bat` opens Google Chrome with its own profile `D:\EcwChrome` (`CDP_PROFILE_DIR`), debug port 9222 on 127.0.0.1, the eCW proxy (claude.ai / anthropic.com go direct, for the Claude extension) and the bot's usual Chrome flags. With `BROWSER_CDP_URL=http://127.0.0.1:9222` in `.env` the bot attaches to that Chrome instead of launching Playwright's Chromium: it opens its own tab, clears **eCW's** cookies and site storage (`ecwcloud.com` only — claude.ai's sign-in stays) when it attaches and again when it closes, closes only its own tabs and leaves Chrome open. Only a loopback URL is accepted. The captcha incognito retry still launches its own Chromium. Blank `BROWSER_CDP_URL` = the old launch. **Since `0f16560` (2026-10-22, operator: "Start on the Portal is all it takes"):** no Chrome on the port at Start → the run **opens Google Chrome itself** (same profile, proxy and flags, through a short-lived Python so Chrome is outside the bot's process tree: a Portal Stop's `taskkill /T` never takes it) and **closes it at the end** (`CDP_CLOSE_AFTER_RUN=1`); a Chrome the run did not open is never closed. Before login the Chrome on the port must carry the eCW proxy (its command line, read with PowerShell, has `--proxy-server=`), else exit 2 — added after a test opened a Chrome with no proxy on 9222 at 19:41 that day, a Portal run attached to it and eCW refused the login as "restricted" (exit 4, guard lockout 5 min; the tests no longer read the real `.env`'s `BROWSER_CDP_URL`). **`.env` on this PC has `BROWSER_CDP_URL=http://127.0.0.1:9222` since 2026-10-22** (backup of the earlier `.env` in the session's temp folder). Live: 19:29 attached and reached the 2FA wait (stopped), **19:53 Portal run opened Chrome, logged in, 50/50 claims (39 Pass / 11 Fail / 0 Review), cleared the eCW cookies and closed Chrome** — exit 0. The landing page after Home took ~2 min there (15 s in Chromium runs); watch it. **Since `8fea168` (2026-10-23, operator):** a run that opened Chrome itself shows a person's box **"eCW Bot — Chrome open"** after the proxy check and before the eCW login, so the Claude extension can be added; OK → login (no OK → on after `MSGBOX_TIMEOUT_SECONDS`, 180 s; shown on a Portal run too). Once the bot's tab is open, that Chrome's **empty first tab** (New Tab / about:blank) is closed, so the window shows eCW only. A Chrome already on the port (`start_ecw_chrome.bat`): no box, every tab kept. Live 2026-10-23 terminal runs: 16:27 — 20 of 50 claims (all Pass), stopped with Ctrl-C (KeyboardInterrupt in a claim box: no `run_end`, so the Runs page shows it as ended without a record); 17:00 — stopped with Ctrl-C at the proxy check, before any login; **17:07 — the box came up, OK, login in 279 s (box wait included), 2FA mail in 42 s, Chrome showed only eCW tabs**, claims running. A full test suite ran beside the 16:27 and 17:07 runs without touching them (the tests' Chrome uses a free port, never 9222). **2026-10-26 17:40 terminal run:** Chrome open box → OK, login 97 s (2FA mail 42 s), **50/50 read, 38 Pass / 12 Fail / 0 Review, no bot error**, ~27 min. Against the 23/10 17:07 run, 49 claims gave the same comment; the one change is eCW data, not code: a patient's sliding fee was renewed (new period from 10/23/2026), so a claim with an earlier DOS is now Fail "Sliding fee NOT STARTED" (§6 Waiting 6). The #25 and Portal changes of 23–26/10 changed no other result. **2026-10-27 terminal runs:** 16:40 — 2FA mail never came in 10 min (eCW timed the approval out), then attempt 2 crashed: the run closed the Chrome it opened and re-attached to 9222 while that Chrome was still closing (`connect_over_cdp: Target page ... has been closed`; fixed in `a89f689`, §10). 16:53 (after a 120 s guard cooldown) — login 220 s, 2FA mail 42 s, **50/50, 38 Pass / 12 Fail / 0 Review** (the 50-claim sheet; same as 26/10 17:40). 17:30 with `Mecor Ready 2 - 10262026.xlsx` (all claims, QUIET_BOXES, ECW_KEEP_OPEN=0) — login 98 s, **50/50, 30 Pass / 15 Fail / 5 Review**, the same as that file on 26/10. No bot error in either. **Client call (2026-10-27, MoM mailed to the team):** open conditions discussed with Madan for clarification; the 50-claim results file is in the shared production folder for Madan to review and give feedback; Madan has a Portal login (daily runs, Pass / Fail claims, comments; changes nothing). eCW data is the client's: we never update anything in eCW, not even to undo the bot's own earlier box changes (operator, 2026-10-28). **2026-10-28 runs:** 17:59 login failed (a late security image page, the password box stayed disabled; fixed `d1aa6da`); 18:00 / 18:03 / 18:04 runs started from VS Code's debugger (F5) — a Stop / restart there kills the run with no log line (18:04 died at claim 29 of 50, 28 saved). **19:28 — first run on the LIVE Medcor Ready queue** (operator: "move the download part live"; `.env` `TEST_CLAIM_IDS_FILE` blanked, the old line kept as a comment; `SHOW_EACH_CLAIM=0`, `QUIET_BOXES=1`, `MSGBOX_TIMEOUT_SECONDS=10`; backup of the earlier `.env` in the session's temp folder): report downloaded, 216 claims in the queue, the first 50 (`CLAIM_PROCESS_LIMIT`) read: **24 Pass / 25 Fail / 1 Review**, exit 0, ~32 min; login 753 s (the security image page waited for a person; 2FA mail 48 s). lab_codes, vaccine_z23_pointer and t1017_non_billable fired on live claims. Still VERIFY: nothing saved. Unattended runs still need the security image Continue clicked by a person (offered: the bot clicks it — not decided). **Runs page (2026-10-22, Stage 1 of EEWA-31..33, `751a177`):** http://127.0.0.1:8000/runs (header link "Runs"). Every Run writes `Logs\events_.jsonl` beside `run_.log` (`core/run_events.py`): one JSON line per event, each with its time to the millisecond, flushed at once, never fatal. The page lists every Run (Started By, Run Source, mode, duration, result + exit code, eCW Login Attempts + total login time, Pass / Fail / Review, Claim Errors, Warnings; filters date / result) and a Run's detail: each eCW Login Attempt's steps with times and the 2FA mail wait, Run Errors with the **traceback** (the log never had one), Claim Errors, Not Reached claims, Warnings, failure screenshots (PNG only) and a timeline; refreshes every 5 s while Running. Runs before 2026-10-22 are not listed. Started By = the signed-in Portal User for a page run (`ECW_STARTED_BY`, Stage 2), the Windows user for a terminal run. Seen live 2026-10-22 16:24 (a terminal run of the 50-claim sheet): every login step and claim recorded; Log In → 2FA mail 46 s. Disk check before login: system drive under `DISK_WARN_MB` (2048) = Warning, under `DISK_STOP_MB` (500) = exit 2. Terms (Run Error / Claim Error / Warning / Fail / Not Reached / eCW Login Attempt / Portal User / Sign-in / Started By): `GLOSSARY.md`. Why files and not SQL: `docs/adr/0001-run-events-in-files.md`. Spec: `docs/superpowers/specs/2026-10-22-run-tracking-and-portal-sign-in-design.md`. **Portal Sign-in (2026-10-22, Stage 2 of EEWA-31..33, `c230a8e`):** every page and `/api` needs a Sign-in. Setup, once: run `tools\sql\create_tables.sql` in SSMS (adds `CV_PortalUser`, `CV_PortalSignIn` and, for Stage 3, `CV_ChargeSummary.started_by`), then `python tools\add_portal_user.py` for the first Super Admin (password typed hidden), then restart `run_web.bat`. Roles since `f99dc17` (2026-10-27, operator: "super admin means me"): **Super Admin** (everything; Users page adds / changes Admins and Viewers — never deletes; nobody changes a Super Admin from the Portal, only the PC tool makes one), **Admin** (everything: Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer** (Dashboard, Claim status, Runs; changes nothing, no Users or Sign-ins). **Delete run (`4cc27a5`, 2026-10-28, Super Admin only):** a day's page lists that day's runs (time, claims, started by) with a Delete run button, shown to the Super Admin only; `POST /admin/runs/delete` checks the role and the page token (Admin / Viewer = 403). A confirm box first; it deletes that run's `CV_ChargeSummary` rows (`WHERE run_at = ?`), never eCW, never the `Logs\` files, and records "run · run deleted · by" in the Sign-in history. A SQL login without DELETE gets "No permission to delete runs" (SQLSTATE 42000) — the Dokploy Portal's `cv_portal` needs `GRANT DELETE ON dbo.CV_ChargeSummary TO cv_portal` (§10). Operator is gone (folded into Admin). The live table was moved 2026-10-27 by the operator in SSMS (the bot's SQL login cannot ALTER the table): the one user is Super Admin; the CHECK allows only the three roles. **Ecare office sign-in (`1da9e7c`, 2026-10-27, operator: the office login API, "only who we want"):** with `AUTH_TOKEN_API_URL` set (`https://auth-wfm.ecareindia.com/api/login/token`; public TLS cert, empty body = 401), a user added with sign-in "Ecare office login" (the Users page default) keeps no Portal password (`pw_iterations` 0): the backend posts their username + password there (15 s); 4xx = wrong password, 5xx / timeout / 404 / 405 = 503 "try again shortly", a good login with no Portal User = 403 `no_access`. The Ecare token is not kept; same session cookie. `/api/auth/login` / `me` / `logout`; 20 tries per address per 15 min = 429. "Portal password" users (the Super Admin's backup, clients) never reach Ecare. NOT live yet: `AUTH_TOKEN_API_URL` is in neither the PC's `.env` nor Dokploy, and no zip with it is deployed. Waiting: which fields the Ecare reply carries (name, emp id?) so the Add form can be username + role only and fill the name at first Sign-in — `D:\ecw-portal-deploy\ecare-fields.ps1` (prints field names, never the token or password) or AutoPilot's `backend/app/api/auth.py`. Passwords PBKDF2-SHA256 (600k, own salt), 10+ characters; a user an Admin adds or resets must choose their own at the next Sign-in. 5 wrong passwords = locked 15 minutes; 30 minutes idle = signed out; sessions live in the server's memory (a restart signs everyone out). The header shows the user, role and Sign out. Started By goes to the bot as `ECW_STARTED_BY` (Runs page, `bot_run.json`, the Dashboard card "started by …"); Stopped By into the Run's `stopped` event. Code: `web/users.py`, `web/app.py`; tests `test_portal_users.py`, `test_portal_signin.py` (the other web tests sign in first). **Set up on this PC 2026-10-22:** the new part of `create_tables.sql` run in SSMS as an admin at 18:19 (the bot's Windows login `MFILESERVER\EC10980` cannot create or alter tables — checked; it can insert / update the new ones through its database roles); first Admin `ec10980` added with `add_portal_user.py` at 18:22 (PBKDF2 600k, nothing plain stored); **first live Sign-in 18:24** (recorded in `CV_PortalSignIn`). To look at the tables in SSMS: `tools/sql/view_portal_tables.sql` (read-only; the user query leaves the hash and salt out). **Last-run banner and Started By in the results (2026-10-22, Stage 3, `ecbbf3a`):** the Dashboard shows a red banner when the latest Run ended in a Run Error or without a record (killed / crashed) — when, Started By, step, exit code, message, claims Not Reached, a link to the Run; never for Done, Stopped or Running, and the next good Run clears it. `dbo.CV_ChargeSummary.started_by` holds the Run's Started By: the bot writes it only once the column exists (checked before each insert; until then the rows are saved without it), the Portal shows it as "Started By" in a claim's side panel, and a Portal reading a table without the column says "Results table needs an update — run create_tables.sql". The Excel Charge Summary is unchanged. Tests: `test_run_alerts.py`. **From the page (2026-10-14):** `.\run_web.bat` → http://127.0.0.1:8000 (the Dashboard) → **Start run** (Super Admin / Admin; Admin / Operator 2026-10-22 to 10-27). The page starts `python run.py` itself, always **VERIFY** (`SAVE_CORRECTIONS=0` forced), information boxes go to the log (`QUIET_BOXES=1`); CAPTCHA / 2FA boxes still come up on the PC. The card shows phase, progress, Pass/Fail/Review and a live log; **Stop** kills the bot and its Chrome. A terminal run is shown too but cannot be stopped from the page. The child reads `.env` afresh (an edit made after the page started is seen) but writes to the page's own results database. All test files at once (there is no `tests/run_all.py` yet). Git Bash: ```bash for t in tests/test_*.py; do echo "$t: $(python "$t" 2>&1 | grep -E '^[0-9]+/[0-9]+ passed' | tail -1)"; done ``` PowerShell: ```powershell Get-ChildItem tests\test_*.py | ForEach-Object { "$($_.Name): $((python $_.FullName 2>&1 | Select-String '^\d+/\d+ passed' | Select-Object -Last 1).Line)" } ``` - Dependencies: `playwright`, `pyotp` only for the bot (locked-down machines; no pytest, no openpyxl in the bot). The results DB and page use `pyodbc`, `fastapi`, `uvicorn`, `jinja2` (+ `httpx` for tests), already installed on this machine, with ODBC Driver 18 for SQL Server. - Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB connections; they never touch eCW or SQL Server (`test_cdp_browser.py` starts a local Chromium on a debug port). As of 2026-10-28: 75 test files, **1286 tests** (full run 2026-10-28 with Z23 and T1017: 1286/1286) (earlier flaky, each failed once and passed on rerun: test_verify_no_live_writes, depression_dx_pointer, progress_note_wait — watch them) (about 5 minutes for all of them). ### Run modes Read by `settings.py`. A setting missing from `.env` takes the code default, so deleting a `.env` line does not switch anything off. | Setting | Meaning | In `.env` now | Code default | |---|---|---|---| | `TEST_CLAIM_IDS_FILE` | If set: skip the bulk export, process the ids in this sheet; summary goes to `Charge Summary_TEST_.xlsx`; missing path = exit 2 before login, unreadable sheet = exit 5 | `Input\Medcor Ready Claim Sheet.xlsx` (since 2026-10-06) | blank (bulk export) | | `SAVE_CORRECTIONS` | 0 = apply fixes for review, close with Cancel. 1 = save after operator OK | **0 (deliberate)** | 0 (since 2026-10-05) | | `APPLY_CORRECTIONS` | Run the conditions at all | not set | 1 | | `CLAIM_PROCESS_LIMIT` | Max claims per run (0 = all) | 20 | 1 | | `CHECK_SLIDING_FEE` / `CHECK_HIGH_RISK` | Dental checks | not set | 1 / 1 | | `WRITE_CHARGE_SUMMARY` / `SUMMARY_ASSIGNED_TO` | End-of-run summary sheet | not set | 1 / `eCW Bot` | | `SHOW_EACH_CLAIM` / `REVIEW_BEFORE_CLOSE` | Per-claim box after close / the one box shown with a corrected claim still open (replaces the per-claim box for that claim) | 1 / not set | 1 / 1 | | `PROGRESS_NOTE_WAIT_MS` | How long to poll for the Progress Note after clicking Prog. Notes | not set | 20000 | | `MSGBOX_TIMEOUT_SECONDS` | Message box timeout | 180 | 180 | | `RESULTS_DB` / `RESULTS_DB_CONN` | Also save the summary rows to SQL Server / ODBC connection string | not set | 0 / Windows login to `MECARE331\MECARE331`, DB `Charge_Validation` | | `DISK_WARN_MB` / `DISK_STOP_MB` | System drive free space before login: Warning / stop with exit 2 | not set | 2048 / 500 | | `BROWSER_CDP_URL` / `CDP_PROFILE_DIR` / `CDP_CLOSE_AFTER_RUN` | Run in Google Chrome on this debug port (127.0.0.1 only; opened by the run when none is there) / its profile / close a Chrome the run opened | `http://127.0.0.1:9222` (since 2026-10-22) / not set / not set | blank (launch Chromium) / `D:\EcwChrome` / 1 | | `WATCHDOG_MINUTES` | No progress on a claim this long = frozen page → driver closed, exit 8 (never under `MSGBOX_TIMEOUT_SECONDS` + 2 min; 0 = off) | not set | 10 | ⚠️ **There is no separate test environment.** Same code, same live eCW for "test" and "live" runs; only the claim list and the save switch differ. Since 2026-10-05 (review item P1) `SAVE_CORRECTIONS` **defaults to 0 in code and in `.env.example`**, so a missing or blank line can never turn saving (the OK click) on; `APPLY_CORRECTIONS` stays 1 (it only fills the form in, closed with Cancel while saving is off, so it does not save, *assuming Cancel discards the edits*: unverified until P16's check on a test claim, since adding a CPT row or ticking a bill-to box may already save on the server). An unquoted `# comment` after whitespace is stripped (`SAVE_CORRECTIONS=0 # note` reads as 0), and a yes/no or number value the bot cannot read stops the run with exit 2 before login. Every run prints `[MODE]` lines (VERIFY / LIVE-WRITE, claim limit, test sheet), and with `SAVE_CORRECTIONS=1` it asks for an OK before login; Enter on that box and on the per-claim save box means Cancel. --- ## 3. Code layout (`src/ecw_bot/`) | Path | Role | |---|---| | `flows/export_medcor_ready.py` | Main flow: login → filters → export → categorize → per-claim loop → per-claim box → summary (~1,280 lines) | | `pages/claim_form.py` | Claim form open/load-wait/read/close/save + all CPT-row edit helpers + telehealth detection; `clear_screen()` (before every claim) and `close_duplicate_forms()` (~2,420 lines) | | `pages/progress_note.py` | Find and read the Progress Note (iframe/tab), keyed by `prisma-section` | | `pages/sliding_fee.py` | Dental: Info → Patient Information → Sliding Fee Schedule date check | | `pages/dental_notes.py` | Dental: "high risk" in Billing Notes | | `pages/claim_filters.py`, `claim_results.py`, `claim_report.py`, `leftnav.py` | Claim lookup screen, search, export; `claim_results.search_blocker()` = Claim No + Lookup on screen, enabled, nothing over them, eCW spinner gone; `wait_search_ready()` gives it up to 20 s before the claim is a failed search | | `selectors/*.py` | All CSS selectors (mapped from live DOM; comments say where from) | | `conditions/*.py` | One module per rule; registry in `conditions/__init__.py` | | `conditions/base.py` | `run_all`: **every `detect()` on the untouched claim first, then `apply()` in registry order**; `ConditionResult.review` / `declined()`, optional `needs_review()` and `summary_comment()` hooks, and a `report` dict (fired / changed / failed / review / comments) | | `core/charge_summary.py` | Charge Summary sheet (24 columns); `build()` (the rows) and `write()` (Excel); `review_reasons()` and `claim_outcome()` → Pass / Fail / Review | | `core/results_db.py` | The same rows → SQL Server `dbo.CV_ChargeSummary` (typed, parameterized insert, never fatal) | | `core/run_lock.py` | The run record `Logs\bot_run.json`: one live run at a time, exit code, source (web / terminal) | | `core/payer_groups.py` + `payer_groups.json` | PAYER → Group Name (dashboard names, `classify()`); `rule_group()` translates them to the names the conditions decide on | | `core/spreadsheet.py` | Minimal xlsx read/write (no openpyxl); `write_xlsx_keep_old()` keeps a same-day file and survives one open in Excel | | `core/page_utils.py` | `shot()` with a FAILURE tag (`is_failure_tag`: failed / missing / not_ / no_ / unconfirmed ...) also saves the page's DOM + frames beside the png as `_.html` (`SAVE_DOM_ON_FAILURE=1`, at most `SAVE_DOM_LIMIT=30` a run; 2026-10-21) — no more hand-copying DevTools into Dom.txt. `visible()` = instant check; `appears()` = real wait (Playwright 1.62's `is_visible(timeout)` never waits); `click_topmost()` clicks the copy on top when dialogs stack (never `.first`) | | `core/fallback.py` + `selectors/fallbacks.py` | Fallback selectors (2026-10-21): the run-critical selectors are CHAINS — primary first, then alternatives taken from the live DOM (ng-click / ng-model / type+value, plain CSS) — joined as a CSS list, so every call site falls back with no change: password field, Log In, Claim No, Lookup, UB Cancel (`closeUBClaimScreen()`, never the X or OK), Dental Cancel (`closeDntl()`), Prog. Notes (`#UBClaimProgressNoteBtn`, `claimProgressNoteBtn`). `report()` logs `[SELECTOR] fallback: — not found, matched ` once a run (pre-flight and login call it) | | `pages/preflight.py` | Pre-flight selector check (2026-10-21): the claims search page before the first claim, the claim form on the first claim of each screen type (UB / HCFA / Dental: Cancel, CPT tab, rows with code / fee / POS / M1). Missing → `[PREFLIGHT] FAIL `, page shot + DOM saved, run stops with **exit 7** (`PREFLIGHT_STOP=0` only warns). No CPT row on a claim → its row checks wait for the next; 3 in a row = rows missing | | `flows/supervisor.py` + root `agent.py` | The agent (2026-10-30, §2): passes of the bot as a child process until nothing is pending; `next_action(code, pending, state)` is the whole retry policy, `pending(queue, rows)` the resume list | | `flows/recovery.py` | A stuck screen inside a pass (§2): `recover()` (logout check, Escape + Patient Information close + clear, reopen search) and `run_claim()` (one retry per claim, 2 in a row → `PageStuck`, exit 9) | | `core/notify.py` | The agent's mail (§2): `incident()` / `summary()`; claim numbers masked, login / error PNGs only; never fatal | | `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back | | `core/run_events.py` | A Run's timed events → `Logs\events_.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username. Since `c9a43ac` (2026-10-30) each line is also copied to `dbo.CV_RunEvent` (§5) — only for a Run in the real `Logs` folder with `RESULTS_DB=1`; the first database error turns the copy off for that Run | | `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login | | `core/msgbox.py` | Timed Windows message boxes; `default_cancel=True` makes Enter mean Cancel | | `core/text.py` | `first_line()` shared by core / conditions / flows | | `auth/*` | Login, 2FA mailbox, captcha, cooldowns; `login.wait_for_home()` answers eCW's "password expires in N days" box with Cancel; `password_entry.type_password()` TYPES the password key by key (never `fill()`, 2026-10-20) | | `settings.py` | `.env` parsing and every setting | Outside `src/`: `web/` (FastAPI results page: `app.py`, `repo.py`, `templates/index.html`; `bot_runner.py` starts / stops the bot as a child process; `run_status.py` reads the run log into the page's live status and per-claim list), `tools/sql/create_tables.sql` (results table), `tools/import_payer_groups.py`, `run_web.bat`, `start_ecw_chrome.bat` → `tools/start_ecw_chrome.py` (§2), `web/users.py` + `tools/add_portal_user.py` + `templates/signin.html`, `password.html`, `users.html`, `signins.html` (Portal Sign-in, §2), `web/runs.py` + `templates/runs.html` / `run.html` (the Runs page, §2). Claim screens: **UB** (`#UBClaimDiv`, has a CPT/HCPCS tab), **HCFA** ("Claim" screen, `data-fieldname` fields, payer in `#billingClaimTbl8`), **Dental** (`dentalClaim`, `#cancelDentalClaimButton`). --- ## 4. Conditions (UB/HCFA claims) Registry order (`conditions/__init__.py`) — order matters: | # | Module | Doc row | Rule | |---|---|---|---| | 1 | `zero_fee_uncheck` | common rule | **FIRST.** Checked CPT line with Billed Fee 0 → uncheck | | 2 | `telehealth` | #21 | Telehealth note → POS 2 + modifier on first CPT row: Medicaid/RAE = GT, Commercial = 95, audio-only (Commercial) = 93. Medicare excluded. Any other payer group (unmapped, blank, Program, Dentaquest) → declined → Review. A row already at POS 2 / right modifier is not a change | | 3 | `medicare_telehealth` | #22 | Medicare telehealth → POS 2 + 95/93, add **G2025**; once G2025 confirmed, uncheck + zero other rows. G2025 not added (or its modifier did not take) → Review, other rows left alone | | 4 | `depression_dx_pointer` | #6 | Z13.31 pointer only on G8510/G8431; removed elsewhere; duplicate G8510/G8431 → keep first priced one. **Not on Medicare claims** (#14 instead) | | 5 | `j2930_j2001_update` | #5 | J2930 and J2001 → uncheck + fee 0 (no replacement code) | | 6 | `dummy_codes` | #8 | DCONS / Dscprit (any case) → uncheck + fee 0. BHT3/BHT4 are NOT dummy codes (operator, 2026-10-06) | | 7 | `dental_dummy_codes` | #48 | DPIMP or **DFIMP** (any case; DFIMP "FINAL IMPRESSION" is the code the live Dental grid carries, 2026-10-20). **Dental payer** (dashboard "Dental - Commercial" or "Dental - CHP+/DentaQuest") → uncheck + fee 0. **Any other payer**, unmapped or none read → nothing changed, claim **Review** with a Comments note naming the payer group ("DPIMP on a Medicaid payer claim — not changed ...") (operator, 2026-10-13). Separate from #8 | | 8 | `dental_payer_check` | #13 | A **billed Dental code** ("D" + 4 digits; DCONS / Dscprit / DPIMP / DFIMP are not; billed = `lines.ends_billed`) and **no insurance row** (primary, secondary ...) is DentaQuest, CHP+ DentaQuest or "Delta Dental …" (by name, case and spaces ignored; Aetna Dental PPO, Cigna Dental, MetLife are NOT allowed) → **Claim Status set to Patient** (`PAT`, `claim_form.set_claim_status`, `selectors.claim_form.SEL_CLAIM_STATUS` — never the `#status` claimRP field), no program check (operator, 2026-10-21, EEWA-27). Only where the run may write (`writes_allowed`: LIVE-WRITE, never the Dental screen) — otherwise Review "set it to Patient by hand". No payer read → Review, nothing changed. Comment names codes and payer(s) | | 9 | `medicaid_uncheck_cpts` | #43, #44 | **Medicaid** payer (rule_group "Medicaid" only — not RAE, CHP+, unmapped or blank; RAE ruled out by the operator 2026-10-14) → 15851, 15852, 73521 uncheck + fee 0; no other line touched (operator, 2026-10-13) | | 10 | `lab_codes` | #3, #4 | **Lab code** = 5 digits starting with 8 (36416 is not one, never touched). By the **primary payer** (EEWA-38, operator 2026-10-28): **Medicare payers** (#25's exact eCW names, `medicare_pps_code.is_pps_payer`) → every lab line unchecked + fee 0; **Cigna Salud Employee** → any lab code; **every other payer**, blank included → only the 13 in-house codes (82948, 81025, 85018, 82270, 82274, 87806, 85610, 87880, 80305, 81001, 81003, 87210, 87636), any other lab line unchecked + fee 0. Code only, not the fee. Not on Dental claims. Comment: "Lab code not billed to Medicare (…)" / "Lab code not in the in-house list (…)" | | 11 | `t1017_non_billable` | #46 | **T1017** by the primary payer (EEWA-40, operator 2026-10-28): a **Medicare payer** (#25's exact eCW names) or **Medicaid** (dashboard "Medicaid" group only, as #43/#44 — not RAE, not CHP+) → unchecked + fee 0, comment "T1017 not billable (Medicaid) - unchecked, fee 0"; **any other payer**, blank or unmapped included → nothing changed, **Review** "T1017 on - check by hand". Not on Dental claims | | 12 | `medicare_billing_screening` | #14 | Medicare payer (rule_group; Medicare Advantage plans are Commercial since 2026-10-21) → G8510/G8431 uncheck + fee 0; no other line touched. No other billed line (screening only) → also Review "provider clarification needed" (operator, 2026-10-07) | | 13 | `medicare_vaccine_a6` | #19 | **Medicare** payer (rule_group) with a billed **G0008 or G0009** (`lines.ends_billed`) → condition code **A6**: UB form ON SCREEN only — A6 already in FL 18-28 → nothing; else typed into the first empty box (`uBClaimCondtnCode1..11`) and read back from the box and its Angular model; all 11 full → Review "add A6 by hand"; typed but not taken → changed + Review. HCFA / Dental → Review "check A6 by hand" — **not to be mapped**: Medicare is billed on UB (#2), so such a claim is on the wrong form (operator, 2026-10-21). **#19 complete**; live test still needs a Medicare UB claim with G0008 / G0009. Only those two codes trigger it — not #18's vaccine list; the bot does not add G0008/G0009 (operator, 2026-10-16) | | 14 | `medicare_wellness_history` | #27, #28, #29 | **Check only, never changes the claim** (operator, 2026-10-20). **Medicare** payer with a billed **G0402** (IPPE, once per lifetime), **G0438** (first AWV, once in 3 years) or **G0439** (subsequent AWV, once a year): reads the patient's earlier claims in **Account Inquiry** (claim form side menu → Patient Inquiry Detail → Show CPT Payments, every page; this claim left out; closed with the X, the tick undone). Billed inside the window, counted either way from this claim's service date (exactly 3 years / 12 months = outside) → **Review**, Comments "G0438 already billed on MM/DD/YYYY (claim N) — once in 3 years". Account Inquiry not read → Review "check … history by hand". `pages/account_inquiry.py`, `selectors/account_inquiry.py`. Since `ac25f83` (2026-10-23) it also leaves its verdict on the claim's data (`_wellness_history`: read or not, the repeats found) for #25 — still nothing changed in eCW | | 15 | `medicaid_pregnancy_th` | #23 | **Medicaid** payer (rule_group "Medicaid" only, NOT RAE) with a **pregnancy-related ICD** (chapter O, Z33, Z34, Z36, Z3A — **Z32.00/.01/.02 pregnancy test never counts**) → **TH** on the first billed E&M line (99202-99499; none → first billed line), **in M1, overwriting what M1 held** (comment says "replacing 25"; never M2-M4); TH already in any box → nothing; no M1 box → Review "add TH by hand". A Medicaid telehealth pregnancy visit: **#21 writes TH instead of GT** (RAE keeps GT). Dental → Review. `9b87b4d`, not run live yet (2026-10-20, EEWA-30) | | 16 | `bh_h_code_modifier` | #12 | H code (H + 4 digits). **BH payer** (name starts with "BH") with any modifier → M1–M4 cleared on that line only; telehealth note → not fired (GT stays); note not read → Review, nothing changed (operator, 2026-10-07). **Any other payer** → billed H code line unchecked + fee 0.00, telehealth or not, modifier or not; blank payer → not fired (operator, 2026-10-12). Comment worded from what was done | | 17 | `vaccine_z23_pointer` | #45 | A line with one of the client's 19 vaccine codes (#18's list, not the admin codes) must carry **Z23**'s ICD pointer (its number in the ICD list) in ICD1–4 (EEWA-39, operator 2026-10-28). Missing → the first empty slot, the line's other pointers kept; already there → nothing; no other line touched. No Z23 in the ICD list → Review "Z23 not on the claim — add it by hand"; four slots full → Review "no free pointer slot". All payers, Medicare too; not Dental. Comment "Z23 pointer added to the vaccine line (…)" | | 18 | `vaccine_admin_codes` | #18 | Vaccine without admin code → add 90460(+90461) age ≤18, 90471(+90472) >18; units 1; all payers except Medicare. **Vaccine = the client's 19-code list only** (90633, 90648, 90651, 90656, 90657, 90660, 90680, 90687, 90698, 90707, 90710, 90713, 90716, 90732, 90734, 90744, 90746, 91321, 91322), by code; **admin = 90460/90461/90471/90472 only** (operator, 2026-10-13; no hover text, no 90476-90759 range). Unclassified payer / no payer / no age → Review; an admin code that could not be added → Review ("NOT added — add by hand") | | 19 | `injection_admin_code` | #20 | Injection code from the client's **41-code list** (J0561 ... J8499, as given — IUD, inhalation and oral J-codes included) billed without **96372** → add 96372 ×1, Units 1, all payers except Medicare. Only 96372 counts as admin billed — also when it is unchecked with a fee (priced_line_check checks it at the end, so no second 96372; review 2026-10-14). J2001 / J2930 never count (#5 unchecks them). Unclassified / no payer → Review; not added → Review; Dental → Review (operator, 2026-10-13) | | 20 | `medicare_pps_code` | #25 | **PPS Payer** (exact eCW names: Medicare, Medicare-Novitas, HCFA Medicare, HCFA Medicare Novitas, Humana Medicare, Devoted MCR UB, Cigna Healthspring, Cigna Health Spring/ Bravo — the two Cigna plans count although Commercial elsewhere) on a **UB** claim: billed (`lines.ends_billed`) **Qualifying Codes** → the **PPS Code** on the client's Medicare G code crosswalk (`CROSSWALK`): G0466 new office, G0466H new home, G0467 established office, G0467NH nursing home, G0467H established home, G0468 IPPE / AWV (G0402 / G0438 / G0439 win over an E&M). Right PPS Code billed → Pass. Missing → **added** via the Fee Schedule picker, Units 1 (LIVE-WRITE; Fail "CPT Code Missing (PPS code G0467 added (99213))"); VERIFY → Review "G0467 not added in VERIFY — add it by hand (99213)". **Review, nothing changed:** codes only on both the G0466 and G0467 lists ("new or established?"); conflicting visit codes; a different PPS Code billed (also beside the right one); mental health 90791 … 90845 ("G0469 or G0470?" until the client rules, EEWA-37; either one billed → Pass); a variant the picker cannot find; HCFA with a Qualifying Code; G0468 when #27–#29 found a repeat, could not read the history, or never checks it (the Cigna plans, Commercial there — operator, 2026-10-23). Dental → skipped (operator). `ac25f83` | | 21 | `priced_line_check` | common rule | **LAST.** Unchecked line with Billed Fee > 0 → check | **Every claim runs every condition above** — each one's `detect()` decides from the claim's codes and payer, so a claim gets every "All payers" condition plus its own payer's (operator's number-one rule, 2026-10-07 / 2026-10-12). **Dental included since 2026-10-12** (spec `docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md`, "as built" section): a Dental claim runs its high risk / sliding fee checks, then the same read, Prog. Notes and `run_all`. The Dental grid has no POS and no modifier columns, so a condition whose fix needs them (telehealth, medicare_telehealth) or a new line (vaccine_admin_codes) declares `DENTAL_UNSUPPORTED` and goes to **Review** on a Dental claim instead of being applied. Dental claims are **never saved**, whatever `SAVE_CORRECTIONS` says (operator, until a live verify run). Any condition whose CPT/HCPCS tab could not be opened is **declined** (Review), not silently skipped; a condition that raises is reported as failed (Review). Neither kind of claim is ever offered for Save. ### Telehealth detection (`pages/claim_form.py`) - Telehealth = "telehealth" or "telephone" in the note, or a `TH` token — **except** a `TH` inside a `Modifiers:` list. That is the obstetric modifier on in-person OB visits ("OB office visit … Modifiers: TH"), not telehealth (found 2026-10-05 in saved notes; before this fix those visits got POS 2 + GT). - Audio vs video, first match wins: explicit audio-only ("Mode of Treatment: Telephone", "audio only", "no video", "(audio)", "phone call only") → audio; explicit video ("audio and video", "video visit", "via video", "Mode of Treatment: Video") → video; any other "telephone" → audio; otherwise video (the document's default). "Audiology" or the consent line "phone and/or video" decide nothing. The box shows the phrase that matched. ### Loading and reading waits - Claim click times out after 5 s (was 30 s), then waits up to 5 s for the form. - A claim form left open by the previous claim is cancelled before the next click; if it will not close, that claim goes to Review instead of being read on the wrong form. - HCFA and Dental: wait (20 s) for the claim number to match and the CPT rows to be filled and stable; not loaded → Review, conditions skipped. Wrong claim number → error. - Progress Note: polled up to `PROGRESS_NOTE_WAIT_MS` until its sections stop changing; one re-click if nothing appears; a note that never settles is read but marked partial (Review). ### What the operator sees (one box per claim) - Corrected claim (SAVE off, `REVIEW_BEFORE_CLOSE=1`): **one** box, shown with the form still open behind it: search result, what changed, any "Needs review" lines, "nothing saved". The old before-apply box is gone (log line only). - Every other claim: the per-claim box after close (`SHOW_EACH_CLAIM`), with "Changed", "Needs review" and "Summary: — ". - Dental: the per-claim box carries the high-risk and sliding-fee results (no separate box, no "?" fields; nothing when `SHOW_EACH_CLAIM=0`). - Test runs prefix every box title with `[TEST] `. All box text is English. ### Dental checks (read-only) Run first on a Dental claim, before the conditions (above). Results show in the per-claim box and go into the same Charge Summary row as the condition comments (§5): HIGH RISK or a sliding fee EXPIRED / NOT_STARTED → **Fail**; sliding fee NONE is noted in Comments (Pass); Billing Notes unreadable or sliding fee UNKNOWN → **Review**. - **#15 High risk** — "high risk" (any case/spacing) in Billing Notes (`#dentalClaimNotes`) **and** at least 79 characters **from where "HIGH RISK" starts to the end of the notes** (`dental_notes.high_risk_length`, `HIGH_RISK_MIN_CHARS`; spaces and line breaks count, trailing blanks do not; text before HIGH RISK does not count — 2026-10-21, was the whole notes) → YES/NO. "High risk" in shorter notes → NO (not Review); the log gives the length. Verified live on 4 Dental test claims: 1 YES, 3 NO; the 79 rule verified live 2026-10-13 (the YES claim's notes: 307 characters). - **#8 Sliding fee** — only **Date from/to** vs claim DOS → ACTIVE / EXPIRED / NOT_STARTED / NONE / UNKNOWN. Closes with Close + Cancel, never Save. Verified live on the same 4 claims (all ACTIVE). ### Removed / not built - **#9 Medicaid "D" codes** — built, then reverted on operator's instruction (commit `6c44165`). - **#1 Claim assigned to the patient → Claim Status Patient** — built (`1c6b0f9`), reverted the same day (`5354c84`) on the operator's instruction. - **#16 secondary Medicaid** — not needed (operator, 2026-10-21). --- ## 5. Charge Summary sheet (`core/charge_summary.py`) 24 columns, exactly the team's format (incl. their spellings `Recevied date`, `Assigned To `): - **A–R** from the downloaded report: 15 columns by the same header, plus `S.No`, `Group Name` (payer_groups), `Pmts/Refunds = PMTS/ADJS − Adjustment`. Report column `CLAIMTYPE` (U = UB, D = Dental, blank = HCFA) is dropped. - **S–X** only for claims this run read: dates = run date, Assigned To = setting, Comments per condition, Current Status, Category. Three categories since 2026-10-05: - **Review**, "Needs manual review": the bot could not check everything. Triggers: search raised; form unreadable or never loaded; conditions not run (`APPLY_CORRECTIONS=0`); progress note not read or partial; zero CPT or zero ICD rows read; a condition failed or declined; an approved save eCW did not confirm; Dental notes unreadable or sliding fee UNKNOWN. Comments = findings then reasons; "Corrections SAVED to eCW" leads when the claim was saved. Review wins over Fail. - **Fail**: a condition changed something (its comment comes from what actually happened, e.g. "G2025 NOT added — add by hand"), or Dental HIGH RISK / sliding EXPIRED / NOT_STARTED. "Ready to Submit" if saved, else "Corrections pending (not saved)". - **Pass**: "Clean claims (…)", Ready to Submit. - A claim the run never reached or did not find stays blank in S–X. - **Payer: sheet vs eCW (`97d4d74`, 2026-10-28, operator):** every condition goes by eCW's payer (the claim form's first insurance row), never the report's PAYER column. When the two differ (case and spacing aside, neither blank) the Comments end with "Payer: sheet says X, eCW says Y - eCW used" — a note, the Category is not changed. The PAYER / Group Name columns stay the report's. - Same-day re-run keeps the old file as `…__HHMMSS.xlsx`. If the old file is open in Excel (rename refused), the old file is left alone and the NEW sheet is saved as `…__HHMMSS.xlsx` instead (D10, 2026-10-05); the `[SUMMARY]` log line names the file actually written, and a box tells the operator the new name. Same for `Medcor Ready_.xlsx` (its box only with `NOTIFY_UNMAPPED_PAYERS=1`). - A `TEST_CLAIM_IDS_FILE` run writes `Charge Summary_TEST_.xlsx` (D1). - Written after **every claim** (operator, 2026-10-12), not once at the end. The first claim's write keeps an earlier same-day file aside as above; every later claim rewrites this run's own file (`charge_summary.write(into=...)`, no `__HHMMSS` copy per claim). Rows of claims not done yet stay blank in S–X until their claim is done. If the operator opens the in-progress file in Excel, the rest of the run goes to a new `__HHMMSS` name. Each box (new name, write error) shows once per run, not per claim. Test: `tests/test_summary_per_claim.py`. ### Payer groups (since 2026-10-06) - `src\ecw_bot\payer_groups.json` holds the team's template: every distinct `Payer Name` → `Payer Group Name` pair from the Claim Status Dashboard (`\\mecare2\Production\Salud-eCW RPA\Claim Status Dashboard Details 1001.xlsx`), 267 payers, plus 5 RAE spellings the dashboard does not list. Payer and group names only, no patient data. - Refresh it when the dashboard changes, from PowerShell (Git Bash cannot reach the share): `python tools\import_payer_groups.py` (`--dry-run` lists what would change without writing). - Group Name columns (`Medcor Ready_.xlsx`, Charge Summary) show the dashboard's names: Commercial Ins, Medicaid, RAE (Behavioral Health), Dental - CHP+/DentaQuest, Dental - Commercial, Medicare, CHP+, Self Pay, Patient... - Conditions decide on `payer_groups.rule_group()`: Commercial Ins → Commercial, RAE (…) → RAE payer, Dental - CHP+/DentaQuest → Dentaquest, Self Pay → Program, Payer Not In Group → unmapped; others pass through. CHP+, Patient and the Dental groups have no telehealth rule (Review); the vaccine rule now applies to them (document #18: all payers except Medicare). - One-off sheet from the 21-Sep report: `Output\Charge Summary_09-21-2026.xlsx` (1,145 claims; 301 with blank Group Name — unmapped payers). With the dashboard table the same report has **0** blank Group Names. ### Results database + web page (since 2026-10-08) The same Charge Summary rows also go to SQL Server, and a local page shows them. Spec: `docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md`. **Portal look since `a436489` (2026-10-26, operator: "konjam attractive, content add, grid correct, header footer align"):** header, breadcrumb, page and footer share one 1180px column; header has a "CV" mark, tab-style links with the current page marked (`aria-current`), a user avatar; a footer at the bottom of every page (brand, PHI note, mode). Dashboard adds the pass rate on Checked today, a **Last 7 days** Pass / Fail / Review chart (a day links to its page), **Today's Fail and Review reasons** (comments grouped with counts; the extra `day_results` query runs only when today has claims) and three equal cards (Claim status, Runs, Find a claim). Motion is movement only, never opacity (a tab the browser holds in the background must not stay faded), and off under `prefers-reduced-motion`; still no inline `style=` attributes (a test). Checked in the Browser pane against a made-up-data preview (port 8765, a scratchpad script; no SQL, no real users), desktop and phone width. The running Portal shows it after `run_web.bat` is restarted. **Sign-in page since `61338b4` (2026-10-26, operator):** a card on a slow navy-to-blue moving gradient — navy brand panel ("Charge Validation" and three feature titles only, no description lines; floating circles, pulsing CV mark) and the form (icon fields, Show / Hide password, Caps Lock warning, idle / lockout rules read from `web/users.py`). Every page has an inline SVG tab icon (no `/favicon.ico` 404). Motion off under `prefers-reduced-motion`. Live on the Portal (restarted 2026-10-26, [portal] terminal tab). - Server `MECARE331\MECARE331`, database `Charge_Validation`, table `dbo.CV_ChargeSummary` (every table of this project starts with **`CV_`**, operator 2026-10-12): the format's 24 columns + `run_at`, `run_mode` (VERIFY / LIVE-WRITE, or TEST VERIFY / TEST LIVE-WRITE since 2026-10-12), `details_json` (conditions fired, before/after changes, review reasons, Dental results). Amounts → DECIMAL, dates → DATE, unconvertible → NULL. - `core/results_db.py`; the flow calls `_save_results_db` after each claim's Excel write, with that claim's row only (since 2026-10-12). Every row of one run shares one `run_at` (kept on the run's `info` dict), so the page shows it as one run. Only rows the run filled in (a Category) are saved; a claim not found saves nothing. `RESULTS_DB=1` turns it on (default off); `RESULTS_DB_CONN` (default: Windows login, no password). A DB error is one `[RESULTS-DB]` log line; the run and the Excel are unaffected. - Setup (once): run `tools/sql/create_tables.sql` in SSMS; give the Windows login db_datareader + db_datawriter (commented lines at the end of that file). **Done 2026-10-12** on MECARE331: table created, grants run for `MFILESERVER\EC10980`, renamed to `CV_ChargeSummary` with `tools/sql/rename_to_cv_prefix.sql`. Checked read-only from the bot's own connection: 28 columns, types / widths / indexes match, 0 rows, the web repo reads it. - **Portal (2026-10-19, `2da2bdd`)**: `run_web.bat` → http://127.0.0.1:8000 (local only, PHI). Operator: a second run hid the first (the old page opened on the newest run only), and the front page was three quarters claims table. Spec / plan: `docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md`. - **Dashboard `/`**: Bot run card (below) with a **Live claims** panel (every claim of the running / last run: result, Checking, Waiting; Not finished / Not checked once over; a saved result opens its day page), today's tiles, **Find a claim**, a big **Claim status** button. No claims table. - **Claim status `/days`**: one card per day a run STARTED (all runs of a day together, last 90 days), newest first: claims checked, runs, Pass / Fail / Review, colour bar. - **One day `/day/YYYY-MM-DD`**: every row of that day's runs; tiles filter by Category; claim # search; short table (#, Claim #, Time, Payer, Result, Comments), 10 a page, no sideways scroll; a row opens every field + conditions JSON in a side panel (empty fields hidden); `?claim=` opens that claim. - **Find a claim `/claim?claim_no=`**: every saved check (newest first, 200 max), each linked to its day. - Navy & blue theme, breadcrumb, VERIFY / LIVE-WRITE pill (`templates/base.html`). Old `/?run_at=` links redirect to the run's day. JSON: `/api/runs`, `/api/results`. The same claim checked twice in a day shows twice; "Not found" claims have no saved row. - Fed by the bot's `[CLAIMS] Queue: ...`, `[CLAIMS] Results run_at: ` (the saved run's time, RESULTS_DB=1 only) and one `[RESULT] claim n/N : ` line per claim. - Bot run card (2026-10-14): **Start run** / **Stop** / live status — `POST /api/bot/start`, `POST /api/bot/stop` (Origin must be this page and header `X-Run-Token` must match the token in the page; else 403), `GET /api/bot/status`. Spec / plan: `docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md`. - **Sage redesign (2026-10-30, the operator-approved mockup, live on Dokploy that day from `portal-20261030-ui.zip`).** `base.html`: the navy header became a sage side menu (`