v0.1.6: human-friendly reports
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6bf0786993
commit
32d5d7f373
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -187,6 +212,83 @@ function deltaArrow(d){ if(d==null||isNaN(+d)) return '';
|
||||
return +d>0? `<span class="delta up">▲ ${(+d).toFixed(1)}</span>`
|
||||
: `<span class="delta dn">▼ ${Math.abs(+d).toFixed(1)}</span>`; }
|
||||
|
||||
// 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,'<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
|
||||
}
|
||||
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<lines.length){
|
||||
const l=lines[i]; let m;
|
||||
if(/^\s*$/.test(l)){ i++; continue; }
|
||||
if(m=l.match(/^(#{1,4})\s+(.*)/)){
|
||||
out.push(`<h${m[1].length}>${mdInline(m[2])}</h${m[1].length}>`); i++; continue; }
|
||||
if(/^\s*-{3,}\s*$/.test(l)){ out.push('<hr>'); i++; continue; }
|
||||
if(isRow(l) && isSep(lines[i+1]||'')){
|
||||
out.push('<table><thead><tr>'+cells(l).map(c=>`<th>${c}</th>`).join('')+'</tr></thead><tbody>');
|
||||
i+=2;
|
||||
while(i<lines.length && isRow(lines[i]))
|
||||
out.push('<tr>'+cells(lines[i++]).map(c=>`<td>${c}</td>`).join('')+'</tr>');
|
||||
out.push('</tbody></table>'); continue; }
|
||||
if(/^\s*>\s?/.test(l)){
|
||||
const q=[];
|
||||
while(i<lines.length && /^\s*>\s?/.test(lines[i]))
|
||||
q.push(mdInline(lines[i++].replace(/^\s*>\s?/,'')));
|
||||
out.push('<blockquote>'+q.join('<br>')+'</blockquote>'); continue; }
|
||||
if(/^\s*[-*]\s+/.test(l)){
|
||||
const li=[];
|
||||
while(i<lines.length && /^\s*[-*]\s+/.test(lines[i]))
|
||||
li.push('<li>'+mdInline(lines[i++].replace(/^\s*[-*]\s+/,''))+'</li>');
|
||||
out.push('<ul>'+li.join('')+'</ul>'); continue; }
|
||||
const p=[];
|
||||
while(i<lines.length && !/^\s*$/.test(lines[i]) && !/^#{1,4}\s|^\s*[->]|^\s*\*\s|^\s*\|/.test(lines[i]))
|
||||
p.push(mdInline(lines[i++]));
|
||||
if(p.length) out.push('<p>'+p.join(' ')+'</p>'); else i++;
|
||||
}
|
||||
return `<div class="mdview">${out.join('\n')}</div>`;
|
||||
}
|
||||
|
||||
// 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)+
|
||||
`<span class="badge"> vs ${esc(prev.period||'prev')}</span>`;
|
||||
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=>
|
||||
`<span class="chip ${k.credit>=0.999?'done':'warn'}">${k.credit>=0.999?'✓':'✗'} `+
|
||||
`${esc(k.name||k.canonical_name||'?')}</span>`).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<worst.a) worst={id,a:+a};
|
||||
});
|
||||
const flags=pen.flags||[];
|
||||
return `<div class="glance">`+
|
||||
`<div class="g"><div class="t">Composite</div><div class="v">${scoreBadge(rec.composite)} ${delta}</div></div>`+
|
||||
`<div class="g"><div class="t">Score mix</div><div class="v small">quant ${f1(q.score)} · `+
|
||||
`qual ${f1(ql.score)} · flags −${f1(pen.total)}</div></div>`+
|
||||
(kres.length?`<div class="g"><div class="t">KPIs vs target — ${hits}/${kres.length} hit</div>`+
|
||||
`<div>${kchips}</div></div>`:'')+
|
||||
(best?`<div class="g"><div class="t">BDEF best / weakest</div><div class="v small">`+
|
||||
`${best.id} · ${esc(BDEF[best.id])} <strong>${best.a.toFixed(1)}</strong><br>`+
|
||||
`${worst.id} · ${esc(BDEF[worst.id])} <strong>${worst.a.toFixed(1)}</strong></div></div>`:'')+
|
||||
`<div class="g"><div class="t">Red flags</div><div class="v">${flags.length||'none'}</div></div>`+
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
// 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?'<span class="tag unreg">unregistered</span>':'';
|
||||
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, `<pre class="tall">${esc(r.ok?t:('error: '+t))}</pre>`,
|
||||
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)
|
||||
: `<pre class="tall">${esc('error: '+t)}</pre>`;
|
||||
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', `<pre class="tall">${esc(r.ok?t:('error: '+t))}</pre>`,
|
||||
setViewer('SCORECARD.md', r.ok? mdToHtml(t) : `<pre class="tall">${esc('error: '+t)}</pre>`,
|
||||
url, `${currentSlug}-SCORECARD.md`);
|
||||
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
|
||||
}catch(e){ alert(e); }
|
||||
|
||||
Reference in New Issue
Block a user