Spaces:
Sleeping
Sleeping
Redesign UI: themed hero, verdict banners, severity chips, toolkit footer
Browse files
app.py
CHANGED
|
@@ -15,14 +15,49 @@ On Hugging Face Spaces this file is the entry point (app_file: app.py).
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import io
|
| 19 |
import zipfile
|
| 20 |
import tempfile
|
|
|
|
| 21 |
|
| 22 |
import gradio as gr
|
| 23 |
|
| 24 |
from scorm_qa.checks import validate_package
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
GOOD_MANIFEST = """<?xml version="1.0"?>
|
| 28 |
<manifest identifier="MANIFEST-1" version="1.0">
|
|
@@ -41,11 +76,9 @@ GOOD_MANIFEST = """<?xml version="1.0"?>
|
|
| 41 |
</manifest>
|
| 42 |
"""
|
| 43 |
|
| 44 |
-
# Built-in samples: a clean package and three with the defects QA teams actually hit.
|
| 45 |
SAMPLES = {
|
| 46 |
"✅ Clean package (1.2, all files present)": {
|
| 47 |
-
"imsmanifest.xml": GOOD_MANIFEST,
|
| 48 |
-
"lesson.html": "<html>Hello</html>",
|
| 49 |
},
|
| 50 |
"❌ Missing launch file (lesson.html absent)": {
|
| 51 |
"imsmanifest.xml": GOOD_MANIFEST,
|
|
@@ -56,15 +89,13 @@ SAMPLES = {
|
|
| 56 |
"lesson.html": "<html/>",
|
| 57 |
},
|
| 58 |
"⚠️ Dangling extra file not in manifest": {
|
| 59 |
-
"imsmanifest.xml": GOOD_MANIFEST,
|
| 60 |
-
"lesson.html": "<html/>",
|
| 61 |
"extras/notes.txt": "orphaned asset",
|
| 62 |
},
|
| 63 |
}
|
| 64 |
|
| 65 |
|
| 66 |
def _zip_from_files(files: dict) -> str:
|
| 67 |
-
"""Write an in-memory SCORM package to a temp .zip and return its path."""
|
| 68 |
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
| 69 |
with zipfile.ZipFile(tmp, "w") as zf:
|
| 70 |
for name, contents in files.items():
|
|
@@ -73,55 +104,77 @@ def _zip_from_files(files: dict) -> str:
|
|
| 73 |
return tmp.name
|
| 74 |
|
| 75 |
|
| 76 |
-
def
|
| 77 |
-
defects, _ = validate_package(zip_path)
|
| 78 |
-
md = report.build_markdown(display_name, defects)
|
| 79 |
critical = any(d["severity"] in ("CRITICAL", "HIGH") for d in defects)
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def run_sample(name: str):
|
| 86 |
if not name:
|
| 87 |
-
return "Pick a sample above.
|
| 88 |
path = _zip_from_files(SAMPLES[name])
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def run_upload(file_obj):
|
| 93 |
if file_obj is None:
|
| 94 |
-
return "⚠️ Upload a SCORM .zip, or try a built-in sample.
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
"
|
|
|
|
|
|
|
|
|
|
| 107 |
)
|
| 108 |
|
| 109 |
with gr.Tab("Try a built-in sample"):
|
| 110 |
-
sample_dd = gr.Dropdown(choices=list(SAMPLES.keys()),
|
| 111 |
-
value=list(SAMPLES.keys())[0],
|
| 112 |
label="Sample package")
|
| 113 |
-
sample_btn = gr.Button("Validate sample", variant="primary")
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
demo.load(run_sample, inputs=sample_dd, outputs=[s_headline, s_report])
|
| 118 |
|
| 119 |
with gr.Tab("Upload your own .zip"):
|
| 120 |
up = gr.File(label="SCORM package (.zip)", file_types=[".zip"])
|
| 121 |
-
up_btn = gr.Button("Validate package", variant="primary")
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
if __name__ == "__main__":
|
|
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
| 18 |
import zipfile
|
| 19 |
import tempfile
|
| 20 |
+
from html import escape
|
| 21 |
|
| 22 |
import gradio as gr
|
| 23 |
|
| 24 |
from scorm_qa.checks import validate_package
|
| 25 |
+
|
| 26 |
+
ACCENT = "#059669" # green
|
| 27 |
+
SEV_COLORS = {"CRITICAL": "#b91c1c", "HIGH": "#ea580c", "MEDIUM": "#ca8a04", "LOW": "#2563eb"}
|
| 28 |
+
|
| 29 |
+
CSS = """
|
| 30 |
+
:root { --accent: %s; }
|
| 31 |
+
.gradio-container { max-width: 1120px !important; }
|
| 32 |
+
#hero { background: linear-gradient(135deg, var(--accent), #0f172a);
|
| 33 |
+
color:#fff; border-radius:18px; padding:26px 30px; margin-bottom:6px; }
|
| 34 |
+
#hero h1 { margin:0 0 8px 0; font-size:1.75rem; font-weight:800; letter-spacing:-.01em; }
|
| 35 |
+
#hero p { margin:0; opacity:.93; font-size:1.02rem; line-height:1.5; max-width:780px; }
|
| 36 |
+
#hero .pill { display:inline-block; background:rgba(255,255,255,.16); border-radius:999px;
|
| 37 |
+
padding:3px 11px; font-size:.74rem; font-weight:700; margin-bottom:12px; letter-spacing:.04em; }
|
| 38 |
+
.verdict { border-radius:12px; padding:14px 18px; font-size:1.12rem; font-weight:800; margin:2px 0 14px; }
|
| 39 |
+
.verdict.pass { background:#dcfce7; color:#166534; border:1px solid #86efac; }
|
| 40 |
+
.verdict.fail { background:#fee2e2; color:#991b1b; border:1px solid #fca5a5; }
|
| 41 |
+
table.qc { width:100%%; border-collapse:collapse; margin:8px 0; font-size:.92rem; }
|
| 42 |
+
table.qc th { text-align:left; padding:7px 10px; border-bottom:2px solid var(--accent); font-weight:700; }
|
| 43 |
+
table.qc td { padding:8px 10px; border-bottom:1px solid rgba(128,128,128,.2); vertical-align:top; }
|
| 44 |
+
.sev { display:inline-block; padding:2px 10px; border-radius:999px; font-size:.7rem;
|
| 45 |
+
font-weight:800; color:#fff; letter-spacing:.03em; }
|
| 46 |
+
.qc code { background:rgba(128,128,128,.16); padding:1px 6px; border-radius:5px; }
|
| 47 |
+
.footer { margin-top:20px; padding-top:14px; border-top:1px solid rgba(128,128,128,.25);
|
| 48 |
+
font-size:.88rem; text-align:center; opacity:.92; }
|
| 49 |
+
.footer a { text-decoration:none; font-weight:700; color:var(--accent); }
|
| 50 |
+
""" % ACCENT
|
| 51 |
+
|
| 52 |
+
FOOTER = """
|
| 53 |
+
<div class="footer">
|
| 54 |
+
🧰 Part of an AI evaluation & QC toolkit by <b>Laela Zorana</b> ·
|
| 55 |
+
🔍 <a href="https://huggingface.co/spaces/LaelaZ/ai-agent-scenario-qc">Scenario QC</a> ·
|
| 56 |
+
⚖️ <a href="https://huggingface.co/spaces/LaelaZ/rlhf-pairwise-rater">RLHF Rater</a> ·
|
| 57 |
+
📦 SCORM QA ·
|
| 58 |
+
<a href="https://github.com/LaelaZorana/scorm-qa-validator">Source on GitHub</a>
|
| 59 |
+
</div>
|
| 60 |
+
"""
|
| 61 |
|
| 62 |
GOOD_MANIFEST = """<?xml version="1.0"?>
|
| 63 |
<manifest identifier="MANIFEST-1" version="1.0">
|
|
|
|
| 76 |
</manifest>
|
| 77 |
"""
|
| 78 |
|
|
|
|
| 79 |
SAMPLES = {
|
| 80 |
"✅ Clean package (1.2, all files present)": {
|
| 81 |
+
"imsmanifest.xml": GOOD_MANIFEST, "lesson.html": "<html>Hello</html>",
|
|
|
|
| 82 |
},
|
| 83 |
"❌ Missing launch file (lesson.html absent)": {
|
| 84 |
"imsmanifest.xml": GOOD_MANIFEST,
|
|
|
|
| 89 |
"lesson.html": "<html/>",
|
| 90 |
},
|
| 91 |
"⚠️ Dangling extra file not in manifest": {
|
| 92 |
+
"imsmanifest.xml": GOOD_MANIFEST, "lesson.html": "<html/>",
|
|
|
|
| 93 |
"extras/notes.txt": "orphaned asset",
|
| 94 |
},
|
| 95 |
}
|
| 96 |
|
| 97 |
|
| 98 |
def _zip_from_files(files: dict) -> str:
|
|
|
|
| 99 |
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
| 100 |
with zipfile.ZipFile(tmp, "w") as zf:
|
| 101 |
for name, contents in files.items():
|
|
|
|
| 104 |
return tmp.name
|
| 105 |
|
| 106 |
|
| 107 |
+
def _result_html(display_name: str, defects: list) -> str:
|
|
|
|
|
|
|
| 108 |
critical = any(d["severity"] in ("CRITICAL", "HIGH") for d in defects)
|
| 109 |
+
if critical:
|
| 110 |
+
banner = ('<div class="verdict fail">❌ FAIL — would break on LMS upload · '
|
| 111 |
+
f"{len(defects)} defect(s)</div>")
|
| 112 |
+
elif defects:
|
| 113 |
+
banner = ('<div class="verdict pass">⚠️ PASS with minor issues · '
|
| 114 |
+
f"{len(defects)} defect(s)</div>")
|
| 115 |
+
else:
|
| 116 |
+
banner = '<div class="verdict pass">✅ PASS — no defects found</div>'
|
| 117 |
+
|
| 118 |
+
if not defects:
|
| 119 |
+
return banner + "<p>Package is well-formed and ready to upload.</p>"
|
| 120 |
+
|
| 121 |
+
rows = ['<table class="qc"><tr><th>Severity</th><th>Location</th><th>Issue</th></tr>']
|
| 122 |
+
order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
| 123 |
+
for d in sorted(defects, key=lambda x: order.get(x["severity"], 9)):
|
| 124 |
+
color = SEV_COLORS.get(d["severity"], "#6b7280")
|
| 125 |
+
rows.append(
|
| 126 |
+
f'<tr><td><span class="sev" style="background:{color}">{escape(d["severity"])}</span></td>'
|
| 127 |
+
f'<td><code>{escape(str(d.get("location", "")))}</code></td>'
|
| 128 |
+
f'<td>{escape(str(d.get("message", "")))}</td></tr>'
|
| 129 |
+
)
|
| 130 |
+
rows.append("</table>")
|
| 131 |
+
return banner + "\n".join(rows)
|
| 132 |
|
| 133 |
|
| 134 |
def run_sample(name: str):
|
| 135 |
if not name:
|
| 136 |
+
return '<div class="verdict fail">Pick a sample above.</div>'
|
| 137 |
path = _zip_from_files(SAMPLES[name])
|
| 138 |
+
defects, _ = validate_package(path)
|
| 139 |
+
return _result_html(name, defects)
|
| 140 |
|
| 141 |
|
| 142 |
def run_upload(file_obj):
|
| 143 |
if file_obj is None:
|
| 144 |
+
return '<div class="verdict fail">⚠️ Upload a SCORM .zip, or try a built-in sample.</div>'
|
| 145 |
+
defects, _ = validate_package(file_obj.name)
|
| 146 |
+
return _result_html(file_obj.name.split("/")[-1], defects)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
theme = gr.themes.Soft(primary_hue="emerald", neutral_hue="slate",
|
| 150 |
+
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"])
|
| 151 |
+
|
| 152 |
+
with gr.Blocks(title="SCORM QA Validator", theme=theme, css=CSS) as demo:
|
| 153 |
+
gr.HTML(
|
| 154 |
+
'<div id="hero"><span class="pill">E-LEARNING QA</span>'
|
| 155 |
+
"<h1>📦 SCORM QA Validator</h1>"
|
| 156 |
+
"<p>SCORM packages fail <b>silently</b> — a missing launch file or a wrong schema version "
|
| 157 |
+
"doesn't error, it just shows the learner a blank screen after upload. This tool opens the "
|
| 158 |
+
"package, parses <code>imsmanifest.xml</code>, and hands you a defect log (severity, location, "
|
| 159 |
+
"fix) <i>before</i> it ships to Docebo, Workday, or Cornerstone.</p></div>"
|
| 160 |
)
|
| 161 |
|
| 162 |
with gr.Tab("Try a built-in sample"):
|
| 163 |
+
sample_dd = gr.Dropdown(choices=list(SAMPLES.keys()), value=list(SAMPLES.keys())[0],
|
|
|
|
| 164 |
label="Sample package")
|
| 165 |
+
sample_btn = gr.Button("Validate sample ▶", variant="primary", size="lg")
|
| 166 |
+
s_out = gr.HTML()
|
| 167 |
+
sample_btn.click(run_sample, inputs=sample_dd, outputs=s_out)
|
| 168 |
+
demo.load(run_sample, inputs=sample_dd, outputs=s_out)
|
|
|
|
| 169 |
|
| 170 |
with gr.Tab("Upload your own .zip"):
|
| 171 |
up = gr.File(label="SCORM package (.zip)", file_types=[".zip"])
|
| 172 |
+
up_btn = gr.Button("Validate package ▶", variant="primary", size="lg")
|
| 173 |
+
u_out = gr.HTML()
|
| 174 |
+
up_btn.click(run_upload, inputs=up, outputs=u_out)
|
| 175 |
+
|
| 176 |
+
gr.HTML(FOOTER)
|
| 177 |
+
gr.Markdown("*Runs the actual package (`scorm_qa/`) — the same code the 6-case pytest suite covers.*")
|
| 178 |
|
| 179 |
|
| 180 |
if __name__ == "__main__":
|