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>
188 lines
7.3 KiB
Python
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
|