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

@@ -0,0 +1,910 @@
# Payer Group Categorization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** After the bot exports the Medcor Ready claim report, automatically add a `Group Name` column (Commercial / Medicaid / Medicare / Dentaquest / Program / …) based on each row's `PAYER`, writing the result to a new file so the existing pipeline's file layout is untouched.
**Architecture:** A new `core/payer_groups.py` does an exact-string lookup against a hand-editable `payer_groups.json`. A new `core/spreadsheet.write_xlsx()` (stdlib only, mirroring the existing reader) writes the categorized rows to `Output\Medcor Ready_<date>.xlsx`. `flows/export_medcor_ready.py` wires the two together as a new, non-fatal step right after the existing claim-id read-back.
**Tech Stack:** Python 3.13, stdlib only (`zipfile`, `xml.etree`, `json`, `xml.sax.saxutils`) — no new third-party dependencies, matching the project's existing "no openpyxl" constraint.
**Spec:** [docs/superpowers/specs/2026-09-15-payer-group-categorization-design.md](../specs/2026-09-15-payer-group-categorization-design.md)
## Global Constraints
- No new third-party dependencies (project has none beyond `playwright`; `spreadsheet.py`'s whole existence is to avoid `openpyxl`).
- Tests are hand-rolled `case_*` functions collected via `globals()` and run by a `main()` — no pytest, matching every existing file under `tests\`.
- `Input\Claim Report_<date>.xlsx` must never be modified by this feature — `claim_file.py` and the charge-entry phase depend on its exact column layout.
- Payer matching is **exact string, case-sensitive, no fuzzy/substring logic** — an unmapped payer must be flagged, never guessed (see spec's "Unknown payer handling").
- Every new step must be non-fatal to the run — matches the existing philosophy in `flows/export_medcor_ready.py` (`_read_back_claim_ids`, `_read_claim_form`).
---
## Task 1: Initialize git
This project currently has no git history at all (verified: `git status` outside a repo). The plan's commit-per-task workflow needs a repo to commit into, so this task creates one and takes a single baseline commit of the existing code before any feature work starts. This is additive only — no existing file is changed.
**Files:**
- Create: `D:\Ecw Process\.git\` (via `git init`)
**Interfaces:** none — infrastructure only.
- [ ] **Step 1: Confirm there is no existing git repo**
Run: `git -C "D:\Ecw Process" status`
Expected: `fatal: not a git repository (or any of the parent directories): .git`
- [ ] **Step 2: Initialize the repository**
Run: `git -C "D:\Ecw Process" init`
Expected: `Initialized empty Git repository in D:/Ecw Process/.git/`
- [ ] **Step 3: Check `.gitignore` already covers the right things**
Run: `cat "D:\Ecw Process\.gitignore"`
Expected: it already excludes `.env`, `Logs\`, `Screenshots\`, `Output\`, `Input\` (per `README.md`'s Layout section, all four are described as gitignored). If any of those four is missing, add it before continuing — this baseline commit must not include patient data or credentials.
- [ ] **Step 4: Stage and commit the current codebase as a baseline**
```bash
cd "D:\Ecw Process"
git add -A
git status
```
Read the `git status` output before committing: confirm nothing under `Input\`, `Output\`, `Logs\`, `Screenshots\`, or `.env` is staged. If any of those appear, fix `.gitignore` and re-run `git add -A`.
```bash
git commit -m "chore: initialize git repository (baseline)"
```
---
## Task 2: `write_xlsx()` in `core/spreadsheet.py`
**Files:**
- Modify: `src/ecw_bot/core/spreadsheet.py`
- Test: `tests/test_spreadsheet_write.py` (new)
**Interfaces:**
- Produces: `spreadsheet.write_xlsx(path, rows: list[list]) -> None` — `rows` is a header row plus data rows, each a list of values (any type, written as `str()`). Raises on I/O failure. Creates parent directories if needed.
- Consumes: nothing new — sits next to the existing `read_rows(path) -> list`.
- [ ] **Step 1: Write the failing round-trip test**
Create `tests/test_spreadsheet_write.py`:
```python
"""
write_xlsx(): the write side of core/spreadsheet.py.
python tests\\test_spreadsheet_write.py
Round-trips through the existing `read_rows()` reader rather than asserting
on raw XML — what matters is that a file this writes is a file this project's
own reader (and real Excel) can open, not the exact bytes.
"""
import sys
import pathlib
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot.core import spreadsheet # noqa: E402
TMP = pathlib.Path(tempfile.mkdtemp())
def case_write_then_read_round_trips():
rows = [
["CLAIMS#", "PAYER", "Group Name"],
["9107000", "Medicaid", "Medicaid"],
["9106000", "DentaQuest", "Dentaquest"],
]
path = TMP / "roundtrip.xlsx"
spreadsheet.write_xlsx(path, rows)
back = spreadsheet.read_rows(path)
assert back == rows, back
def case_special_characters_are_escaped():
rows = [["PATIENT", "NOTE"],
["Smith & Sons", "A < B > C"],
["O'Brien", '"quoted"']]
path = TMP / "escaped.xlsx"
spreadsheet.write_xlsx(path, rows)
back = spreadsheet.read_rows(path)
assert back == rows, back
def case_wide_row_uses_correct_column_letters():
"""27 columns exercises the single-letter -> AA rollover."""
header = [f"col{i}" for i in range(27)]
data = [str(i) for i in range(27)]
path = TMP / "wide.xlsx"
spreadsheet.write_xlsx(path, [header, data])
back = spreadsheet.read_rows(path)
assert back[1][26] == "26", back[1] # column AA (index 26)
assert back[1] == data, back[1]
def case_creates_parent_directory():
path = TMP / "nested" / "new_dir" / "file.xlsx"
assert not path.parent.exists()
spreadsheet.write_xlsx(path, [["A"], ["1"]])
assert path.exists()
def case_non_string_values_are_stringified():
rows = [["N"], [5], [3.5]]
path = TMP / "numbers.xlsx"
spreadsheet.write_xlsx(path, rows)
back = spreadsheet.read_rows(path)
assert back == [["N"], ["5"], ["3.5"]], back
CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")]
def main() -> int:
failures = 0
for fn in CASES:
name = fn.__name__[5:].replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 2: Run it to verify it fails**
Run: `python tests\test_spreadsheet_write.py`
Expected: `ERROR` on every case with `AttributeError: module 'ecw_bot.core.spreadsheet' has no attribute 'write_xlsx'`
- [ ] **Step 3: Implement `write_xlsx()` in `core/spreadsheet.py`**
Add these imports at the top of `src/ecw_bot/core/spreadsheet.py` (alongside the existing `import csv / re / zipfile` block):
```python
from xml.sax.saxutils import escape
```
Append this to the end of `src/ecw_bot/core/spreadsheet.py`:
```python
# =============================================================================
# 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>'
)
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(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)
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `python tests\test_spreadsheet_write.py`
Expected: `5/5 passed`
- [ ] **Step 5: Sanity-check the file actually opens in Excel/WPS**
This machine has Excel/WPS installed (seen in the video). Manually:
```bash
python -c "import sys; sys.path.insert(0,'src'); from ecw_bot.core import spreadsheet as s; s.write_xlsx('Output/_manual_check.xlsx', [['A','B'],['1','2']])"
```
Open `Output\_manual_check.xlsx` by hand and confirm it opens with no "repair" prompt and shows the two cells. Delete the file afterward — it's a throwaway check, not a fixture.
- [ ] **Step 6: Commit**
```bash
cd "D:\Ecw Process"
git add src/ecw_bot/core/spreadsheet.py tests/test_spreadsheet_write.py
git commit -m "feat: add write_xlsx() to core/spreadsheet.py"
```
---
## Task 3: `core/payer_groups.py` + seed `payer_groups.json`
**Files:**
- Create: `src/ecw_bot/core/payer_groups.py`
- Create: `src/ecw_bot/payer_groups.json`
- Test: `tests/test_payer_groups.py` (new)
**Interfaces:**
- Produces: `payer_groups.load_mapping(path=None) -> dict[str, str]`, `payer_groups.classify(payer: str, mapping: dict) -> str | None`.
- Consumes: nothing from Task 2.
- [ ] **Step 1: Write the failing tests**
Create `tests/test_payer_groups.py`:
```python
"""
Payer -> Group Name lookup.
python tests\\test_payer_groups.py
Exact-string match only, on purpose: eCW's own PAYER values already differ by
case and spacing for what is really the same carrier (`United Healthcare` /
`United Health Care` / `United healthcare` all appear in one real export), so
"close enough" matching would have to guess which of several real, distinct
strings a new one is closest to. Billing data does not get a guess — an
unrecognized string must come back None and get a human's attention.
"""
import sys
import json
import pathlib
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot.core import payer_groups # noqa: E402
TMP = pathlib.Path(tempfile.mkdtemp())
MAPPING = {
"Medicaid": "Medicaid",
"DentaQuest": "Dentaquest",
"Anthem Blue Cross Blue Shield": "Commercial",
}
def case_exact_match_returns_group():
assert payer_groups.classify("Medicaid", MAPPING) == "Medicaid"
assert payer_groups.classify("DentaQuest", MAPPING) == "Dentaquest"
def case_unmapped_payer_returns_none():
assert payer_groups.classify("Some New Payer LLC", MAPPING) is None
def case_case_sensitive_no_fuzzy_matching():
"""Different case is a different string, on purpose — see module docstring."""
assert payer_groups.classify("medicaid", MAPPING) is None
assert payer_groups.classify("MEDICAID", MAPPING) is None
def case_blank_or_none_payer_is_unmapped():
assert payer_groups.classify("", MAPPING) is None
assert payer_groups.classify(None, MAPPING) is None
def case_load_mapping_reads_json_file():
path = TMP / "mapping.json"
path.write_text(json.dumps({"Medicaid": "Medicaid"}), encoding="utf-8")
loaded = payer_groups.load_mapping(path)
assert loaded == {"Medicaid": "Medicaid"}, loaded
def case_load_mapping_missing_file_raises():
try:
payer_groups.load_mapping(TMP / "does_not_exist.json")
except FileNotFoundError as exc:
assert "does_not_exist.json" in str(exc), exc
return
raise AssertionError("expected FileNotFoundError")
def case_seed_config_loads_and_covers_medicaid_and_dentaquest():
"""The real config file this ships with, not a fixture."""
mapping = payer_groups.load_mapping()
assert mapping.get("Medicaid") == "Medicaid", mapping.get("Medicaid")
assert mapping.get("DentaQuest") == "Dentaquest", mapping.get("DentaQuest")
CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")]
def main() -> int:
failures = 0
for fn in CASES:
name = fn.__name__[5:].replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 2: Run it to verify it fails**
Run: `python tests\test_payer_groups.py`
Expected: `ERROR` on every case — `ModuleNotFoundError: No module named 'ecw_bot.core.payer_groups'`
- [ ] **Step 3: Create the seed config, `src/ecw_bot/payer_groups.json`**
```json
{
"Medicaid": "Medicaid",
"DentaQuest": "Dentaquest",
"CHP+ DENTAQUEST": "Dentaquest",
"AARP Medicare Advantage": "Medicare",
"Aetna Medicare ADV": "Medicare",
"OUTREACH PROGRAM": "Program",
"Anthem Blue Cross Blue Shield": "Commercial",
"Cigna HealthCare Of Colorado": "Commercial",
"Cigna Healthcare of CO": "Commercial",
"Cigna Salud Employee": "Commercial",
"Cigna": "Commercial",
"Cypress Benefit Admin / Cigna PPO": "Commercial",
"United Healthcare": "Commercial",
"United Health Care": "Commercial",
"United healthcare": "Commercial",
"Unitedhealth One": "Commercial",
"Oxford Health Insurance UHC": "Commercial",
"UMR": "Commercial",
"Aetna": "Commercial",
"Aetna Alaska Care": "Commercial",
"AETNA HEALTH AND LIFE INSURANCE": "Commercial",
"BIND BENEFITS INC": "Commercial",
"Select Health": "Commercial"
}
```
This is the "Mapped (seeded)" table from the spec, byte-for-byte. The 17 payers in the spec's "Left unmapped" list are deliberately absent — `classify()` returning `None` for them, and the flow logging/flagging that, is the intended behavior until a human adds them.
- [ ] **Step 4: Implement `core/payer_groups.py`**
```python
"""
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.
Adding a payer is a one-line edit to `payer_groups.json`, no code change.
"""
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")
return json.loads(p.read_text(encoding="utf-8"))
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())
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `python tests\test_payer_groups.py`
Expected: `7/7 passed`
- [ ] **Step 6: Commit**
```bash
cd "D:\Ecw Process"
git add src/ecw_bot/core/payer_groups.py src/ecw_bot/payer_groups.json tests/test_payer_groups.py
git commit -m "feat: add payer_groups lookup module and seed config"
```
---
## Task 4: Wire the categorization step into the export flow
**Files:**
- Modify: `src/ecw_bot/settings.py`
- Modify: `.env.example`
- Modify: `src/ecw_bot/flows/export_medcor_ready.py`
- Test: `tests/test_categorize_by_payer.py` (new)
**Interfaces:**
- Consumes: `spreadsheet.read_rows`, `spreadsheet.write_xlsx` (Task 2); `payer_groups.load_mapping`, `payer_groups.classify` (Task 3).
- Produces: `export_medcor_ready._categorize_by_payer(saved: Path | None) -> Path | None` — writes `Output\Medcor Ready_<date>.xlsx` and returns its path, or `None` if the step could not run (never raises).
- [ ] **Step 1: Add the new setting**
In `src/ecw_bot/settings.py`, immediately after the `SHOW_CLAIM_IDS` setting (currently ending around line 311, just before the `# ── Per-claim processing ──` section header), add:
```python
# 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)
```
- [ ] **Step 2: Add the matching line to `.env.example`**
Open `.env.example`, find the block documenting `SHOW_CLAIM_IDS` (search for that key), and add directly after it:
```
# After categorizing the export by payer, show a box listing any PAYER value
# with no entry in src/ecw_bot/payer_groups.json (those rows get a blank
# Group Name, not a guess). 1 = show it, 0 = log only.
NOTIFY_UNMAPPED_PAYERS=1
```
Match whatever `1`/`0` convention the surrounding boolean settings in that file already use — check `SHOW_CLAIM_IDS`'s line right above it and mirror its exact style so the two files can't drift the way the earlier `OTP_WAIT_SECONDS` mismatch did.
- [ ] **Step 3: Write the failing test for `_categorize_by_payer`**
Create `tests/test_categorize_by_payer.py`:
```python
"""
export_medcor_ready._categorize_by_payer(): the payer -> Group Name step.
python tests\\test_categorize_by_payer.py
Builds a real xlsx (same helper shape as test_claim_file.py's `xlsx()`) so
this exercises the actual read -> classify -> write path, not a mock of it.
"""
import sys
import zipfile
import pathlib
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
from ecw_bot.core import spreadsheet # noqa: E402
from ecw_bot import settings as cfg # noqa: E402
TMP = pathlib.Path(tempfile.mkdtemp())
NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
def xlsx(header, data_rows, name="t.xlsx") -> pathlib.Path:
"""A minimal real xlsx: header + data rows, both plain string lists."""
rows = [header] + data_rows
body = []
for r, row in enumerate(rows, start=1):
cells = "".join(
f'<c r="{chr(65 + c)}{r}" t="str"><v>{v}</v></c>'
for c, v in enumerate(row))
body.append(f'<row r="{r}">{cells}</row>')
sheet = (f'<?xml version="1.0"?><worksheet {NS}><sheetData>'
f'{"".join(body)}</sheetData></worksheet>')
path = TMP / name
with zipfile.ZipFile(path, "w") as z:
z.writestr("xl/worksheets/sheet1.xml", sheet)
return path
HEADER = ["CLAIMS#", "PAYER", "CHARGES"]
def case_categorizes_known_payers():
saved = xlsx(HEADER, [
["9107000", "Medicaid", "275.00"],
["9106000", "DentaQuest", "117.00"],
], name="known.xlsx")
out = flow._categorize_by_payer(saved)
assert out is not None and out.exists(), out
rows = spreadsheet.read_rows(out)
assert rows[0] == HEADER + ["Group Name"], rows[0]
assert rows[1] == ["9107000", "Medicaid", "275.00", "Medicaid"], rows[1]
assert rows[2] == ["9106000", "DentaQuest", "117.00", "Dentaquest"], rows[2]
def case_unmapped_payer_gets_blank_group_not_a_guess():
saved = xlsx(HEADER, [
["9108000", "Some Brand New Payer", "50.00"],
], name="unmapped.xlsx")
out = flow._categorize_by_payer(saved)
rows = spreadsheet.read_rows(out)
assert rows[1][-1] == "", rows[1] # blank, not a guessed group
def case_none_input_is_a_no_op():
assert flow._categorize_by_payer(None) is None
def case_missing_payer_column_does_not_raise():
saved = xlsx(["CLAIMS#", "CHARGES"], [["9108000", "50.00"]],
name="nopayer.xlsx")
assert flow._categorize_by_payer(saved) is None # skipped, not a crash
CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")]
def main() -> int:
failures = 0
for fn in CASES:
name = fn.__name__[5:].replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Run it to verify it fails**
Run: `python tests\test_categorize_by_payer.py`
Expected: `ERROR` on every case — `AttributeError: module 'ecw_bot.flows.export_medcor_ready' has no attribute '_categorize_by_payer'`
- [ ] **Step 5: Implement `_categorize_by_payer` in `flows/export_medcor_ready.py`**
Change the import line near the top (currently `from ..core import guard, runlog, msgbox`) to:
```python
from ..core import guard, runlog, msgbox, spreadsheet, payer_groups
```
Add this new function directly after `_read_back_claim_ids` (which ends at the `return info` around line 85), before `_read_claim_form`:
```python
def _categorize_by_payer(saved):
"""
Add a Group Name column (Commercial / Medicaid / Medicare / Dentaquest /
Program / ...) next to the saved report, based on each row's PAYER, and
write it to Output\\Medcor Ready_<date>.xlsx.
Never touches `saved` itself: claim_file.py and everything after it reads
that file's columns by position (see claim_file.py's module docstring),
so this always writes a SEPARATE file rather than editing it in place.
Never fatal. An unmapped payer, a missing PAYER column, or a write
failure is worth reporting loudly, not worth losing the already-saved
report over — same reasoning as `_read_back_claim_ids` above.
"""
if saved is None:
return None
try:
rows = spreadsheet.read_rows(saved)
except Exception as exc:
print(f"[CATEGORIZE] Could not read {saved.name}: {exc}")
return None
if not rows:
print(f"[CATEGORIZE] {saved.name} has no rows — skipping")
return None
header, data = rows[0], rows[1:]
if "PAYER" not in header:
print(f"[CATEGORIZE] No PAYER column in {saved.name} — skipping. "
f"Header reads: {header}")
return None
payer_col = header.index("PAYER")
try:
mapping = payer_groups.load_mapping()
except Exception as exc:
print(f"[CATEGORIZE] Could not load payer_groups.json: {exc}")
return None
out_rows = [header + ["Group Name"]]
unmapped = {}
mapped_count = 0
for row in data:
payer = row[payer_col] if payer_col < len(row) else ""
group = payer_groups.classify(payer, mapping)
if group:
mapped_count += 1
else:
unmapped[payer] = unmapped.get(payer, 0) + 1
out_rows.append(list(row) + [group or ""])
out_path = cfg.ROOT / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx"
try:
spreadsheet.write_xlsx(out_path, out_rows)
except Exception as exc:
print(f"[CATEGORIZE] Could not write {out_path.name}: {exc}")
return None
print(f"[CATEGORIZE] {mapped_count} of {len(data)} row(s) categorized "
f"-> {out_path.name}")
if unmapped:
listed = sorted(unmapped.items(), key=lambda kv: -kv[1])
print(f"[CATEGORIZE] {sum(unmapped.values())} row(s) with no group "
f"in payer_groups.json:")
for payer, count in listed:
print(f"[CATEGORIZE] {count:4d} {payer!r}")
if cfg.NOTIFY_UNMAPPED_PAYERS:
body = (f"{sum(unmapped.values())} row(s)-ku Group Name "
f"kidaikkala.\n\n"
f"{out_path.name} file-la '' (blank) Group Name-oda "
f"irukkum. Idha payer_groups.json-la add pannunga:\n\n"
+ "\n".join(f" {p} ({n} row)" for p, n in listed[:15]))
if len(listed) > 15:
body += f"\n ... innum {len(listed) - 15}"
msgbox.info_box("eCW Bot — Group Name podala", body)
return out_path
```
Add the call into `_claims_phase` — change:
```python
saved = claim_report.export_claims_report(page)
info = _read_back_claim_ids(saved)
if info:
_process_claims(page, info)
```
to:
```python
saved = claim_report.export_claims_report(page)
info = _read_back_claim_ids(saved)
_categorize_by_payer(saved)
if info:
_process_claims(page, info)
```
Finally, update the module's own flow docstring at the top of the file — change:
```
→ Input\Claim Report_<MM-DD-YYYY>.xlsx
→ read the file back, pull the CLAIMS# column
→ search each claim id on its own (CLAIM_PROCESS_LIMIT)
```
to:
```
→ Input\Claim Report_<MM-DD-YYYY>.xlsx
→ read the file back, pull the CLAIMS# column
→ categorize by payer → Output\Medcor Ready_<MM-DD-YYYY>.xlsx
→ search each claim id on its own (CLAIM_PROCESS_LIMIT)
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `python tests\test_categorize_by_payer.py`
Expected: `4/4 passed`
- [ ] **Step 7: Run the full existing test suite to confirm nothing else broke**
```bash
python tests\test_settings.py
python tests\test_guard.py
python tests\test_dead_end.py
python tests\test_claim_file.py
python tests\test_claim_form.py
python tests\test_progress_note.py
python tests\test_input_archive.py
python tests\test_spreadsheet_write.py
python tests\test_payer_groups.py
python tests\test_categorize_by_payer.py
```
Expected: every one reports all cases passed. `test_settings.py` in particular asserts the module graph stays acyclic and that no selector leaks into `settings` — confirm it still passes since this task touched `settings.py`.
- [ ] **Step 8: Commit**
```bash
cd "D:\Ecw Process"
git add src/ecw_bot/settings.py .env.example src/ecw_bot/flows/export_medcor_ready.py tests/test_categorize_by_payer.py
git commit -m "feat: categorize Medcor Ready export by payer after each run"
```
---
## Task 5: Document it
**Files:**
- Modify: `README.md`
- Modify: `CHANGELOG.md`
**Interfaces:** none — documentation only.
- [ ] **Step 1: Add a short section to `README.md`**
Find the "### Reading the claim ids back" section (the last subsection under "## Claim lookup", ending just before "## Known gaps"). Add a new subsection directly after it:
```markdown
### Categorizing by payer
Right after the claim ids are read back, every row of the just-saved report
gets a `Group Name` (`Commercial`, `Medicaid`, `Medicare`, `Dentaquest`,
`Program`, ...) based on its `PAYER`, and the result is saved to
`Output\Medcor Ready_<MM-DD-YYYY>.xlsx`. The original `Input\` file is never
touched — `claim_file.py` and the charge-entry phase depend on its exact
column layout.
The mapping is `src\ecw_bot\payer_groups.json` — a flat, hand-editable
`{"exact PAYER string": "Group Name"}` table. **Exact match only, no fuzzy
matching**: eCW's own PAYER values already differ by case and spacing for
what is really the same carrier (`United Healthcare` / `United Health Care`
/ `United healthcare` are three separate real values), so a PAYER string
with no entry gets a **blank** Group Name rather than a guess. Unmapped rows
are logged and, unless `NOTIFY_UNMAPPED_PAYERS=0`, shown in a message box —
adding one is a one-line edit to the JSON file, no code change.
Never fatal: a missing PAYER column, an unreadable report, or a write
failure is logged and the run continues — the export already succeeded by
the time this step runs.
```
- [ ] **Step 2: Add a CHANGELOG entry**
Append to the end of `CHANGELOG.md`:
```markdown
---
## Day 4 — 2026-09-15
**Automated the payer categorization step.** Reviewed against
`Salud-Charge Entry_Medcor Ready.mp4` (2:45-10:00): after the Medcor Ready
export, an operator was filtering the report by `PAYER` and hand-filling a
`Group Name` column (`Commercial`, `Medicaid`, `Medicare`, `Dentaquest`,
`Program`, ...) — the same manual, repetitive, rule-based step every other
phase of this bot exists to remove.
`core/payer_groups.py` does the lookup: exact string match against
`payer_groups.json`, on purpose — eCW's own PAYER values already differ by
case and spacing for what is really the same carrier (`United Healthcare` /
`United Health Care` / `United healthcare` are three separate values in one
real export), so anything looser has no safe way to pick which known string
a new one is closest to. An unrecognized payer gets a **blank** Group Name,
not a guess, and is logged and message-boxed — this is billing data, and
wrong is worse than blank.
`core/spreadsheet.py` gained a write side (`write_xlsx()`) to go with its
read side, built the same way — stdlib only, no openpyxl — but with the full
minimal OOXML part set (`[Content_Types].xml`, both `.rels` files,
`workbook.xml`) the reader doesn't need but real Excel does, since this file
is meant to be opened by a person.
The categorized output is a **new** file, `Output\Medcor Ready_<date>.xlsx`
— `Input\Claim Report_<date>.xlsx` is never modified, because `claim_file.py`
and the charge-entry phase read its columns by position.
**Seeded, not complete.** The mapping table ships with 23 payers covering
roughly 95% of a real export's row volume (`Medicaid`, `DentaQuest`, and the
clearly-commercial carriers), derived from the actual current export rather
than guessed from the video. 17 payers (`BH COLORADO ACCESS`, the Delta
Dental variants, `Tricare`, `Devoted`, several TPA names, ...) are
deliberately left out — first run will flag them, and filling them in is a
one-line JSON edit, no redeploy.
**Out of scope for this round:** splitting rows into the `Patient` /
`Pending with Error` / `Program` sibling workbooks the video also shows, and
writing anything to the network `Charge\Allocation\...` folder — both stay
manual for now.
```
- [ ] **Step 3: Commit**
```bash
cd "D:\Ecw Process"
git add README.md CHANGELOG.md
git commit -m "docs: document the payer categorization step"
```
---
## Self-review notes
- **Spec coverage:** architecture (Task 2-4), components (`payer_groups.py` Task 3, `write_xlsx` Task 2, flow wiring Task 4), data flow (Task 4 Step 5), error handling (unmapped payer / missing file / write failure, all in Task 4 Step 5 and covered by tests), testing (every task has its own `test_*.py`), seed mapping table (Task 3 Step 3, copied verbatim from the spec). All covered.
- **Placeholder scan:** no TBD/TODO; every step has real, complete code.
- **Type consistency:** `classify(payer, mapping)` signature is identical everywhere it's called (Task 3 tests, Task 4's `_categorize_by_payer`); `load_mapping(path=None)` likewise.
- **Scope:** single subsystem (categorization only), matches the spec's explicit out-of-scope list — no split-into-3-files logic anywhere in this plan.
@@ -0,0 +1,108 @@
# Results DB + Web Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store every run's Charge Summary rows in SQL Server (`Charge_Validation.dbo.ChargeSummary`) and show them on a local FastAPI page.
**Architecture:** A pure module `core/results_db.py` turns Charge Summary rows (the same rows the Excel file gets) into typed DB records and inserts them with one parameterized statement; the flow calls it after the Excel write, behind `RESULTS_DB`, and any DB error is a log line only. A separate `web/` FastAPI app reads the table through a small repository class and renders one Jinja2 page plus two JSON endpoints, bound to 127.0.0.1.
**Tech Stack:** Python 3.13, pyodbc + ODBC Driver 18 for SQL Server, FastAPI 0.141 + Jinja2 + uvicorn, Starlette TestClient (httpx). All already installed.
**Spec:** `docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md`
## Global Constraints
- Server `MECARE331\MECARE331`, database `Charge_Validation`, table `dbo.ChargeSummary`.
- Default connection: `DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\MECARE331;DATABASE=Charge_Validation;Trusted_Connection=yes;TrustServerCertificate=yes;`
- `RESULTS_DB` default **off**. The bot never creates/alters tables; the operator runs `tools/sql/create_tables.sql` in SSMS.
- A DB failure never stops the run and never touches the Excel summary.
- Only parameterized SQL; web is read-only (`SELECT` only).
- Web bound to `127.0.0.1` only. No external scripts/CDNs.
- No new entries in `requirements.txt`. Tests are standalone scripts (`python tests\test_x.py`, prints `N/M passed`), no pytest, no real DB.
- Never commit unless the operator asks; never commit `run.py`, `Input/`, `Output/`, `Logs/`, `Screenshots/`, `Dom.txt`, client `.xlsx`.
## Rulings (plan vs spec)
- Ruling: the flow builds the sheet once more for the DB (`charge_summary.build`) instead of threading one sheet through `write()` — keeps `write()` and its tests unchanged; both builds read the same report and outcomes on the same day, so the rows are identical — cost if wrong: a row differing between Excel and DB, visible on the page.
- Ruling: only rows the run filled in (non-blank Category) are saved — unprocessed / not-found rows are blank in the Excel too and would only clutter the page — cost if wrong: the page lacks blank rows the Excel has.
## File map
| File | Responsibility |
|---|---|
| `tools/sql/create_tables.sql` | Table + indexes, run by the operator |
| `src/ecw_bot/settings.py` | `RESULTS_DB`, `RESULTS_DB_CONN` |
| `src/ecw_bot/core/results_db.py` | Column map, value conversion, `details_for`, `insert_rows`, `save_summary` |
| `src/ecw_bot/core/charge_summary.py` | `build()` split out of `write()` |
| `src/ecw_bot/flows/export_medcor_ready.py` | `changes` in `_conditions`, `details` dict, `_run_mode()`, `_save_results_db()` |
| `web/__init__.py`, `web/repo.py`, `web/app.py`, `web/templates/index.html` | Read-only page + JSON API |
| `run_web.bat` | Start the page on 127.0.0.1:8000 |
| `tests/test_results_db.py`, `tests/test_web_app.py` | Tests |
---
### Task 1: SQL script, settings, `results_db` module
**Files:**
- Create: `tools/sql/create_tables.sql`, `src/ecw_bot/core/results_db.py`, `tests/test_results_db.py`
- Modify: `src/ecw_bot/settings.py` (after `SUMMARY_ASSIGNED_TO`)
**Interfaces:**
- Produces: `results_db.COLUMNS: list[tuple[str, str]]` (header, db column), `DB_COLUMNS: list[str]`, `TABLE = "dbo.ChargeSummary"`, `to_money(v) -> Decimal|None`, `to_date(v) -> date|None`, `to_record(row: dict, *, run_at, run_mode, details) -> dict`, `details_for(data: dict) -> dict`, `insert_rows(conn, records) -> int`, `save_summary(sheet, details, *, run_at, run_mode, conn_str, connect=None) -> int|None`; settings `RESULTS_DB: bool`, `RESULTS_DB_CONN: str`.
- [ ] **Step 1: Write the failing tests** — `tests/test_results_db.py` with cases: every HEADER mapped (24) and `DB_COLUMNS` = run_at, run_mode, 24 columns, details_json; `to_money("$1,234.50") == Decimal("1234.50")`, `"(12.00)"` → -12.00, blank/"abc"/"NaN"/"1e20" → None, `365` → 365.00; `to_date("7/31/2026") == date(2026,7,31)`, ISO and datetime accepted, blank/junk → None; `to_record` converts types, blank text → None, `details_json` is JSON or None; `details_for` copies `_conditions` keys, Dental results, error keys; `insert_rows` sends one `INSERT INTO dbo.ChargeSummary (...) VALUES (?, ...)` with values in `DB_COLUMNS` order and commits once; `save_summary` skips blank-Category rows, closes the connection, returns the count; a raising `connect` returns None and prints `[RESULTS-DB]`. (Full code: see the test file written in Step 1 of execution.)
- [ ] **Step 2: Run** `python tests\test_results_db.py` — Expected: ImportError (no `results_db`).
- [ ] **Step 3: Implement** `results_db.py` per the Interfaces above (money: strip `$ ,`, `(x)` negative, finite and `< 10**10`, quantize 0.01; dates: `mm/dd/yyyy`, `yyyy-mm-dd`, `date`/`datetime`; `INSERT` built only from `DB_COLUMNS` names and `?` markers; `save_summary` wraps everything in `try/except Exception` → `[RESULTS-DB] Could not save ...` + None). Settings:
```python
# ── 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 DB error is a log line only.
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;")
```
and `tools/sql/create_tables.sql` = the spec's `CREATE TABLE` wrapped in `IF OBJECT_ID('dbo.ChargeSummary','U') IS NULL BEGIN ... END`.
- [ ] **Step 4: Run** `python tests\test_results_db.py` — Expected: all passed.
### Task 2: `charge_summary.build()` and the flow hook
**Files:**
- Modify: `src/ecw_bot/core/charge_summary.py` (`write()`), `src/ecw_bot/flows/export_medcor_ready.py` (`_read_claim_form` `_conditions`, claim loop, after `_write_charge_summary`)
- Test: `tests/test_results_db.py` (flow cases), `tests/test_charge_summary.py` (unchanged, must stay green)
**Interfaces:**
- Consumes: `results_db.details_for`, `results_db.save_summary`, `cfg.RESULTS_DB`, `cfg.RESULTS_DB_CONN`.
- Produces: `charge_summary.build(report_path, outcomes, assigned_to) -> list` (header + rows); flow `_run_mode() -> str` ("TEST" / "LIVE-WRITE" / "VERIFY"), `_save_results_db(info, outcomes, details) -> None`.
- [ ] **Step 1: Failing tests** in `tests/test_results_db.py`: `_run_mode()` for the three settings combinations; `_save_results_db` with `RESULTS_DB` off calls nothing; on, it calls `charge_summary.build(info["file"], outcomes, cfg.SUMMARY_ASSIGNED_TO)` and `results_db.save_summary(sheet, details, run_at=<datetime>, run_mode=..., conn_str=cfg.RESULTS_DB_CONN)`; a raising `build` prints `[RESULTS-DB]` and does not raise.
- [ ] **Step 2: Run** — Expected: AttributeError (`_run_mode` missing).
- [ ] **Step 3: Implement**: move `write()`'s read+`build_rows` into `build()`, `write()` calls `build()`; in `_read_claim_form` add `"changes": {r.condition_name: r.changes for r in results}` to `data["_conditions"]`; in the claim loop `details = {}` and `details[claim_id] = results_db.details_for(data)`; after `_write_charge_summary(info, outcomes)` call `_save_results_db(info, outcomes, details)`.
- [ ] **Step 4: Run** `tests\test_results_db.py`, `tests\test_charge_summary.py`, `tests\test_claim_boxes.py` — Expected: all passed.
### Task 3: Web page
**Files:**
- Create: `web/__init__.py`, `web/repo.py`, `web/app.py`, `web/templates/index.html`, `run_web.bat`, `tests/test_web_app.py`
**Interfaces:**
- Consumes: `results_db.COLUMNS`, `results_db.TABLE`, `cfg.RESULTS_DB_CONN`.
- Produces: `web.repo.SqlResultsRepo(conn_str, connect=None)` with `runs() -> list[dict]` (`run_at, run_mode, claims, pass_count, fail_count, review_count`, newest first) and `results(run_at, category=None, claim_no=None) -> list[dict]`; `web.app.create_app(repo) -> FastAPI`; module-level `web.app.app`.
- [ ] **Step 1: Failing tests** `tests/test_web_app.py` with a fake repo: `/` lists rows of the latest run, shows Pass/Fail/Review counts and the Charge Summary headers; `?category=Review` passes the filter to the repo; `?category=Bad` → 400; `?claim_no=55-51` searches `5551`… digits only; `?run_at=not-a-date` → 400; `/api/runs` and `/api/results` JSON shapes (dates ISO, money numbers); repo raising → 503 with "Results database not reachable"; no runs → page says no results yet; `SqlResultsRepo.results` builds a parameterized query (fake connection) with `category = ?` and `claim_no LIKE ?`.
- [ ] **Step 2: Run** — Expected: ImportError.
- [ ] **Step 3: Implement** repo, app (`docs_url=None`), template (form: run select, category select, claim search; tiles; table in Charge Summary order with Category colour; `<details>` per row with pretty JSON), `run_web.bat` (`python -m uvicorn web.app:app --host 127.0.0.1 --port 8000`).
- [ ] **Step 4: Run** `python tests\test_web_app.py` — Expected: all passed.
### Task 4: Docs and full suite
- [ ] Context.md: a "Results DB + web page" section (setup: run `tools/sql/create_tables.sql`, grant the Windows login read/write, `RESULTS_DB=1`, `run_web.bat`).
- [ ] Full suite: `bash run_tests.sh` → TOTAL all passed.
- [ ] Report to the operator; commit only when asked.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
# Results Portal Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the one-page results table with a Dashboard → Claim status (date cards) → One day (short table + side panel) portal, plus a claim history search, in a navy & blue theme.
**Architecture:** Server-rendered FastAPI + Jinja pages sharing `base.html`. Three new read-only queries in `web/repo.py` group the existing `CV_ChargeSummary` rows by the day a run started. The Bot run card and `/api/*` stay as they are; old `/?run_at=` links redirect to the day page.
**Tech Stack:** FastAPI, Jinja2, pyodbc (SQL Server), vanilla JS, standalone test scripts (`python tests\test_web_app.py`).
**Spec:** `docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md`
## Global Constraints
- Bound to 127.0.0.1; Host allowlist, `no-store` headers, DB errors by kind — unchanged.
- Start / Stop need the page Origin and `X-Run-Token`; page runs are VERIFY only.
- All new pages read-only; every SQL statement parameterized; values never in SQL text.
- DB text is escaped: Jinja autoescape, panel data via `tojson` in an attribute, written with `textContent`.
- No claim ids or patient data in tracked docs; tests use made-up ids.
- No git commits unless the operator asks.
---
### Task 1: Day-wise queries in the repo
**Files:** Modify `web/repo.py`; Test `tests/test_web_app.py`
**Interfaces — Produces:**
- `SqlResultsRepo.days(limit: int = 90) -> list[dict]` — keys `day` (date), `claims`, `runs`, `pass_count`, `fail_count`, `review_count`; newest first.
- `SqlResultsRepo.day_results(day: date, category=None, claim_no=None) -> list[dict]` — full rows (`_SELECT_COLUMNS`) with `run_at >= day AND run_at < day+1`, ordered `run_at, s_no, id`.
- `SqlResultsRepo.claim_history(claim_no: str) -> list[dict]` — `TOP 200` full rows `WHERE claim_no LIKE ?` ordered `run_at DESC, s_no, id`.
- `_coerce` also turns a text `day` into a `date`.
- [ ] Tests: each query's SQL is a parameterized SELECT with the clauses above and the expected params (`[datetime(day), datetime(day)+1 day]`, `["%no%"]`); text `day` coerced.
- [ ] Implement; run `python tests\test_web_app.py`.
### Task 2: Routes
**Files:** Modify `web/app.py`; Test `tests/test_web_app.py`
**Interfaces — Consumes:** Task 1. **Produces:** routes
- `GET /` → `dashboard.html` with `today` (days() row for `date.today()` or zero counts), `mode` ("VERIFY"/"LIVE-WRITE"), `run_token`. `GET /?run_at=<iso>` → 303 to `/day/<date>` (keeps `category`, `claim_no`); bad `run_at` → 400.
- `GET /days` → `days.html` with `days`.
- `GET /day/{day}` (`YYYY-MM-DD`, else 400) with `category`, `claim_no`, `claim` → `day.html` with `day`, `counts` (from `days()` row or computed from the unfiltered rows), `rows` (each with `fields` = list of `[header, display text]` and `details_pretty`), filters.
- `GET /claim?claim_no=` → `claim.html` with `rows` (history) and `claim_no`; empty search → page with the box only.
- FakeRepo in tests gains `days`, `day_results`, `claim_history`.
- [ ] Tests for each route (data shown, links, filters passed, 400s, redirect, DB down 503, hardening incl. new paths).
- [ ] Implement; run tests.
### Task 3: Templates and theme
**Files:** Create `web/templates/base.html`, `dashboard.html`, `days.html`, `day.html`, `claim.html`; delete `web/templates/index.html`.
- `base.html`: `:root` navy palette, header (title, mode pill, breadcrumb block), `{% block content %}`, `{% block scripts %}`, footer PHI note.
- `dashboard.html`: Bot run card (moved from index.html, with the Live claims panel replacing the old Claim status panel; a saved claim opens `/day/<results_run_at date>?claim=<id>`), today tiles, Find a claim form, big Claim status button.
- `days.html`: date cards grid with counts and colour bar; empty state.
- `day.html`: tiles as filter links, claim search, short table (#, Claim #, Time, Payer, Result, Comments), 10-a-page pager, side panel from `data-fields` (`tojson`) via `textContent`; `?claim=` opens it.
- `claim.html`: search box and history table linking to day pages.
- [ ] Tests on rendered HTML (ids, links, no inline `style=`, theme vars, panel wiring, one-scroll lock, Live claims link target).
- [ ] Run `python tests\test_web_app.py` and `python tests\test_run_status.py`.
### Task 4: Browser check
- [ ] Serve the app on a fake repo with sample data (scratch script, made-up ids), open each page in the in-app browser, screenshot, check pager, panel, filters, empty states, narrow width.
- [ ] Full suite.
@@ -0,0 +1,182 @@
# Payer → Group Name categorization — design
Status: approved by user (Vasanth), 2026-09-15. Not yet implemented.
## Background
This is the next phase of the Salud eCW Charge Entry bot (see `README.md`),
which already automates login, 2FA, claim lookup, and exporting the
"Medcor Ready" claim queue to `Input\Claim Report_<date>.xlsx`.
The next manual step in the real process (shown in
`Salud-Charge Entry_Medcor Ready.mp4`, reviewed 2:45–10:00) is: an operator
renames a copy of that export to "Medcor ready", opens it in a dated network
folder (`...\Charge\Allocation\<Month YYYY>\<MMDDYYYY>\`, alongside sibling
workbooks `Patient`, `Pending with Error`, and `Program`), and manually
categorizes every row by filtering on the `PAYER` column and bulk-filling a
`Group Name` column with one of a small set of values (observed in the video:
`Commercial`, `Dentaquest`, `Medicaid`, `Medicare`, `Program`, plus at least
one more category for payers like `BH COLORADO ACCESS`).
This spec covers automating **only** that categorization step — not the
downstream split into the three sibling workbooks, which stays manual for now
and may be scoped separately later.
## Scope decisions (from user)
- **Scope**: categorize only. Do not build the split into
Patient/Pending with Error/Program workbooks.
- **Mapping source**: derived from the actual current export
(`Input\Claim Report_09-11-2026.xlsx`, 729 rows, 39 distinct `PAYER`
strings), not invented from video frames. Only high-confidence entries are
seeded; everything else is left for a human to fill in (see "Seed mapping
table" below) — this is intentional, not a gap to silently paper over.
- **Unknown payer handling**: flag, never guess. A `PAYER` value with no
entry in the mapping table gets a blank `Group Name`, is logged, and is
called out in the run summary. No fuzzy/substring matching — exact string
match only, so a typo'd or renamed payer can never be silently bucketed
into the wrong group.
- **Integration point**: inside the existing bot, not a standalone script.
Runs automatically as part of `flows/export_medcor_ready.py`.
- **Output location**: a new file, `Output\Medcor Ready_<date>.xlsx`. The
original `Input\Claim Report_<date>.xlsx` is never modified — `claim_file.py`
and everything downstream of it depends on that file's exact column
layout (per `README.md`'s "Reading the claim ids back" section), so this
step must not touch it.
## Architecture
```
flows/export_medcor_ready.py
... existing steps (login → claim lookup → export → claim_file.claim_ids()) ...
→ categorize_by_payer() NEW — reads Input\, writes Output\
core/payer_groups.py NEW — the lookup table + classify()
payer_groups.json NEW — editable config, exact PAYER → Group Name
core/spreadsheet.py EXTENDED — write_xlsx(), stdlib only
```
`categorize_by_payer()` runs after the existing claim-id read-back (the point
where the report is already saved and validated — see
`README.md`'s "Reading the claim ids back"). It:
1. Reads `Input\Claim Report_<date>.xlsx` with the existing `spreadsheet.read_rows()`.
2. For each row, looks up `PAYER` in `payer_groups.classify()`.
3. Writes every original column plus a new `Group Name` column to
`Output\Medcor Ready_<date>.xlsx`.
4. Logs and message-boxes a summary: rows categorized, rows left blank, and
the distinct list of unmapped `PAYER` strings — mirroring the existing
`NOTIFY_AFTER_EXPORT` / `SHOW_CLAIM_IDS` pattern in the same file.
Never fatal: an unmapped payer does not stop the run, the same philosophy
`export_claims_report()` already uses for other soft failures.
## Components
### `src/ecw_bot/core/payer_groups.py`
```python
def classify(payer: str) -> str | None: ... # exact match against the config, or None
def load_mapping() -> dict[str, str]: ... # reads payer_groups.json once
```
Exact-string lookup only. No normalization beyond what's needed to match
eCW's own values (the config keys are the literal `PAYER` strings as eCW
exports them).
### `src/ecw_bot/payer_groups.json`
A flat `{"<exact PAYER string>": "<Group Name>"}` map, hand-editable by
anyone on the team without touching code — same reasoning as `.env` for
settings and `selectors\` for DOM selectors: this data changes for a
different reason (a new payer shows up) than the code around it does.
### `core/spreadsheet.py` — new `write_xlsx()`
The project deliberately has no `openpyxl` dependency (see README: "every
added package is an approval"). `write_xlsx()` follows the same approach
already used for reading — a minimal single-sheet xlsx as a zip of XML, cells
as inline strings (no `sharedStrings.xml`, matching what eCW's own exports
already do, per the existing reader's comments) — built with `zipfile` and
string templates only.
## Data flow
```
Input\Claim Report_<date>.xlsx
→ read_rows() existing
→ for each row: classify(PAYER) new
→ write_xlsx(rows + Group Name) new
→ Output\Medcor Ready_<date>.xlsx
```
## Error handling
- Unmapped payer: blank `Group Name`, logged, summarized — not fatal.
- `Input\` file missing/unreadable: this step is skipped with a clear log
line; it never blocks the rest of the run, since the export already
succeeded by the time this step runs.
- `write_xlsx()` failure (disk full, permission): logged and raised — same
severity as any other unexpected I/O failure in this flow, since silently
losing the categorized output would defeat the point of the step.
## Testing
`tests/test_payer_groups.py`, hand-rolled in the existing style (no pytest):
- Exact-match lookups against a small fixture mapping.
- Unmapped payer → `None`, never a guess.
- Round-trip test for `write_xlsx()`: write a small fixture table, read it
back with the existing `read_rows()`, assert the data matches — the same
"never touched eCW" local-fixture philosophy as `test_claim_form.py`.
## Seed mapping table
Built from the real, current payer list (`Input\Claim Report_09-11-2026.xlsx`,
729 rows). Only high-confidence entries are seeded — this covers roughly 95%
of row volume in that file.
**Mapped (seeded):**
| Payer (exact eCW string) | Group |
|---|---|
| Medicaid | Medicaid |
| DentaQuest | Dentaquest |
| CHP+ DENTAQUEST | Dentaquest |
| AARP Medicare Advantage | Medicare |
| Aetna Medicare ADV | Medicare |
| OUTREACH PROGRAM | Program |
| Anthem Blue Cross Blue Shield | Commercial |
| Cigna HealthCare Of Colorado | Commercial |
| Cigna Healthcare of CO | Commercial |
| Cigna Salud Employee | Commercial |
| Cigna | Commercial |
| Cypress Benefit Admin / Cigna PPO | Commercial |
| United Healthcare | Commercial |
| United Health Care | Commercial |
| United healthcare | Commercial |
| Unitedhealth One | Commercial |
| Oxford Health Insurance UHC | Commercial |
| UMR | Commercial |
| Aetna | Commercial |
| Aetna Alaska Care | Commercial |
| AETNA HEALTH AND LIFE INSURANCE | Commercial |
| BIND BENEFITS INC | Commercial |
| Select Health | Commercial |
**Left unmapped — needs a human decision before first real run (or will be
flagged on first run and filled in afterward):**
BH COLORADO ACCESS (15 rows), Delta Dental of Colorado (3), Devoted (2),
Clinic Sliding Fee Scale (2), Patient (1), Gravie Admin Services (1),
Tricare (1), Rocky Mountain Health Plans (1), Denver Elevate Health Plan (1),
The Kempton Group Administrators Inc (1), Optum Care Network (1), GPA (1),
Northeast Health Partners (1), Delta Dental of Pennsylvania (1),
Delta Dental of Wisconsin (1), Delta Dental (1), Delta Dental of Arkansas (1).
## Out of scope (explicitly)
- Splitting rows into the Patient / Pending with Error / Program sibling
workbooks.
- Writing to the network `Charge\Allocation\...` folder — output stays local
to `Output\`.
- Any change to `Input\Claim Report_<date>.xlsx` or anything that reads it.
@@ -0,0 +1,269 @@
# Condition Framework — Design
**Status:** Approved by user, 2026-09-16
**Supersedes nothing** — this formalizes the pattern Condition 1 (telehealth)
already established informally in `flows/export_medcor_ready.py`.
## Why
The Charge Validation Process is, by the client's own description, a long
list of per-claim conditions to check and — when a condition's criteria are
met — a correction to apply (Condition 1: a telehealth visit needs POS=11 and
M1=GT). Conditions are being implemented one at a time, and there will be
many more of them. This is a healthcare billing bot: every write it makes
changes what gets billed to a real payer, so the process must be:
- **Reusable** — adding Condition N must not require touching the code for
Conditions 1..N-1, and must follow one obvious, fixed shape every time.
- **Exception-safe** — a bug in one condition's logic must never corrupt
another condition's result on the same claim, crash the whole overnight
batch, or leave a claim half-changed with no record of what happened.
- **Provably correct before it ever touches a real claim** — every condition
must be unit-testable against a fixture, exactly like
`tests/test_telehealth_fix.py` already does for Condition 1.
This spec explicitly rejects driving the live automation with an
LLM-agent-controlled browser (e.g. Playwright MCP) as the *engine* for this
reason: none of the three properties above hold for an engine whose
per-claim behavior is an LLM's live judgment call rather than fixed,
versioned code. Playwright MCP remains valuable as a *development and
debugging aid* (inspecting live eCW DOM, prototyping selectors) — that usage
is unaffected by this spec and requires no code change.
## Non-goals
- Not changing how claims are found, opened, or read (`claim_form.py`,
`export_medcor_ready.py`'s claims-phase — untouched).
- Not building a self-healing/adaptive-selector system. If eCW's DOM shifts
enough to break a selector, that is still a `selectors/claim_form.py`
update, done and tested like every past one (see CHANGELOG "Day 5").
- Not retrying failed conditions automatically. A condition that throws is
logged and skipped for that claim, once, this run.
## Architecture
### New package: `src/ecw_bot/conditions/`
```
src/ecw_bot/conditions/
__init__.py # CONDITIONS: list[module] — the registry
base.py # ConditionResult dataclass + run_all()
telehealth.py # Condition 1 — detect/apply/describe wrapper
```
### The Condition contract
Every condition module in the registry exposes exactly these three
functions. This is the whole interface — nothing else is required, nothing
else is read by the runner.
```python
def detect(claim_id: str, data: dict) -> bool:
"""
Pure. No `page`, no I/O, no side effects. `data` is the same dict
`claim_form.extract()` already produces (plus `progress_notes`, when
OPEN_PROGRESS_NOTES is on). Must be safe to call with a partially
populated dict — return False, never raise, on missing keys.
"""
def apply(page, claim_id: str, data: dict) -> "ConditionResult":
"""
Only called when detect() returned True. Makes the eCW field edits for
this condition and returns what changed. MUST NOT click Save/OK and
MUST NOT show any confirmation dialog itself — the runner's caller owns
both, once, after every condition has had a chance to run. Raising is
allowed and expected to be handled by the caller (see Exception
handling below) — apply() does not need its own try/except for its own
field-write logic, only for optional recovery it wants to attempt
itself.
"""
def describe(result: "ConditionResult") -> str:
"""
One human-readable block for this condition's contribution to the
combined confirmation dialog and the log line — mirrors the per-row
lines `_apply_telehealth_fix` already builds today.
"""
```
### `ConditionResult` (`conditions/base.py`)
```python
@dataclass
class ConditionResult:
condition_name: str
changed: bool
changes: list # same shape as claim_form.set_cpt_pos_modifier()'s
# return value: [{"row", "code", "before", "after"}, ...]
review: str = "" # added 2026-10-05 (unit D) — see below
@classmethod
def none(cls, condition_name: str) -> "ConditionResult":
return cls(condition_name=condition_name, changed=False, changes=[])
@classmethod
def declined(cls, condition_name: str, reason: str) -> "ConditionResult":
return cls(condition_name=condition_name, changed=False, changes=[],
review=reason)
```
**Amendment 2026-10-05 (review items D2/P2, unit D).** `none()` means
"nothing to do — the claim already follows this rule". A condition that
matched the claim but could not act on it (the CPT/HCPCS tab would not
open, an admin code would not add with the grid untouched) now returns
`declined(name, reason)` instead: nothing changed, but the claim is not
clean, and `reason` reaches the Charge Summary as a "Review" row. A
changed result may also carry `review` (a failed add that still moved the
grid). Two optional module hooks join `detect/apply/describe`:
- `needs_review(claim_id, data) -> str | None` — asked only when
`detect()` returned False, for a claim that matched the trigger but
cannot be acted on (vaccine_admin_codes on an unmapped payer, no payer
name, no usable age — never Medicare, which has its own rule).
- `summary_comment(result) -> str` — the Charge Summary comment for a
changed result, built from what actually happened, in place of the
summary's fixed `COMMENTS[name]`.
### The runner (`conditions/base.py`)
```python
def run_all(page, claim_id: str, data: dict) -> list[ConditionResult]:
"""
Runs every registered condition against one claim, in registry order.
Returns only the results where something actually changed — a claim
with nothing to fix returns an empty list, and the caller shows no
dialog and saves nothing.
"""
results = []
for cond in CONDITIONS:
name = cond.__name__.rsplit(".", 1)[-1]
try:
if not cond.detect(claim_id, data):
continue
result = cond.apply(page, claim_id, data)
except Exception as exc:
print(f"[COND {name} {claim_id}] Failed: {exc}")
shot(page, f"condition_{name}_failed_{claim_id}")
continue # this condition's failure must not block the next one
if result.changed:
results.append(result)
return results
```
`shot()` is the existing screenshot helper already used by `open_claim()`'s
diagnostic wrapping (`claim_form.py`) — reused here, not reinvented.
**Amendment 2026-10-05 (review items D2/P2, unit D).** The real signature
is now `run_all(page, claim_id, data, conditions=None, before_apply=None,
report=None)`. Its return value is unchanged — only the changed results —
but that list is not everything that happened, so a caller that passes a
dict as `report` gets the whole account:
| key | contents |
|---|---|
| `fired` | names whose `detect()` returned True |
| `changed` | names with a changed result (= the return value) |
| `failed` | `{"name", "stage", "error"}` per raise; `stage` is `"detect"`, `"apply"` or `"needs_review"`, `error` the exception's first line |
| `review` | `{"name", "reason"}` from every result (changed or not) with a non-empty `review`, and from `needs_review()` of every condition whose `detect()` returned False |
| `comments` | `{name: summary_comment(result)}` for changed results whose module has the hook (a hook that raises is logged and left out — the summary falls back to `COMMENTS[name]`) |
Exceptions are still isolated exactly as in the sketch above: logged,
screenshotted, and the next condition still runs. `detect()` and `apply()`
now each sit in their own try (the sketch's single try is how it began), so
the report can say which stage raised.
### Caller contract (replaces `_apply_telehealth_fix`'s direct call)
`export_medcor_ready.py`'s `_read_claim_form()` changes from calling
`_apply_telehealth_fix(page, claim_id, data)` directly to:
```python
if cfg.APPLY_CORRECTIONS:
results = conditions.run_all(page, claim_id, data)
saved_write = _confirm_and_save(page, claim_id, results)
```
(As built since 2026-10-05: `run_all(..., report=report)` and
`_confirm_and_save(page, claim_id, results, report)`; the report is stored
on the claim's data as `data["_conditions"]` = `{"fired", "saved",
"failed", "review", "comments"}`, which the Charge Summary reads.)
`_confirm_and_save` (new, small, lives in `export_medcor_ready.py` — it is
orchestration, not a condition):
1. If `results` is empty → return `False` immediately, no dialog, no save.
(A claim where nothing fired is not different from today's "no
telehealth mention" case.)
2. Otherwise build one combined message: every fired condition's
`describe()` output, concatenated, in the same
`msgbox.ok_cancel(...)` shape Condition 1 uses today.
3. One Yes/No. Yes → `claim_form.save_claim(page)`. No → log "declined",
return `False` (the caller's existing `finally` still closes with
Cancel, unchanged).
4. Return whether the save actually happened, same as
`_apply_telehealth_fix` returns today — this is what `_read_claim_form`
uses to decide whether its own `finally` should call `close_claim()`.
**Amendment 2026-10-05 (review item P2, unit D):** between 1 and 2 —
and after the SAVE_CORRECTIONS gate — if the report has any `failed` or
`review` entry, the box is never shown: the log says `[COND <id>] Not
offering Save: <reasons> — closing with Cancel` and it returns `False`.
**Why one dialog, not one per condition:** if two conditions both want
changes on the same claim, saving after only reading half the operator's
decision is exactly the kind of half-applied state this spec exists to
prevent. The operator sees the whole picture — every condition that fired,
every field that would change — before any single Save/Cancel decision.
## Exception handling — the actual rules
This is the section the user asked for explicitly, spelled out rather than
left implicit:
| Failure | What happens |
|---|---|
| One condition's `detect()` raises | Caught by `run_all`'s try/except (its own try since 2026-10-05, so the report can say `stage: "detect"`). Logged, screenshotted, skipped, and recorded in the report's `failed` — the claim reads "Review", not clean. Other conditions still run. |
| One condition's `apply()` raises partway through a multi-row edit | Caught by `run_all`. Logged, screenshotted. That condition contributes nothing to `results`. **Corrected 2026-10-05 (review item P2):** this row used to say its partial edits "are never saved, because Save only happens ... for what's in `results`". That was wrong: Save (`saveAllData`) writes the whole form, partial edits included, and the box would have listed only the other conditions. So the raise is recorded in `run_all`'s report (`failed`, stage `"apply"`), and `_confirm_and_save` never offers Save for a claim whose report has a `failed` or `review` entry — the claim is closed with Cancel and reported "Review" in the Charge Summary. |
| `_confirm_and_save`'s dialog itself fails to show (msgbox error) | Not caught here — surfaces to `_read_claim_form`'s existing outer try/except, which logs "Could not read the form" and closes via `finally`. No new behavior needed; the existing outer boundary already covers this. |
| `save_claim()` returns `False` (save click didn't work) | Same as today: logged, `saved_write` stays `False`, `finally` calls `close_claim()` — the claim is left as read, not half-saved. |
| A condition throws on every claim in the batch (systemic bug in that one condition) | By design, this spec does not add batch-level circuit-breaking — each claim's screenshot + log line is enough signal for the operator to notice a pattern across a run's log file. Adding an auto-disable-after-N-failures behavior is explicitly deferred (YAGNI): today's batches run at `CLAIM_PROCESS_LIMIT` scale, small enough that log review is a real diagnostic step, not a chore. |
## Testing
- `tests/test_telehealth_fix.py` — unchanged. It tests
`claim_form.note_mentions_telehealth` and `claim_form.set_cpt_pos_modifier`
directly; those functions do not move.
- `tests/test_conditions_telehealth.py` (new) — tests
`conditions/telehealth.py`'s thin wrapper: `detect()` delegates to
`note_mentions_telehealth` correctly on a plain dict (no page needed);
`apply()` calls `set_cpt_pos_modifier` and returns a populated
`ConditionResult`; `describe()` produces the expected text.
- `tests/test_conditions_runner.py` (new) — tests `run_all()` and
`ConditionResult` in isolation with fake condition modules (no real
telehealth logic involved):
- a condition whose `detect()` returns `False` is never `apply()`-ed
- a condition whose `apply()` raises is skipped, and a *second*,
healthy condition registered after it in `CONDITIONS` still runs and
its result is still returned
- `results` is empty when every condition's `detect()` returns `False`
- a condition's raised exception does not propagate out of `run_all`
## Migration checklist (informs the implementation plan)
1. Create `conditions/base.py` (`ConditionResult`, `run_all`).
2. Create `conditions/telehealth.py` — move `note_mentions_telehealth`-based
detection logic into `detect()`, wrap `set_cpt_pos_modifier` in
`apply()`, write `describe()` from the existing message-building lines
in `_apply_telehealth_fix`.
3. Create `conditions/__init__.py` with `CONDITIONS = [telehealth]`.
4. Add `_confirm_and_save` to `export_medcor_ready.py`; delete
`_apply_telehealth_fix` (its logic is now split across
`conditions/telehealth.py` and `_confirm_and_save`).
5. Update `_read_claim_form` to call `conditions.run_all` +
`_confirm_and_save` instead of `_apply_telehealth_fix` directly.
6. New tests (above) all passing; full existing suite (88+ tests) still
passing unchanged.
7. Condition 2 (whatever it turns out to be) is the first real proof this
spec worked: it should require touching only a new
`conditions/<name>.py` + its test file + one line in `CONDITIONS`.
@@ -0,0 +1,137 @@
# Dental claims run every applicable condition — design
Date: 2026-10-07. Status: approved in chat (operator, 2026-10-07);
implemented 2026-10-12 (operator: "this is our number one condition").
## 2026-10-12 update (as built)
- The rule now also covers #12 (H codes: BH payer → modifier removed;
any other payer → line unchecked + fee 0) and #14 (Medicare: G8510 /
G8431 unchecked + fee 0, #6 not run). Both run on Dental claims like
every other condition; #12's BH path has no modifier column to read on
the Dental grid, so it never fires there.
- A fix the Dental screen cannot hold → **Review** (operator): a condition
module declares `DENTAL_UNSUPPORTED = "<reason>"` and `run_all` records
that reason instead of applying it on a `screen == "dental"` read —
telehealth, medicare_telehealth, vaccine_admin_codes.
- Dental claims are **never saved** in this change (operator), whatever
`SAVE_CORRECTIONS` says; the review-before-close box still shows.
- A Dental claim is read and run through the conditions even with
CHECK_HIGH_RISK and CHECK_SLIDING_FEE both off (it used to come back
blank).
- The Charge Summary applies the same claim-level checks (note read, CPT /
ICD rows read, conditions run) to a Dental read as to UB/HCFA.
- Row selectors gained a third (Dental) alternative, scoped to the Dental
window on screen (`:visible`); `extract()` returns `screen`
("ub" / "hcfa" / "dental").
- Not live-verified yet: the first live run should be SAVE-off on a few
Dental claims (one with DCONS or Dscprit).
## Rule (operator, 2026-10-07)
Every claim is checked against **every condition whose Payer column is
"All" / "All payers"**, **plus** every condition specific to that claim's
payer (Medicare, Medicaid, Dental payers, ...). This holds for every payer
and every claim screen — UB, HCFA and Dental alike.
UB and HCFA claims already work this way: each claim runs the whole
registry (`conditions/__init__.py` `CONDITIONS`) and each condition's own
`detect()` decides from the claim's codes and payer. **The gap is Dental:** a
Dental claim today runs only #15 high risk and #8 sliding fee and never
reaches `run_all`. So, for example, `dummy_codes` (DCONS/Dscprit, which are
Dental codes) can never fire on any claim.
## What a Dental claim will run
| Doc # | Condition | Payer (document) | Module |
|---|---|---|---|
| 5 | J2930/J2001 uncheck + fee 0 | ALL | `j2930_j2001_update` |
| 6 | Depression DX pointer | All | `depression_dx_pointer` |
| 7 | Zero-fee uncheck / priced line check | ALL | `zero_fee_uncheck`, `priced_line_check` |
| 21 | Telehealth POS 2 + modifier | All | `telehealth` |
| 22 | Medicare telehealth G2025 | Medicare | `medicare_telehealth` |
| 18 | Vaccine admin codes | All except Medicare | `vaccine_admin_codes` |
| 8 | DCONS/Dscprit uncheck + fee 0 | All Dental Payer | `dummy_codes` |
| 8 | Sliding fee date check | All Dental Payer | `pages/sliding_fee` (unchanged) |
| 15 | High risk in Billing Notes | Dental Payers | `pages/dental_notes` (unchanged) |
No condition gains a form-type check: each still decides from codes and
payer only. A condition whose codes are not on the claim does nothing.
## Dental screen facts (from Dom.txt, claim screen `.modal[id^="dentalClaim"]`)
- CPT/CDT grid `table[id^="claimCPTCodesTable"]`; real rows are
`tr[ng-repeat^="CPT in CPTCodes"]` (no `CPTROW_` id).
- Per row: checkbox `[ng-click*="updateBillToIns("]` (same as UB/HCFA), code
`[data-fieldname="web_cptcode"]`, pointers `web_icd1`..`web_icd4`, units
`web_units`, fee `web_billedFee` (blur runs `editCPTRow('BILLED_FEE')`),
DOS `DentalClaimCPTSDOS`.
- **No per-row POS and no modifier columns.** POS is claim-level
(`ng-model="ClaimData.pos"`).
- Prog. Notes button `button[ng-click="viewProgNote()"]`
(`dentalClaimProgressNoteBtn…`) — the existing `SEL_PROGRESS_NOTES` matches.
- Insurance grid: `#DentalClaimTbl2` (Bill To, Ins Id, Name, ...).
- Billing Notes: `#dentalClaimNotes` (already read by high risk).
None of the row fields has been exercised live yet; Dom.txt is one capture.
## Design
1. **Selectors** (`selectors/claim_form.py`): add the Dental alternative to
each row selector the helpers already use — `SEL_CPT_ROWS`,
`SEL_CPT_CODE`, `SEL_CPT_ICD1..4`, `SEL_CPT_BILLED_FEE`, `SEL_CPT_UNITS`.
The Dental row selector is scoped under `.modal[id^="dentalClaim"]` so it
cannot match a grid on another screen. Within one row only one alternative
ever matches, the same contract the UB/HCFA pairs rely on.
2. **Reader** (`pages/claim_form.py` `_EXTRACT_JS`): read Dental rows into the
same `cpt` row shape (`code`, `icd1..4`, `units`, `billed_fee`, `billed`,
`sdos`; `pos`/`mod1..4` empty), insurance from `#DentalClaimTbl2`, the
Dental claim number, ICD list, and `notes` from `#dentalClaimNotes`.
One `extract()` for all three screens.
3. **Flow** (`flows/export_medcor_ready.py`): a Dental claim runs high risk +
sliding fee first (unchanged, read-only), then the same path as UB/HCFA:
`extract()`, Prog. Notes (when `OPEN_PROGRESS_NOTES`), `run_all`, per-claim
box, Charge Summary. The Dental results stay on the claim's data so the box
and the summary show both.
4. **A fix the Dental screen cannot make** — e.g. telehealth needs a row
modifier and the Dental grid has none, or POS is claim-level: the condition
returns `declined(...)` with the reason ("Dental screen has no modifier
column"), so the claim is **Review** in the Charge Summary. Never a silent
skip, never a guess.
5. **Charge Summary**: one row per claim combining condition comments with the
Dental findings (HIGH RISK, sliding fee). Category: Review beats Fail beats
Pass, as today.
6. **Saving**: Dental claims are **never saved** in this change, whatever
`SAVE_CORRECTIONS` says. Their corrections are shown and listed as
"Corrections pending (not saved)". Dental save is enabled only after a live
verification-mode run confirms the row writes.
## Error handling
- Dental form not loaded (`wait_claim_loaded`): conditions skipped, claim is
Review — same as HCFA today.
- A Dental row field missing (fee or checkbox not found): the existing helpers
already treat a missing field as unknown, not as zero/unchecked.
- High risk / sliding fee failures keep their current Review handling.
## Testing
- Grow `tests/fixtures/dental_claim.html` from Dom.txt's real Dental grid
(rows, checkbox, fee, pointers, insurance grid, Prog. Notes button) with no
patient data.
- `extract()` on the Dental fixture returns the expected `cpt` and `insurance`.
- Per condition, a Dental case: dummy codes (DCONS/Dscprit) uncheck + zero;
J2001 uncheck + zero; zero-fee uncheck; priced line check; depression
pointer retag on `web_icd*`; telehealth on Dental → declined/Review.
- Flow: a Dental claim gets high risk + sliding fee **and** condition results
in one summary row; never offered Save.
- Full suite stays green.
- Live: one SAVE-off run on Dental test claims (with a DCONS or Dscprit line)
before Dental save is considered.
## Out of scope
- New document conditions not yet built (#3, #4, #9-#14, #16, #19, #20,
#23-#55).
- Saving Dental claims.
- "Special programs" half of #8.
@@ -0,0 +1,164 @@
# Charge Summary in SQL Server + results web page — design
Date: 2026-10-08. Status: design approved in chat (operator, 2026-10-08),
spec under review.
## Goal
Every run's Charge Summary rows are also stored in SQL Server, and a simple
local web page (FastAPI) shows them. This is the basic version; later it is
folded into the full portal.
## Decisions (operator, 2026-10-08)
- Server `MECARE331\MECARE331` (SQL Server 2022, on this machine), database
**`Charge_Validation`** (created by the operator).
- The table follows the team's **Charge Summary File Format**
(`\\mecare2\Production\Salud-eCW RPA\Charge Summary File Format.xlsx`) —
the same 24 columns as `core/charge_summary.HEADERS`, plus run columns and
a details JSON. PATIENT, Account Number and Guarantor Name are stored, as
in the Excel file.
- FastAPI for the backend. All needed packages are already installed
(fastapi 0.141, uvicorn, jinja2, httpx, pyodbc, ODBC Driver 18); nothing new
is added to `requirements.txt` for the bot itself.
## Table
`tools/sql/create_tables.sql`, run by the operator in SSMS (the bot never
creates or alters tables):
```sql
USE Charge_Validation;
CREATE TABLE dbo.ChargeSummary (
id INT IDENTITY(1,1) PRIMARY KEY,
run_at DATETIME2 NOT NULL,
run_mode NVARCHAR(20) NOT NULL, -- VERIFY / LIVE-WRITE / TEST
s_no INT NULL,
claim_no NVARCHAR(20) NOT NULL,
service_date DATE NULL,
pvdr NVARCHAR(50) NULL,
patient NVARCHAR(200) NULL,
payer NVARCHAR(200) NULL,
group_name NVARCHAR(100) NULL,
status NVARCHAR(100) NULL,
charges DECIMAL(12,2) NULL,
pmts_adjs DECIMAL(12,2) NULL,
pmts_refunds DECIMAL(12,2) NULL,
adjustment DECIMAL(12,2) NULL,
withheld DECIMAL(12,2) NULL,
balance DECIMAL(12,2) NULL,
visit_type NVARCHAR(100) NULL,
account_number NVARCHAR(50) NULL,
provider_name NVARCHAR(200) NULL,
guarantor_name NVARCHAR(200) NULL,
received_date DATE NULL,
assigned_to NVARCHAR(100) NULL,
completed_on DATE NULL,
comments NVARCHAR(MAX) NULL,
current_status NVARCHAR(200) NULL,
category NVARCHAR(20) NULL,
details_json NVARCHAR(MAX) NULL
);
CREATE INDEX IX_ChargeSummary_run_at ON dbo.ChargeSummary (run_at);
CREATE INDEX IX_ChargeSummary_claim_no ON dbo.ChargeSummary (claim_no);
```
Column map (Excel → DB) lives in one place, `results_db.COLUMNS`, built from
`charge_summary.HEADERS` so the two cannot drift: 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.
Value conversion: money columns strip `$`, `,` and spaces → Decimal; dates
accept `mm/dd/yyyy` (and an Excel date/datetime) → date; blank or
unparseable → NULL (the raw text is not lost: the Excel file still has it).
## Bot side — `src/ecw_bot/core/results_db.py`
- Settings (`settings.py`, from `.env`):
- `RESULTS_DB` (bool, default **off**): write rows to the DB.
- `RESULTS_DB_CONN` (string): ODBC connection string. Default:
`DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\MECARE331;DATABASE=Charge_Validation;Trusted_Connection=yes;TrustServerCertificate=yes;`
(Windows login, no password). A SQL login's password, if ever used, is put
in `.env` by the operator only.
- `to_record(row: dict, *, run_at, run_mode, details) -> dict`: one Charge
Summary row (header→value) to DB column→value, converted.
- `insert_rows(conn, records) -> int`: one parameterized `INSERT` per row
(`executemany`), one commit; returns rows written. Never builds SQL from
values.
- `save_summary(sheet, details, run_at, run_mode, connect=pyodbc.connect) ->
int | None`: `sheet` is the header+rows list `charge_summary.build_rows`
already returns; `details` maps claim no → details dict. Opens the
connection, inserts, closes. **Any exception is caught**: one
`[RESULTS-DB] Could not save ...` log line, returns None. The run, the Excel
summary and the claims are never affected.
- `details_json` per claim: `{"fired", "changed", "review", "failed",
"comments", "saved", "changes": {condition: [change, ...]},
"dental": {"high_risk", "sliding_fee"}}` — taken from the claim's
`data["_conditions"]` (the flow adds `changes` there) and Dental results.
## Flow changes
- `charge_summary.write()` is split: `charge_summary.build(report_path,
outcomes, assigned_to) -> sheet` (read + `build_rows`), and `write()` saves
that sheet as today. The flow builds once and hands the same sheet to Excel
and to the DB, so both always hold the same rows.
- `_read_claim_form` adds `"changes": {r.condition_name: r.changes}` to
`data["_conditions"]`; the claim loop keeps `details[claim_id]` next to
`outcomes[claim_id]`.
- `_write_charge_summary` calls `results_db.save_summary(...)` after the Excel
write when `RESULTS_DB` is on, and logs `[RESULTS-DB] N row(s) saved`.
- `run_mode`: `TEST` when `TEST_CLAIM_IDS_FILE` is set, else `LIVE-WRITE`
when `SAVE_CORRECTIONS` is on, else `VERIFY` — the same words as the
`[MODE]` banner.
## Web page — `web/`
- `web/app.py` (FastAPI) and `web/templates/index.html` (Jinja2), plain
HTML/CSS, no external scripts or CDNs.
- Read-only: only `SELECT` statements, parameterized.
- `GET /` — page:
- run picker (distinct `run_at`, newest first; default latest run),
- Category filter (All / Pass / Fail / Review) and Claim# search,
- counts per Category for the selected run (Pass / Fail / Review tiles),
- the table in Charge Summary column order, Category coloured,
- each row expands to show `details_json` (conditions fired, before/after
changes, review reasons).
- `GET /api/runs` — runs with counts. `GET /api/results?run_at=&category=&claim_no=`
— rows as JSON (for the later portal).
- Start: `python -m uvicorn web.app:app --host 127.0.0.1 --port 8000`.
Bound to **127.0.0.1 only**: the page shows PHI and is not reachable from
the network. A `run_web.bat` helper does the same.
- The DB connection string is the same `RESULTS_DB_CONN`. If the DB cannot be
reached the page shows a plain error message, not a stack trace.
## Error handling
- DB unreachable / permission denied / table missing during a run → one log
line, run continues, Excel written.
- A value that does not convert → NULL for that column, row still saved.
- Web page: DB errors → HTTP 503 page "Results database not reachable".
## Testing (no real DB)
- `tests/test_results_db.py`: header→column map covers all 24 HEADERS;
money/date conversion (incl. `$1,234.50`, blank, junk); `to_record` output;
`insert_rows` sends one parameterized statement with values in column
order (fake connection records calls); `save_summary` swallows a connect
error and returns None; `details_json` shape.
- `tests/test_charge_summary.py`: `build()` + `write()` split keeps today's
output.
- `tests/test_web_app.py`: FastAPI `TestClient` with a fake repository —
page renders rows and Category counts, filters work, `/api/results` JSON
shape, DB error → 503.
- Full suite stays green. Live: operator runs `create_tables.sql`, sets
`RESULTS_DB=1`, one test run, then opens the page.
## Out of scope (later portal)
Login/roles, editing rows, charts beyond the Category tiles, scheduling,
AI features, hosting on the network.
@@ -0,0 +1,176 @@
# Start the bot from the results web page — design
Date: 2026-10-14. Approved section by section by the operator in chat
(approach A, sections 1–3).
## Goal
The operator starts a Charge Validation run from the local results page
(`run_web.bat` → http://127.0.0.1:8000) instead of the terminal. While it
runs, the page shows live status; it can stop it; when it ends, the page
shows that run's results.
## Decisions (operator, 2026-10-14)
| Question | Answer |
|---|---|
| Controls | Start + live status + Stop (no settings editing on the page) |
| Save mode | **VERIFY only.** A page-started run is always `SAVE_CORRECTIONS=0`, whatever `.env` says. LIVE-WRITE stays terminal-only. |
| Message boxes | **Off** for the run's information boxes (per-claim, review-before-close, "Run the Lookup?", summaries). Boxes that need a person to act in the browser — CAPTCHA, 2FA manual step, security image — **stay on**. |
| Approach | A: the web server starts `run.py` as a separate child process (not a thread, not Task Scheduler). |
Out of scope (YAGNI): editing settings from the page, scheduling, more than
one run at a time, LIVE-WRITE from the page.
## Components
### New: `web/bot_runner.py` — process control only
- `start() -> dict` — starts `python run.py` (the project's own `run.py`,
`sys.executable`, cwd = project root) as a child process with no console
window of its own (`CREATE_NO_WINDOW`); the Chrome window still shows.
The child's stdout/stderr go to `Logs\web_run_<ts>.out`, which catches a
crash before the bot opens its own log; the bot's `Logs\run_<ts>.log`
(named in the lock file) is the status source.
Refuses (raises `AlreadyRunning`) when the lock file names a live pid.
- `stop() -> dict` — `taskkill /PID <pid> /T /F`: the bot and its Chrome
close together. Clears the lock file. Status becomes "Stopped by
operator".
- `status() -> dict` — `running` (bool), `pid`, `started_at`, `log_path`,
`exit_code` (when this web process started it and it has ended),
`stopped_by_operator`.
- The command is fixed. Nothing from the request reaches the command line
or the environment.
**Child environment** — set on the child process, so it wins over `.env`
(`settings.py` loads `.env` with `os.environ.setdefault`):
```
SAVE_CORRECTIONS=0
ECW_KEEP_OPEN=0 # no "Press Enter to close" at the end
QUIET_BOXES=1 # run-flow information boxes go to the log
CONFIRM_BEFORE_LOOKUP=0
```
Everything else follows `.env` (claim file, `CLAIM_PROCESS_LIMIT`,
`RESULTS_DB`, proxy, ...).
### Lock file: `Logs\bot_run.json`
`{"pid", "started_at", "log_path", "source": "web" | "terminal"}`.
- Written by the **bot itself** at the start of `main()` (after
`runlog.start()`, so `log_path` is known) and removed when `main()`
returns — so a terminal run is seen by the page too.
- Before writing it, `main()` refuses to start (exit 2, before login) when
the file names a pid that is alive: two runs would share one eCW login
guard and one Charge Summary.
- A file naming a dead pid is stale: removed and ignored (a killed run
cannot remove its own file).
- `bot_runner.start()` checks it too, so the page answers 409 at once.
### New: `web/run_status.py` — read the run log
Pure functions over log text (no I/O except reading the file tail):
- **Phase** from the last matching line:
`[LOGIN]` / `[CAPTCHA]` → "Logging in"; `[OTP]` / `[2FA]` → "Waiting for
the 2FA approval mail"; `[OK] Logged in` → "Logged in"; `[CLAIMS]` →
"Checking claims"; process ended → "Finished" or a failure text by exit
code.
- **Progress** from `[CLAIMS] --- claim n/N: <id> ---` → `n`, `N`, current
claim id.
- **Counts** from the new `[RESULT]` lines (below) → Pass / Fail / Review.
- **Tail**: the last 30 lines.
- **Exit codes** (from `flows/export_medcor_ready.main`): 0 finished; 2
config / mode gate; 3 login guard refused (its `[GUARD]` line shown); 5
claims phase failed; 6 2FA timed out; other → "ended with code N".
### Changed: `flows/export_medcor_ready.py`
- After each claim's outcome is known in `_process_claims`, one
machine-readable line (terminal runs print it too):
```
[RESULT] claim 3/7 9126000: Review
```
The word is the Category the Charge Summary gets for that claim; a claim
that gets no Charge Summary row (not found in the grid) prints
`Not found`.
- `main()` writes / removes the lock file as above.
### Changed: `core/msgbox.py`
- New setting `QUIET_BOXES` (default off; `settings.py`).
- `info_box(title, msg, needs_person=False)`: with `QUIET_BOXES=1` and
`needs_person=False`, no box — one log line instead:
`[BOX] <title>: <first line of msg>`.
- `ok_cancel` / `confirm` are unchanged (every remaining caller is a
person-needed step: CAPTCHA, 2FA manual, LIVE-WRITE confirm — the last
is never reached in VERIFY). The security-image `info_box` in
`auth/login.py` passes `needs_person=True`.
### Changed: `web/app.py` + `web/templates/index.html`
Endpoints:
| Method | Path | Answer |
|---|---|---|
| POST | `/api/bot/start` | 200 `{status}`; 409 already running; 403 bad Origin/token |
| POST | `/api/bot/stop` | 200 `{status}`; 409 nothing running; 403 bad Origin/token |
| GET | `/api/bot/status` | `{running, phase, progress, counts, tail, started_at, exit_code, message}` |
Page: a "Bot run" card above the results.
- Idle: **[Start run]** and "VERIFY mode — nothing is saved to eCW".
- Running (polls `/api/bot/status` every 2 s): phase, progress bar
"3 / 7", current claim, Pass/Fail/Review chips, collapsible "Live log"
(last 30 lines), **[Stop]** (with a confirm dialog).
- Ended: "Finished in 4m 12s — exit 0" (or the failure text); the results
table reloads on the newest run.
## Security
The page was read-only; Start/Stop are its first actions. The existing
TrustedHost check stops DNS rebinding but not a cross-site POST from
another tab, which could burn eCW login attempts. So every POST needs both:
1. `Origin` (or, when absent, `Referer`) is `http://127.0.0.1:8000` or
`http://localhost:8000` — else 403.
2. Header `X-Run-Token` equals a token made at server start
(`secrets.token_urlsafe(32)`) and embedded in the page — else 403.
Compared with `secrets.compare_digest`.
No password on the page; `.env` credentials are read only by the bot.
Responses keep `no-store`. The page stays bound to 127.0.0.1. The live log
tail can carry PHI, as the results table already does — local only.
## Error handling
- Already running (page or terminal) → 409 "A run is already going
(started HH:MM)".
- Guard refused (exit 3) → the `[GUARD]` line shown ("next attempt
allowed at ...").
- Stop mid-claim: the claim form may stay open in eCW. VERIFY saves
nothing; the next run's `clear_screen` closes it.
- Web server restarted or page closed: the bot keeps running; on reload
the page finds it through the lock file. `exit_code` is unknown then
(the run is shown "Ended" when its pid is gone).
- Log file not there yet → phase "Starting".
## Testing (standalone "N/M passed" scripts)
- `tests/test_bot_runner.py` — a fake child script in place of `run.py`
(injectable command): start → running → stop → stopped; second start →
`AlreadyRunning`; stale lock (dead pid) cleared; the child sees the forced
env (`SAVE_CORRECTIONS=0`, `ECW_KEEP_OPEN=0`, `QUIET_BOXES=1`,
`CONFIRM_BEFORE_LOOKUP=0`) even when the parent env says otherwise.
- `tests/test_run_status.py` — sample log text → phase, progress, counts,
tail; each exit code's text.
- `tests/test_web_app.py` (added cases) — POST without/with wrong Origin or
token → 403; right Origin + token → 200 (runner stubbed); status JSON
shape; the token is in the page.
- `tests/test_msgbox.py` (added cases) — `QUIET_BOXES=1`: info box logged,
not shown; `needs_person=True` still shown.
- Flow test — `[RESULT]` line per claim; `main()` lock file written,
refused on a live one, removed at the end.
@@ -0,0 +1,96 @@
# Results portal redesign — design
Date: 2026-10-19. Operator requests (voice, Tanglish), choices made on the
visual companion mockups (`.superpowers/brainstorm/828-1792395410/`).
## Problem
The results page (`web/`, FastAPI + Jinja, 127.0.0.1:8000) opens on the
newest run only. A second run (20 claims) hides the first (10 claims) behind
a "Run" dropdown nobody noticed, so earlier runs look lost. The front page
is three quarters claims table (24 columns, sideways slider), which the
operator does not want on the home page. The look is "very basic".
## What the operator chose
| Question | Choice |
|---|---|
| Home page | **Dashboard**: Bot run card (Start / Stop / live progress), today's tiles, one big **Claim status** button |
| Claim status page | **Date cards**, newest first |
| A date with several runs | **All claims of the day together** (every run's rows in one list) |
| One day's page | **Short table + side panel** |
| Colours | **Navy & blue** (navy header, white cards on light grey, blue buttons) |
| Live run's claim list | In the **Bot run card** ("Live claims" side panel) |
| Search one claim over all dates | **Yes** — "Find a claim" on the Dashboard |
| Approach | **A** — server pages (FastAPI + Jinja), one page per screen |
## Pages
All pages share `base.html`: navy header with the title, a mode pill
(VERIFY / LIVE-WRITE from `SAVE_CORRECTIONS`) and a breadcrumb
(Dashboard › Claim status › 16 Oct 2026). Light grey background, white
cards, blue buttons; Pass green, Fail red, Review amber everywhere.
1. **Dashboard `/`**
- Bot run card — the existing Start / Stop / progress / live log, plus a
**Live claims** link opening a side panel with every claim of the
running (or last) run: Waiting / Checking / result. A claim with a
saved result opens `/day/<date of its run>?claim=<no>`.
- Today tiles: claims checked today, Pass, Fail, Review (0 when no run
today).
- **Find a claim** box → `/claim?claim_no=…`.
- Big **Claim status →** button → `/days`.
- No claims table.
2. **Claim status `/days`** — one card per day (last 90 days with a run),
newest first: weekday + date, claims checked, number of runs,
Pass / Fail / Review and a thin colour bar. A card opens `/day/<date>`.
3. **One day `/day/YYYY-MM-DD`**
- Tiles All / Pass / Fail / Review; a tile is a filter link
(`?category=Fail`). Claim # search box (`?claim_no=`).
- Short table, 10 rows a page (client-side pager): #, Claim #, Time
(the run's time), Payer, Result chip, Comments (one line, cut with
an ellipsis). No sideways scroll.
- Row click → side panel with every Charge Summary field, the run's
time and mode, and the conditions/changes JSON.
- `?claim=<no>` opens that claim's panel on load (on its page).
4. **Claim history `/claim?claim_no=…`** — every saved check of claims
matching the number (newest first, up to 200): date, time, mode,
result, comments; each row opens `/day/<date>?claim=<no>`.
`/?run_at=<time>` (old links) redirects (303) to `/day/<date of that run>`.
`/api/runs`, `/api/results`, `/api/bot/*` stay as they are.
## Data
No schema change. `web/repo.py` gains three parameterized SELECTs:
- `days(limit=90)` — `GROUP BY CAST(run_at AS DATE)`: day, claims,
runs (`COUNT(DISTINCT run_at)`), pass / fail / review counts, newest
first.
- `day_results(day, category, claim_no)` — rows with
`run_at >= day AND run_at < day + 1`, ordered by run_at, S.No, id.
- `claim_history(claim_no)` — `TOP 200 … WHERE claim_no LIKE ?` ordered
by run_at DESC.
Rules: a run belongs to the day it **started** (every row of a run shares
its `run_at`), so a run never splits over two cards. The same claim
checked twice in a day shows twice, each with its time; a day's count is
checks. "Not found" claims have no saved row (as today) and appear only in
the live panel. A legacy driver returning the day as text is coerced to a
date, like `run_at` already is.
## Unchanged
127.0.0.1 only; Host allowlist; `no-store` and the other headers; DB
errors reported by kind; Start / Stop need the page's Origin and token;
page runs are VERIFY only. All new pages are read-only. Page text from the
DB goes out escaped (Jinja autoescape; the panel's data as `tojson` in an
attribute, rendered with `textContent`).
## Testing
`tests/test_web_app.py` (fake repo, fake DB connection): each page renders
with its data and links; filters and the bad-input 400s; the redirect;
the three new SQL statements are parameterized and shaped as above; the
hardening, API and Start / Stop cases kept. A browser check of the four
pages on fixture data (Playwright, the in-app browser).
@@ -0,0 +1,161 @@
# Run tracking and Portal Sign-in — design
Date: 2026-10-22. Agreed with the operator in chat, question by question
(Q1–Q31, all "yes" to the recommended answers; Q3 changed to "who" = the
Portal User). Terms: `GLOSSARY.md`. Decision record:
`docs/adr/0001-run-events-in-files.md`. Jira: EEWA-31 (Stage 1),
EEWA-32 (Stage 2), EEWA-33 (Stage 3), each blocking the next.
## Goal
See every Run on the Portal: who started it, how long the eCW login took and
where the time went, every Run Error, Claim Error and Warning with its time,
and which claims a Run never reached. Only signed-in Portal Users see the
Portal; every Run records who started (and stopped) it.
## Decisions
| # | Decision |
|---|---|
| Q1 | Three kinds: **Run Error** (the Run stopped), **Claim Error** (one claim not checked), **Warning** (nothing stopped). **Fail** stays a Charge Summary category only. |
| Q2/Q28 | Per **eCW Login Attempt**: start, end, duration, 2FA mail wait, result; steps timed: page open, password typed, captcha, 2FA mail requested (the Log In click), mail received, approval clicked, home page. |
| Q3/Q8 | "Who" = the **Portal User** who signed in; **Started By** / **Stopped By**. |
| Q4 | Events go to `Logs\events_<run>.jsonl` (ADR 0001). |
| Q5/Q25 | Old run logs are not imported. Nothing is deleted. |
| Q6/Q18/Q19/Q22 | A **Runs** page on the Portal: list + detail, detail refreshes every 5 s while its Run is going. |
| Q9 | The Portal stays on 127.0.0.1. |
| Q10–Q17, Q20, Q21 | Sign-in (Stage 2, below). |
| Q14 | Terminal Runs are allowed; Started By = the Windows user. |
| Q15/Q23 | Stage 3: Run Error banner; `started_by` column in `CV_ChargeSummary`; the Excel sheet unchanged. |
| Q24 | Disk check at Run start: system drive under 2 GB = Warning, under 500 MB = Run Error, exit 2, before login. |
| Q26 | Claim Error = search raised / form not read / never finished loading; Charge Summary still says Review. A claim not found in eCW = Warning. |
| Q27 | **Not Reached** = queued claims a Run Error (or Stop) came before. |
## Stage 1 — Run events and the Runs page (EEWA-31)
### `core/run_events.py` (new)
- `start(log_path)` — the events file is the log's name with `run_` →
`events_` and `.log` → `.jsonl` (`run_20261022_141530.log` →
`events_20261022_141530.jsonl`); the Run's id is `20261022_141530`.
Writes `run_start`.
- `emit(kind, **fields)` — one JSON line, `{"t": local ISO time to the
millisecond, "kind": kind, ...}`, flushed at once. **Never raises**: an
event that cannot be written is dropped, the Run carries on.
- Helpers: `warning(code, message, **f)`, `run_error(step, message,
exit_code, exc=None)` (with `traceback.format_exc()` when an exception is
given), `claim_error(claim, step, message)`, `login_step(step, **f)`,
`login_attempt_start()` / `login_attempt_end(result, message="")` (the
module numbers attempts).
- No OTP code, password or eCW username is ever put in an event.
### Event kinds
| kind | fields |
|---|---|
| `run_start` | `source` (portal / terminal), `started_by`, `mode` (VERIFY / LIVE-WRITE), `claim_limit`, `test_sheet` (file name only), `pid`, `log` |
| `disk` | `drive`, `free_mb` |
| `login_attempt_start` | `attempt` |
| `login_step` | `attempt`, `step`: `page_open`, `password`, `captcha_shown`, `captcha_solved`, `captcha_failed`, `twofa_requested`, `mail_received`, `approval_clicked`, `home` |
| `login_attempt_end` | `attempt`, `result`: `ok`, `twofa_timeout`, `captcha_failed`, `restricted`, `failed`; `message` |
| `queue` | `claims` (list) |
| `claim_start` | `n`, `total`, `claim` |
| `claim_result` | `n`, `claim`, `category` (Pass / Fail / Review / Not found) |
| `claim_error` | `claim`, `step` (`search` / `form` / `load`), `message` |
| `warning` | `code` (`not_found`, `fallback`, `dialog`, `cancel_retry`, `form_not_closed`, `disk_low`, `preflight`), `message`, optional `claim` |
| `screenshot` | `path` (a failure shot), optional `claim` |
| `run_error` | `step` (`config`, `disk`, `guard`, `login`, `restricted`, `twofa_timeout`, `preflight`, `claims`, `unexpected`), `exit_code`, `message`, `traceback` |
| `run_end` | `exit_code` |
| `stopped` | `stopped_by` (written by the Portal after Stop; Stage 2 fills the name) |
`started_by`: env `ECW_STARTED_BY` (the Portal sets it, Stage 2), else the
Windows user. `source`: as `bot_run.json` has it (`ECW_RUN_SOURCE`).
### Where the bot emits
- `flows/export_medcor_ready.py`: `main()` starts the events after
`runlog.start()` and writes `run_end` in its `finally`; `_main` writes a
`run_error` at every non-zero return and in every `except`, with the
traceback; the disk check sits before login next to the CDP check;
`_login_with_retry` wraps each `login()` in attempt start / end;
`_process_claims` writes `queue`, `claim_start`, `claim_result`,
`claim_error` (search raised, `_error`, `_not_evaluated`) and the
not-found Warning.
- `auth/login.py` / `captcha.py` / `twofactor.py` / `mailbox.py`: the login
steps.
- `core/fallback.py` `report()`, `core/page_utils.py` `auto_dialog` (the
event only — its behaviour is unchanged, see Context.md §10) and the
failure `shot()`, `pages/claim_form.py` Cancel retries and form-not-closed,
`pages/preflight.py` FAIL: Warnings / screenshots.
- `web/bot_runner.py` `stop()`: appends `stopped` to the Run's events file.
### Runs page
- `web/runs.py` (new): reads `Logs\events_*.jsonl` into Run summaries and a
Run's detail. A Run with no `run_end` is **Running** when `bot_run.json`
names it and its pid is alive, **Stopped** when a `stopped` event is
there, else **Ended without a record** (killed). Result: Done (exit 0),
Run Error (exit and step), Stopped, Running.
- `GET /runs` — newest first: start, Started By, Run Source, mode,
duration, result, eCW Login Attempts + total login time, Pass / Fail /
Review, Claim Errors, Warnings. Filters: from / to date, result.
- `GET /runs/{run_id}` — the login attempts table (each step's time, 2FA
wait = mail received − 2FA requested), Run Errors (traceback folded),
Claim Errors, Warnings, Not Reached claims, the timeline, screenshot
links; refreshes every 5 s while Running.
- `GET /runs/{run_id}/shot/{n}` — the Run's n-th screenshot event, served
only from inside `Screenshots\` and only as `.png`: a saved page's HTML
served from the Portal's origin would run eCW's scripts there, so the
detail page names where the DOM file is instead of linking it.
- A "Runs" link on every page's header.
### Disk check
Settings `DISK_WARN_MB=2048`, `DISK_STOP_MB=500`, on the system drive.
## Stage 2 — Sign-in (EEWA-32)
- `CV_PortalUser` (username, display name, role, PBKDF2-SHA256 hash + salt
+ iterations, must_change, disabled, created / changed) and
`CV_PortalSignIn` (time, username, event: sign_in / sign_out /
wrong_password / locked / timeout) in `Charge_Validation`.
- Roles Admin / Operator / Viewer. Start / Stop: Admin, Operator. Users
page and Sign-in history: Admin.
- `tools\add_portal_user.py` (password with `getpass`). Passwords ≥ 10
characters; change own; Admin reset → must change at next Sign-in;
disable, never delete.
- 5 wrong passwords → 15 minutes locked. Session cookie (HttpOnly,
SameSite=Strict), 30 minutes idle. Every page and `/api` behind it;
127.0.0.1 + Origin / token checks stay.
- The Portal passes the signed-in user to the child as `ECW_STARTED_BY`;
`stop()` writes `stopped_by`. `bot_run.json` keeps `started_by` too, so
the Dashboard's run card says "started by …".
- As built (`web/users.py`, `web/app.py`): sessions in the Portal's memory
(a restart signs everyone out); disabling a user, resetting their
password or changing their role ends / updates their open session at
once; an unknown username reads and costs the same as a wrong password;
the last active Admin cannot be demoted or disabled, nobody disables
themselves; every Admin change is a `CV_PortalSignIn` row with `by_user`;
every form POST carries the page token and is checked for Origin;
`next` after Sign-in never leaves the site.
## Stage 3 — Alerts and `started_by` (EEWA-33)
- Dashboard banner when the last Run ended in a Run Error.
- `CV_ChargeSummary.started_by` (nullable column, written from the Run's
Started By).
- As built: the banner also shows for a Run that ended without a record
(killed or crashed — no `run_end`), never for Done, Stopped or Running;
it names when, Started By, the step, exit code and message, how many
claims were Not Reached, and links the Run. `create_tables.sql` adds the
column to an existing table (`IF COL_LENGTH ... IS NULL ALTER TABLE`).
The bot checks the column before each insert (`results_db.has_column`)
and saves the rows without it while it is missing — a table not yet
updated never costs a Run its rows. The Portal selects it, shows it as
"Started By" in a claim's side panel, and a table without it reads
"Results table needs an update — run create_tables.sql" (SQLSTATE 42S22).
## Out of scope
Network access to the Portal, email / Teams alerts, importing old logs,
deleting old events, editing settings from the Portal.