merve HF Staff commited on
Commit
1252cb9
·
verified ·
1 Parent(s): ac89393

Initial bulletin app: Qwen3.6-35B-A3B → vintage 1080×1440 retro report

Browse files
Files changed (10) hide show
  1. .gitattributes +1 -0
  2. README.md +24 -1
  3. analyze.py +245 -0
  4. app.py +136 -0
  5. assets/grain_small.png +3 -0
  6. assets/seal_small.png +0 -0
  7. dataset.py +41 -0
  8. extract.py +59 -0
  9. render.py +603 -0
  10. requirements.txt +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/grain_small.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -8,6 +8,29 @@ sdk_version: 6.14.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
+ short_description: Roast yourself from your agent traces
12
  ---
13
 
14
+ # Trace Personality Bulletin
15
+
16
+ A Gradio app that, given a Hugging Face *agent traces* dataset repo, fetches the
17
+ sessions, sends per-session digests through `Qwen/Qwen3.6-35B-A3B` on the
18
+ Hugging Face Inference API, and renders a printed **personality bulletin** of
19
+ what the model thinks of the user. A "Save as PNG" button on the card
20
+ screenshots it client-side for sharing.
21
+
22
+ ## Configuration
23
+
24
+ The app calls the HF Inference API and needs `HF_TOKEN` to be available at
25
+ runtime. Set it as a Space secret:
26
+
27
+ - Space → Settings → **Variables and secrets** → add `HF_TOKEN`
28
+ (any token with Inference API access).
29
+
30
+ ## Local development
31
+
32
+ ```bash
33
+ uv venv .venv --python 3.12
34
+ uv pip install --python .venv/bin/python -r requirements.txt
35
+ HF_TOKEN=hf_... .venv/bin/python app.py
36
+ ```
analyze.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InferenceClient calls: map (per-session digests) + reduce (bulletin)."""
2
+
3
+ import datetime as dt
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from huggingface_hub import InferenceClient
10
+
11
+ MODEL = "Qwen/Qwen3.6-35B-A3B"
12
+
13
+ _NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
14
+
15
+
16
+ def get_client(token: str | None = None) -> InferenceClient:
17
+ """Build the InferenceClient. Centralised so OAuth swap is one place."""
18
+ if token is None:
19
+ token = os.environ.get("HF_TOKEN")
20
+ if not token:
21
+ raise RuntimeError(
22
+ "HF_TOKEN is not set. Export it in your shell or pass token= explicitly."
23
+ )
24
+ return InferenceClient(model=MODEL, token=token)
25
+
26
+
27
+ # ---------- map: per-session digest ----------
28
+
29
+ _DIGEST_SYSTEM = """You are analysing a single coding-agent session transcript. The TRANSCRIPT shows messages between a HUMAN USER and an AGENT (the AI). Return signals about the HUMAN USER only — never about the agent.
30
+
31
+ Return STRICT JSON:
32
+ {
33
+ "session_id": <echo>,
34
+ "intent": "<one sentence: what the user was trying to do>",
35
+ "top_quotes": [<1-3 short verbatim quotes from USER messages only>],
36
+ "tells": [<3-5 short strings: signals about the user — frustration, confidence, knowledge gaps, communication style, premature optimization, doc-avoidance, etc.>],
37
+ "mood": "<one short phrase: the session's emotional arc>"
38
+ }
39
+
40
+ Hard rules:
41
+ - Only include things the user actually said or did. Do not attribute agent behaviour to the user.
42
+ - top_quotes must literally appear in user messages.
43
+ - Be concise and specific. No invented quotes."""
44
+
45
+
46
+ def digest_session(client: InferenceClient, transcript: str, session_id: str) -> dict:
47
+ user_prompt = f"session_id: {session_id}\n\nTranscript:\n{transcript}"
48
+ try:
49
+ resp = client.chat_completion(
50
+ messages=[
51
+ {"role": "system", "content": _DIGEST_SYSTEM},
52
+ {"role": "user", "content": user_prompt},
53
+ ],
54
+ response_format={"type": "json_object"},
55
+ max_tokens=800,
56
+ temperature=0.4,
57
+ extra_body=_NO_THINK,
58
+ )
59
+ raw = resp.choices[0].message.content or "{}"
60
+ data = json.loads(raw)
61
+ data.setdefault("session_id", session_id)
62
+ return data
63
+ except Exception as e:
64
+ return {"session_id": session_id, "error": str(e)}
65
+
66
+
67
+ def digest_all(
68
+ client: InferenceClient,
69
+ transcripts: list[tuple[str, str]],
70
+ max_workers: int = 8,
71
+ ) -> list[dict]:
72
+ """Run digest_session over all transcripts in parallel. Drops error entries."""
73
+ def _one(item):
74
+ sid, text = item
75
+ return digest_session(client, text, sid)
76
+
77
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
78
+ results = list(ex.map(_one, transcripts))
79
+ return [r for r in results if "error" not in r]
80
+
81
+
82
+ # ---------- stats from raw events ----------
83
+
84
+
85
+ def _parse_ts(ts: str) -> dt.datetime | None:
86
+ try:
87
+ return dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def compute_stats(sessions: list[tuple[str, list[dict]]]) -> dict:
93
+ """Count user turns, distinct tool names, and the first→last timestamp span."""
94
+ turns = 0
95
+ tools: set[str] = set()
96
+ timestamps: list[dt.datetime] = []
97
+ for _path, events in sessions:
98
+ for ev in events:
99
+ if ev.get("type") == "user":
100
+ turns += 1
101
+ ts = ev.get("timestamp")
102
+ if isinstance(ts, str):
103
+ parsed = _parse_ts(ts)
104
+ if parsed:
105
+ timestamps.append(parsed)
106
+ msg = ev.get("message") or {}
107
+ content = msg.get("content")
108
+ if isinstance(content, list):
109
+ for block in content:
110
+ if isinstance(block, dict) and block.get("type") == "tool_use":
111
+ name = block.get("name")
112
+ if isinstance(name, str) and name:
113
+ tools.add(name)
114
+
115
+ span = ""
116
+ if timestamps:
117
+ timestamps.sort()
118
+ first, last = timestamps[0], timestamps[-1]
119
+ if first.year == last.year:
120
+ span = f"{first.strftime('%b %d')} → {last.strftime('%b %d, %Y')}"
121
+ else:
122
+ span = f"{first.strftime('%b %d, %Y')} → {last.strftime('%b %d, %Y')}"
123
+ return {"turns": turns, "tools": len(tools), "span": span}
124
+
125
+
126
+ def serial_for(user: str) -> str:
127
+ """Stable per-user 4-digit serial."""
128
+ h = int(hashlib.sha256(user.encode("utf-8")).hexdigest(), 16)
129
+ return f"PR-{h % 10000:04d}"
130
+
131
+
132
+ # ---------- reduce: bulletin generation ----------
133
+
134
+ # Adapted from the design handoff's CONTENT_PROMPT.md.
135
+ _BULLETIN_SYSTEM = """You are the Hugging Face Roastery. You read agent-trace dataset digests and write a gently savage personality bulletin about the HUMAN USER who was prompting the agent — never about the agent itself. The output is a vintage printed card; every field has a strict length budget. Be specific, be funny, never punch down.
136
+
137
+ You will receive:
138
+ - user: the Hugging Face handle of the operator.
139
+ - dataset: the Hub dataset ID being analysed.
140
+ - digests: a JSON list of per-session digests already extracted from the traces (intent, top_quotes, tells, mood).
141
+
142
+ Return EXACTLY one JSON object, no prose, no markdown:
143
+ {
144
+ "user": "<bare handle, no @>",
145
+ "archetype": ["The <adjective>", "<Noun>"],
146
+ "tagline": "<130-170 chars, 2-3 italic lines, sentences only, end on a punchline>",
147
+ "sins": [
148
+ {"n":"01","title":"<50-90 chars, one concrete user behaviour, smart quotes for quoted phrases>","meta":"<30-60 chars, lowercase, mono, cite with · separators (turns, tools, sessions, percents)>"},
149
+ {"n":"02","title":"...","meta":"..."},
150
+ {"n":"03","title":"...","meta":"..."}
151
+ ],
152
+ "forecast": {"headline":"The week ahead","body":"<270-340 chars, horoscope-style, end with 'Lucky <x>: <y>. Avoid: <z>.'>"}
153
+ }
154
+
155
+ Field budgets (hard limits — overflow breaks the layout):
156
+ - archetype[0]: 8-18 chars (line 1, usually "The <adjective>")
157
+ - archetype[1]: 6-14 chars (line 2, title-cased punch noun)
158
+ - tagline: 130-170 chars
159
+ - sins[].title: 50-90 chars
160
+ - sins[].meta: 30-60 chars
161
+ - forecast.body: 270-340 chars, ends with "Lucky <x>: <y>. Avoid: <z>."
162
+
163
+ Voice:
164
+ - Sharp but loving — group-chat energy, not insult-comic. Roast habits a thoughtful friend would call out.
165
+ - Sentence case for titles. Smart quotes ( " " ), en-dashes ( – ), em-dashes ( — ). No exclamation marks. No emojis.
166
+ - Specific, not generic. Every observation must be grounded in something the digests actually contain.
167
+
168
+ Hard rules:
169
+ 1. Roast the USER, not the agent. The user cannot run code; only the agent can. Wrong: "Parsed JSON with a regex twice." (that's the agent). Right: "Asked the agent to parse JSON with a regex twice." / "Demanded a regex over a JSON parser, against advice."
170
+ 2. No invented quotes. Anything in "…" inside sins[].title or tagline must literally appear in a digest's top_quotes (case-flexible, light editing for length OK).
171
+ 3. Cite or omit. Every sins[].meta must reference something verifiable — sessions, mood, counts, tools — drawn from the digests. If you cannot cite, pick a different sin.
172
+ 4. No PII. No emails, no real names, no private repos. Public handles and public dataset names are fine.
173
+ 5. No identity punching. Roast process and habits — not who the user is. Off-limits: appearance, nationality, gender, politics, illness. Fair game: ignoring docs, refactor addiction, regex misuse, vibes-driven coding, asking the same thing six ways, premature optimisation, late-night commits.
174
+
175
+ Procedure:
176
+ 1. Skim the digests for recurring patterns (repeated questions, premature optimisation, doc avoidance, tone, tool misuse, mood arc).
177
+ 2. Pick ONE crisp archetype. Examples: The Premature Optimizer · The Vibes Driver · The Doc Avoider · The Refactor Romantic · The Confidence Auditor · The Apology Engineer · The TODO Composer. Invent freely.
178
+ 3. Pick three sins the digests support. Cite each.
179
+ 4. Tagline: 2-3 short sentences piling on the archetype with concrete examples. End on a punchline.
180
+ 5. Horoscope: one absurd technical prediction grounded in a real user pattern. Close with "Lucky <something>: <x>. Avoid: <y>."
181
+ 6. Validate lengths against budgets. Trim or pad before emitting.
182
+ 7. Emit JSON only. No code fences. No commentary."""
183
+
184
+
185
+ def bulletin(
186
+ client: InferenceClient,
187
+ digests: list[dict],
188
+ user: str,
189
+ dataset_id: str,
190
+ ) -> dict:
191
+ """Generate the report content (archetype, tagline, sins, forecast). One JSON call."""
192
+ user_prompt = (
193
+ f"user: {user}\n"
194
+ f"dataset: {dataset_id}\n\n"
195
+ f"digests (JSON list):\n{json.dumps(digests, ensure_ascii=False, indent=2)}"
196
+ )
197
+ resp = client.chat_completion(
198
+ messages=[
199
+ {"role": "system", "content": _BULLETIN_SYSTEM},
200
+ {"role": "user", "content": user_prompt},
201
+ ],
202
+ response_format={"type": "json_object"},
203
+ max_tokens=900,
204
+ temperature=0.85,
205
+ extra_body=_NO_THINK,
206
+ )
207
+ raw = resp.choices[0].message.content or "{}"
208
+ return json.loads(raw)
209
+
210
+
211
+ def build_report(
212
+ client: InferenceClient,
213
+ digests: list[dict],
214
+ user: str,
215
+ dataset_id: str,
216
+ stats: dict,
217
+ ) -> dict:
218
+ """Combine model output + computed stats into the full report dict for render.py."""
219
+ data = bulletin(client, digests, user, dataset_id)
220
+ today = dt.date.today().strftime("%b %d, %Y")
221
+ archetype = data.get("archetype") or ["The", "Unreadable"]
222
+ if not isinstance(archetype, list) or len(archetype) < 2:
223
+ archetype = ["The", "Unreadable"]
224
+ sins = data.get("sins") or []
225
+ sins = sins[:3] + [{"n": f"{i+1:02d}", "title": "—", "meta": "—"} for i in range(len(sins), 3)]
226
+ forecast = data.get("forecast") or {"headline": "The week ahead", "body": "The cards are quiet today."}
227
+ return {
228
+ "user": str(data.get("user") or user),
229
+ "archetype": [str(archetype[0]), str(archetype[1])],
230
+ "tagline": str(data.get("tagline") or ""),
231
+ "sins": [
232
+ {"n": str(s.get("n") or f"{i+1:02d}"), "title": str(s.get("title") or "—"), "meta": str(s.get("meta") or "—")}
233
+ for i, s in enumerate(sins[:3])
234
+ ],
235
+ "forecast": {
236
+ "headline": str(forecast.get("headline") or "The week ahead"),
237
+ "body": str(forecast.get("body") or ""),
238
+ },
239
+ "dataset": dataset_id,
240
+ "turns": int(stats.get("turns") or 0),
241
+ "tools": int(stats.get("tools") or 0),
242
+ "span": str(stats.get("span") or ""),
243
+ "generated": today,
244
+ "serial": serial_for(user),
245
+ }
app.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "merve/ml-intern-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:
29
+ client = get_client()
30
+ except Exception as e:
31
+ yield f"❌ {e}", empty_bulletin_html("HF_TOKEN missing")
32
+ return
33
+
34
+ try:
35
+ yield "Listing sessions…", empty_bulletin_html("Listing sessions…")
36
+ paths = list_sessions(repo_id)
37
+ if not paths:
38
+ yield (
39
+ "No sessions found in `sessions/**/*.jsonl`.",
40
+ empty_bulletin_html("No sessions to roast."),
41
+ )
42
+ return
43
+
44
+ n = min(int(max_sessions), len(paths))
45
+ yield (
46
+ f"Fetching {n} of {len(paths)} sessions…",
47
+ empty_bulletin_html(f"Fetching {n} sessions…"),
48
+ )
49
+ sessions = fetch_sessions(repo_id, n)
50
+ if not sessions:
51
+ yield (
52
+ "Found session files but couldn't parse any.",
53
+ empty_bulletin_html("Parse error."),
54
+ )
55
+ return
56
+
57
+ stats = compute_stats(sessions)
58
+ transcripts = [
59
+ (path, truncate_transcript(events_to_transcript(evs), 40_000))
60
+ for path, evs in sessions
61
+ ]
62
+
63
+ yield (
64
+ f"Reading {len(transcripts)} sessions in parallel…",
65
+ empty_bulletin_html("Consulting the traces…"),
66
+ )
67
+ digests = digest_all(client, transcripts)
68
+ if not digests:
69
+ yield (
70
+ "Every per-session digest failed. Try again or lower max sessions.",
71
+ empty_bulletin_html("Digest error."),
72
+ )
73
+ return
74
+
75
+ yield (
76
+ f"Drafting bulletin from {len(digests)} digests…",
77
+ empty_bulletin_html("Drafting bulletin…"),
78
+ )
79
+
80
+ owner = _owner_from(repo_id)
81
+ try:
82
+ report = build_report(
83
+ client=client,
84
+ digests=digests,
85
+ user=owner,
86
+ dataset_id=repo_id,
87
+ stats=stats,
88
+ )
89
+ except Exception as e:
90
+ yield (
91
+ f"❌ Bulletin generation failed: {e}",
92
+ empty_bulletin_html("The presses jammed."),
93
+ )
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:
101
+ yield (
102
+ f"❌ {type(e).__name__}: {e}",
103
+ empty_bulletin_html("Error."),
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())
assets/grain_small.png ADDED

Git LFS Details

  • SHA256: 2c3a00045c39b41871019868b97ab85a076903f5f25baedffda5bc54f9e8880d
  • Pointer size: 131 Bytes
  • Size of remote file: 193 kB
assets/seal_small.png ADDED
dataset.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Hub I/O for agent-trace JSONL session files."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from huggingface_hub import HfApi, hf_hub_download
8
+
9
+ _SESSION_RE = re.compile(r"^sessions/(\d{4}-\d{2}-\d{2})/[^/]+\.jsonl$")
10
+
11
+
12
+ def list_sessions(repo_id: str) -> list[str]:
13
+ """Return JSONL session paths from a dataset repo, newest date first."""
14
+ info = HfApi().dataset_info(repo_id)
15
+ paths = [s.rfilename for s in info.siblings if _SESSION_RE.match(s.rfilename)]
16
+ paths.sort(key=lambda p: _SESSION_RE.match(p).group(1), reverse=True)
17
+ return paths
18
+
19
+
20
+ def fetch_sessions(repo_id: str, n: int) -> list[tuple[str, list[dict]]]:
21
+ """Download up to `n` session files (newest first), parse JSONL into events.
22
+
23
+ Returns a list of (session_path, events) tuples. Sessions that fail to
24
+ parse are skipped.
25
+ """
26
+ paths = list_sessions(repo_id)[:n]
27
+ out: list[tuple[str, list[dict]]] = []
28
+ for path in paths:
29
+ local = hf_hub_download(repo_id, path, repo_type="dataset")
30
+ events: list[dict] = []
31
+ try:
32
+ for line in Path(local).read_text().splitlines():
33
+ line = line.strip()
34
+ if not line:
35
+ continue
36
+ events.append(json.loads(line))
37
+ except json.JSONDecodeError:
38
+ continue
39
+ if events:
40
+ out.append((path, events))
41
+ return out
extract.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure transforms on agent-trace event lists. No I/O."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def _user_content_to_text(content: Any) -> str:
7
+ if isinstance(content, str):
8
+ return content
9
+ if isinstance(content, list):
10
+ parts: list[str] = []
11
+ for block in content:
12
+ if not isinstance(block, dict):
13
+ continue
14
+ if block.get("type") == "tool_result":
15
+ continue
16
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
17
+ parts.append(block["text"])
18
+ elif "content" in block and isinstance(block["content"], str) and block.get("type") != "tool_result":
19
+ parts.append(block["content"])
20
+ return "\n".join(parts)
21
+ return ""
22
+
23
+
24
+ def _assistant_content_to_text(content: Any) -> str:
25
+ if isinstance(content, str):
26
+ return content
27
+ if isinstance(content, list):
28
+ parts: list[str] = []
29
+ for block in content:
30
+ if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
31
+ parts.append(block["text"])
32
+ return "".join(parts)
33
+ return ""
34
+
35
+
36
+ def events_to_transcript(events: list[dict]) -> str:
37
+ lines: list[str] = []
38
+ for ev in events:
39
+ msg = ev.get("message") or {}
40
+ content = msg.get("content")
41
+ if ev.get("type") == "user":
42
+ text = _user_content_to_text(content).strip()
43
+ if text:
44
+ lines.append(f"User: {text}")
45
+ elif ev.get("type") == "assistant":
46
+ text = _assistant_content_to_text(content).strip()
47
+ if text:
48
+ lines.append(f"Assistant: {text}")
49
+ return "\n\n".join(lines)
50
+
51
+
52
+ def truncate_transcript(text: str, max_chars: int = 40_000) -> str:
53
+ if len(text) <= max_chars:
54
+ return text
55
+ head_len = max_chars // 2
56
+ tail_len = max_chars // 4
57
+ head = text[:head_len]
58
+ tail = text[-tail_len:]
59
+ return f"{head}\n\n[... truncated ...]\n\n{tail}"
render.py ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trace Personality Bulletin — HTML rendering.
2
+
3
+ The bulletin is a self-contained HTML document with embedded CSS, base64 assets,
4
+ and an html2canvas-driven 'Save as PNG' button that screenshots the card
5
+ client-side in the browser. No server-side image rendering, so the app deploys
6
+ to a Hugging Face Space with zero extra system dependencies.
7
+
8
+ Design adapted from the Anthropic Design handoff `Personality Report.html`
9
+ (1080×1440 portrait, retro bulletin aesthetic).
10
+ """
11
+
12
+ import base64
13
+ import html as html_mod
14
+ from pathlib import Path
15
+
16
+ _ASSETS = Path(__file__).parent / "assets"
17
+
18
+
19
+ def _data_url(path: Path, mime: str = "image/png") -> str:
20
+ return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode()}"
21
+
22
+
23
+ _SEAL_URL = _data_url(_ASSETS / "seal_small.png")
24
+ _GRAIN_URL = _data_url(_ASSETS / "grain_small.png")
25
+
26
+ _ROMAN = ["I", "II", "III", "IV", "V"]
27
+ _NBSP = " "
28
+
29
+ # --- Palette (Retro · Cream from the handoff) ---
30
+ _INK = "#353E60" # hf-blue-deep (navy)
31
+ _INK_SOFT = "rgba(53,62,96,0.7)"
32
+ _SURFACE = "#FFF8DE" # hf-cream
33
+ _ACCENT = "#00704A" # Starbucks-style green
34
+ _ACCENT2 = "#FF9D00" # hf-orange
35
+ _SUNBURST = "rgba(255,157,0,0.35)"
36
+ _SINS_BG = "#353E60"
37
+ _SINS_INK = "#FFF8DE"
38
+ _SINS_ACCENT = "#FF9D00"
39
+ _SINS_DOTS = "rgba(255,157,0,0.18)"
40
+ _RED = "#DB3328" # hf-red — second offset on the title shadow
41
+ _YELLOW = "#FFD21E"
42
+
43
+ # --- Fonts (Google Fonts CDN) ---
44
+ _FONTS_LINK = (
45
+ '<link href="https://fonts.googleapis.com/css2?'
46
+ 'family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&'
47
+ 'family=IBM+Plex+Mono:wght@400;500;600&'
48
+ 'family=Alfa+Slab+One&display=swap" rel="stylesheet">'
49
+ )
50
+
51
+
52
+ def _sunburst_svg(rays: int = 32, color: str = _SUNBURST) -> str:
53
+ parts = []
54
+ for i in range(rays):
55
+ a = (i * 360) / rays
56
+ parts.append(
57
+ f'<rect x="99" y="0" width="2" height="100" fill="{color}" '
58
+ f'transform="rotate({a} 100 100)"/>'
59
+ )
60
+ return (
61
+ '<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" '
62
+ 'style="width:100%;height:100%;display:block;">'
63
+ + "".join(parts)
64
+ + "</svg>"
65
+ )
66
+
67
+
68
+ def _inner_html(data: dict) -> str:
69
+ """Build the standalone HTML *document* that renders inside the srcdoc iframe.
70
+
71
+ Wrapped by `bulletin_html` in an iframe so its CSS is isolated from Gradio.
72
+ """
73
+ e = html_mod.escape
74
+ user = e(str(data.get("user") or ""))
75
+ arch1, arch2 = (data.get("archetype") or ["", ""])[:2]
76
+ arch1 = e(str(arch1))
77
+ arch2 = e(str(arch2))
78
+ tagline = e(str(data.get("tagline") or ""))
79
+ sins = data.get("sins") or []
80
+ forecast = data.get("forecast") or {}
81
+ headline = e(str(forecast.get("headline") or "The week ahead"))
82
+ body = str(forecast.get("body") or "")
83
+ drop_cap = e(body[:1])
84
+ body_rest = e(body[1:])
85
+ dataset = e(str(data.get("dataset") or ""))
86
+ turns = int(data.get("turns") or 0)
87
+ span = e(str(data.get("span") or ""))
88
+ generated = e(str(data.get("generated") or ""))
89
+ serial = e(str(data.get("serial") or ""))
90
+
91
+ # Build the sins list with ornamental dividers between each pair
92
+ sin_blocks = []
93
+ for i, sin in enumerate(sins[:3]):
94
+ if i > 0:
95
+ sin_blocks.append(
96
+ '<div class="tpb-sin-divider">'
97
+ '<span class="tpb-rule"></span>'
98
+ '<span class="tpb-rule-glyph">✺ ✺ ✺</span>'
99
+ '<span class="tpb-rule"></span>'
100
+ "</div>"
101
+ )
102
+ sin_blocks.append(
103
+ '<div class="tpb-sin-row">'
104
+ f'<div class="tpb-sin-n">{e(_ROMAN[i])}.</div>'
105
+ '<div class="tpb-sin-body">'
106
+ f'<p class="tpb-sin-title">{e(str(sin.get("title") or ""))}</p>'
107
+ f'<span class="tpb-sin-meta">{e(str(sin.get("meta") or ""))}</span>'
108
+ "</div></div>"
109
+ )
110
+ sins_html = "".join(sin_blocks)
111
+
112
+ return f"""<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8">
116
+ <title>Trace Personality Bulletin</title>
117
+ {_FONTS_LINK}
118
+ <style>
119
+ html, body {{ margin: 0; padding: 0; background: transparent;
120
+ font-family: 'Source Sans 3', -apple-system, system-ui, sans-serif;
121
+ color: {_INK}; }}
122
+ * {{ box-sizing: border-box; }}
123
+ .tpb-wrap {{
124
+ display: flex;
125
+ flex-direction: column;
126
+ align-items: center;
127
+ gap: 14px;
128
+ padding: 18px 6px;
129
+ background: transparent;
130
+ font-family: 'Source Sans 3', -apple-system, system-ui, sans-serif;
131
+ }}
132
+ .tpb-actions {{
133
+ display: flex; gap: 10px; align-items: center;
134
+ }}
135
+ .tpb-save {{
136
+ background: {_INK};
137
+ color: {_SURFACE};
138
+ border: none;
139
+ padding: 8px 16px;
140
+ border-radius: 6px;
141
+ font-family: 'IBM Plex Mono', monospace;
142
+ font-size: 12px;
143
+ letter-spacing: 0.12em;
144
+ text-transform: uppercase;
145
+ cursor: pointer;
146
+ }}
147
+ .tpb-save:hover {{ opacity: 0.85; }}
148
+
149
+ /* The card — fixed 1080×1440 logical size, scaled to fit via transform */
150
+ .tpb-stage {{
151
+ width: min(880px, 96vw);
152
+ aspect-ratio: 1080 / 1440;
153
+ position: relative;
154
+ overflow: visible;
155
+ }}
156
+ .tpb-card {{
157
+ position: absolute;
158
+ top: 0; left: 0;
159
+ width: 1080px;
160
+ height: 1440px;
161
+ background: {_SURFACE};
162
+ color: {_INK};
163
+ box-shadow: 0 24px 80px rgba(0,0,0,0.45), 0 8px 24px rgba(0,0,0,0.30);
164
+ border-radius: 8px;
165
+ transform-origin: top left;
166
+ overflow: hidden;
167
+ isolation: isolate;
168
+ }}
169
+ .tpb-stage[data-scaled] .tpb-card {{
170
+ transform: scale(var(--tpb-scale, 1));
171
+ }}
172
+
173
+ /* Grain texture */
174
+ .tpb-grain {{
175
+ position: absolute; inset: 0;
176
+ background-image: url('{_GRAIN_URL}');
177
+ background-size: 700px;
178
+ opacity: 0.22;
179
+ mix-blend-mode: multiply;
180
+ pointer-events: none;
181
+ z-index: 1;
182
+ }}
183
+
184
+ /* Frame */
185
+ .tpb-frame-outer {{
186
+ position: absolute; inset: 22px;
187
+ border: 2px solid {_INK};
188
+ pointer-events: none;
189
+ z-index: 2;
190
+ }}
191
+ .tpb-frame-inner {{
192
+ position: absolute; inset: 30px;
193
+ border: 1px solid {_INK};
194
+ pointer-events: none;
195
+ z-index: 2;
196
+ }}
197
+ .tpb-corner {{
198
+ position: absolute;
199
+ width: 24px; height: 24px;
200
+ background: {_SURFACE};
201
+ color: {_INK};
202
+ display: flex; align-items: center; justify-content: center;
203
+ font-size: 16px; font-weight: 700;
204
+ z-index: 3;
205
+ }}
206
+
207
+ /* Content */
208
+ .tpb-content {{
209
+ position: absolute;
210
+ top: 42px; bottom: 42px;
211
+ left: 64px; right: 64px;
212
+ display: flex;
213
+ flex-direction: column;
214
+ z-index: 3;
215
+ }}
216
+
217
+ .tpb-masthead {{ text-align: center; color: {_INK}; }}
218
+ .tpb-masthead-tiny {{
219
+ font-family: 'IBM Plex Mono', monospace;
220
+ font-size: 11px;
221
+ letter-spacing: 0.34em;
222
+ text-transform: uppercase;
223
+ color: {_INK_SOFT};
224
+ margin-bottom: 8px;
225
+ }}
226
+ .tpb-masthead-big {{
227
+ font-family: 'Alfa Slab One', serif;
228
+ font-weight: 400;
229
+ font-size: 36px;
230
+ line-height: 1;
231
+ letter-spacing: 0.04em;
232
+ color: {_INK};
233
+ text-transform: uppercase;
234
+ margin: 0;
235
+ }}
236
+
237
+ .tpb-intro {{
238
+ margin-top: 18px;
239
+ text-align: center;
240
+ font-style: italic;
241
+ font-size: 15px;
242
+ color: {_INK_SOFT};
243
+ }}
244
+ .tpb-user-stamp {{
245
+ margin-top: 8px; display: flex; justify-content: center;
246
+ }}
247
+ .tpb-user-stamp-inner {{
248
+ font-family: 'IBM Plex Mono', monospace;
249
+ font-size: 15px;
250
+ font-weight: 600;
251
+ color: {_INK};
252
+ padding: 6px 18px;
253
+ border: 1.5px solid {_INK};
254
+ border-radius: 4px;
255
+ letter-spacing: 0.06em;
256
+ }}
257
+
258
+ /* Archetype */
259
+ .tpb-archetype {{
260
+ margin-top: 14px;
261
+ position: relative;
262
+ text-align: center;
263
+ padding: 12px 0 8px;
264
+ }}
265
+ .tpb-sunburst {{
266
+ position: absolute;
267
+ inset: -24px;
268
+ z-index: -1;
269
+ display: flex;
270
+ justify-content: center;
271
+ align-items: center;
272
+ }}
273
+ .tpb-eyebrow {{
274
+ font-family: 'IBM Plex Mono', monospace;
275
+ font-size: 13px;
276
+ letter-spacing: 0.32em;
277
+ text-transform: uppercase;
278
+ color: {_ACCENT};
279
+ font-weight: 600;
280
+ margin-bottom: 14px;
281
+ }}
282
+ .tpb-arch-title {{
283
+ font-family: 'Alfa Slab One', serif;
284
+ font-weight: 400;
285
+ font-size: 88px;
286
+ line-height: 0.92;
287
+ letter-spacing: -0.005em;
288
+ color: {_ACCENT};
289
+ margin: 0;
290
+ text-shadow: 4px 4px 0 {_INK}, -2px -2px 0 {_ACCENT2};
291
+ }}
292
+
293
+ /* Tagline */
294
+ .tpb-tagline {{
295
+ margin: 20px auto 0;
296
+ text-align: center;
297
+ font-style: italic;
298
+ font-size: 21px;
299
+ line-height: 1.4;
300
+ font-weight: 500;
301
+ color: {_INK};
302
+ max-width: 820px;
303
+ }}
304
+
305
+ /* Sins */
306
+ .tpb-sins-head {{
307
+ margin-top: 24px;
308
+ text-align: center;
309
+ font-family: 'Alfa Slab One', serif;
310
+ font-weight: 400;
311
+ font-size: 24px;
312
+ letter-spacing: 0.18em;
313
+ color: {_INK};
314
+ text-transform: uppercase;
315
+ }}
316
+ .tpb-sins-block {{
317
+ margin-top: 12px;
318
+ background: {_SINS_BG};
319
+ color: {_SINS_INK};
320
+ border-radius: 4px;
321
+ padding: 18px 30px;
322
+ position: relative;
323
+ overflow: hidden;
324
+ box-shadow: 5px 5px 0 {_INK};
325
+ }}
326
+ .tpb-sins-halftone {{
327
+ position: absolute; inset: 0;
328
+ background-image: radial-gradient({_SINS_DOTS} 1.4px, transparent 1.6px);
329
+ background-size: 14px 14px;
330
+ pointer-events: none;
331
+ opacity: 0.7;
332
+ }}
333
+ .tpb-sin-row {{
334
+ display: grid;
335
+ grid-template-columns: 60px 1fr;
336
+ gap: 18px;
337
+ align-items: baseline;
338
+ padding: 4px 0;
339
+ position: relative;
340
+ }}
341
+ .tpb-sin-n {{
342
+ font-style: italic;
343
+ font-weight: 500;
344
+ font-size: 26px;
345
+ line-height: 0.9;
346
+ color: {_SINS_ACCENT};
347
+ text-align: right;
348
+ letter-spacing: 0.02em;
349
+ }}
350
+ .tpb-sin-body {{ display: flex; flex-direction: column; gap: 4px; }}
351
+ .tpb-sin-title {{
352
+ font-size: 19px;
353
+ line-height: 1.25;
354
+ font-weight: 600;
355
+ color: {_SINS_INK};
356
+ margin: 0;
357
+ }}
358
+ .tpb-sin-meta {{
359
+ font-family: 'IBM Plex Mono', monospace;
360
+ font-size: 12px;
361
+ font-weight: 500;
362
+ color: {_SINS_INK};
363
+ opacity: 0.85;
364
+ letter-spacing: 0.04em;
365
+ }}
366
+ .tpb-sin-divider {{
367
+ display: flex; align-items: center; gap: 12px;
368
+ color: {_SINS_INK};
369
+ opacity: 0.5;
370
+ margin: 6px 28px;
371
+ }}
372
+ .tpb-rule {{ flex: 1; height: 1px; background: currentColor; }}
373
+ .tpb-rule-glyph {{
374
+ font-size: 13px; letter-spacing: 0.4em; white-space: nowrap;
375
+ }}
376
+
377
+ /* Horoscope */
378
+ .tpb-horo-head {{
379
+ margin-top: 44px;
380
+ text-align: center;
381
+ font-family: 'Alfa Slab One', serif;
382
+ font-weight: 400;
383
+ font-size: 22px;
384
+ letter-spacing: 0.18em;
385
+ color: {_INK};
386
+ text-transform: uppercase;
387
+ }}
388
+ .tpb-horo {{
389
+ margin-top: 14px;
390
+ padding: 0 8px;
391
+ }}
392
+ .tpb-dropcap {{
393
+ font-family: 'Alfa Slab One', serif;
394
+ font-weight: 400;
395
+ font-size: 74px;
396
+ line-height: 0.85;
397
+ color: {_ACCENT};
398
+ float: left;
399
+ margin-right: 12px;
400
+ margin-top: 4px;
401
+ text-shadow: 3px 3px 0 {_ACCENT2};
402
+ }}
403
+ .tpb-horo-body {{
404
+ font-size: 19px;
405
+ line-height: 1.45;
406
+ font-weight: 500;
407
+ color: {_INK};
408
+ margin: 0;
409
+ text-align: justify;
410
+ }}
411
+
412
+ /* Signoff */
413
+ .tpb-signoff {{
414
+ margin-top: auto;
415
+ padding-top: 14px;
416
+ display: grid;
417
+ grid-template-columns: 100px 1fr;
418
+ gap: 20px;
419
+ align-items: center;
420
+ }}
421
+ .tpb-stamp {{
422
+ width: 100px; height: 100px;
423
+ transform: rotate(-9deg);
424
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,0.05));
425
+ }}
426
+ .tpb-stamp img {{ width: 100%; height: 100%; object-fit: contain; display: block; }}
427
+ .tpb-sign-right {{ display: flex; flex-direction: column; gap: 6px; }}
428
+ .tpb-sign-title {{
429
+ font-family: 'IBM Plex Mono', monospace;
430
+ font-size: 10px;
431
+ letter-spacing: 0.28em;
432
+ text-transform: uppercase;
433
+ color: {_INK_SOFT};
434
+ }}
435
+ .tpb-sign-script {{
436
+ font-style: italic;
437
+ font-weight: 500;
438
+ font-size: 34px;
439
+ line-height: 1;
440
+ color: {_INK};
441
+ letter-spacing: -0.01em;
442
+ transform: skew(-6deg);
443
+ transform-origin: left center;
444
+ display: inline-block;
445
+ }}
446
+ .tpb-sign-line {{
447
+ margin-top: 6px;
448
+ height: 1px;
449
+ background: {_INK};
450
+ width: 70%;
451
+ }}
452
+ .tpb-sign-meta {{
453
+ font-family: 'IBM Plex Mono', monospace;
454
+ font-size: 11px;
455
+ letter-spacing: 0.12em;
456
+ color: {_INK_SOFT};
457
+ display: flex;
458
+ flex-wrap: wrap;
459
+ gap: 10px;
460
+ }}
461
+ </style>
462
+ </head>
463
+ <body>
464
+
465
+ <div class="tpb-wrap">
466
+ <div class="tpb-actions">
467
+ <button type="button" class="tpb-save" onclick="window.__tpbSave && window.__tpbSave()">
468
+ Save as PNG
469
+ </button>
470
+ </div>
471
+ <div class="tpb-stage" id="tpb-stage">
472
+ <div class="tpb-card" id="tpb-card">
473
+ <div class="tpb-grain"></div>
474
+ <div class="tpb-frame-outer"></div>
475
+ <div class="tpb-frame-inner"></div>
476
+ <div class="tpb-corner" style="top:14px;left:14px;">✺</div>
477
+ <div class="tpb-corner" style="top:14px;right:14px;">✺</div>
478
+ <div class="tpb-corner" style="bottom:14px;left:14px;">✺</div>
479
+ <div class="tpb-corner" style="bottom:14px;right:14px;">✺</div>
480
+
481
+ <div class="tpb-content">
482
+ <div class="tpb-masthead">
483
+ <div class="tpb-masthead-tiny">★ Presented by Hugging Face ★ Anno MMXXVI ★</div>
484
+ <h2 class="tpb-masthead-big">Trace Personality Bulletin</h2>
485
+ </div>
486
+
487
+ <div class="tpb-intro">This bulletin hereby certifies, after careful observation, that the operator behind the handle</div>
488
+ <div class="tpb-user-stamp"><div class="tpb-user-stamp-inner">@{user}</div></div>
489
+
490
+ <div class="tpb-archetype">
491
+ <div class="tpb-sunburst">{_sunburst_svg()}</div>
492
+ <div class="tpb-eyebrow">is</div>
493
+ <h1 class="tpb-arch-title">{arch1}<br>{arch2}</h1>
494
+ </div>
495
+
496
+ <p class="tpb-tagline">{tagline}</p>
497
+
498
+ <div class="tpb-sins-head">✺ Three Mortal Sins ✺</div>
499
+ <div class="tpb-sins-block">
500
+ <div class="tpb-sins-halftone"></div>
501
+ {sins_html}
502
+ </div>
503
+
504
+ <div class="tpb-horo-head">✺ {headline} ✺</div>
505
+ <div class="tpb-horo">
506
+ <p class="tpb-horo-body">
507
+ <span class="tpb-dropcap">{drop_cap}</span>{body_rest}
508
+ </p>
509
+ </div>
510
+
511
+ <div class="tpb-signoff">
512
+ <div class="tpb-stamp"><img src="{_SEAL_URL}" alt="Hugging Face Roastery seal"/></div>
513
+ <div class="tpb-sign-right">
514
+ <div class="tpb-sign-title">Signed —</div>
515
+ <div class="tpb-sign-script">Hugging Face Roastery</div>
516
+ <div class="tpb-sign-line"></div>
517
+ <div class="tpb-sign-meta">
518
+ <span>Dated · {generated}</span><span>·</span>
519
+ <span>{turns:,} turns analysed</span><span>·</span>
520
+ <span>{dataset}</span><span>·</span>
521
+ <span>{serial}</span>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ </div>
528
+ </div>
529
+
530
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
531
+ <script>
532
+ (function() {{
533
+ function fit() {{
534
+ var stage = document.getElementById('tpb-stage');
535
+ if (!stage) return;
536
+ var card = stage.querySelector('.tpb-card');
537
+ if (!card) return;
538
+ var s = stage.clientWidth / 1080;
539
+ card.style.transform = 'scale(' + s + ')';
540
+ stage.style.height = (1440 * s) + 'px';
541
+ stage.setAttribute('data-scaled', '1');
542
+ }}
543
+ window.addEventListener('resize', fit);
544
+ // Run once now and again on the next frame to catch late-loaded fonts.
545
+ fit();
546
+ requestAnimationFrame(fit);
547
+ setTimeout(fit, 200);
548
+
549
+ window.__tpbSave = async function() {{
550
+ var card = document.getElementById('tpb-card');
551
+ if (!card) return;
552
+ // Temporarily un-scale the card so we screenshot it at 1080x1440.
553
+ var prev = card.style.transform;
554
+ card.style.transform = 'scale(1)';
555
+ try {{
556
+ var canvas = await html2canvas(card, {{
557
+ width: 1080, height: 1440,
558
+ backgroundColor: '{_SURFACE}',
559
+ useCORS: true, scale: 1, logging: false,
560
+ }});
561
+ var link = document.createElement('a');
562
+ link.download = 'trace-personality-bulletin-{serial}.png';
563
+ link.href = canvas.toDataURL('image/png');
564
+ link.click();
565
+ }} finally {{
566
+ card.style.transform = prev;
567
+ }}
568
+ }};
569
+ }})();
570
+ </script>
571
+
572
+ </body>
573
+ </html>
574
+ """.strip()
575
+
576
+
577
+ def bulletin_html(data: dict) -> str:
578
+ """Wrap the standalone bulletin document in an iframe so its CSS is isolated
579
+ from Gradio's theme. Returns an HTML snippet suitable for `gr.HTML`.
580
+ """
581
+ doc = _inner_html(data)
582
+ # Escape for the srcdoc attribute value (double-quoted). Order matters:
583
+ # & must be encoded first, then ". Inside the iframe the parser will
584
+ # decode these back to literal '&' and '"' before parsing the HTML body.
585
+ escaped = doc.replace("&", "&amp;").replace('"', "&quot;")
586
+ return (
587
+ f'<iframe srcdoc="{escaped}" '
588
+ 'style="width:100%;aspect-ratio:1080/1440;border:none;display:block;'
589
+ 'background:transparent;" '
590
+ 'sandbox="allow-scripts allow-downloads allow-same-origin"></iframe>'
591
+ )
592
+
593
+
594
+ def empty_bulletin_html(message: str = "Awaiting bulletin…") -> str:
595
+ """Lightweight placeholder shown before a report is generated."""
596
+ return f"""
597
+ <div style="display:flex;justify-content:center;align-items:center;
598
+ min-height:360px;background:{_SURFACE};border-radius:8px;
599
+ font-family:'Source Sans 3', sans-serif;color:{_INK_SOFT};
600
+ font-style:italic;font-size:18px;padding:40px;text-align:center;">
601
+ {html_mod.escape(message)}
602
+ </div>
603
+ """.strip()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=6.14
2
+ huggingface_hub>=0.28
3
+ Pillow>=10.0