Spaces:
Running on Zero
Running on Zero
feat: include rendered png in demo bundle
Browse filesCo-authored-by: Codex <noreply@openai.com>
- README.md +2 -2
- hackathon_advisor/artifact_bundle.py +8 -15
- hackathon_advisor/png_export.py +283 -0
- pyproject.toml +1 -0
- requirements.txt +1 -0
- tests/test_app.py +1 -0
- tests/test_artifact_bundle.py +12 -2
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
|
| 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 |
-
|
|
|
|
| 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":
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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
|