Files
ecw-charge-validation/web/run_status.py
T
ManikandanandClaude Opus 5.5 ba15a574ab 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>
2026-10-23 18:38:17 +05:30

188 lines
7.3 KiB
Python

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