Implement BDEF v1.1 grading: scoring core, per-deck pipeline, ledger, dashboard, StartOS layer

- Deterministic scoring.py (quant 60 / qual 40 / flags -15, profitability heaviest)
- Per-company JSON ledger with forecast-target chaining deck N-1 -> N
- Single-shot sandbox agent with guided-JSON fallback ladder (no tool loop)
- Portfolio dashboard with sparklines, KPI hit rates, BDEF category bars
- 48 unit tests green; endpoints smoke-tested; npm check+build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-06 14:15:12 -05:00
co-authored by Claude Fable 5
parent 1dde915540
commit b1d7aed9f4
48 changed files with 4907 additions and 971 deletions
+267 -52
View File
@@ -1,28 +1,34 @@
"""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.
Serves the portfolio dashboard and a small JSON API, and starts the background
job runner (see jobs.py) that grades the dropped board decks. Most configuration
happens through the StartOS *actions* (Configure Sparks / Models / Graders /
Grading); this UI is for dropping decks per company, triggering a grading run,
watching it, and reading the per-company scorecard ledger.
"""
from __future__ import annotations
import os
import threading
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
import bm_config
import extraction
import reviewers as rev_mod
import decks
import graders as grader_mod
import jobs
import ledger as ledger_mod
import serving
from jobs import runner, INBOX, REPORTS_DIR
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
LEDGER_DIR = os.path.join(DATA_DIR, "ledger")
runner = jobs.runner
INBOX = getattr(jobs, "INBOX", os.path.join(DATA_DIR, "inbox"))
REPORTS_DIR = getattr(jobs, "REPORTS_DIR", os.path.join(DATA_DIR, "reports"))
app = FastAPI(title="Boardroom Map Orchestrator")
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
@@ -53,19 +59,19 @@ def status():
"configured": {
"sparks": bool(cfg.get("primarySparkHost")),
"models": len(cfg.get("models") or []),
"reviewers": len(cfg.get("reviewers") or []),
"graders": len(cfg.get("graders") or []),
},
"networkMode": cfg.get("networkMode"),
"synthesis": bool(cfg.get("synthesisEnabled")),
"adjudicator": bool(cfg.get("adjudicatorEnabled")),
"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(),
"panel": [{"name": g.get("name"), "model": g.get("model"),
"persona": bool((g.get("persona") or "").strip()),
"known": (g.get("model") in catalog)}
for g in (cfg.get("graders") or [])],
"inbox": _inbox_grouped(),
"runtime": runner.snapshot(),
}
@@ -75,46 +81,89 @@ 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
# ----------------------------------------------------------------------- inbox
def _inbox_grouped() -> dict:
"""Company-grouped inbox view: {companies: {slug: [file dicts]}, skipped: [...]}."""
try:
d = decks.discover(INBOX)
except Exception:
return {"companies": {}, "skipped": []}
companies: dict[str, list] = {}
for u in d.get("units") or []:
lst = companies.setdefault(u["company_slug"], [])
for f in u.get("files") or []:
try:
size = os.path.getsize(f)
except OSError:
size = 0
lst.append({"name": os.path.basename(f), "bytes": size,
"period": u.get("period"), "supported": True})
for fn in u.get("ignored") or []:
lst.append({"name": fn, "bytes": 0, "period": None, "supported": False})
# discover() drops units with no supported files, so sweep the company dirs
# for anything it didn't list (unsupported strays) and flag them.
try:
for entry in sorted(os.listdir(INBOX)):
cdir = os.path.join(INBOX, entry)
if entry.startswith(".") or not os.path.isdir(cdir):
continue
slug = decks.slugify(entry)
seen = {f["name"] for f in companies.get(slug, [])}
for fn in sorted(os.listdir(cdir)):
if fn.startswith(".") or fn in seen or not os.path.isfile(os.path.join(cdir, fn)):
continue
try:
size = os.path.getsize(os.path.join(cdir, fn))
except OSError:
size = 0
companies.setdefault(slug, []).append(
{"name": fn, "bytes": size, "period": decks.parse_period_from_name(fn),
"supported": os.path.splitext(fn)[1].lower() in decks.SUPPORTED_EXTS})
except OSError:
pass
return {"companies": companies, "skipped": d.get("skipped") or []}
@app.get("/api/inbox")
def inbox():
return {"inbox": _inbox_list()}
return _inbox_grouped()
# ----------------------------------------------------------------------- documents
@app.post("/api/upload")
async def upload(files: list[UploadFile] = File(...)):
os.makedirs(INBOX, exist_ok=True)
async def upload(request: Request,
files: list[UploadFile] = File(...),
company: str | None = Form(None)):
name = (company or request.query_params.get("company") or "").strip()
if not name:
raise HTTPException(400, "company is required — root-level files are not graded")
slug = decks.slugify(name)
dest_dir = os.path.join(INBOX, slug)
os.makedirs(dest_dir, exist_ok=True)
saved = []
for f in files:
name = os.path.basename(f.filename or "document")
dest = os.path.join(INBOX, name)
fn = os.path.basename(f.filename or "deck")
dest = os.path.join(dest_dir, fn)
with open(dest, "wb") as out:
while chunk := await f.read(1 << 20):
out.write(chunk)
saved.append(name)
return {"ok": True, "saved": saved}
saved.append(fn)
return {"ok": True, "company": slug, "saved": saved}
@app.post("/api/inbox/clear")
def inbox_clear():
import shutil
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)
try:
if os.path.isfile(p):
os.remove(p)
elif os.path.isdir(p):
shutil.rmtree(p)
except OSError:
pass
return {"ok": True}
@@ -124,12 +173,16 @@ 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.")
if not (cfg.get("models") and cfg.get("graders")):
raise HTTPException(400, "Configure at least one model and one grader first.")
try:
units = decks.discover(INBOX).get("units") or []
except Exception:
units = []
if not units:
raise HTTPException(400, "Inbox is empty — upload decks into a company folder first.")
runner.request_run()
return {"ok": True, "message": "Review requested — watch the activity log."}
return {"ok": True, "message": "Grading requested — watch the activity log."}
@app.get("/api/serving")
@@ -140,20 +193,21 @@ def serving_status():
return {"serving": serving.health(cfg)}
@app.post("/api/reviewer/build-image")
def build_reviewer_image():
@app.post("/api/grader/build-image")
def build_grader_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."}
return {"ok": True, "message": "Building grader image on the head Spark — watch the activity log."}
def _safe_build(cfg: dict):
try:
rev_mod.ensure_reviewer_image(cfg, runner.log)
fn = getattr(grader_mod, "ensure_grader_image", None) or grader_mod.ensure_reviewer_image
fn(cfg, runner.log)
except Exception as e:
runner.log(f"[reviewers] image build failed: {e}")
runner.log(f"[graders] image build failed: {e}")
@app.post("/api/stop")
@@ -167,21 +221,182 @@ def stop():
return {"ok": True}
# ----------------------------------------------------------------------- reports
# ----------------------------------------------------------------------- companies / ledger
def _config_companies(cfg: dict) -> list[dict]:
"""Companies registered in the StartOS config (may have zero graded decks)."""
out = []
for c in (cfg.get("companies") or []):
if isinstance(c, dict):
name = (c.get("name") or c.get("company") or c.get("slug") or "").strip()
else:
name = str(c).strip()
if name:
out.append({"slug": decks.slugify(name), "name": name})
return out
def _ledger_companies() -> list[dict]:
try:
led = ledger_mod.Ledger(LEDGER_DIR)
return led.all_companies() or []
except Exception:
return []
@app.get("/api/companies")
def companies():
cfg = bm_config.load()
rows, seen = [], set()
for c in _ledger_companies():
try:
hist = [{"period": h.get("period"), "composite": h.get("composite")}
for h in (c.get("history") or [])]
latest = None
if hist:
delta = None
cur, prev = hist[-1]["composite"], (hist[-2]["composite"] if len(hist) >= 2 else None)
if isinstance(cur, (int, float)) and isinstance(prev, (int, float)):
delta = round(cur - prev, 2)
latest = {"period": hist[-1]["period"], "composite": cur, "delta": delta}
slug = c.get("slug") or decks.slugify(c.get("name") or "")
seen.add(slug)
rows.append({"slug": slug, "name": c.get("name") or slug,
"auto_created": bool(c.get("auto_created")),
"deck_count": len(hist), "latest": latest, "history": hist})
except Exception:
continue
for c in _config_companies(cfg):
if c["slug"] not in seen:
seen.add(c["slug"])
rows.append({"slug": c["slug"], "name": c["name"], "auto_created": False,
"deck_count": 0, "latest": None, "history": []})
rows.sort(key=lambda r: (r["name"] or "").lower())
return {"companies": rows}
def _kpi_hit_rate(records: list[dict]) -> dict:
"""Per canonical KPI across records (oldest→newest):
attempts, hits (credit>=0.999), streak of hits ending at the latest attempt,
last_credit and profitability tag."""
stats: dict[str, dict] = {}
for rec in records:
for k in (rec.get("kpi_results") or []):
cn = k.get("canonical_name") or k.get("name")
if not cn:
continue
s = stats.setdefault(cn, {"name": k.get("name") or cn, "attempts": 0, "hits": 0,
"streak": 0, "last_credit": None, "profitability": False})
if k.get("name"):
s["name"] = k["name"]
s["profitability"] = bool(k.get("profitability", s["profitability"]))
credit = k.get("credit")
if not isinstance(credit, (int, float)):
continue # no target matched — not an attempt
s["attempts"] += 1
s["last_credit"] = credit
if credit >= 0.999:
s["hits"] += 1
s["streak"] += 1
else:
s["streak"] = 0
return stats
def _categories_latest(records: list[dict]) -> dict:
if not records:
return {}
def cats(rec):
return ((rec.get("qual") or {}).get("categories")) or {}
latest = cats(records[-1])
prev = cats(records[-2]) if len(records) >= 2 else {}
out = {}
for cid in "ABCDEFGH":
cur = latest.get(cid)
if cur is None and prev.get(cid) is None:
continue
out[cid] = {"latest_adjusted": (cur or {}).get("adjusted"),
"previous_adjusted": (prev.get(cid) or {}).get("adjusted")}
return out
@app.get("/api/companies/{slug}")
def company_detail(slug: str):
slug = os.path.basename(slug)
company, records = None, []
try:
led = ledger_mod.Ledger(LEDGER_DIR)
company = led.get_company(slug)
if company is not None:
records = led.deck_records(slug) or []
except Exception:
company, records = None, []
if company is None:
cfg = bm_config.load()
match = next((c for c in _config_companies(cfg) if c["slug"] == slug), None)
if not match:
raise HTTPException(404, "no such company")
company = {"slug": slug, "name": match["name"], "auto_created": False,
"kpi_aliases": {}, "pinned_targets": [], "extracted_targets": {},
"history": []}
open_flags = (((records[-1].get("penalties") or {}).get("flags")) or []) if records else []
return {
"company": company,
"records": records,
"kpi_hit_rate": _kpi_hit_rate(records),
"categories_latest": _categories_latest(records),
"open_flags": open_flags,
}
@app.get("/api/companies/{slug}/scorecard", response_class=PlainTextResponse)
def company_scorecard(slug: str):
slug = os.path.basename(slug)
path = os.path.join(LEDGER_DIR, slug, "SCORECARD.md")
if not os.path.exists(path):
raise HTTPException(404, "no scorecard yet for this company")
return open(path, errors="replace").read()
@app.get("/api/companies/{slug}/decks/{deck_id}")
def deck_record(slug: str, deck_id: str):
slug, deck_id = os.path.basename(slug), os.path.basename(deck_id)
rec = None
try:
led = ledger_mod.Ledger(LEDGER_DIR)
rec = led.deck_record(slug, deck_id)
except Exception:
rec = None
if rec is None:
raise HTTPException(404, "no such deck record")
return JSONResponse(rec)
@app.get("/api/companies/{slug}/decks/{deck_id}/report", response_class=PlainTextResponse)
def deck_report(slug: str, deck_id: str):
slug, deck_id = os.path.basename(slug), os.path.basename(deck_id)
if deck_id.endswith(".md"):
deck_id = deck_id[:-3]
path = os.path.join(LEDGER_DIR, slug, "decks", f"{deck_id}.md")
if not os.path.exists(path):
raise HTTPException(404, "no report for this deck")
return open(path, errors="replace").read()
# ----------------------------------------------------------------------- reports (legacy job 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}
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 "(no report yet — drop decks in a company folder and run a grading job)"
return open(path, errors="replace").read().strip() or "(empty report)"