Files
semif-agent/semif_agent/dashboard.py
T

250 lines
8.3 KiB
Python

"""Local browser dashboard for the SemIf agent.
A pure-stdlib HTTP server on localhost serving a Redux-DevTools-style
inspector over the agent's decision flow. Reads the decision log
(`decisions.jsonl`) plus the run lifecycle trace (`runs.jsonl`), exposes the
static skill tree, the dream cost report, and two write endpoints: submit a
request and relabel a decision (human override).
The scheduler's engine and LLM are built lazily, so the dashboard runs on the
thin dev box in replay mode (reads logs; submit degrades to a JSON error) and
in live mode on the box with SemIf + a local LLM.
python -m semif_agent.cli dashboard [--port 8765]
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from .dream import dream
from .scheduler import Scheduler
from .skills import build_skills, build_tree
STATIC_DIR = Path(__file__).parent / "static"
JSON_HEADERS = {"Content-Type": "application/json; charset=utf-8"}
def _phase(row: dict) -> str:
return (row.get("extra") or {}).get("phase", "")
def _enrich_decision(row: dict, costs: dict) -> dict:
out = dict(row)
out["phase"] = _phase(row)
cost = costs.get(row.get("id"))
if cost is not None:
out["cost"] = cost
return out
def build_payload(scheduler: Scheduler) -> dict:
"""Group logged decisions by run, attach lifecycle events and dream costs."""
events_by_run = scheduler.trace.runs()
rows = scheduler.log.read()
costs = {}
for cost_row in dream(scheduler.log).rows:
costs[cost_row.decision_id] = {
"predicted": cost_row.predicted,
"nll": cost_row.nll,
"weight": cost_row.weight,
"correct": cost_row.correct,
}
by_run: dict[str, dict] = {}
for row in rows:
run_id = (row.get("extra") or {}).get("run_id", "?")
run = by_run.setdefault(
run_id, {"run_id": run_id, "events": [], "decisions": [], "first_ts": None}
)
run["first_ts"] = row["ts"] if run["first_ts"] is None else min(run["first_ts"], row["ts"])
run["decisions"].append(_enrich_decision(row, costs))
for run_id, events in events_by_run.items():
run = by_run.setdefault(
run_id, {"run_id": run_id, "events": [], "decisions": [], "first_ts": None}
)
run["events"] = events
event_ts = [e["ts"] for e in events if "ts" in e]
if event_ts:
earliest = min(event_ts)
run["first_ts"] = earliest if run["first_ts"] is None else min(run["first_ts"], earliest)
runs = sorted(by_run.values(), key=lambda r: (r["first_ts"] is None, r["first_ts"] or 0))
for run in runs:
run["decisions"].sort(key=lambda d: d["ts"])
return {"runs": runs}
def build_dream_report(scheduler: Scheduler) -> dict:
report = dream(scheduler.log)
return {
"rows": len(report.rows),
"skipped": report.skipped,
"human_overrides": report.human_overrides,
"cross_entropy": report.cross_entropy,
"accuracy": report.accuracy,
"ece": report.ece,
}
def build_status(scheduler: Scheduler) -> dict:
current = (
{"skill": scheduler.current.skill, "request_id": scheduler.current.request.id}
if scheduler.current
else None
)
queue = [
{"id": request.id, "weight": weight, "text": request.text[:80]}
for weight, request in scheduler.queue.items()
]
return {
"current": current,
"queue": queue,
"tau": scheduler.tau,
"queue_max": scheduler.queue.max_size,
}
def build_tree_payload(scheduler: Scheduler) -> dict:
tree = scheduler.tree
return {
"categories": {
category: [{"name": skill.name, "description": skill.description} for skill in skills]
for category, skills in sorted(tree.items())
}
}
class DashboardHandler(BaseHTTPRequestHandler):
scheduler: Scheduler
lock: threading.Lock = threading.Lock()
server_version = "semif-dashboard/0.1"
# ---- helpers ----
def _send(self, code: int, payload: dict, headers: dict | None = None):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
for key, value in JSON_HEADERS.items():
self.send_header(key, value)
if headers:
for key, value in headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_error(self, code: int, message: str):
self._send(code, {"error": message})
def _read_json(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if length <= 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def _serve_static(self, rel: str):
if not rel or rel == "/":
rel = "index.html"
target = (STATIC_DIR / rel).resolve()
if not str(target).startswith(str(STATIC_DIR.resolve())):
self._send_error(403, "forbidden")
return
if not target.is_file():
self._send_error(404, "not found")
return
body = target.read_bytes()
content_type = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
}.get(target.suffix, "application/octet-stream")
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ---- routing ----
def do_GET(self):
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/" or path.startswith("/static/"):
rel = path.removeprefix("/static/").removeprefix("/")
self._serve_static(rel)
return
if path == "/api/trace":
self._send(200, build_payload(self.scheduler))
return
if path == "/api/tree":
self._send(200, build_tree_payload(self.scheduler))
return
if path == "/api/dream":
self._send(200, build_dream_report(self.scheduler))
return
if path == "/api/status":
self._send(200, build_status(self.scheduler))
return
self._send_error(404, "no such endpoint")
def do_POST(self):
path = self.path.split("?", 1)[0]
if path == "/api/submit":
with self.lock:
try:
body = self._read_json()
status, detail = self.scheduler.submit(
str(body.get("text", "")), source=str(body.get("source", "dashboard"))
)
except Exception as exc:
self._send_error(500, str(exc))
return
self._send(200, {"status": status, "detail": detail})
return
if path == "/api/relabel":
with self.lock:
try:
body = self._read_json()
ok = self.scheduler.log.relabel(str(body.get("id", "")), str(body.get("outcome", "")))
except Exception as exc:
self._send_error(500, str(exc))
return
self._send(200, {"ok": ok})
return
self._send_error(404, "no such endpoint")
def log_message(self, format, *args):
pass
def serve(scheduler: Scheduler, port: int = 8765, host: str = "127.0.0.1") -> None:
handler = type("Handler", (DashboardHandler,), {"scheduler": scheduler, "lock": threading.Lock()})
server = ThreadingHTTPServer((host, port), handler)
bound = server.server_address[0]
url = f"http://{bound}:{server.server_address[1]}/" if bound != "0.0.0.0" else _lan_url(port)
print(f"semif dashboard on {url} (Ctrl-C to stop)")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def _lan_url(port: int) -> str:
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(("8.8.8.8", 80))
ip = sock.getsockname()[0]
sock.close()
except OSError:
ip = "127.0.0.1"
return f"http://{ip}:{port}/"