No-AI desktop path: PyQt bot wired to the page

- templates/bot_runner.py (new, no Qt): runs the bot as a child process
  (unbuffered, UTF-8), tails its output, keeps raw lines in bot_raw.log,
  and turns lines into step/log/status events. Step markers and friendly
  rules use specific substrings, first match wins; unmatched error lines
  are always shown, other unmatched lines hidden. Dry run:
  python dashboard/bot_runner.py prints the events the page would get.
- bridge.py: delegates to bot_runner, one run at a time, restores the
  saved dark mode on every page load (fixes sample-data mode in PyQt),
  adds load_latest_report / download_report slots the page calls.
- SKILL.md: skip the stack question for a PyQt project; desktop wiring
  steps from the bot's real output; verify steps for the host.
- tests: assert-based self-check for the line logic and run loop; Seam 1
  run replay (status dot, Run button, steps, log) and wired-page mode;
  fixture b-pyqt-bot and its Seam 2 eval (all 14 checks pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 14:28:51 +05:30
1 parent 3eb3fdf00e
commit 162edd41df
17 files changed
+753 -47

No files matched your search

@@ -0,0 +1,64 @@
"""Bot wiring for the desktop host: runs the bot and turns its output into page events.
No Qt here, so it can be tested and dry-run on its own. Fill BOT_CMD, STEP_MARKERS and
FRIENDLY_RULES from lines the bot really prints.
Dry run, no window: python bot_runner.py (prints each event the page would get)
"""
import json, os, subprocess, sys
from pathlib import Path
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # the bot, started as a child process
BOT_CWD = Path(__file__).resolve().parents[1] # folder BOT_CMD runs in (the project root)
RAW_LOG = Path(__file__).with_name("bot_raw.log") # every raw bot line, kept for audit
# (specific substring, step label from STEPS in dashboard.html, pct). First match wins.
# e.g. ("[step 2]", "Login", 40). Never a bare word like "login": it matches unrelated lines.
STEP_MARKERS = []
# (specific substring, plain-English message, type: info|success|warn|error). First match wins.
# e.g. ("[login] done", "Logged in.", "success"). Unmatched lines are hidden, except error lines.
FRIENDLY_RULES = []
# A line holding any of these is an error line. Unmatched error lines are shown raw, never hidden.
ERROR_WORDS = ("error", "exception", "traceback", "fatal", "failed")
def _first(rules, line):
low = line.lower()
return next((r for r in rules if r[0].lower() in low), None)
def events_for(line):
"""The page events, as (event, data) pairs, for one raw bot line."""
out = []
if marker := _first(STEP_MARKERS, line):
out.append(("step", {"label": marker[1], "pct": marker[2]}))
if rule := _first(FRIENDLY_RULES, line):
out.append(("log", {"msg": rule[1], "type": rule[2]}))
elif any(w in line.lower() for w in ERROR_WORDS): # never hide a real failure
out.append(("log", {"msg": line, "type": "error"}))
return out
def run(send):
"""Run the bot in its own process and send(event, data) for each line it prints, as it prints it."""
send("status", {"state": "running", "msg": "Running…"})
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} # live lines; no cp1252 crash
try:
raw = open(RAW_LOG, "a", encoding="utf-8")
p = subprocess.Popen(BOT_CMD, cwd=BOT_CWD, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace")
except OSError as e:
send("log", {"msg": f"Could not start the bot: {e}", "type": "error"})
send("status", {"state": "error", "msg": "Could not start the bot"})
return
with p, raw:
for line in p.stdout:
raw.write(line)
for event, data in events_for(line.rstrip()):
send(event, data)
ok = p.returncode == 0
send("status", {"state": "done", "msg": "Completed"} if ok else
{"state": "error", "msg": f"Failed (exit code {p.returncode})"})
if __name__ == "__main__":
run(lambda event, data: print(json.dumps({"event": event, "data": data}), flush=True))
+26 -35
View File
@@ -1,25 +1,23 @@
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
The bot runs as a child process that prints lines, and this window tails them.
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
The bot runs as a child process and this window shows its progress. The bot wiring
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
Run it with: python dashboard/bridge.py
"""
import json, subprocess, sys, threading
import json, sys, threading
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
import bot_runner
HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
STEPS = ["{{STEP_1}}", "{{STEP_2}}"] # same list as STEPS in dashboard.html
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
def classify(line):
s = line.lower()
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
def settings():
return QSettings("{{ORG}}", "{{APP_NAME}}")
class Bridge(QObject):
@@ -27,8 +25,10 @@ class Bridge(QObject):
def __init__(self, view):
super().__init__()
self.view = view
self.view, self._one_run = view, threading.Lock()
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
# Restore the saved dark-mode choice on every page load, sample-data mode included.
view.loadFinished.connect(lambda _ok: self.send_to_js("apply_dark", {"on": settings().value("dark", False, bool)}))
def send_to_js(self, event, data):
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
@@ -36,38 +36,29 @@ class Bridge(QObject):
@pyqtSlot()
def load_dashboard(self):
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
@pyqtSlot()
def load_latest_report(self):
self.send_to_js("report_data", {"rows": []}) # TODO: read the latest report rows
@pyqtSlot()
def download_report(self):
pass # TODO: export the report rows to .xlsx
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
settings().setValue("dark", on)
@pyqtSlot()
def start_automation(self):
threading.Thread(target=self._run, daemon=True).start()
if self._one_run.acquire(blocking=False): # a second click can't start a second bot
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
self.send_to_js("status", {"state": "running", "msg": "Running…"})
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for raw in p.stdout:
self._on_line(raw.rstrip())
ok = p.wait() == 0
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
def _on_line(self, line):
# ponytail: raw line only printed here; persist to file/DB if audit is needed
low, kind = line.lower(), classify(line)
for marker, idx, pct in STEP_MARKERS:
if marker in low:
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
break
for marker, msg, t in FRIENDLY_RULES:
if marker in low:
if msg:
self.send_to_js("log", {"msg": msg, "type": t})
return
if kind == "error": # never hide a real failure
self.send_to_js("log", {"msg": line, "type": "error"})
try:
bot_runner.run(self.send_to_js)
finally:
self._one_run.release()
if __name__ == "__main__":
@@ -2,6 +2,7 @@
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
`bot_runner.py` turns the bot's output lines into `status`, `step` and `log` events, and keeps every raw line in `bot_raw.log`.
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.