"""Boardroom Map orchestrator web app. Serves the control-panel UI and a small JSON API, and starts the background job runner (see jobs.py) that actually convenes the panel. Most configuration happens through the StartOS *actions* (Configure Sparks / Models / Reviewers / Review); this UI is for dropping documents, triggering a review, watching it run, and reading reports. """ from __future__ import annotations import os import threading from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.templating import Jinja2Templates from starlette.requests import Request import bm_config import extraction import reviewers as rev_mod import serving from jobs import runner, INBOX, REPORTS_DIR DATA_DIR = os.environ.get("BM_DATA_DIR", "/data") app = FastAPI(title="Boardroom Map Orchestrator") templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) @app.on_event("startup") def _startup(): runner.start() # ----------------------------------------------------------------------- UI @app.get("/", response_class=HTMLResponse) def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) @app.get("/healthz") def healthz(): return {"ok": True} # ----------------------------------------------------------------------- status @app.get("/api/status") def status(): cfg = bm_config.load() catalog = {m["alias"] for m in (cfg.get("models") or [])} return { "configured": { "sparks": bool(cfg.get("primarySparkHost")), "models": len(cfg.get("models") or []), "reviewers": len(cfg.get("reviewers") or []), }, "networkMode": cfg.get("networkMode"), "synthesis": bool(cfg.get("synthesisEnabled")), "wipeRemoteDocs": bool(cfg.get("wipeRemoteDocs")), "autoRunOnDrop": bool(cfg.get("autoRunOnDrop")), "models": [{"alias": m["alias"], "hfModel": m["hfModel"], "spark": m.get("spark", "primary")} for m in (cfg.get("models") or [])], "panel": [{"name": r.get("name"), "model": r.get("model"), "persona": bool((r.get("persona") or "").strip()), "known": (r.get("model") in catalog)} for r in (cfg.get("reviewers") or [])], "inbox": _inbox_list(), "runtime": runner.snapshot(), } @app.get("/api/events") def events(): return {"events": runner.events()} def _inbox_list() -> list[dict]: if not os.path.isdir(INBOX): return [] out = [] for fn in sorted(os.listdir(INBOX)): p = os.path.join(INBOX, fn) if os.path.isfile(p): ext = os.path.splitext(fn)[1].lower() out.append({"name": fn, "bytes": os.path.getsize(p), "supported": ext in extraction.SUPPORTED}) return out @app.get("/api/inbox") def inbox(): return {"inbox": _inbox_list()} # ----------------------------------------------------------------------- documents @app.post("/api/upload") async def upload(files: list[UploadFile] = File(...)): os.makedirs(INBOX, exist_ok=True) saved = [] for f in files: name = os.path.basename(f.filename or "document") dest = os.path.join(INBOX, name) with open(dest, "wb") as out: while chunk := await f.read(1 << 20): out.write(chunk) saved.append(name) return {"ok": True, "saved": saved} @app.post("/api/inbox/clear") def inbox_clear(): if os.path.isdir(INBOX): for fn in os.listdir(INBOX): p = os.path.join(INBOX, fn) if os.path.isfile(p): os.remove(p) return {"ok": True} # ----------------------------------------------------------------------- run @app.post("/api/run") def run_now(): cfg = bm_config.load() if not cfg.get("primarySparkHost"): raise HTTPException(400, "No Spark configured (Configure Sparks).") if not (cfg.get("models") and cfg.get("reviewers")): raise HTTPException(400, "Configure at least one model and one reviewer first.") if not _inbox_list(): raise HTTPException(400, "Inbox is empty — upload documents first.") runner.request_run() return {"ok": True, "message": "Review requested — watch the activity log."} @app.get("/api/serving") def serving_status(): cfg = bm_config.load() if not cfg.get("primarySparkHost"): raise HTTPException(400, "No Spark configured.") return {"serving": serving.health(cfg)} @app.post("/api/reviewer/build-image") def build_reviewer_image(): cfg = bm_config.load() if not cfg.get("primarySparkHost"): raise HTTPException(400, "No Spark configured.") threading.Thread(target=lambda: _safe_build(cfg), daemon=True).start() return {"ok": True, "message": "Building reviewer image on the head Spark — watch the activity log."} def _safe_build(cfg: dict): try: rev_mod.ensure_reviewer_image(cfg, runner.log) except Exception as e: runner.log(f"[reviewers] image build failed: {e}") @app.post("/api/stop") def stop(): cfg = bm_config.load() if not cfg.get("primarySparkHost"): raise HTTPException(400, "No Spark configured.") serving.tear_down_all(cfg, runner.log) runner.phase = "idle" runner._persist() return {"ok": True} # ----------------------------------------------------------------------- reports @app.get("/api/reports") def list_reports(): if not os.path.isdir(REPORTS_DIR): return {"reports": []} jobs = sorted((d for d in os.listdir(REPORTS_DIR) if os.path.isdir(os.path.join(REPORTS_DIR, d))), reverse=True) return {"reports": jobs} @app.get("/api/report", response_class=PlainTextResponse) def latest_report(): path = os.path.join(REPORTS_DIR, "latest.md") if not os.path.exists(path): return "(no report yet — drop documents in the inbox and run a review)" return open(path, errors="replace").read().strip() or "(empty report)" @app.get("/api/reports/{job}", response_class=PlainTextResponse) def get_report(job: str): job = os.path.basename(job) path = os.path.join(REPORTS_DIR, job, "report.md") if not os.path.exists(path): raise HTTPException(404, "no such report") return open(path, errors="replace").read()