"""Render audit JSON as HTML for Gradio. Design: a pre-publish readiness check, not a magazine. Cool grayscale instrument panel where the only saturated colour is the verdict itself (severity = meaning). Signature element: a linear readiness meter whose score can be visibly capped by a blocking finding. """ from __future__ import annotations import html import json from typing import Any DIM_LABELS = { "hook": "Hook", "clarity": "Clarity", "audienceFit": "Audience fit", "goalService": "Goal service", "cta": "Call to action", } SEV_ORDER = {"critical": 0, "warning": 1, "info": 2} SEV_CLASS = {"critical": "crit", "warning": "warn", "info": "info"} SEV_LABEL = {"critical": "blocker", "warning": "warning", "info": "note"} def _esc(s: Any) -> str: return html.escape(str(s if s is not None else "")) def _band(pct: int) -> str: if pct < 40: return "crit" if pct < 70: return "warn" return "ok" def _dim_band(score: int) -> str: if score <= 2: return "crit" if score == 3: return "warn" return "ok" def _verdict(overall: int, capped: bool) -> tuple[str, str]: """Return (headline state, band) — the human verdict that opens the report.""" if capped: return "Not ready to publish", "crit" if overall < 40: return "Not ready to publish", "crit" if overall < 70: return "Needs work", "warn" return "Ready to publish", "ok" def _meter(pct: int, capped: bool) -> str: pct = max(0, min(100, pct)) band = _band(pct) ceiling = ( '
' '
ceiling
' if capped else "" ) return f"""
{ceiling}
04070100
""" def _render_audit(data: dict[str, Any]) -> str: ga = data.get("goalAlignment") or {} dims = ga.get("dimensions") or [] overall = int(ga.get("overall") or 0) capped_list = ga.get("cappedBy") or [] capped = bool(capped_list) state, band = _verdict(overall, capped) warns = sorted( data.get("warnings") or [], key=lambda w: SEV_ORDER.get(w.get("severity", "info"), 9), ) counts: dict[str, int] = {} for w in warns: sev = w.get("severity", "info") counts[sev] = counts.get(sev, 0) + 1 count_str = " · ".join( f"{counts[s]} {SEV_LABEL[s]}{'s' if counts[s] > 1 else ''}" for s in ("critical", "warning", "info") if counts.get(s) ) # Hero — the verdict, the meter, and (the signature) the cap indictment. cap_row = "" if capped: codes = "".join(f'{_esc(c)}' for c in capped_list) cap_row = ( f'
held at {overall} / 100' f'blocked by{codes}
' ) summary = _esc(ga.get("summary") or "") hero = f"""
pre-publish readiness check

{_esc(state)}

{overall}/100
{_meter(overall, capped)} {cap_row} {f'

{summary}

' if summary else ''}
""" parts = [hero] if dims: parts.append('

Goal dimensions' 'score / 5

') for dm in dims: s = int(dm.get("score") or 0) dband = _dim_band(s) key = dm.get("key", "") parts.append( f"""
{_esc(DIM_LABELS.get(key, key))}{_esc(key)}
{s}/5
{_esc(dm.get("rationale") or "")}
""" ) parts.append("
") if warns: parts.append( f'

Findings' f'{_esc(count_str)}

' ) for w in warns: cl = SEV_CLASS.get(w.get("severity", "info"), "info") label = SEV_LABEL.get(w.get("severity", "info"), "note") src = f'{_esc(w.get("source"))}' if w.get("source") else "" ev = ( f'
{_esc(w.get("evidence"))}
' if w.get("evidence") else "" ) parts.append( f"""
{_esc(label)} {_esc(w.get("code"))} {src}
{_esc(w.get("message"))}
{ev}
""" ) parts.append("
") hints = data.get("rewriteHints") or [] if hints: items = "".join(f"
  • {_esc(h)}
  • " for h in hints) parts.append( f'

    Fix before publishing

    ' f'
      {items}
    ' ) return "".join(parts) def _render_brief(data: dict[str, Any]) -> str: ok = data.get("status") == "ok" state = "Brief accepted" if ok else "Needs a clearer goal" band = "ok" if ok else "warn" note = ( "The audit ran on the goal and audience inferred below." if ok else "The goal is too vague to judge the post against. Pick a concrete target, then re-run." ) inferred = data.get("inferred") or {} parts = [ f"""
    pre-publish readiness check

    {_esc(state)}

    {_esc(note)}

    goal · inferred
    {_esc(inferred.get("goal") or "—")}
    audience · inferred
    {_esc(inferred.get("audience") or "—")}
    """ ] gaps = data.get("gaps") or [] if gaps: parts.append('

    What to clarify

    ') for g in gaps: chips = "".join(f'{_esc(c)}' for c in g.get("candidates") or []) parts.append( f"""
    {_esc(g.get("field"))}
    {_esc(g.get("reason") or "")}
    {chips}
    """ ) parts.append("
    ") return "".join(parts) REPORT_CSS = """ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&family=Space+Grotesk:wght@500;700&display=swap'); .post-audit-report{ --bg:#eef1f5; --surface:#ffffff; --ink:#161b22; --muted:#586172; --faint:#8a93a3; --line:#e3e7ee; --line2:#cfd6e0; --ok:#0e7a4f; --ok-bg:#e7f5ee; --ok-line:#c2e4d2; --warn:#8a5a00; --warn-bg:#fbefcf; --warn-line:#eedba0; --crit:#c12626; --crit-bg:#fcebe9; --crit-line:#f2cbc7; --info:#1f5fae; --info-bg:#e9f0fb; --info-line:#cbdcf3; --sans:"IBM Plex Sans",system-ui,-apple-system,sans-serif; --display:"Space Grotesk",var(--sans); --mono:"IBM Plex Mono",ui-monospace,monospace; background:var(--bg); color:var(--ink); font-family:var(--sans); color-scheme:light; font-size:16px; line-height:1.6; border:1px solid var(--line2); border-radius:16px; padding:6px; -webkit-font-smoothing:antialiased; } .post-audit-report *{box-sizing:border-box} .post-audit-report .mono{font-family:var(--mono);font-feature-settings:"tnum" 1} .post-audit-report h1,.post-audit-report h2{margin:0} .post-audit-report .hero{background:var(--surface);border:1px solid var(--line); border-radius:13px;padding:22px 22px 20px;position:relative;overflow:hidden} .post-audit-report .hero::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px} .post-audit-report .hero.ok::before{background:var(--ok)} .post-audit-report .hero.warn::before{background:var(--warn)} .post-audit-report .hero.crit::before{background:var(--crit)} .post-audit-report .eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--faint)} .post-audit-report .verdict-row{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:8px 0 4px} .post-audit-report .state{font-family:var(--display);font-weight:700;font-size:27px;line-height:1.08;letter-spacing:-.01em} .post-audit-report .hero.ok .state{color:var(--ok)} .post-audit-report .hero.warn .state{color:var(--warn)} .post-audit-report .hero.crit .state{color:var(--crit)} .post-audit-report .score{font-family:var(--display);display:flex;align-items:baseline;gap:3px;flex:none} .post-audit-report .score b{font-size:46px;font-weight:700;line-height:.9;letter-spacing:-.02em} .post-audit-report .score span{font-size:13px;color:var(--faint)} .post-audit-report .hero.ok .score b{color:var(--ok)} .post-audit-report .hero.warn .score b{color:var(--warn)} .post-audit-report .hero.crit .score b{color:var(--crit)} .post-audit-report .meter{margin:14px 0 2px} .post-audit-report .m-track{position:relative;height:10px;border-radius:6px;background:#e6eaf0} .post-audit-report .m-fill{position:absolute;left:0;top:0;height:100%;border-radius:6px; animation:pa-grow .5s cubic-bezier(.2,.7,.2,1) both} .post-audit-report .m-fill.ok{background:var(--ok)} .post-audit-report .m-fill.warn{background:var(--warn)} .post-audit-report .m-fill.crit{background:var(--crit)} .post-audit-report .m-ceiling{position:absolute;top:0;right:0;height:100%;border-radius:0 6px 6px 0; background:repeating-linear-gradient(45deg,transparent,transparent 4px,rgba(193,38,38,.16) 4px,rgba(193,38,38,.16) 7px); border-left:1.5px dashed var(--crit-line)} .post-audit-report .m-ceil-lbl{position:absolute;top:-17px;transform:translateX(4px);font-family:var(--mono); font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--crit)} .post-audit-report .m-tick{position:absolute;top:-3px;width:1.5px;height:16px;background:var(--line2);transform:translateX(-50%)} .post-audit-report .m-scale{position:relative;height:14px;margin-top:7px} .post-audit-report .m-scale span{position:absolute;transform:translateX(-50%);font-family:var(--mono);font-size:10px;color:var(--faint)} .post-audit-report .m-scale span:first-child{transform:none} .post-audit-report .m-scale span:last-child{transform:translateX(-100%)} .post-audit-report .cap{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:14px; background:var(--crit-bg);border:1px solid var(--crit-line);border-radius:9px;padding:9px 12px} .post-audit-report .cap-k{font-size:11px;color:var(--crit);letter-spacing:.02em} .post-audit-report .cap-by{font-size:12px;color:var(--muted)} .post-audit-report .summary{font-size:16px;line-height:1.55;color:var(--ink);margin:14px 0 0;max-width:60ch} .post-audit-report .sec{margin-top:22px} .post-audit-report .sec-h{display:flex;justify-content:space-between;align-items:baseline; font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.18em;text-transform:uppercase; color:var(--muted);padding-bottom:9px;border-bottom:1.5px solid var(--ink);margin-bottom:14px} .post-audit-report .sec-meta{color:var(--faint);letter-spacing:.04em} .post-audit-report .dims{display:flex;flex-direction:column;gap:2px} .post-audit-report .dim{display:grid;grid-template-columns:142px 1fr;gap:10px 18px;align-items:start; padding:13px 0;border-bottom:1px solid var(--line)} .post-audit-report .dim:last-child{border-bottom:none} .post-audit-report .dim-name{font-weight:500;font-size:15px;color:var(--ink)} .post-audit-report .dim-k{display:block;font-size:10.5px;color:var(--faint);font-weight:400;margin-top:3px;letter-spacing:.02em} .post-audit-report .dim-bar{display:flex;align-items:center;gap:11px;margin-bottom:6px} .post-audit-report .dim-track{flex:1;height:6px;border-radius:4px;background:#e6eaf0;overflow:hidden} .post-audit-report .dim-fill{height:100%;border-radius:4px;animation:pa-grow .5s cubic-bezier(.2,.7,.2,1) both} .post-audit-report .dim-fill.ok{background:var(--ok)} .post-audit-report .dim-fill.warn{background:var(--warn)} .post-audit-report .dim-fill.crit{background:var(--crit)} .post-audit-report .dim-sc{font-size:13px;color:var(--muted);min-width:34px;text-align:right} .post-audit-report .dim-sc span{color:var(--faint)} .post-audit-report .dim-rat{font-size:14px;color:var(--muted);line-height:1.5} .post-audit-report .finds{display:flex;flex-direction:column;gap:10px} .post-audit-report .find{background:var(--surface);border:1px solid var(--line);border-left-width:4px; border-radius:10px;padding:13px 15px 14px} .post-audit-report .find.crit{border-left-color:var(--crit)} .post-audit-report .find.warn{border-left-color:var(--warn)} .post-audit-report .find.info{border-left-color:var(--info)} .post-audit-report .find-row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px} .post-audit-report .sev{font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:3px 8px;border-radius:5px;font-weight:500} .post-audit-report .sev.crit{color:var(--crit);background:var(--crit-bg)} .post-audit-report .sev.warn{color:var(--warn);background:var(--warn-bg)} .post-audit-report .sev.info{color:var(--info);background:var(--info-bg)} .post-audit-report .code{font-size:12.5px;font-weight:500;color:var(--ink)} .post-audit-report .src{margin-left:auto;font-size:10px;color:var(--faint);border:1px solid var(--line2);border-radius:20px;padding:2px 9px} .post-audit-report .find-msg{font-size:14.5px;line-height:1.5;color:var(--ink)} .post-audit-report .ev{margin-top:10px;font-size:12px;color:var(--muted);background:#f1f4f8;border-radius:7px; padding:8px 11px;border-left:2px solid var(--line2);white-space:pre-wrap;word-break:break-word} .post-audit-report .ev::before{content:"evidence";display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:4px} .post-audit-report ol.fixes{list-style:none;counter-reset:f;margin:0;padding:0} .post-audit-report ol.fixes li{counter-increment:f;position:relative;padding:11px 0 11px 40px; border-bottom:1px solid var(--line);font-size:15px;line-height:1.5;color:var(--ink)} .post-audit-report ol.fixes li:last-child{border-bottom:none} .post-audit-report ol.fixes li::before{content:counter(f,decimal-leading-zero);position:absolute;left:0;top:12px; font-family:var(--mono);font-size:12px;color:var(--ink);font-weight:500} .post-audit-report .cards2{display:grid;grid-template-columns:1fr 1fr;gap:12px} .post-audit-report .infcard{background:var(--surface);border:1px solid var(--line);border-radius:11px;padding:14px 16px} .post-audit-report .infcard-t{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:7px} .post-audit-report .infcard-v{font-size:15px;line-height:1.5;color:var(--ink)} .post-audit-report .gap{background:var(--surface);border:1px solid var(--line);border-radius:11px;padding:14px 16px;margin-bottom:11px} .post-audit-report .fld{display:inline-block;font-size:11px;background:var(--warn-bg);color:var(--warn);padding:3px 9px;border-radius:5px;margin-bottom:9px} .post-audit-report .gap-reason{font-size:14.5px;color:var(--muted);margin-bottom:11px} .post-audit-report .chips{display:flex;flex-wrap:wrap;gap:7px} .post-audit-report .chip{font-size:13px;border:1px solid var(--line2);border-radius:20px;padding:5px 13px;background:var(--surface);color:var(--ink)} @media(max-width:620px){ .post-audit-report .cards2{grid-template-columns:1fr} .post-audit-report .dim{grid-template-columns:1fr} .post-audit-report .score b{font-size:38px} .post-audit-report .state{font-size:23px} } @keyframes pa-grow{from{width:0 !important}} @media(prefers-reduced-motion:reduce){.post-audit-report .m-fill,.post-audit-report .dim-fill{animation:none}} """ def render_report_html(payload: dict[str, Any]) -> str: """Build full HTML fragment for gr.HTML from merged or viewer payload.""" if payload.get("goalAlignment"): body = _render_audit(payload) elif payload.get("status") or payload.get("inferred"): body = _render_brief(payload) else: body = ( '
    pre-publish readiness check
    ' '

    Unexpected output

    ' f'

    {_esc(json.dumps(payload)[:200])}

    ' ) return f'
    {body}
    '