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:
co-authored by
Claude Fable 5
parent
1dde915540
commit
b1d7aed9f4
+267
-52
@@ -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)"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user