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:
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))
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user