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:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,187 @@
|
||||
r"""
|
||||
The bot's run log, read into the results page's live status (spec
|
||||
docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md).
|
||||
|
||||
Everything comes from lines the bot already prints, plus three
|
||||
fixed-format ones: `[CLAIMS] Queue: <id>, <id>, ...` (every claim of the
|
||||
run, before the first starts), `[CLAIMS] Results run_at: <ISO time>` right
|
||||
after it (the run's time in the results table; only with RESULTS_DB=1), and
|
||||
one per claim, `[RESULT] claim n/N <id>: <Category>`.
|
||||
|
||||
The live log tail shows the log's text as it is, except the 2FA code
|
||||
(`[OTP] Code ...`) and the eCW username (`[CONFIG] user=...`), which are
|
||||
hidden. What is left can still hold patient data (claim numbers, form
|
||||
values) — the page shows it on 127.0.0.1 only, as it shows the results
|
||||
table.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
TAIL_LINES = 30
|
||||
|
||||
_QUEUE = re.compile(r"^\[CLAIMS\] Queue: (.*)$")
|
||||
_RUN_AT = re.compile(r"^\[CLAIMS\] Results run_at: "
|
||||
r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)$")
|
||||
_CLAIM = re.compile(r"^\[CLAIMS\] --- claim (\d+)/(\d+): (\S+) ---$")
|
||||
_RESULT = re.compile(r"^\[RESULT\] claim (\d+)/(\d+) (\S+): (.+)$")
|
||||
|
||||
# Hidden in the tail only. The OTP line loses everything after "Code": the
|
||||
# mail subject it quotes often repeats the code.
|
||||
_REDACT = (
|
||||
(re.compile(r"(\[OTP\] Code )\S.*$"), r"\1(hidden)"),
|
||||
(re.compile(r"(\[CONFIG\] user=)\S*"), r"\1(hidden)"),
|
||||
)
|
||||
|
||||
|
||||
def _redact(line: str) -> str:
|
||||
for pattern, repl in _REDACT:
|
||||
line = pattern.sub(repl, line)
|
||||
return line
|
||||
|
||||
_PHASES = (
|
||||
(("[LOGIN]", "[CAPTCHA]"), "Logging in"),
|
||||
(("[OTP]", "[2FA]"), "Waiting for the 2FA approval mail"),
|
||||
(("[OK] Logged in",), "Logged in"),
|
||||
(("[CLAIMS]", "[RESULT]"), "Checking claims"),
|
||||
)
|
||||
|
||||
_EXIT = {
|
||||
0: "Finished",
|
||||
1: "The run failed (see the log)",
|
||||
2: "Stopped before login — a setting needs fixing, or another run is "
|
||||
"going (see the log)",
|
||||
3: "Login guard refused the login — too many attempts this hour",
|
||||
4: "eCW restricted the login — do not retry; check the account "
|
||||
"(see the log)",
|
||||
5: "The claims step failed (see the log)",
|
||||
6: "The 2FA approval mail did not arrive in time",
|
||||
7: "Stopped: eCW changed a screen — a selector needs fixing (see the "
|
||||
"log's [PREFLIGHT] line)",
|
||||
8: "Stopped: a claim stopped making progress (the page froze) — see "
|
||||
"the Runs page",
|
||||
}
|
||||
|
||||
# A claim's status once the run is over (finished, stopped, crashed):
|
||||
# nothing is being checked or waiting any more.
|
||||
_STATUS_ONCE_OVER = {"Checking": "Not finished", "Waiting": "Not checked"}
|
||||
|
||||
|
||||
_PAGE_KEYS = ("running", "starting", "started_at", "ended_at", "exit_code",
|
||||
"stopped_by_operator", "source", "started_by")
|
||||
|
||||
|
||||
def parse(text: str) -> dict:
|
||||
"""Phase, progress, counts, guard line, tail — and `claims`: every
|
||||
claim of the run as `{"n", "id", "status", "done"}`, status = its
|
||||
result (`done`), or "Checking" (started, no result yet), or "Waiting"
|
||||
(in the `[CLAIMS] Queue:` line, not started). A log with no Queue line
|
||||
lists the claims it has seen start. `results_run_at` is the run's time
|
||||
in the results table, None when the run saves none."""
|
||||
phase, progress, current, guard = "Starting", None, None, None
|
||||
counts = {"Pass": 0, "Fail": 0, "Review": 0, "Not found": 0}
|
||||
queue, status, done, run_at = [], {}, set(), None
|
||||
lines = (text or "").splitlines()
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
for prefixes, name in _PHASES:
|
||||
if line.startswith(prefixes):
|
||||
phase = name
|
||||
m = _QUEUE.match(line)
|
||||
if m:
|
||||
queue = [c.strip() for c in m.group(1).split(",") if c.strip()]
|
||||
status, done, run_at = {}, set(), None
|
||||
m = _RUN_AT.match(line)
|
||||
if m:
|
||||
run_at = m.group(1)
|
||||
m = _CLAIM.match(line)
|
||||
if m:
|
||||
progress = [int(m.group(1)), int(m.group(2))]
|
||||
current = m.group(3)
|
||||
if current not in queue:
|
||||
queue.append(current)
|
||||
status[current] = "Checking"
|
||||
m = _RESULT.match(line)
|
||||
if m:
|
||||
cat = m.group(4).strip()
|
||||
counts[cat] = counts.get(cat, 0) + 1
|
||||
status[m.group(3)] = cat
|
||||
done.add(m.group(3))
|
||||
if line.startswith("[GUARD]"):
|
||||
guard = line
|
||||
claims = [{"n": n, "id": cid, "status": status.get(cid, "Waiting"),
|
||||
"done": cid in done}
|
||||
for n, cid in enumerate(queue, start=1)]
|
||||
return {"phase": phase, "progress": progress, "current": current,
|
||||
"counts": counts, "guard": guard, "claims": claims,
|
||||
"results_run_at": run_at,
|
||||
"tail": [_redact(ln.rstrip()) for ln in lines[-TAIL_LINES:]]}
|
||||
|
||||
|
||||
def exit_text(code, stopped: bool = False) -> str:
|
||||
if stopped:
|
||||
return "Stopped by operator"
|
||||
if code is None:
|
||||
return "Ended (no exit code — the run was stopped or crashed)"
|
||||
return _EXIT.get(code, f"Ended with code {code}")
|
||||
|
||||
|
||||
def read_tail(path, max_bytes: int | None = 256_000) -> str:
|
||||
"""The end of a text file ('' when there is none); max_bytes=None reads
|
||||
the whole file."""
|
||||
if not path:
|
||||
return ""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
fh.seek(0, 2)
|
||||
size = fh.tell()
|
||||
fh.seek(0 if max_bytes is None else max(0, size - max_bytes))
|
||||
text = fh.read().decode("utf-8", errors="replace")
|
||||
return text.replace("\r\n", "\n")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _elapsed(started, ended, running):
|
||||
"""Seconds from start to end — to now while the run is going. A run
|
||||
that ended with no end time written (killed outside the page, a crash)
|
||||
has no duration: measured to now it grew on every poll (review,
|
||||
2026-10-14)."""
|
||||
if not ended and not running:
|
||||
return None
|
||||
try:
|
||||
a = datetime.fromisoformat(started)
|
||||
b = datetime.fromisoformat(ended) if ended else datetime.now()
|
||||
return int((b - a).total_seconds())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def summarize(st: dict) -> dict:
|
||||
"""The runner's status (web/bot_runner.py) plus what the log says.
|
||||
Only an allowlist of keys reaches the page (never pid or file paths).
|
||||
The whole log is read for counts/progress, so a long run is not
|
||||
undercounted; `tail` is still just the last TAIL_LINES lines."""
|
||||
text = (read_tail(st.get("log_path"), None)
|
||||
or read_tail(st.get("out_path"), None))
|
||||
p = parse(text)
|
||||
running = bool(st.get("running"))
|
||||
claims = p["claims"]
|
||||
if running:
|
||||
message = p["phase"]
|
||||
else:
|
||||
message = exit_text(st.get("exit_code"),
|
||||
st.get("stopped_by_operator", False))
|
||||
if st.get("exit_code") == 3 and p["guard"]:
|
||||
message += " — " + p["guard"].removeprefix("[GUARD]").strip()
|
||||
claims = [dict(c, status=_STATUS_ONCE_OVER.get(c["status"],
|
||||
c["status"]))
|
||||
for c in claims]
|
||||
out = {k: st.get(k) for k in _PAGE_KEYS}
|
||||
out.update(phase=p["phase"], progress=p["progress"],
|
||||
current=p["current"], counts=p["counts"], tail=p["tail"],
|
||||
message=message, claims=claims,
|
||||
results_run_at=p["results_run_at"],
|
||||
elapsed_s=_elapsed(st.get("started_at"), st.get("ended_at"),
|
||||
running))
|
||||
return out
|
||||
Reference in new issue
Block a user