Setting the file. One moment.
Generate Viewer · Expo Skill Eval · expo/skills · Skills Docs
ContentsBack to the top of the page scripts/ generate_viewer.py
Python · 450 lines · 21 KB
15 are embedded as base64 data: URIs so the file is fully self-contained (and
16 satisfies the Artifact CSP). Self-paced by argv so it can be a checked-in script
17 rather than regenerated each run.
18 """
19
20 import base64
21 import json
22 import sys
23 import webbrowser
24 from pathlib import Path
25
26
27 def b64_img (path):
28 if not path:
29 return None
30 p = Path(path)
31 if not p.exists() or not p.is_file():
32 return None
33 with open (p, "rb" ) as f:
34 data = base64.b64encode(f.read()).decode()
35 ext = p.suffix.lower().lstrip( "." )
36 mime = { "png" : "image/png" , "jpg" : "image/jpeg" , "jpeg" : "image/jpeg" }.get(ext, "image/png" )
37 return f "data: { mime } ;base64, { data } "
38
39
40 def score_color (score, max_score):
41 if not max_score:
42 return "#888"
43 pct = score / max_score
44 if pct >= 0.85 :
45 return "#4ade80"
46 elif pct >= 0.65 :
47 return "#fbbf24"
48 else :
49 return "#f87171"
50
51
52 def load_json (path, default = None ):
53 p = Path(path)
54 if not p.exists():
55 return default
56 try :
57 with open (p) as f:
58 return json.load(f)
59 except Exception :
60 return default
61
62
63 def render_expectations (expectations):
64 if not expectations:
65 return ""
66 html = '<ul class="exp-list">'
67 for exp in expectations:
68 if isinstance (exp, dict ):
69 passed = exp.get( "passed" , None )
70 text = exp.get( "text" , str (exp))
71 evidence = exp.get( "evidence" , "" )
72 if passed is True :
73 badge = '<span class="badge pass">PASS</span>'
74 elif passed is False :
75 badge = '<span class="badge fail">FAIL</span>'
76 else :
77 badge = '<span class="badge unknown">?</span>'
78 ev_html = f '<div class="evidence"> { evidence } </div>' if evidence else ""
79 html += f '<li> { badge } { text }{ ev_html } </li>'
80 else :
81 html += f '<li><span class="badge unknown">?</span> { exp } </li>'
82 html += "</ul>"
83 return html
84
85
86 def render_quality (quality):
87 if not quality:
88 return ""
89 dims = quality.get( "dimensions" , [])
90 subtotal = quality.get( "subtotal" , 0 )
91 max_total = quality.get( "max" , 0 )
92 summary = quality.get( "summary" , "" )
93 html = '<div class="quality-block">'
94 html += f '<div class="quality-header">Design Quality: <b> { subtotal } / { max_total } </b></div>'
95 for d in dims:
96 name = d.get( "name" , "" )
97 score = d.get( "score" , 0 )
98 mx = d.get( "max" , 3 )
99 evidence = d.get( "evidence" , "" )
100 pct = (score / mx * 100 ) if mx else 0
101 color = score_color(score, mx)
102 html += f '''<div class="quality-dim">
103 <div class="dim-label"> { name } <span style="color: { color } "> { score } / { mx } </span></div>
104 <div class="dim-bar-wrap"><div class="dim-bar" style="width: { pct :.0f} %;background: { color } "></div></div>
105 { f '<div class="dim-evidence"> { evidence } </div>' if evidence else "" }
106 </div>'''
107 if summary:
108 html += f '<div class="quality-summary"> { summary } </div>'
109 html += "</div>"
110 return html
111
112
113 def render_config_card (iter_dir, eval_id, config, eval_case, grading, static_result):
114 screenshots = []
115 for plat in eval_case.get( "runtime" , {}).get( "platforms" , [ "ios" ]):
116 img_path = iter_dir / f "eval- { eval_id } " / config / "outputs" / f " { plat } .png"
117 screenshots.append((plat, b64_img(img_path)))
118
119 static_pass = static_result and static_result.get( "exit_code" ) == 0
120 static_badge = '<span class="badge pass">BUILD OK</span>' if static_pass else '<span class="badge fail">BUILD FAIL</span>'
121
122 score = grading.get( "score" , 0 ) if grading else None
123 max_score = grading.get( "max_score" , 1 ) if grading else 1
124 score_html = ""
125 if score is not None :
126 color = score_color(score, max_score)
127 score_html = f '<div class="score" style="color: { color } "> { score } / { max_score } </div>'
128
129 label = "With Skill" if config == "with_skill" else "Without Skill"
130 html = '<div class="config-card">'
131 html += f '<div class="config-header"><span class="config-label { "with" if config == "with_skill" else "without" } "> { label } </span> { static_badge }{ score_html } </div>'
132
133 for plat, img_b64 in screenshots:
134 if img_b64:
135 html += f '<div class="screenshot-wrap"><div class="plat-label"> { plat } </div>'
136 html += f '<img src=" { img_b64 } " class="screenshot" onclick="this.classList.toggle( \' zoomed \' )" />'
137 html += '</div>'
138 else :
139 html += f '<div class="screenshot-wrap"><div class="plat-label"> { plat } </div><div class="no-screenshot">No screenshot</div></div>'
140
141 if grading:
142 html += render_expectations(grading.get( "expectations" , []))
143
144 ref_match = grading.get( "reference_match" )
145 if ref_match:
146 rm_score = ref_match.get( "score" , 0 )
147 rm_max = ref_match.get( "max" , 10 )
148 rm_color = score_color(rm_score, rm_max)
149 html += f '<div class="ref-match"><b>Reference Match:</b> <span style="color: { rm_color } "> { rm_score } / { rm_max } </span>'
150 if ref_match.get( "evidence" ):
151 html += f '<div class="evidence"> { ref_match[ "evidence" ] } </div>'
152 html += '</div>'
153
154 html += render_quality(grading.get( "quality" ))
155
156 notes = grading.get( "user_notes_summary" , {})
157 if notes and notes.get( "notes" ):
158 html += f '<div class="reviewer-notes"><b>Notes:</b> { notes[ "notes" ] } </div>'
159
160 html += '</div>'
161 return html
162
163
164 def render_iteration (iter_dir):
165 evals = load_json(iter_dir / "evals.json" )
166 if not evals:
167 return f "<p>No evals.json found in { iter_dir } </p>"
168
169 html = ""
170 total_with = total_without = 0
171 scored_with = scored_without = 0
172 qual_with = qual_without = 0
173 total_evals = 0
174
175 for ev in evals:
176 eid = ev[ "id" ]
177 ref_img_b64 = b64_img(ev.get( "reference_image" , "" ))
178
179 with_grading = load_json(iter_dir / f "eval- { eid } " / "with_skill" / "grading.json" )
180 without_grading = load_json(iter_dir / f "eval- { eid } " / "without_skill" / "grading.json" )
181 with_static = load_json(iter_dir / f "eval- { eid } " / "with_skill" / "static.json" )
182 without_static = load_json(iter_dir / f "eval- { eid } " / "without_skill" / "static.json" )
183
184 total_evals += 1
185
186 if with_grading and with_grading.get( "max_score" ):
187 scored_with += with_grading.get( "score" , 0 )
188 total_with += with_grading.get( "max_score" , 0 )
189 if without_grading and without_grading.get( "max_score" ):
190 scored_without += without_grading.get( "score" , 0 )
191 total_without += without_grading.get( "max_score" , 0 )
192 if with_grading and with_grading.get( "quality" ):
193 qual_with += with_grading[ "quality" ].get( "subtotal" , 0 )
194 if without_grading and without_grading.get( "quality" ):
195 qual_without += without_grading[ "quality" ].get( "subtotal" , 0 )
196
197 html += '<div class="eval-case">'
198 html += f '<div class="eval-header">Eval # { eid } </div>'
199 html += f '<div class="eval-prompt"> { ev.get( "prompt" , "" )[: 200 ] } </div>'
200
201 if ref_img_b64:
202 html += '<div class="ref-image-wrap"><div class="ref-label">Target Reference</div>'
203 html += f '<img src=" { ref_img_b64 } " class="ref-image" onclick="this.classList.toggle( \' zoomed \' )" />'
204 html += '</div>'
205
206 html += '<div class="configs-row">'
207 html += render_config_card(iter_dir, eid, "with_skill" , ev, with_grading, with_static)
208 html += render_config_card(iter_dir, eid, "without_skill" , ev, without_grading, without_static)
209 html += '</div>'
210 html += '</div>'
211
212 with_pct = (scored_with / total_with * 100 ) if total_with else 0
213 without_pct = (scored_without / total_without * 100 ) if total_without else 0
214 delta = with_pct - without_pct
215 delta_color = "#4ade80" if delta > 0 else "#f87171" if delta < 0 else "#888"
216
217 qual_html = ""
218 if qual_with or qual_without:
219 qdelta = qual_with - qual_without
220 qcolor = "#4ade80" if qdelta > 0 else "#f87171" if qdelta < 0 else "#888"
221 qual_html = f ' <div class="summary-item">Quality Δ: <span style="color: { qcolor } "> { qdelta :+d} </span></div> \n '
222
223 summary = f '''<div class="summary-bar">
224 <div class="summary-item">With Skill: <span style="color: { score_color(scored_with, total_with) if total_with else "#888" } "> { with_pct :.0f} %</span></div>
225 <div class="summary-item">Without Skill: <span style="color: { score_color(scored_without, total_without) if total_without else "#888" } "> { without_pct :.0f} %</span></div>
226 <div class="summary-item">Delta: <span style="color: { delta_color } "> { delta :+.0f} pp</span></div>
227 { qual_html } <div class="summary-item">Evals: { total_evals } </div>
228 </div>'''
229
230 return summary + html
231
232
233 def render_trigger_table (results_path):
234 data = load_json(results_path)
235 if not data:
236 return ""
237
238 html = '<div class="trigger-section"><h2>Trigger Accuracy</h2>'
239 recall = data.get( "recall" , 0 )
240 color = score_color(recall, 1 )
241 passed_count = data.get( "passed" , data.get( "triggered" , 0 ))
242 html += f '<div class="trigger-summary">Recall: <span style="color: { color } "> { 100 * recall :.0f} %</span> ( { passed_count } / { data.get( "total" ) } passed)</div>'
243 html += '<table class="trigger-table"><tr><th>#</th><th>Prompt</th><th>Should Trigger</th><th>Triggered</th><th>Result</th><th>Time</th></tr>'
244 for i, r in enumerate (data.get( "results" , []), 1 ):
245 # Support both schema variants: {id, prompt, should_trigger, pass} and {query, triggered, duration}
246 rid = r.get( "id" , i)
247 prompt = r.get( "prompt" , r.get( "query" , "" ))
248 should_trigger = r.get( "should_trigger" , True ) # default True for recall-only runs
249 triggered = r.get( "triggered" , False )
250 passed = r.get( "pass" , triggered if should_trigger else not triggered)
251 result_badge = '<span class="badge pass">PASS</span>' if passed else '<span class="badge fail">FAIL</span>'
252 should = "Yes" if should_trigger else "No"
253 triggered_str = "Yes" if triggered else "No"
254 elapsed = r.get( "elapsed_s" , r.get( "duration" , "?" ))
255 html += f '<tr><td> { rid } </td><td class="prompt-cell"> { prompt[: 80 ] } </td><td> { should } </td><td> { triggered_str } </td><td> { result_badge } </td><td> { elapsed } s</td></tr>'
256 html += '</table></div>'
257 return html
258
259
260 CSS = """ \
261 :root {
262 --ground: #090C14;
263 --surface: #0E1320;
264 --surface-hi: #131929;
265 --border: #1A2238;
266 --text: #D9E2F5;
267 --text-muted: #6B7A9E;
268 --text-dim: #3A4560;
269 --accent: #7B6FD3;
270 --accent-glow: rgba(123,111,211,0.12);
271 --pass: #4DB87C;
272 --pass-bg: rgba(77,184,124,0.10);
273 --fail: #E05555;
274 --fail-bg: rgba(224,85,85,0.10);
275 --score-with: #9B8EE8;
276 --score-without: #5A6480;
277 }
278 * { box-sizing: border-box; margin: 0; padding: 0; }
279 body { background: var(--ground); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; padding: 20px 24px; }
280 h1 { font-size: 15px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 20px; }
281 h2 { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 24px 0 10px; }
282
283 /* Tabs */
284 .tabs { display: flex; gap: 6px; margin-bottom: 20px; }
285 .tab { background: var(--surface); border: 1px solid var(--border); color: var(--text-muted); padding: 5px 14px; border-radius: 5px; cursor: pointer; font-size: 12px; font-weight: 500; transition: color .15s, border-color .15s; }
286 .tab:hover { color: var(--text); border-color: #2A3558; }
287 .tab.active { background: var(--surface-hi); border-color: var(--accent); color: var(--text); }
288
289 /* Summary strip */
290 .summary-bar { display: flex; gap: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 20px; }
291 .summary-item { flex: 1; padding: 12px 16px; border-right: 1px solid var(--border); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
292 .summary-item:last-child { border-right: none; }
293 .summary-item span { display: block; font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; color: var(--text); margin-top: 2px; }
294
295 /* Eval card */
296 .eval-case { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; overflow: hidden; }
297 .eval-header { padding: 10px 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); }
298 .eval-prompt { padding: 12px 16px; font-size: 13px; color: var(--text); border-bottom: 1px solid var(--border); line-height: 1.55; }
299
300 /* Reference image */
301 .ref-image-wrap { padding: 16px; background: #07090F; border-bottom: 1px solid var(--border); }
302 .ref-label { font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; }
303 .ref-image { max-height: 260px; width: auto; border-radius: 10px; border: 1px solid #1E2840; cursor: zoom-in; display: block; }
304 .ref-image.zoomed { max-height: none; cursor: zoom-out; }
305
306 /* Side-by-side config columns */
307 .configs-row { display: grid; grid-template-columns: 1fr 1fr; }
308 .config-card { padding: 16px; border-right: 1px solid var(--border); position: relative; }
309 .config-card:last-child { border-right: none; }
310
311 /* The aesthetic risk: the with_skill card gets a faint violet radial glow from its header edge */
312 .config-card:first-child::before {
313 content: "";
314 position: absolute;
315 top: 0; left: 0; right: 0; height: 180px;
316 background: radial-gradient(ellipse 80% 120px at 50% 0, var(--accent-glow), transparent 70%);
317 pointer-events: none;
318 }
319
320 .config-header { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
321 .config-label { font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; padding: 3px 8px; border-radius: 4px; }
322 .config-label.with { background: rgba(123,111,211,0.15); color: var(--accent); border: 1px solid rgba(123,111,211,0.25); }
323 .config-label.without { background: var(--surface-hi); color: var(--text-muted); border: 1px solid var(--border); }
324 .score { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; margin-left: auto; }
325 .config-card:first-child .score { color: var(--score-with); }
326 .config-card:last-child .score { color: var(--score-without); }
327
328 /* Badges */
329 .badge { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 3px; letter-spacing: 0.05em; text-transform: uppercase; }
330 .badge.pass { background: var(--pass-bg); color: var(--pass); border: 1px solid rgba(77,184,124,0.2); }
331 .badge.fail { background: var(--fail-bg); color: var(--fail); border: 1px solid rgba(224,85,85,0.2); }
332 .badge.unknown { background: var(--surface-hi); color: var(--text-dim); border: 1px solid var(--border); }
333
334 /* Screenshots */
335 .screenshot-wrap { margin-bottom: 14px; }
336 .plat-label { font-size: 9px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px; }
337 .screenshot { max-width: 100%; max-height: 380px; width: auto; border-radius: 14px; border: 1px solid #1A2540; cursor: zoom-in; display: block; box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
338 .screenshot.zoomed { max-height: none; cursor: zoom-out; }
339 .no-screenshot { color: var(--text-dim); font-size: 11px; padding: 16px; background: var(--surface-hi); border-radius: 6px; text-align: center; border: 1px dashed var(--border); }
340
341 /* Expectations */
342 .exp-list { list-style: none; margin-top: 12px; }
343 .exp-list li { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
344 .exp-list li:last-child { border-bottom: none; }
345 .evidence { font-size: 10px; color: var(--text-dim); width: 100%; line-height: 1.5; margin-top: 2px; }
346
347 /* Reference match block */
348 .ref-match { margin-top: 12px; font-size: 12px; color: var(--text-muted); background: var(--surface-hi); padding: 10px 12px; border-radius: 6px; border-left: 2px solid var(--accent); }
349 .ref-match b { color: var(--text); }
350
351 /* Quality rubric */
352 .quality-block { margin-top: 12px; background: var(--surface-hi); border: 1px solid var(--border); border-radius: 6px; padding: 12px; }
353 .quality-header { font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px; }
354 .quality-dim { margin-bottom: 10px; }
355 .quality-dim:last-of-type { margin-bottom: 0; }
356 .dim-label { font-size: 10px; color: var(--text-muted); margin-bottom: 4px; display: flex; justify-content: space-between; }
357 .dim-label span { font-variant-numeric: tabular-nums; }
358 .dim-bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
359 .dim-bar { height: 100%; border-radius: 2px; }
360 .dim-evidence { font-size: 10px; color: var(--text-dim); margin-top: 3px; line-height: 1.45; }
361 .quality-summary { font-size: 11px; color: var(--text-dim); margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
362 .reviewer-notes { margin-top: 10px; font-size: 11px; color: var(--text-dim); background: var(--ground); padding: 8px 10px; border-radius: 4px; }
363
364 /* Trigger table */
365 .trigger-section { margin-top: 32px; }
366 .trigger-summary { font-size: 13px; margin-bottom: 12px; color: var(--text-muted); }
367 .trigger-table { width: 100%; border-collapse: collapse; font-size: 12px; }
368 .trigger-table th { background: var(--surface); padding: 9px 10px; text-align: left; font-size: 10px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); }
369 .trigger-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); vertical-align: top; }
370 .trigger-table tr:last-child td { border-bottom: none; }
371 .trigger-table tr:hover td { background: var(--surface-hi); }
372 .prompt-cell { max-width: 320px; word-break: break-word; color: var(--text); }
373 @media (prefers-reduced-motion: reduce) { .dim-bar { transition: none; } }
374 """
375
376 JS = """ \
377 function showTab(i) {
378 document.querySelectorAll('.panel').forEach((p, idx) => { p.style.display = idx === i ? 'block' : 'none'; });
379 document.querySelectorAll('.tab').forEach((t, idx) => { t.classList.toggle('active', idx === i); });
380 localStorage.setItem('expo-eval-tab', i);
381 }
382 (function() {
383 const saved = parseInt(localStorage.getItem('expo-eval-tab') || '0', 10);
384 if (saved > 0) showTab(saved);
385 })();
386 """
387
388
389 def build_page (ws, title, artifact = False ):
390 iterations = sorted (p for p in ws.glob( "iteration-*" ) if p.is_dir())
391 if not iterations:
392 return f "<p>No iteration directories found under { ws } .</p>"
393
394 tabs_html = ""
395 panels_html = ""
396 for i, it in enumerate (iterations):
397 active = "active" if i == 0 else ""
398 tabs_html += f '<button class="tab { active } " onclick="showTab( { i } )" id="tab- { i } "> { it.name } </button>'
399 content = render_iteration(it)
400 panels_html += f '<div class="panel" id="panel- { i } " style="display: { "block" if i == 0 else "none" } "> { content } </div>'
401
402 trigger_html = render_trigger_table(ws / "trigger-evals" / "trigger_results.json" )
403 body_content = f '<h1> { title } </h1> \n <div class="tabs"> { tabs_html } </div> \n{ panels_html }\n{ trigger_html } '
404
405 if artifact:
406 return f '<title> { title } </title> \n <style> \n{ CSS } </style> \n{ body_content }\n <script> \n{ JS } </script>'
407 return f '''<!DOCTYPE html>
408 <html lang="en">
409 <head>
410 <meta charset="UTF-8" />
411 <meta name="viewport" content="width=device-width, initial-scale=1" />
412 <title> { title } </title>
413 <style>
414 { CSS }
415 </style>
416 </head>
417 <body>
418 { body_content }
419 <script>
420 { JS }
421 </script>
422 </body>
423 </html>'''
424
425
426 def main ():
427 args = [a for a in sys.argv[ 1 :] if a != "--artifact" ]
428 artifact = "--artifact" in sys.argv
429 if not args:
430 print ( "usage: generate_viewer.py <workspace-root> [--artifact]" , file = sys.stderr)
431 sys.exit( 1 )
432
433 ws = Path(args[ 0 ]).resolve()
434 skill = ws.name.replace( "expo-skill-eval-" , "" )
435 title = f " { skill } Skill Eval"
436 html = build_page(ws, title, artifact = artifact)
437
438 if artifact:
439 out = ws / "viewer_artifact.html"
440 out.write_text(html)
441 print ( f "Artifact viewer written: { out } " )
442 else :
443 out = ws / "viewer.html"
444 out.write_text(html)
445 print ( f "Viewer written: { out } " )
446 webbrowser.open( "file://" + str (out))
447
448
449 if __name__ == "__main__" :
450 main()