mervenoyan commited on
Commit
4c3cbe1
·
1 Parent(s): 4448d5a

Split frontend into custom HTML via gradio.Server

Browse files

Convert app.py to use gradio.Server with @app .api(generate_bulletin)
and serve a custom index.html for the input UI. Report generation is
unchanged. The new frontend is a retro newsprint card matching the
bulletin's palette (cream/navy/orange, Alfa Slab One + IBM Plex Mono)
with a styled range slider and stamped text input. Status streams
update in place and the bulletin HTML is injected after generation.

Files changed (2) hide show
  1. app.py +23 -36
  2. index.html +460 -0
app.py CHANGED
@@ -1,28 +1,36 @@
1
- """Gradio Blocks app: dataset extract analyze bulletin HTML."""
 
 
 
 
 
2
 
3
  import os
 
4
 
5
- import gradio as gr
 
6
 
7
- from analyze import (
8
- MODEL,
9
- build_report,
10
- compute_stats,
11
- digest_all,
12
- get_client,
13
- )
14
  from dataset import fetch_sessions, list_sessions
15
  from extract import events_to_transcript, truncate_transcript
16
  from render import bulletin_html, empty_bulletin_html
17
 
18
  DEFAULT_REPO = "julien-c/pi-sessions"
19
 
 
 
 
 
20
 
21
  def _owner_from(repo_id: str) -> str:
22
  return repo_id.split("/")[0] if "/" in repo_id else repo_id
23
 
24
 
25
- def run(repo_id: str, max_sessions: int):
 
 
 
26
  yield "Connecting…", empty_bulletin_html("Connecting…")
27
 
28
  try:
@@ -94,7 +102,7 @@ def run(repo_id: str, max_sessions: int):
94
  return
95
 
96
  yield (
97
- f"Bulletin issued for **@{report['user']}***{report['archetype'][0]} {report['archetype'][1]}*.",
98
  bulletin_html(report),
99
  )
100
  except Exception as e:
@@ -104,33 +112,12 @@ def run(repo_id: str, max_sessions: int):
104
  )
105
 
106
 
107
- def build():
108
- with gr.Blocks(title="Trace Personality Bulletin") as demo:
109
- gr.Markdown("# 🧾 Trace Personality Bulletin")
110
- gr.Markdown(
111
- "Drop a Hugging Face agent-traces dataset repo. The Roastery fetches "
112
- "your sessions, sends them through `Qwen/Qwen3.6-35B-A3B`, and issues "
113
- "a printed bulletin of what the model thinks of you. "
114
- "Use **Save as PNG** on the card to download a 1080×1440 share-ready image."
115
- )
116
-
117
- with gr.Row():
118
- repo = gr.Textbox(label="Dataset repo", value=DEFAULT_REPO, scale=4)
119
- n = gr.Slider(1, 50, value=30, step=1, label="Max sessions", scale=2)
120
- btn = gr.Button("🔮 Issue my bulletin", variant="primary")
121
- status = gr.Markdown("")
122
- html_out = gr.HTML(empty_bulletin_html("Awaiting bulletin…"))
123
-
124
- btn.click(
125
- run,
126
- inputs=[repo, n],
127
- outputs=[status, html_out],
128
- concurrency_limit=1,
129
- )
130
- return demo
131
 
132
 
133
  if __name__ == "__main__":
134
  if not os.environ.get("HF_TOKEN"):
135
  print("warning: HF_TOKEN not set; the app will error on the first click.")
136
- build().launch(theme=gr.themes.Soft())
 
1
+ """Gradio Server app: custom HTML frontend + Gradio-managed API endpoint.
2
+
3
+ The input UI lives in `index.html` and talks to the `generate_bulletin`
4
+ endpoint below via `@gradio/client`. Report generation logic is unchanged
5
+ from the original Blocks app.
6
+ """
7
 
8
  import os
9
+ from pathlib import Path
10
 
11
+ from fastapi.responses import HTMLResponse
12
+ from gradio import Server
13
 
14
+ from analyze import build_report, compute_stats, digest_all, get_client
 
 
 
 
 
 
15
  from dataset import fetch_sessions, list_sessions
16
  from extract import events_to_transcript, truncate_transcript
17
  from render import bulletin_html, empty_bulletin_html
18
 
19
  DEFAULT_REPO = "julien-c/pi-sessions"
20
 
21
+ app = Server()
22
+
23
+ _INDEX = Path(__file__).parent / "index.html"
24
+
25
 
26
  def _owner_from(repo_id: str) -> str:
27
  return repo_id.split("/")[0] if "/" in repo_id else repo_id
28
 
29
 
30
+ @app.api(name="generate_bulletin", concurrency_limit=1)
31
+ def generate_bulletin(repo_id: str, max_sessions: int):
32
+ """Streams (status, html) updates; final tick carries the bulletin HTML."""
33
+
34
  yield "Connecting…", empty_bulletin_html("Connecting…")
35
 
36
  try:
 
102
  return
103
 
104
  yield (
105
+ f"Bulletin issued for @{report['user']} — {report['archetype'][0]} {report['archetype'][1]}.",
106
  bulletin_html(report),
107
  )
108
  except Exception as e:
 
112
  )
113
 
114
 
115
+ @app.get("/", response_class=HTMLResponse)
116
+ async def homepage():
117
+ return _INDEX.read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
  if __name__ == "__main__":
121
  if not os.environ.get("HF_TOKEN"):
122
  print("warning: HF_TOKEN not set; the app will error on the first click.")
123
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Trace Personality Bulletin</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=IBM+Plex+Mono:wght@400;500;600&family=Alfa+Slab+One&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <style>
14
+ :root {
15
+ --ink: #353e60;
16
+ --ink-soft: rgba(53, 62, 96, 0.7);
17
+ --ink-faint: rgba(53, 62, 96, 0.18);
18
+ --surface: #fff8de;
19
+ --surface-deep: #f6eecf;
20
+ --accent: #00704a;
21
+ --accent2: #ff9d00;
22
+ --red: #db3328;
23
+ --yellow: #ffd21e;
24
+ }
25
+ * { box-sizing: border-box; }
26
+ html, body {
27
+ margin: 0;
28
+ padding: 0;
29
+ background: var(--surface);
30
+ color: var(--ink);
31
+ font-family: 'Source Sans 3', -apple-system, system-ui, sans-serif;
32
+ min-height: 100vh;
33
+ }
34
+ /* Faint dotted texture across the whole page */
35
+ body {
36
+ background-image:
37
+ radial-gradient(var(--ink-faint) 1px, transparent 1.4px);
38
+ background-size: 22px 22px;
39
+ }
40
+
41
+ .page {
42
+ max-width: 880px;
43
+ margin: 0 auto;
44
+ padding: 48px 24px 80px;
45
+ }
46
+
47
+ /* ---- Input panel ("retro newsprint card") ---- */
48
+ .panel {
49
+ position: relative;
50
+ background: var(--surface);
51
+ color: var(--ink);
52
+ padding: 44px 56px 40px;
53
+ box-shadow:
54
+ 0 24px 60px rgba(0, 0, 0, 0.18),
55
+ 0 6px 16px rgba(0, 0, 0, 0.10);
56
+ border-radius: 6px;
57
+ isolation: isolate;
58
+ }
59
+ .panel::before,
60
+ .panel::after {
61
+ content: "";
62
+ position: absolute;
63
+ pointer-events: none;
64
+ }
65
+ .panel::before {
66
+ inset: 14px;
67
+ border: 2px solid var(--ink);
68
+ border-radius: 3px;
69
+ }
70
+ .panel::after {
71
+ inset: 22px;
72
+ border: 1px solid var(--ink);
73
+ border-radius: 2px;
74
+ }
75
+ .corner {
76
+ position: absolute;
77
+ width: 22px;
78
+ height: 22px;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ background: var(--surface);
83
+ color: var(--ink);
84
+ font-size: 14px;
85
+ font-weight: 700;
86
+ z-index: 2;
87
+ }
88
+ .corner.tl { top: 6px; left: 6px; }
89
+ .corner.tr { top: 6px; right: 6px; }
90
+ .corner.bl { bottom: 6px; left: 6px; }
91
+ .corner.br { bottom: 6px; right: 6px; }
92
+
93
+ .masthead {
94
+ text-align: center;
95
+ position: relative;
96
+ }
97
+ .masthead .tiny {
98
+ font-family: 'IBM Plex Mono', monospace;
99
+ font-size: 11px;
100
+ letter-spacing: 0.34em;
101
+ text-transform: uppercase;
102
+ color: var(--ink-soft);
103
+ margin-bottom: 10px;
104
+ }
105
+ .masthead h1 {
106
+ font-family: 'Alfa Slab One', serif;
107
+ font-weight: 400;
108
+ font-size: clamp(28px, 5vw, 40px);
109
+ letter-spacing: 0.04em;
110
+ line-height: 1;
111
+ text-transform: uppercase;
112
+ color: var(--ink);
113
+ margin: 0;
114
+ }
115
+ .masthead h1 .accent {
116
+ color: var(--accent);
117
+ text-shadow: 3px 3px 0 var(--ink), -2px -2px 0 var(--accent2);
118
+ }
119
+ .rule {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 12px;
123
+ margin: 14px auto 0;
124
+ max-width: 540px;
125
+ color: var(--ink-soft);
126
+ font-family: 'IBM Plex Mono', monospace;
127
+ font-size: 12px;
128
+ letter-spacing: 0.32em;
129
+ }
130
+ .rule .line {
131
+ flex: 1;
132
+ height: 1px;
133
+ background: currentColor;
134
+ opacity: 0.5;
135
+ }
136
+ .lede {
137
+ margin: 18px auto 0;
138
+ text-align: center;
139
+ max-width: 580px;
140
+ font-style: italic;
141
+ font-size: 16px;
142
+ line-height: 1.45;
143
+ color: var(--ink-soft);
144
+ }
145
+
146
+ /* ---- Form ---- */
147
+ .form {
148
+ margin-top: 34px;
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 26px;
152
+ }
153
+
154
+ .field-label {
155
+ display: flex;
156
+ align-items: center;
157
+ justify-content: space-between;
158
+ gap: 12px;
159
+ font-family: 'IBM Plex Mono', monospace;
160
+ font-size: 11px;
161
+ letter-spacing: 0.28em;
162
+ text-transform: uppercase;
163
+ color: var(--ink);
164
+ margin-bottom: 10px;
165
+ }
166
+ .field-label .value {
167
+ font-family: 'Alfa Slab One', serif;
168
+ font-size: 22px;
169
+ line-height: 1;
170
+ letter-spacing: 0.02em;
171
+ color: var(--accent);
172
+ text-shadow: 2px 2px 0 var(--ink);
173
+ }
174
+
175
+ /* Text input — looks like a typewritten form line */
176
+ .repo-input {
177
+ width: 100%;
178
+ background: var(--surface-deep);
179
+ color: var(--ink);
180
+ border: 1.5px solid var(--ink);
181
+ border-radius: 4px;
182
+ padding: 14px 16px;
183
+ font-family: 'IBM Plex Mono', monospace;
184
+ font-size: 16px;
185
+ letter-spacing: 0.02em;
186
+ box-shadow: 4px 4px 0 var(--ink);
187
+ transition: transform 80ms ease, box-shadow 80ms ease;
188
+ }
189
+ .repo-input::placeholder {
190
+ color: var(--ink-soft);
191
+ font-style: italic;
192
+ }
193
+ .repo-input:focus {
194
+ outline: none;
195
+ transform: translate(-1px, -1px);
196
+ box-shadow: 5px 5px 0 var(--accent2);
197
+ }
198
+
199
+ /* Slider — custom retro track + dial */
200
+ .slider-wrap { padding: 4px 4px 6px; }
201
+ .slider {
202
+ -webkit-appearance: none;
203
+ appearance: none;
204
+ width: 100%;
205
+ height: 22px;
206
+ background: transparent;
207
+ margin: 0;
208
+ cursor: pointer;
209
+ }
210
+ .slider:focus { outline: none; }
211
+ .slider::-webkit-slider-runnable-track {
212
+ height: 6px;
213
+ background:
214
+ linear-gradient(to right, var(--accent2) 0%, var(--accent2) var(--pct, 60%), var(--ink-faint) var(--pct, 60%), var(--ink-faint) 100%);
215
+ border: 1.5px solid var(--ink);
216
+ border-radius: 999px;
217
+ }
218
+ .slider::-moz-range-track {
219
+ height: 6px;
220
+ background: var(--ink-faint);
221
+ border: 1.5px solid var(--ink);
222
+ border-radius: 999px;
223
+ }
224
+ .slider::-moz-range-progress {
225
+ height: 6px;
226
+ background: var(--accent2);
227
+ border-radius: 999px 0 0 999px;
228
+ }
229
+ .slider::-webkit-slider-thumb {
230
+ -webkit-appearance: none;
231
+ appearance: none;
232
+ width: 22px;
233
+ height: 22px;
234
+ border-radius: 50%;
235
+ background: var(--surface);
236
+ border: 2px solid var(--ink);
237
+ box-shadow: 2px 2px 0 var(--ink);
238
+ margin-top: -10px;
239
+ cursor: grab;
240
+ }
241
+ .slider::-webkit-slider-thumb:active { cursor: grabbing; transform: translate(1px, 1px); box-shadow: 1px 1px 0 var(--ink); }
242
+ .slider::-moz-range-thumb {
243
+ width: 22px;
244
+ height: 22px;
245
+ border-radius: 50%;
246
+ background: var(--surface);
247
+ border: 2px solid var(--ink);
248
+ box-shadow: 2px 2px 0 var(--ink);
249
+ cursor: grab;
250
+ }
251
+ .slider-ticks {
252
+ display: flex;
253
+ justify-content: space-between;
254
+ margin-top: 6px;
255
+ font-family: 'IBM Plex Mono', monospace;
256
+ font-size: 10px;
257
+ letter-spacing: 0.18em;
258
+ color: var(--ink-soft);
259
+ }
260
+
261
+ /* Submit button */
262
+ .submit-row {
263
+ display: flex;
264
+ justify-content: center;
265
+ margin-top: 6px;
266
+ }
267
+ .submit {
268
+ appearance: none;
269
+ border: 2px solid var(--ink);
270
+ background: var(--ink);
271
+ color: var(--surface);
272
+ font-family: 'Alfa Slab One', serif;
273
+ font-weight: 400;
274
+ font-size: 18px;
275
+ letter-spacing: 0.18em;
276
+ text-transform: uppercase;
277
+ padding: 14px 30px;
278
+ border-radius: 4px;
279
+ cursor: pointer;
280
+ box-shadow: 6px 6px 0 var(--accent2);
281
+ transition: transform 90ms ease, box-shadow 90ms ease, background 120ms;
282
+ }
283
+ .submit:hover { background: #2a3251; }
284
+ .submit:active {
285
+ transform: translate(3px, 3px);
286
+ box-shadow: 3px 3px 0 var(--accent2);
287
+ }
288
+ .submit:disabled {
289
+ cursor: not-allowed;
290
+ opacity: 0.65;
291
+ box-shadow: 6px 6px 0 var(--ink-faint);
292
+ }
293
+ .submit .glyph { color: var(--accent2); margin: 0 10px; }
294
+
295
+ .status {
296
+ margin-top: 4px;
297
+ text-align: center;
298
+ min-height: 24px;
299
+ font-family: 'IBM Plex Mono', monospace;
300
+ font-size: 13px;
301
+ letter-spacing: 0.06em;
302
+ color: var(--ink-soft);
303
+ }
304
+ .status.error { color: var(--red); }
305
+
306
+ /* Output area */
307
+ .output-wrap {
308
+ margin-top: 40px;
309
+ }
310
+
311
+ @media (max-width: 600px) {
312
+ .page { padding: 24px 12px 60px; }
313
+ .panel { padding: 36px 22px 30px; }
314
+ .masthead h1 { font-size: 28px; }
315
+ }
316
+ </style>
317
+ </head>
318
+ <body>
319
+ <main class="page">
320
+ <section class="panel" aria-labelledby="title">
321
+ <span class="corner tl">✺</span>
322
+ <span class="corner tr">✺</span>
323
+ <span class="corner bl">✺</span>
324
+ <span class="corner br">✺</span>
325
+
326
+ <div class="masthead">
327
+ <div class="tiny">★ Presented by Hugging Face ★ Anno MMXXVI ★</div>
328
+ <h1 id="title">Trace <span class="accent">Personality</span> Bulletin</h1>
329
+ <div class="rule"><span class="line"></span>✺ ✺ ✺<span class="line"></span></div>
330
+ <p class="lede">
331
+ Drop a Hugging Face agent-traces dataset. The Roastery reads your
332
+ sessions and issues a printed bulletin of what the model thinks of you.
333
+ </p>
334
+ </div>
335
+
336
+ <form class="form" id="bulletin-form" autocomplete="off">
337
+ <div>
338
+ <div class="field-label">
339
+ <span>Dataset repo</span>
340
+ <span style="font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:0.2em;opacity:.6;">owner/name</span>
341
+ </div>
342
+ <input
343
+ id="repo"
344
+ class="repo-input"
345
+ type="text"
346
+ value="julien-c/pi-sessions"
347
+ spellcheck="false"
348
+ autocapitalize="off"
349
+ placeholder="owner/name"
350
+ />
351
+ </div>
352
+
353
+ <div>
354
+ <div class="field-label">
355
+ <span>Max sessions</span>
356
+ <span class="value" id="n-value">30</span>
357
+ </div>
358
+ <div class="slider-wrap">
359
+ <input id="n" class="slider" type="range" min="1" max="50" step="1" value="30" />
360
+ <div class="slider-ticks">
361
+ <span>1</span><span>10</span><span>20</span><span>30</span><span>40</span><span>50</span>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <div class="submit-row">
367
+ <button class="submit" id="submit" type="submit">
368
+ <span class="glyph">✺</span>Issue my bulletin<span class="glyph">✺</span>
369
+ </button>
370
+ </div>
371
+
372
+ <div class="status" id="status" role="status" aria-live="polite">Awaiting bulletin…</div>
373
+ </form>
374
+ </section>
375
+
376
+ <div class="output-wrap" id="output"></div>
377
+ </main>
378
+
379
+ <script type="module">
380
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
381
+
382
+ const $ = (id) => document.getElementById(id);
383
+ const form = $("bulletin-form");
384
+ const repoInput = $("repo");
385
+ const nInput = $("n");
386
+ const nValue = $("n-value");
387
+ const submitBtn = $("submit");
388
+ const statusEl = $("status");
389
+ const outputEl = $("output");
390
+
391
+ function paintSlider() {
392
+ const min = +nInput.min, max = +nInput.max;
393
+ const pct = ((+nInput.value - min) * 100) / (max - min);
394
+ nInput.style.setProperty("--pct", pct + "%");
395
+ nValue.textContent = nInput.value;
396
+ }
397
+ nInput.addEventListener("input", paintSlider);
398
+ paintSlider();
399
+
400
+ function setStatus(text, isError = false) {
401
+ statusEl.textContent = text || "";
402
+ statusEl.classList.toggle("error", !!isError);
403
+ }
404
+
405
+ let clientPromise = null;
406
+ function getClient() {
407
+ if (!clientPromise) clientPromise = Client.connect(window.location.origin);
408
+ return clientPromise;
409
+ }
410
+
411
+ form.addEventListener("submit", async (e) => {
412
+ e.preventDefault();
413
+ const repo_id = repoInput.value.trim();
414
+ const max_sessions = parseInt(nInput.value, 10);
415
+ if (!repo_id) {
416
+ setStatus("Enter a dataset repo first.", true);
417
+ return;
418
+ }
419
+ submitBtn.disabled = true;
420
+ setStatus("Connecting…");
421
+ outputEl.innerHTML = "";
422
+
423
+ try {
424
+ const client = await getClient();
425
+ const job = client.submit("/generate_bulletin", {
426
+ repo_id,
427
+ max_sessions,
428
+ });
429
+
430
+ let lastHtml = "";
431
+ for await (const msg of job) {
432
+ if (msg.type !== "data") continue;
433
+ const [status, html] = msg.data || [];
434
+ if (typeof status === "string") {
435
+ setStatus(status, status.startsWith("❌"));
436
+ }
437
+ if (typeof html === "string") {
438
+ lastHtml = html;
439
+ }
440
+ }
441
+ if (lastHtml) {
442
+ outputEl.innerHTML = lastHtml;
443
+ // Execute inline scripts injected by the response (bulletin uses iframe srcdoc, so usually a no-op).
444
+ outputEl.querySelectorAll("script").forEach((old) => {
445
+ const s = document.createElement("script");
446
+ for (const a of old.attributes) s.setAttribute(a.name, a.value);
447
+ s.text = old.text;
448
+ old.replaceWith(s);
449
+ });
450
+ }
451
+ } catch (err) {
452
+ console.error(err);
453
+ setStatus("❌ " + (err && err.message ? err.message : String(err)), true);
454
+ } finally {
455
+ submitBtn.disabled = false;
456
+ }
457
+ });
458
+ </script>
459
+ </body>
460
+ </html>