"""Post Audit — Gradio Space entrypoint.""" from __future__ import annotations import html import gradio as gr from audit_client import backend_label, call_modal_audit from examples import EXAMPLE_CHAT_DUMP, EXAMPLE_WEBINAR from merge import merge_audit, viewer_payload from render import render_report_html from rules import run_rules # Shared visual language with the report (see render.py :root): cool slate, # Space Grotesk for the verdict word, IBM Plex Mono for meta. _STATUS_CSS = """ """ _LOADING_HTML = _STATUS_CSS + """
Analyzing your post… Running rule linters and Gemma 4 E4B on GPU. A cold start can take up to ~2 minutes while the model loads — this is normal, no need to click again.
""" def _empty_html() -> str: return _STATUS_CSS + ( '
' "Nothing to auditEnter a post draft above, then run the audit." "
" ) def _error_html(exc: Exception) -> str: return _STATUS_CSS + ( '
' "Audit failed" f"{html.escape(str(exc))}" "
" ) def _done_status(rule_count: int) -> str: return _STATUS_CSS + ( '
' "Audit complete" f'Inference: {backend_label()} · rule warnings: {rule_count}' "
" ) def run_audit(platform: str, goal: str, audience: str, post: str): """Generator: emit an immediate loading state, then the rendered report. Yields (status_report_html, raw_json, button_update) so the UI updates the instant the user clicks — instead of looking frozen during the long GPU call. """ btn_busy = gr.update(value="Analyzing…", interactive=False) btn_idle = gr.update(value="Run audit", interactive=True) if not post.strip(): yield _empty_html(), {}, btn_idle return # Instant feedback + lock the button against repeat clicks. yield _LOADING_HTML, {}, btn_busy rule_warnings = run_rules(platform, goal, audience, post) try: llm_payload = call_modal_audit(platform, goal, audience, post) except Exception as exc: # noqa: BLE001 — surface any backend failure to the user yield _error_html(exc), {}, btn_idle return merged = merge_audit(llm_payload, rule_warnings) report_html = render_report_html(viewer_payload(merged)) yield _done_status(len(rule_warnings)) + report_html, merged, btn_idle def load_example(example: dict): return example["platform"], example["goal"], example["audience"], example["post"] # Dark mode in Gradio is just a `dark` class on (the served bundle does # `document.body.classList.add("dark")`); toggling it flips every theme CSS var to # its `_dark` variant. The report/status cards pin their own light colors # (color-scheme:light) on purpose, so they stay legible either way. # # Standalone-only by design: this matches Gradio's *standalone* mode (a hosted HF # Space — our deploy target). If this app is ever embedded as a on # another site, Gradio puts `dark` on the host's parentElement and renders into a # shadow root, so both `document.body` and `getElementById` below would miss and # the toggle would silently no-op. # # We persist the choice to localStorage so a manual toggle survives a reload; # `_THEME_INIT_JS` re-applies it on load (otherwise refresh resets to the system # preference / ?__theme). The imperative `textContent` write is safe only because # `theme_btn` is never wired as an event *output* — Svelte never re-renders the # button, so it won't clobber our label. Don't add it as an output without moving # the labeling into the handler's return value. _THEME_TOGGLE_JS = """ () => { const dark = document.body.classList.toggle('dark'); try { localStorage.setItem('pa-theme', dark ? 'dark' : 'light'); } catch (e) {} const b = document.getElementById('pa-theme-toggle'); if (b) b.textContent = dark ? '☀ Light mode' : '☾ Dark mode'; } """ # Re-apply the saved choice and label the button for the *current* state on load. # Without the saved choice the app falls back to whatever Gradio picked (system # preference or ?__theme=dark) before the user ever clicks. _THEME_INIT_JS = """ () => { let dark = document.body.classList.contains('dark'); try { const saved = localStorage.getItem('pa-theme'); if (saved === 'dark' && !dark) { document.body.classList.add('dark'); dark = true; } else if (saved === 'light' && dark) { document.body.classList.remove('dark'); dark = false; } } catch (e) {} const b = document.getElementById('pa-theme-toggle'); if (b) b.textContent = dark ? '☀ Light mode' : '☾ Dark mode'; } """ # Page identity matches the report (render.py): cool slate instrument panel, # Space Grotesk display, IBM Plex Sans body, IBM Plex Mono labels/codes. _THEME = gr.themes.Base( primary_hue=gr.themes.colors.slate, neutral_hue=gr.themes.colors.slate, font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"], ).set( body_background_fill="#eef1f5", body_text_color="#161b22", block_background_fill="#ffffff", block_border_color="#e3e7ee", block_label_text_color="#586172", block_title_text_color="#161b22", input_background_fill="#ffffff", input_border_color="#cfd6e0", input_border_color_focus="#161b22", button_large_radius="10px", button_primary_background_fill="#161b22", button_primary_background_fill_hover="#2a313c", button_primary_text_color="#ffffff", button_primary_border_color="#161b22", button_secondary_background_fill="#ffffff", button_secondary_background_fill_hover="#f1f4f8", button_secondary_border_color="#cfd6e0", button_secondary_text_color="#161b22", ) _PAGE_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'); .gradio-container{background:#eef1f5} .pa-head{padding:6px 2px 4px} .pa-head .eyebrow{font-family:"IBM Plex Mono",monospace;font-size:11px;letter-spacing:.24em; text-transform:uppercase;color:#8a93a3} .pa-head h1{font-family:"Space Grotesk","IBM Plex Sans",sans-serif;font-weight:700;font-size:30px; letter-spacing:-.015em;color:#161b22;margin:7px 0 0} .pa-head .sub{font-family:"IBM Plex Sans",sans-serif;font-size:15px;color:#586172;line-height:1.5;margin:9px 0 0;max-width:62ch} .pa-head .note{font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:#8a93a3;margin:9px 0 0} .pa-head .rule{height:1.5px;background:#161b22;margin:16px 0 2px} /* component labels as quiet mono captions, echoing the report's section heads */ .gradio-container .block .label-wrap > span, .gradio-container label[data-testid] > span:first-child, .gradio-container span[data-testid="block-info"]{font-family:"IBM Plex Mono",monospace; font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#586172} .gradio-container .primary{font-weight:500} """ _HEADER_HTML = """
pre-publish readiness check

Post Audit

Check a draft against your stated goal and audience before you publish — deterministic rule linters plus Gemma 4 E4B on Modal.

First run after idle can take up to ~2 min while the GPU loads the model; later runs are faster.

""" # theme/css are set in BOTH places on purpose (Gradio 6). Whichever code path # serves the app, the theme survives: # - run as __main__ (`python app.py`, HF Spaces): the explicit launch() args win. # - imported & launched by another runner with no args (the `gradio` CLI reload # runner, and possibly HF's launcher): the constructor args are re-applied at # launch() time via Gradio's `_deprecated_theme`/`_deprecated_css` shim. # The constructor form emits a (benign) deprecation warning; that is the price of # covering the import-and-launch path. See tests/test_app_theme.py. with gr.Blocks(title="Post Audit", theme=_THEME, css=_PAGE_CSS) as demo: gr.HTML(_HEADER_HTML) with gr.Row(): platform = gr.Dropdown( choices=["Telegram", "LinkedIn", "X/Twitter", "Other"], value="Telegram", label="Platform", ) goal = gr.Textbox(label="Goal", placeholder="Register attendees for Thursday 7pm webinar") audience = gr.Textbox( label="Audience", placeholder="Product managers who own product metrics", ) post = gr.Textbox( label="Post draft", lines=10, placeholder="Paste your post here…", ) with gr.Row(): audit_btn = gr.Button("Run audit", variant="primary") ex_webinar = gr.Button("Load example: weak webinar CTA") ex_chat = gr.Button("Load example: chat dump") theme_btn = gr.Button("☾ Dark mode", elem_id="pa-theme-toggle", scale=0) status_report = gr.HTML() with gr.Accordion("Raw JSON", open=False): raw_json = gr.JSON(label="Pipeline output") audit_btn.click( fn=run_audit, inputs=[platform, goal, audience, post], outputs=[status_report, raw_json, audit_btn], show_progress="hidden", # our own loading card is the progress indicator ) ex_webinar.click( fn=lambda: load_example(EXAMPLE_WEBINAR), outputs=[platform, goal, audience, post], ) ex_chat.click( fn=lambda: load_example(EXAMPLE_CHAT_DUMP), outputs=[platform, goal, audience, post], ) theme_btn.click(fn=None, inputs=None, outputs=None, js=_THEME_TOGGLE_JS) demo.load(fn=None, inputs=None, outputs=None, js=_THEME_INIT_JS) if __name__ == "__main__": demo.launch(theme=_THEME, css=_PAGE_CSS)