JacobLinCool Codex commited on
Commit
f25fee8
·
verified ·
1 Parent(s): e25f6ac

feat: include rendered png in demo bundle

Browse files

Co-authored-by: Codex <noreply@openai.com>

README.md CHANGED
@@ -129,8 +129,8 @@ evidence.
129
 
130
  `/api/demo-bundle.zip` downloads a server-built ZIP for the deterministic demo session. The bundle includes a manifest,
131
  demo session JSON, Prize Ledger JSON, trace JSONL, Field Notes, Almanac chapter, LoRA SFT JSONL, LoRA training kit,
132
- Submission Packet, and a PNG export note. This gives judges or collaborators one auditable package without depending on
133
- browser `localStorage`.
134
 
135
  ## Prize Ledger
136
 
 
129
 
130
  `/api/demo-bundle.zip` downloads a server-built ZIP for the deterministic demo session. The bundle includes a manifest,
131
  demo session JSON, Prize Ledger JSON, trace JSONL, Field Notes, Almanac chapter, LoRA SFT JSONL, LoRA training kit,
132
+ Submission Packet, and the rendered fate-page PNG. This gives judges or collaborators one auditable package without
133
+ depending on browser `localStorage`.
134
 
135
  ## Prize Ledger
136
 
hackathon_advisor/artifact_bundle.py CHANGED
@@ -10,6 +10,7 @@ from hackathon_advisor.chapter import build_chapter_markdown
10
  from hackathon_advisor.field_notes import build_field_notes_markdown
11
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
12
  from hackathon_advisor.lora_training_kit import build_lora_training_kit_zip
 
13
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
14
  from hackathon_advisor.trace_export import build_trace_jsonl
15
 
@@ -29,7 +30,8 @@ def build_demo_bundle_zip(
29
 
30
  buffer = BytesIO()
31
  with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
32
- archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
 
33
  for filename, content in files.items():
34
  archive.writestr(filename, content)
35
  return buffer.getvalue()
@@ -41,16 +43,19 @@ def _bundle_files(
41
  ledger: dict[str, Any],
42
  demo: dict[str, Any],
43
  ) -> dict[str, str | bytes]:
 
 
 
44
  return {
45
  "demo-session.json": json.dumps(demo, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
46
- "prize-ledger.json": json.dumps(ledger, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
47
  "trace.jsonl": build_trace_jsonl(session, metadata),
48
  "field-notes.md": build_field_notes_markdown(session, metadata),
49
  "almanac-chapter.md": build_chapter_markdown(session, metadata),
50
  "lora-sft.jsonl": build_lora_dataset_jsonl(session, metadata),
51
  "lora-training-kit.zip": build_lora_training_kit_zip(session, metadata, ledger),
52
  "submission-packet.md": build_submission_packet_markdown(session, metadata, ledger),
53
- "png-export-note.md": _png_note(demo),
54
  }
55
 
56
 
@@ -83,18 +88,6 @@ def _manifest(
83
  },
84
  }
85
 
86
-
87
- def _png_note(demo: dict[str, Any]) -> str:
88
- artifact = demo.get("artifact") if isinstance(demo.get("artifact"), dict) else {}
89
- title = _clean(artifact.get("title")) or "current fate page"
90
- return (
91
- "# PNG Export Note\n\n"
92
- "The visual fate-page PNG is rendered client-side from the same session artifact so the browser can draw the "
93
- "canvas with the deployed CSS and fonts. Load the Demo session in the Space, then press `PNG` to export it.\n\n"
94
- f"Demo artifact title: {title}\n"
95
- )
96
-
97
-
98
  def _clean(value: Any) -> str:
99
  if value is None:
100
  return ""
 
10
  from hackathon_advisor.field_notes import build_field_notes_markdown
11
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
12
  from hackathon_advisor.lora_training_kit import build_lora_training_kit_zip
13
+ from hackathon_advisor.png_export import artifact_png_filename, render_artifact_png
14
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
15
  from hackathon_advisor.trace_export import build_trace_jsonl
16
 
 
30
 
31
  buffer = BytesIO()
32
  with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
33
+ manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)
34
+ archive.writestr("manifest.json", f"{manifest_json}\n")
35
  for filename, content in files.items():
36
  archive.writestr(filename, content)
37
  return buffer.getvalue()
 
43
  ledger: dict[str, Any],
44
  demo: dict[str, Any],
45
  ) -> dict[str, str | bytes]:
46
+ artifact = demo.get("artifact") if isinstance(demo.get("artifact"), dict) else {}
47
+ png_filename = artifact_png_filename(artifact)
48
+ ledger_json = json.dumps(ledger, ensure_ascii=False, indent=2, sort_keys=True)
49
  return {
50
  "demo-session.json": json.dumps(demo, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
51
+ "prize-ledger.json": f"{ledger_json}\n",
52
  "trace.jsonl": build_trace_jsonl(session, metadata),
53
  "field-notes.md": build_field_notes_markdown(session, metadata),
54
  "almanac-chapter.md": build_chapter_markdown(session, metadata),
55
  "lora-sft.jsonl": build_lora_dataset_jsonl(session, metadata),
56
  "lora-training-kit.zip": build_lora_training_kit_zip(session, metadata, ledger),
57
  "submission-packet.md": build_submission_packet_markdown(session, metadata, ledger),
58
+ png_filename: render_artifact_png(artifact),
59
  }
60
 
61
 
 
88
  },
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _clean(value: Any) -> str:
92
  if value is None:
93
  return ""
hackathon_advisor/png_export.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from io import BytesIO
4
+ import math
5
+ import re
6
+ from typing import Any
7
+
8
+ from PIL import Image, ImageDraw, ImageFont
9
+
10
+
11
+ CANVAS_WIDTH = 1200
12
+ CANVAS_HEIGHT = 675
13
+
14
+ INK = (37, 22, 14)
15
+ INK_SOFT = (107, 78, 53)
16
+ PARCHMENT_LIGHT = (234, 215, 167)
17
+ PARCHMENT_MID = (212, 180, 118)
18
+ PARCHMENT_DARK = (185, 138, 76)
19
+ OXBLOOD = (141, 45, 38)
20
+ LEAF = (47, 122, 73)
21
+ GOLD = (182, 138, 18)
22
+ CREAM = (255, 240, 181)
23
+
24
+
25
+ def artifact_png_filename(artifact: dict[str, Any]) -> str:
26
+ slug = re.sub(r"[^a-z0-9]+", "-", str(artifact.get("title") or "unwritten-page").lower())
27
+ slug = slug.strip("-")[:60] or "unwritten-page"
28
+ return f"{slug}.png"
29
+
30
+
31
+ def render_artifact_png(artifact: dict[str, Any]) -> bytes:
32
+ image = Image.new("RGB", (CANVAS_WIDTH, CANVAS_HEIGHT), PARCHMENT_LIGHT)
33
+ draw = ImageDraw.Draw(image)
34
+ _draw_parchment(draw)
35
+
36
+ seal = artifact.get("seal") if isinstance(artifact.get("seal"), dict) else {}
37
+ verdict = str(artifact.get("verdict") or seal.get("verdict") or "UNWRITTEN")
38
+ overall = _number(artifact.get("overall") or seal.get("overall"), default=0)
39
+
40
+ title_font = _font(58)
41
+ body_font = _font(28)
42
+ label_font = _font(20)
43
+ small_font = _font(15)
44
+ stamp_font = _font(27)
45
+ score_font = _font(58)
46
+ map_label_font = _font(18)
47
+
48
+ _wrap_text(
49
+ draw,
50
+ str(artifact.get("title") or "Unwritten Page"),
51
+ (78, 94),
52
+ 760,
53
+ 66,
54
+ title_font,
55
+ INK,
56
+ )
57
+ _wrap_text(draw, str(artifact.get("caption") or ""), (82, 235), 720, 36, body_font, INK_SOFT)
58
+ echoes = seal.get("echoes") if isinstance(seal.get("echoes"), list) else []
59
+ _draw_citations(draw, echoes, (742, 335), 330, small_font)
60
+
61
+ _draw_stamp(draw, (930, 205), verdict, overall, stamp_font, score_font)
62
+ _draw_score_bars(draw, seal, verdict, (82, 418), label_font)
63
+ wood_map = artifact.get("wood_map") if isinstance(artifact.get("wood_map"), dict) else {}
64
+ _draw_wood_map(draw, wood_map, (742, 465), (330, 105), verdict, map_label_font, small_font)
65
+
66
+ buffer = BytesIO()
67
+ image.save(buffer, format="PNG", optimize=True)
68
+ return buffer.getvalue()
69
+
70
+
71
+ def _draw_parchment(draw: ImageDraw.ImageDraw) -> None:
72
+ for y in range(CANVAS_HEIGHT):
73
+ t = y / max(1, CANVAS_HEIGHT - 1)
74
+ left = _mix(PARCHMENT_LIGHT, PARCHMENT_MID, min(1, t * 1.6))
75
+ right = _mix(PARCHMENT_MID, PARCHMENT_DARK, t)
76
+ for x in range(CANVAS_WIDTH):
77
+ u = x / max(1, CANVAS_WIDTH - 1)
78
+ draw.point((x, y), fill=_mix(left, right, u))
79
+ for i in range(360):
80
+ x = (i * 73) % CANVAS_WIDTH
81
+ y = (i * 37) % CANVAS_HEIGHT
82
+ color = (59, 33, 15)
83
+ draw.rectangle((x, y, x + 2 + (i % 7), y), fill=_blend(color, 0.16, PARCHMENT_MID))
84
+ draw.rectangle((28, 28, CANVAS_WIDTH - 28, CANVAS_HEIGHT - 28), outline=(72, 39, 18), width=16)
85
+
86
+
87
+ def _draw_stamp(
88
+ draw: ImageDraw.ImageDraw,
89
+ center: tuple[int, int],
90
+ verdict: str,
91
+ overall: float,
92
+ stamp_font: ImageFont.ImageFont,
93
+ score_font: ImageFont.ImageFont,
94
+ ) -> None:
95
+ color = GOLD if verdict.startswith("UNWRITTEN") else OXBLOOD
96
+ x, y = center
97
+ radius = 104
98
+ draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=color)
99
+ _wrap_text(draw, verdict, (x - 86, y - 30), 172, 32, stamp_font, CREAM, align="center")
100
+ text = f"{overall:.1f}"
101
+ box = draw.textbbox((0, 0), text, font=score_font)
102
+ draw.text((x - (box[2] - box[0]) / 2, y + 4), text, font=score_font, fill=CREAM)
103
+
104
+
105
+ def _draw_score_bars(
106
+ draw: ImageDraw.ImageDraw,
107
+ seal: dict[str, Any],
108
+ verdict: str,
109
+ origin: tuple[int, int],
110
+ font: ImageFont.ImageFont,
111
+ ) -> None:
112
+ rows = [
113
+ ("Originality", seal.get("originality")),
114
+ ("Delight", seal.get("delight")),
115
+ ("AI Need", seal.get("ai_necessity")),
116
+ ("Feasible", seal.get("feasibility")),
117
+ ("Goal Fit", seal.get("goal_fit")),
118
+ ]
119
+ fill = LEAF if verdict.startswith("UNWRITTEN") else OXBLOOD
120
+ x, y = origin
121
+ for index, (label, value) in enumerate(rows):
122
+ row_y = y + index * 34
123
+ score = _number(value, default=0)
124
+ draw.text((x, row_y - 18), label, font=font, fill=INK_SOFT)
125
+ draw.rectangle((240, row_y - 17, 560, row_y - 1), fill=(177, 148, 111))
126
+ draw.rectangle((240, row_y - 17, 240 + int(32 * score), row_y - 1), fill=fill)
127
+ draw.text((582, row_y - 20), str(int(score)), font=font, fill=INK)
128
+
129
+
130
+ def _draw_wood_map(
131
+ draw: ImageDraw.ImageDraw,
132
+ map_data: dict[str, Any],
133
+ origin: tuple[int, int],
134
+ size: tuple[int, int],
135
+ verdict: str,
136
+ label_font: ImageFont.ImageFont,
137
+ small_font: ImageFont.ImageFont,
138
+ ) -> None:
139
+ dots = map_data.get("dots") if isinstance(map_data.get("dots"), list) else []
140
+ if not dots:
141
+ return
142
+ x, y = origin
143
+ width, height = size
144
+ draw.rounded_rectangle(
145
+ (x, y, x + width, y + height),
146
+ radius=8,
147
+ fill=(231, 205, 149),
148
+ outline=(80, 47, 22),
149
+ width=2,
150
+ )
151
+ draw.text((x, y - 26), "YOU VS THE WOOD", font=label_font, fill=INK_SOFT)
152
+
153
+ for dot in dots:
154
+ if not isinstance(dot, dict):
155
+ continue
156
+ px = x + int(width * _bounded_percent(dot.get("x")) / 100)
157
+ py = y + int(height * _bounded_percent(dot.get("y")) / 100)
158
+ radius = max(3, min(10, int(_number(dot.get("radius"), default=4))))
159
+ kind = str(dot.get("kind") or "inked")
160
+ if kind == "idea":
161
+ color = LEAF if verdict.startswith("UNWRITTEN") else OXBLOOD
162
+ outline = CREAM
163
+ elif kind == "echo":
164
+ color = OXBLOOD
165
+ outline = CREAM
166
+ else:
167
+ color = (120, 88, 58)
168
+ outline = color
169
+ draw.ellipse(
170
+ (px - radius, py - radius, px + radius, py + radius),
171
+ fill=color,
172
+ outline=outline,
173
+ width=2,
174
+ )
175
+
176
+ caption = _ellipsize(str(map_data.get("caption") or ""), 78)
177
+ _wrap_text(draw, caption, (x, y + height + 12), width, 18, small_font, INK_SOFT)
178
+
179
+
180
+ def _draw_citations(
181
+ draw: ImageDraw.ImageDraw,
182
+ echoes: list[Any],
183
+ origin: tuple[int, int],
184
+ max_width: int,
185
+ font: ImageFont.ImageFont,
186
+ ) -> None:
187
+ if not echoes:
188
+ return
189
+ x, y = origin
190
+ draw.text((x, y - 20), "CLOSEST PAGES", font=_font(18), fill=INK_SOFT)
191
+ for index, echo in enumerate(echoes[:3]):
192
+ if not isinstance(echo, dict):
193
+ continue
194
+ project = echo.get("project") if isinstance(echo.get("project"), dict) else {}
195
+ page = echo.get("page_number") or "?"
196
+ title = project.get("title") or project.get("id") or "Untitled"
197
+ label = f"Page {page}: {title}"
198
+ _wrap_text(draw, label, (x, y + 8 + index * 30), max_width, 18, font, INK_SOFT)
199
+
200
+
201
+ def _wrap_text(
202
+ draw: ImageDraw.ImageDraw,
203
+ text: str,
204
+ origin: tuple[int, int],
205
+ max_width: int,
206
+ line_height: int,
207
+ font: ImageFont.ImageFont,
208
+ fill: tuple[int, int, int],
209
+ align: str = "left",
210
+ ) -> int:
211
+ words = str(text).split()
212
+ if not words:
213
+ return origin[1]
214
+ x, y = origin
215
+ line = ""
216
+ for word in words:
217
+ next_line = f"{line} {word}".strip()
218
+ if _text_width(draw, next_line, font) > max_width and line:
219
+ _draw_line(draw, line, x, y, max_width, font, fill, align)
220
+ line = word
221
+ y += line_height
222
+ else:
223
+ line = next_line
224
+ if line:
225
+ _draw_line(draw, line, x, y, max_width, font, fill, align)
226
+ y += line_height
227
+ return y
228
+
229
+
230
+ def _draw_line(
231
+ draw: ImageDraw.ImageDraw,
232
+ text: str,
233
+ x: int,
234
+ y: int,
235
+ max_width: int,
236
+ font: ImageFont.ImageFont,
237
+ fill: tuple[int, int, int],
238
+ align: str,
239
+ ) -> None:
240
+ if align == "center":
241
+ x = x + (max_width - _text_width(draw, text, font)) // 2
242
+ draw.text((x, y), text, font=font, fill=fill)
243
+
244
+
245
+ def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
246
+ box = draw.textbbox((0, 0), text, font=font)
247
+ return box[2] - box[0]
248
+
249
+
250
+ def _font(size: int) -> ImageFont.ImageFont:
251
+ return ImageFont.load_default(size=size)
252
+
253
+
254
+ def _ellipsize(value: str, limit: int) -> str:
255
+ text = " ".join(value.split())
256
+ if len(text) <= limit:
257
+ return text
258
+ return text[: max(0, limit - 1)].rstrip() + "..."
259
+
260
+
261
+ def _number(value: Any, default: float) -> float:
262
+ try:
263
+ number = float(value)
264
+ except (TypeError, ValueError):
265
+ return default
266
+ return number if math.isfinite(number) else default
267
+
268
+
269
+ def _bounded_percent(value: Any) -> float:
270
+ return max(4, min(96, _number(value, default=50)))
271
+
272
+
273
+ def _mix(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]:
274
+ t = max(0, min(1, t))
275
+ return tuple(round(a[index] * (1 - t) + b[index] * t) for index in range(3))
276
+
277
+
278
+ def _blend(
279
+ foreground: tuple[int, int, int],
280
+ alpha: float,
281
+ background: tuple[int, int, int],
282
+ ) -> tuple[int, int, int]:
283
+ return _mix(background, foreground, alpha)
pyproject.toml CHANGED
@@ -8,6 +8,7 @@ license = { text = "MIT" }
8
  authors = [{ name = "Jacob LinCool" }]
9
  dependencies = [
10
  "gradio>=6.16.0,<7",
 
11
  ]
12
 
13
  [project.optional-dependencies]
 
8
  authors = [{ name = "Jacob LinCool" }]
9
  dependencies = [
10
  "gradio>=6.16.0,<7",
11
+ "pillow>=10,<13",
12
  ]
13
 
14
  [project.optional-dependencies]
requirements.txt CHANGED
@@ -1 +1,2 @@
1
  gradio>=6.16.0,<7
 
 
1
  gradio>=6.16.0,<7
2
+ pillow>=10,<13
tests/test_app.py CHANGED
@@ -151,6 +151,7 @@ def test_demo_bundle_endpoint_returns_zip_attachment() -> None:
151
  assert "submission-packet.md" in names
152
  assert "lora-sft.jsonl" in names
153
  assert "lora-training-kit.zip" in names
 
154
  assert manifest["turn_count"] == 2
155
 
156
 
 
151
  assert "submission-packet.md" in names
152
  assert "lora-sft.jsonl" in names
153
  assert "lora-training-kit.zip" in names
154
+ assert "archive-cartographer.png" in names
155
  assert manifest["turn_count"] == 2
156
 
157
 
tests/test_artifact_bundle.py CHANGED
@@ -18,13 +18,19 @@ def test_demo_bundle_contains_submission_evidence_files() -> None:
18
  **trace_metadata(index),
19
  "project_count": len(index.projects),
20
  }
21
- content = build_demo_bundle_zip(build_demo_rehearsal(engine), metadata, prize_ledger(engine.runtime_status()))
 
 
 
 
22
 
23
  with ZipFile(BytesIO(content)) as archive:
24
  names = set(archive.namelist())
25
  manifest = json.loads(archive.read("manifest.json"))
26
  trace = archive.read("trace.jsonl").decode("utf-8")
27
  packet = archive.read("submission-packet.md").decode("utf-8")
 
 
28
 
29
  assert names == {
30
  "manifest.json",
@@ -36,10 +42,14 @@ def test_demo_bundle_contains_submission_evidence_files() -> None:
36
  "lora-sft.jsonl",
37
  "lora-training-kit.zip",
38
  "submission-packet.md",
39
- "png-export-note.md",
40
  }
41
  assert manifest["type"] == "demo_bundle_manifest"
42
  assert manifest["turn_count"] == 2
 
43
  assert manifest["badge_status"]["Well-Tuned"] == "training-kit-ready"
44
  assert "agent_turn" in trace
45
  assert "## Prize Evidence" in packet
 
 
 
 
18
  **trace_metadata(index),
19
  "project_count": len(index.projects),
20
  }
21
+ content = build_demo_bundle_zip(
22
+ build_demo_rehearsal(engine),
23
+ metadata,
24
+ prize_ledger(engine.runtime_status()),
25
+ )
26
 
27
  with ZipFile(BytesIO(content)) as archive:
28
  names = set(archive.namelist())
29
  manifest = json.loads(archive.read("manifest.json"))
30
  trace = archive.read("trace.jsonl").decode("utf-8")
31
  packet = archive.read("submission-packet.md").decode("utf-8")
32
+ png_names = [name for name in names if name.endswith(".png")]
33
+ png = archive.read(png_names[0])
34
 
35
  assert names == {
36
  "manifest.json",
 
42
  "lora-sft.jsonl",
43
  "lora-training-kit.zip",
44
  "submission-packet.md",
45
+ "archive-cartographer.png",
46
  }
47
  assert manifest["type"] == "demo_bundle_manifest"
48
  assert manifest["turn_count"] == 2
49
+ assert manifest["file_count"] == len(names) - 1
50
  assert manifest["badge_status"]["Well-Tuned"] == "training-kit-ready"
51
  assert "agent_turn" in trace
52
  assert "## Prize Evidence" in packet
53
+ assert png_names == ["archive-cartographer.png"]
54
+ assert png.startswith(b"\x89PNG\r\n\x1a\n")
55
+ assert len(png) > 10_000