Scaffold: fork of Chambers architecture, renamed to Boardroom Map
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user