From 32d5d7f37370e8e714f6b10e765213606876bf06 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Thu, 30 Jul 2026 09:31:40 -0500 Subject: [PATCH] v0.1.6: human-friendly reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard renders deck reports and scorecards as formatted pages (self-contained markdown renderer inlined in index.html — headings, styled tables, evidence blockquotes; no CDN, air-gap friendly) - At-a-glance strip on every deck report: composite with delta vs the prior deck, quant/qual/penalty mix, KPI hit/miss chips, BDEF best/weakest category, red-flag count - Generated DECK_REPORT.md now leads with a concise "At a glance" summary table (scorecard.py); jobs.py passes the previous deck record so the delta appears in the file too; dashboard hides the duplicate section since the strip covers it - .gitignore: .venv/ Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + README.md | 6 +- orchestrator/jobs.py | 5 +- orchestrator/scorecard.py | 61 ++++++++++++++-- orchestrator/templates/index.html | 116 +++++++++++++++++++++++++++++- startos/versions/index.ts | 5 +- startos/versions/v_0_1_6.ts | 15 ++++ 7 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 startos/versions/v_0_1_6.ts diff --git a/.gitignore b/.gitignore index 534a291..7cb77c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ javascript/ .claude/ .startos/ start-technologies/ +.venv/ diff --git a/README.md b/README.md index fcf0187..d591902 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Canonical repo: `https://gitea.ten31.ai/Ten31AI/boardroom-map`. ## Status -**v0.1.5 — live in production.** Deployed on a StartOS box driving a DGX Spark +**v0.1.6 — live in production.** Deployed on a StartOS box driving a DGX Spark in single-spark air-gapped mode (gemma-4-31B panel: munger-lens / girdley-operator / buffett-owner). First full grading run completed 2026-07-29: a three-deck company history graded end-to-end into a running @@ -133,7 +133,9 @@ poll-until-loaded, crash fast-fail); v0.1.4 fixed air-gapped serving (`HF_HUB_OFFLINE` — the `--internal` network has no DNS) and raised grader timeouts for ~3.6 tok/s local generation; v0.1.5 fixed the dashboard viewer (stays open across background refresh) and added report/JSON/scorecard -downloads. +downloads; v0.1.6 made reports human-friendly — rendered markdown in the +dashboard, an at-a-glance strip per deck report, and a concise "At a glance" +summary table atop every generated DECK_REPORT.md. Known optimization not yet done: the wave is torn down per deck, so the 31B reloads from disk (~6 min) between decks even when the model set is unchanged. diff --git a/orchestrator/jobs.py b/orchestrator/jobs.py index a4a4e8c..fe9c597 100644 --- a/orchestrator/jobs.py +++ b/orchestrator/jobs.py @@ -513,7 +513,10 @@ class JobRunner: # --- reports ------------------------------------------------------------ self.phase = "collecting"; self._persist() - deck_md = scorecard.render_deck_report(record, ext_obj, adjudication_md) + all_recs = led.deck_records(slug_c) # period-sorted, incl. the new record + idx = next((i for i, r in enumerate(all_recs) if r.get("deck_id") == deck_id), -1) + prev_rec = all_recs[idx - 1] if idx > 0 else None + deck_md = scorecard.render_deck_report(record, ext_obj, adjudication_md, prev_rec) if rec_path and str(rec_path).endswith(".json"): ledger_md = os.path.splitext(str(rec_path))[0] + ".md" else: diff --git a/orchestrator/scorecard.py b/orchestrator/scorecard.py index 4c47bda..0807c67 100644 --- a/orchestrator/scorecard.py +++ b/orchestrator/scorecard.py @@ -42,19 +42,68 @@ def _bucket_row(name: str, b: dict) -> str: f"| {b.get('kpi_count', 0)} | |") -def render_deck_report(record: dict, extraction: dict, adjudication_md: str | None = None) -> str: +def _glance_lines(record: dict, prev_record: dict | None) -> list[str]: + """Concise 'At a glance' table: the whole deck in five rows.""" + lines = ["## At a glance", "", "| | |", "|---|---|"] + + comp = f"**{_fmt(record.get('composite'))} / 100**" + if prev_record is not None and prev_record.get("composite") is not None \ + and record.get("composite") is not None: + d = round(record["composite"] - prev_record["composite"], 1) + comp += (f" ({_arrow(d)} {'+' if d > 0 else ''}{_fmt(d)}" + f" vs {prev_record.get('period') or 'previous deck'})") + elif prev_record is None: + comp += " (first graded deck)" + lines.append(f"| Composite | {comp} |") + lines.append(f"| Score mix | quant {_fmt(record.get('quant', {}).get('score'))}" + f" + qual {_fmt(record.get('qual', {}).get('score'))}" + f" − penalties {_fmt(record.get('penalties', {}).get('total'))} |") + + kpis = [k for k in (record.get("kpi_results") or []) + if isinstance(k.get("credit"), (int, float))] + if kpis: + missed = [k.get("name") or k.get("canonical_name") or "?" + for k in kpis if k["credit"] < 0.999] + hit_txt = f"{len(kpis) - len(missed)} of {len(kpis)} hit" + if missed: + hit_txt += f" — missed: {', '.join(missed)}" + lines.append(f"| KPIs vs target | {hit_txt} |") + else: + lines.append("| KPIs vs target | none targeted |") + + scored = [(cid, c["adjusted"]) for cid in _CATEGORIES + if (c := record.get("qual", {}).get("categories", {}).get(cid, {})) + .get("adjusted") is not None] + if scored: + best = max(scored, key=lambda x: x[1]) + worst = min(scored, key=lambda x: x[1]) + lines.append(f"| BDEF best / weakest | {best[0]} · {_CATEGORY_TITLES[best[0]]}" + f" ({_fmt(best[1])}) / {worst[0]} · {_CATEGORY_TITLES[worst[0]]}" + f" ({_fmt(worst[1])}) |") + + flags = record.get("penalties", {}).get("flags") or [] + if flags: + worst_flag = max(flags, key=lambda f: f.get("points") or 0) + lines.append(f"| Red flags | {len(flags)} " + f"(−{_fmt(record.get('penalties', {}).get('total'))} pts;" + f" worst: `{worst_flag.get('code')}`) |") + else: + lines.append("| Red flags | none |") + lines.append("") + return lines + + +def render_deck_report(record: dict, extraction: dict, adjudication_md: str | None = None, + prev_record: dict | None = None) -> str: """One deck's full markdown report.""" lines: list[str] = [] company = record.get("company") or "?" period = record.get("period") or "unknown period" lines.append(f"# Deck report — {company} · {period}") lines.append("") - lines.append(f"## Composite: **{_fmt(record.get('composite'))} / 100**") - lines.append("") + lines.extend(_glance_lines(record, prev_record)) q = record.get("quant", {}) - lines.append(f"Quant {_fmt(q.get('score'))} · Qual {_fmt(record.get('qual', {}).get('score'))}" - f" · Penalties −{_fmt(record.get('penalties', {}).get('total'))}" - f" · graded {record.get('graded_at') or '?'} (job {record.get('job_id') or '?'})") + lines.append(f"Graded {record.get('graded_at') or '?'} (job {record.get('job_id') or '?'})") lines.append("") # --- quantitative buckets diff --git a/orchestrator/templates/index.html b/orchestrator/templates/index.html index 38b8d11..ac2c7a5 100644 --- a/orchestrator/templates/index.html +++ b/orchestrator/templates/index.html @@ -82,6 +82,31 @@ .inbox-co{margin-top:8px} .inbox-co .co-name{font-weight:600;font-size:12px;color:var(--accent);letter-spacing:.5px} details.jsonv summary{cursor:pointer;color:var(--dim);font-size:12px;margin:8px 0 4px} + /* rendered markdown reports */ + .mdview{background:#0c0f16;border:1px solid var(--edge);border-radius:10px; + padding:14px 18px;max-height:560px;overflow:auto;font-size:13px;line-height:1.55} + .mdview h1{font-size:15px;margin:2px 0 10px;letter-spacing:.5px} + .mdview h2{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--accent); + margin:18px 0 8px;border-bottom:1px solid var(--edge);padding-bottom:4px} + .mdview h2:first-child,.mdview h1+h2{margin-top:6px} + .mdview h3{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);margin:14px 0 6px} + .mdview table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0} + .mdview th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim); + text-align:left;padding:3px 8px;border-bottom:1px solid var(--edge)} + .mdview td{padding:4px 8px;border-bottom:1px dashed var(--edge);vertical-align:top} + .mdview blockquote{margin:6px 0;padding:4px 12px;border-left:3px solid var(--accent); + color:var(--dim);font-style:italic;background:#141926;border-radius:0 8px 8px 0} + .mdview code{background:#1c2430;border-radius:4px;padding:0 5px; + font:11px ui-monospace,Menlo,monospace;color:#cdd6ff} + .mdview p{margin:6px 0} .mdview ul{margin:6px 0;padding-left:22px} .mdview li{margin:2px 0} + .mdview hr{border:0;border-top:1px solid var(--edge);margin:12px 0} + /* at-a-glance strip above a deck report */ + .glance{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 12px;align-items:stretch} + .glance .g{background:#0c0f16;border:1px solid var(--edge);border-radius:10px; + padding:8px 14px;min-width:110px} + .glance .g .t{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);margin-bottom:4px} + .glance .g .v{font-size:14px;font-weight:600} + .glance .g .v.small{font-size:12px;font-weight:500} @@ -187,6 +212,83 @@ function deltaArrow(d){ if(d==null||isNaN(+d)) return ''; return +d>0? `▲ ${(+d).toFixed(1)}` : `▼ ${Math.abs(+d).toFixed(1)}`; } +// Minimal markdown renderer for our own reports (headings, tables, quotes, +// lists, bold/`code`) — self-contained so the air-gapped box needs no CDN. +function mdInline(s){ + return esc(s) + .replace(/`([^`]+)`/g,'$1') + .replace(/\*\*([^*]+)\*\*/g,'$1'); +} +function mdToHtml(md){ + const lines=String(md||'').split(/\r?\n/), out=[]; let i=0; + const isRow=l=>/^\s*\|.*\|\s*$/.test(l); + const isSep=l=>/^\s*\|[\s:|-]+\|\s*$/.test(l); + const cells=r=>r.trim().replace(/^\||\|$/g,'').split('|').map(c=>mdInline(c.trim())); + while(i${mdInline(m[2])}`); i++; continue; } + if(/^\s*-{3,}\s*$/.test(l)){ out.push('
'); i++; continue; } + if(isRow(l) && isSep(lines[i+1]||'')){ + out.push(''+cells(l).map(c=>``).join('')+''); + i+=2; + while(i'+cells(lines[i++]).map(c=>``).join('')+''); + out.push('
${c}
${c}
'); continue; } + if(/^\s*>\s?/.test(l)){ + const q=[]; + while(i\s?/.test(lines[i])) + q.push(mdInline(lines[i++].replace(/^\s*>\s?/,''))); + out.push('
'+q.join('
')+'
'); continue; } + if(/^\s*[-*]\s+/.test(l)){ + const li=[]; + while(i'+mdInline(lines[i++].replace(/^\s*[-*]\s+/,''))+''); + out.push('
    '+li.join('')+'
'); continue; } + const p=[]; + while(i]|^\s*\*\s|^\s*\|/.test(lines[i])) + p.push(mdInline(lines[i++])); + if(p.length) out.push('

'+p.join(' ')+'

'); else i++; + } + return `
${out.join('\n')}
`; +} + +// At-a-glance strip for one deck record (prev = the preceding graded deck). +function glanceStrip(rec,prev){ + if(!rec) return ''; + const q=rec.quant||{}, ql=rec.qual||{}, pen=rec.penalties||{}; + const f1=x=>(x==null||isNaN(+x))?'—':(+x).toFixed(1); + let delta=''; + if(prev && prev.composite!=null && rec.composite!=null) + delta=deltaArrow(rec.composite-prev.composite)+ + ` vs ${esc(prev.period||'prev')}`; + const kres=(rec.kpi_results||[]).filter(k=>typeof k.credit==='number'); + const hits=kres.filter(k=>k.credit>=0.999).length; + const kchips=kres.map(k=> + `${k.credit>=0.999?'✓':'✗'} `+ + `${esc(k.name||k.canonical_name||'?')}`).join(''); + const cats=ql.categories||{}; let best=null,worst=null; + Object.keys(BDEF).forEach(id=>{ + const a=(cats[id]||{}).adjusted; + if(a==null||isNaN(+a)) return; + if(!best||+a>best.a) best={id,a:+a}; + if(!worst||+a`+ + `
Composite
${scoreBadge(rec.composite)} ${delta}
`+ + `
Score mix
quant ${f1(q.score)} · `+ + `qual ${f1(ql.score)} · flags −${f1(pen.total)}
`+ + (kres.length?`
KPIs vs target — ${hits}/${kres.length} hit
`+ + `
${kchips}
`:'')+ + (best?`
BDEF best / weakest
`+ + `${best.id} · ${esc(BDEF[best.id])} ${best.a.toFixed(1)}
`+ + `${worst.id} · ${esc(BDEF[worst.id])} ${worst.a.toFixed(1)}
`:'')+ + `
Red flags
${flags.length||'none'}
`+ + ``; +} + // Inline SVG sparkline: min-max normalized polyline (~120x28). function sparkline(hist,w,h){ w=w||120; h=h||28; @@ -320,8 +422,10 @@ function closeCompany(){ document.getElementById('companyCard').style.display='none'; } +let currentRecs=[]; function renderDetail(d,changed){ const co=d.company||{}, recs=d.records||[]; + currentRecs=recs; document.getElementById('coName').textContent=co.name||co.slug||'?'; document.getElementById('coBadges').innerHTML=co.auto_created?'unregistered':''; document.getElementById('coTrend').innerHTML= @@ -377,7 +481,15 @@ async function viewDeckReport(deckId){ try{ const r=await fetch(base+'/report'); const t=await r.text(); - setViewer('Deck report — '+deckId, `
${esc(r.ok?t:('error: '+t))}
`, + const idx=currentRecs.findIndex(x=>x.deck_id===deckId); + const rec=idx>=0?currentRecs[idx]:null, prev=idx>0?currentRecs[idx-1]:null; + // The glance strip covers the report's own "At a glance" table — drop the + // duplicate section in the dashboard view (downloads keep it). + let md=t; + if(rec) md=md.replace(/\n## At a glance[\s\S]*?(?=\n(?:Graded |## ))/,'\n'); + const body=r.ok? glanceStrip(rec,prev)+mdToHtml(md) + : `
${esc('error: '+t)}
`; + setViewer('Deck report — '+deckId, body, base+'/report', `${currentSlug}-${deckId}-report.md`); }catch(e){ alert(e); } } @@ -400,7 +512,7 @@ async function toggleScorecard(){ const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard'; const r=await fetch(url); const t=await r.text(); - setViewer('SCORECARD.md', `
${esc(r.ok?t:('error: '+t))}
`, + setViewer('SCORECARD.md', r.ok? mdToHtml(t) : `
${esc('error: '+t)}
`, url, `${currentSlug}-SCORECARD.md`); scorecardOpen=true; btn.textContent='Hide SCORECARD.md'; }catch(e){ alert(e); } diff --git a/startos/versions/index.ts b/startos/versions/index.ts index bca7286..357527b 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -5,9 +5,10 @@ import { v_0_1_2 } from './v_0_1_2' import { v_0_1_3 } from './v_0_1_3' import { v_0_1_4 } from './v_0_1_4' import { v_0_1_5 } from './v_0_1_5' +import { v_0_1_6 } from './v_0_1_6' /** The current version MUST be the first argument (`current`). */ export const versions = VersionGraph.of({ - current: v_0_1_5, - other: [v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], + current: v_0_1_6, + other: [v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], }) diff --git a/startos/versions/v_0_1_6.ts b/startos/versions/v_0_1_6.ts new file mode 100644 index 0000000..2ef9b3c --- /dev/null +++ b/startos/versions/v_0_1_6.ts @@ -0,0 +1,15 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +/** Human-friendly reports. ExVer form `:`. */ +export const v_0_1_6 = VersionInfo.of({ + version: '0.1.6:0', + releaseNotes: + 'Reports are now human-friendly: deck reports and scorecards render as ' + + 'formatted pages in the dashboard (headings, styled tables, evidence ' + + 'quotes) instead of raw markdown, with an at-a-glance strip on every ' + + 'deck report — composite with delta vs the prior deck, quant/qual/penalty ' + + 'mix, KPI hit/miss chips, best/weakest BDEF category, and red-flag count. ' + + 'Generated DECK_REPORT.md files lead with the same concise At a glance ' + + 'summary table, so downloaded and emailed copies read well too.', + migrations: {}, +})