dehezhang2 commited on
Commit
313ee8f
·
verified ·
1 Parent(s): eceddd8

deploy rater (Docker/FastAPI): registration + completeness gate + /api/submit → dataset

Browse files
Files changed (3) hide show
  1. app.py +71 -0
  2. static/admin.html +482 -0
  3. static/index.html +9 -9
app.py CHANGED
@@ -25,6 +25,12 @@ from huggingface_hub.utils import HfHubHTTPError
25
  DATASET_REPO = "dehezhang2/Frameworker_User_Study"
26
  ANNOTATIONS_PATH = "annotations.json"
27
 
 
 
 
 
 
 
28
  # Mirror of the client-side ADMIN_NAMES list. Matched case-insensitively
29
  # against the rater's `name` field. Server-side computation is the
30
  # authoritative one — clients can't spoof the flag by lying in the payload.
@@ -265,6 +271,71 @@ def _load_annotations() -> dict:
265
  return {"annotations": {}}
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  @app.get("/api/rater_state")
269
  async def get_rater_state(email: str = ""):
270
  """Return the rater's most recent in-progress JSON (if any).
 
25
  DATASET_REPO = "dehezhang2/Frameworker_User_Study"
26
  ANNOTATIONS_PATH = "annotations.json"
27
 
28
+ # Plain shared-secret gate for the admin dashboard (`/admin.html`).
29
+ # Override via `ADMIN_PASSWORD` env var / Space secret. The default is
30
+ # intentionally weak — this isn't auth-grade, just a screen against random
31
+ # visitors stumbling into the submissions list.
32
+ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "12345")
33
+
34
  # Mirror of the client-side ADMIN_NAMES list. Matched case-insensitively
35
  # against the rater's `name` field. Server-side computation is the
36
  # authoritative one — clients can't spoof the flag by lying in the payload.
 
271
  return {"annotations": {}}
272
 
273
 
274
+ def _require_admin_password(req: Request) -> None:
275
+ pw = req.headers.get("x-admin-password") or req.query_params.get("pw") or ""
276
+ if pw != ADMIN_PASSWORD:
277
+ raise HTTPException(401, "admin password required")
278
+
279
+
280
+ @app.post("/api/admin/login")
281
+ async def admin_login(req: Request):
282
+ try:
283
+ payload = await req.json()
284
+ except Exception:
285
+ raise HTTPException(400, "invalid JSON")
286
+ if (payload.get("password") or "").strip() != ADMIN_PASSWORD:
287
+ raise HTTPException(401, "wrong password")
288
+ return JSONResponse({"ok": True})
289
+
290
+
291
+ @app.get("/api/admin/submissions")
292
+ async def admin_submissions(req: Request):
293
+ """List every submission JSON in the dataset with light metadata.
294
+
295
+ Admin-only (X-Admin-Password header). The page fetches each blob's full
296
+ body directly from the public dataset resolve URL for visualisation.
297
+ """
298
+ _require_admin_password(req)
299
+ if _api is None:
300
+ return JSONResponse({"submissions": [], "reason": "no token"})
301
+ from huggingface_hub import hf_hub_download
302
+ out = []
303
+ for tree in _api.list_repo_tree(
304
+ repo_id=DATASET_REPO, repo_type="dataset",
305
+ path_in_repo="submissions", recursive=True,
306
+ ):
307
+ path = getattr(tree, "path", "")
308
+ if not path.endswith(".json"):
309
+ continue
310
+ record: dict = {"path": path}
311
+ try:
312
+ local = hf_hub_download(
313
+ repo_id=DATASET_REPO, repo_type="dataset",
314
+ filename=path, force_download=False,
315
+ )
316
+ with open(local, "r", encoding="utf-8") as f:
317
+ data = json.load(f)
318
+ rater = data.get("rater") or {}
319
+ srv = data.get("_server") or {}
320
+ cases = data.get("cases") or []
321
+ record.update({
322
+ "rater_name": rater.get("name") or "",
323
+ "rater_email": rater.get("email") or "",
324
+ "is_admin": bool(data.get("is_admin", False)),
325
+ "n_cases": len(cases),
326
+ "n_scores": srv.get("n_scores"),
327
+ "status": srv.get("status"),
328
+ "received_at_utc": srv.get("received_at_utc"),
329
+ "chosen_case_ids": rater.get("chosen_case_ids") or [],
330
+ })
331
+ except Exception as e:
332
+ record["error"] = str(e)[:120]
333
+ out.append(record)
334
+ # newest first
335
+ out.sort(key=lambda r: r.get("received_at_utc") or "", reverse=True)
336
+ return JSONResponse({"submissions": out, "total": len(out)})
337
+
338
+
339
  @app.get("/api/rater_state")
340
  async def get_rater_state(email: str = ""):
341
  """Return the rater's most recent in-progress JSON (if any).
static/admin.html ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Admin · FrameWorker user study</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1115; --panel: #161922; --panel-2: #1c2030; --panel-3: #232839;
11
+ --border: #2a2f42; --border-strong: #3a4060;
12
+ --fg: #e6e8ef; --muted: #8b91a8; --muted-2: #5e6480;
13
+ --accent: #6aa9ff; --good: #28c76f; --warn: #ff9f43; --bad: #ea5455;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
17
+ font-family: -apple-system, "Inter", "PingFang SC", sans-serif;
18
+ font-size: 14px; line-height: 1.5; }
19
+
20
+ header { padding: 12px 24px; border-bottom: 1px solid var(--border);
21
+ background: var(--panel); display: flex; gap: 14px; align-items: center;
22
+ flex-wrap: wrap; }
23
+ header h1 { font-size: 16px; margin: 0; }
24
+ header .meta { color: var(--muted); font-size: 12px; }
25
+ header .right { margin-left: auto; display: flex; gap: 10px; align-items: center; }
26
+
27
+ main { padding: 18px 24px 80px; max-width: 1280px; margin: 0 auto; }
28
+
29
+ section { background: var(--panel); border: 1px solid var(--border);
30
+ border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; }
31
+ h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent); }
32
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
33
+ .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
34
+ color: var(--fg); padding: 6px 12px; border-radius: 4px; cursor: pointer;
35
+ font-family: inherit; font-size: 12px; }
36
+ .btn:hover { background: #2c3450; }
37
+ .btn.primary { background: var(--accent); color: #0a1a2a;
38
+ border-color: var(--accent); font-weight: 600; }
39
+ .btn.danger { background: var(--bad); color: #fff; border-color: var(--bad); }
40
+ .btn:disabled { background: var(--panel-3); color: var(--muted-2);
41
+ border-color: var(--border); cursor: not-allowed; }
42
+
43
+ /* login screen */
44
+ .login-wrap { display: flex; align-items: center; justify-content: center;
45
+ min-height: 70vh; }
46
+ .login-card { background: var(--panel); border: 1px solid var(--border-strong);
47
+ border-radius: 10px; padding: 22px 24px; max-width: 380px; width: 100%; }
48
+ .login-card h2 { margin: 0 0 6px; color: var(--accent); }
49
+ .login-card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; }
50
+ .login-card label { font-size: 11px; color: var(--muted);
51
+ text-transform: uppercase; letter-spacing: 0.5px; }
52
+ .login-card input { width: 100%; background: var(--panel-3);
53
+ border: 1px solid var(--border); color: var(--fg); padding: 10px 12px;
54
+ border-radius: 5px; font-family: inherit; font-size: 16px; margin-top: 4px; }
55
+ .login-card .err { color: var(--bad); font-size: 12px; margin-top: 6px;
56
+ min-height: 16px; }
57
+ .login-card .login-btn { width: 100%; margin-top: 12px; padding: 10px;
58
+ font-size: 14px; font-weight: 700; }
59
+
60
+ /* table */
61
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
62
+ th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border); }
63
+ th { color: var(--muted); font-weight: 600; font-size: 11px;
64
+ text-transform: uppercase; letter-spacing: 0.5px;
65
+ background: var(--panel-2); position: sticky; top: 0; }
66
+ tr:hover td { background: var(--panel-2); }
67
+ td.path, td.email { font-family: ui-monospace, monospace; font-size: 11px;
68
+ color: var(--muted); word-break: break-all; }
69
+ td .badge { display: inline-block; padding: 2px 6px; border-radius: 8px;
70
+ font-size: 10px; font-weight: 700; }
71
+ td .badge.final { background: #1c3422; color: var(--good); }
72
+ td .badge.in_progress { background: #3a3520; color: var(--warn); }
73
+ td .badge.admin { background: #3a2f60; color: #d9b6ff; margin-left: 4px; }
74
+ .progress-bar { background: var(--panel-3); height: 4px; border-radius: 2px;
75
+ overflow: hidden; min-width: 80px; }
76
+ .progress-bar .fill { background: var(--accent); height: 100%; }
77
+
78
+ .filter-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
79
+ margin-bottom: 10px; }
80
+ .filter-row input { background: var(--panel-3); border: 1px solid var(--border);
81
+ color: var(--fg); padding: 5px 8px; border-radius: 4px; font-size: 12px;
82
+ font-family: inherit; min-width: 200px; }
83
+
84
+ /* charts */
85
+ #viz { background: var(--panel); border: 1px solid var(--border);
86
+ border-radius: 8px; padding: 14px 16px; margin-top: 14px; }
87
+ .q-block { background: var(--panel-2); border: 1px solid var(--border);
88
+ border-radius: 6px; padding: 10px 12px; margin: 8px 0; }
89
+ .q-block .q-title { font-weight: 600; font-size: 13px; }
90
+ .q-block .q-dim { color: var(--muted); font-size: 11px; }
91
+ canvas { max-height: 220px; }
92
+ .legend-dot { display: inline-block; width: 9px; height: 9px;
93
+ border-radius: 50%; margin-right: 4px; vertical-align: middle; }
94
+
95
+ .toast { position: fixed; bottom: 20px; right: 20px;
96
+ background: var(--good); color: #0a1a0a; padding: 10px 16px;
97
+ border-radius: 6px; font-weight: 600; opacity: 0;
98
+ transition: opacity 0.2s; pointer-events: none; z-index: 100; }
99
+ .toast.show { opacity: 1; }
100
+ .toast.error { background: var(--bad); color: #fff; }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div id="app"></div>
105
+ <div class="toast" id="toast"></div>
106
+
107
+ <script>
108
+ const ADMIN_PW_KEY = "user_study_admin_pw_v1";
109
+
110
+ // Canonical rubric (must stay in sync with index.html DIMENSIONS).
111
+ const DIMENSIONS = [
112
+ { key: "instruction_following", label: "Instruction following",
113
+ questions: [
114
+ ["controllability", "Controllability"],
115
+ ["prompt_adherence", "Prompt adherence"],
116
+ ["no_irrelevant_frames", "No irrelevant frames"],
117
+ ] },
118
+ { key: "temporal_consistency", label: "Temporal consistency",
119
+ questions: [
120
+ ["person_stability", "Person stability"],
121
+ ["object_stability", "Object stability"],
122
+ ["environment_stability", "Environment stability"],
123
+ ] },
124
+ { key: "motion_realism", label: "Realism",
125
+ questions: [
126
+ ["realism", "Static realism"],
127
+ ["motion_logic", "Motion logic"],
128
+ ["camera", "Camera"],
129
+ ] },
130
+ ];
131
+ const ALL_QKEYS = DIMENSIONS.flatMap(d => d.questions.map(([k]) => k));
132
+
133
+ let SUBMISSIONS = []; // list from /api/admin/submissions
134
+ let SELECTED = new Set(); // paths the admin has ticked
135
+
136
+ function el(tag, attrs={}, ...children) {
137
+ const e = document.createElement(tag);
138
+ for (const [k,v] of Object.entries(attrs)) {
139
+ if (k === "class") e.className = v;
140
+ else if (k === "html") e.innerHTML = v;
141
+ else if (k.startsWith("on") && typeof v === "function")
142
+ e.addEventListener(k.slice(2).toLowerCase(), v);
143
+ else if (v !== false && v != null) e.setAttribute(k, v);
144
+ }
145
+ for (const c of children) {
146
+ if (c == null || c === false) continue;
147
+ e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
148
+ }
149
+ return e;
150
+ }
151
+ function showToast(msg, isErr=false) {
152
+ const t = document.getElementById("toast");
153
+ t.textContent = msg;
154
+ t.classList.toggle("error", !!isErr);
155
+ t.classList.add("show");
156
+ setTimeout(()=>t.classList.remove("show"), 1800);
157
+ }
158
+ function adminPw() { try { return sessionStorage.getItem(ADMIN_PW_KEY) || ""; } catch(e){ return ""; } }
159
+ function setAdminPw(pw) { try { sessionStorage.setItem(ADMIN_PW_KEY, pw); } catch(e){} }
160
+ function clearAdminPw() { try { sessionStorage.removeItem(ADMIN_PW_KEY); } catch(e){} }
161
+
162
+ /* ---------- login ---------- */
163
+ function renderLogin() {
164
+ const app = document.getElementById("app");
165
+ app.innerHTML = "";
166
+ app.appendChild(el("header", {},
167
+ el("h1", {}, "Admin · FrameWorker user study"),
168
+ el("span", { class: "meta" }, "password-gated"),
169
+ el("div", { class: "right" },
170
+ el("a", { href: "./", style: "color:var(--accent);font-size:12px" }, "← back to study"),
171
+ ),
172
+ ));
173
+ const wrap = el("div", { class: "login-wrap" });
174
+ const card = el("div", { class: "login-card" });
175
+ card.appendChild(el("h2", {}, "Admin login"));
176
+ card.appendChild(el("p", {}, "Enter the admin password to view all study submissions."));
177
+ card.appendChild(el("label", { "for": "pw" }, "Password"));
178
+ const input = el("input", {
179
+ id: "pw", type: "password", autocomplete: "current-password",
180
+ placeholder: "·····",
181
+ onkeydown: e => { if (e.key === "Enter") submitLogin(); },
182
+ });
183
+ card.appendChild(input);
184
+ const err = el("div", { class: "err", id: "loginErr" });
185
+ card.appendChild(err);
186
+ card.appendChild(el("button", {
187
+ class: "btn primary login-btn",
188
+ onclick: submitLogin,
189
+ }, "Sign in"));
190
+ wrap.appendChild(card);
191
+ app.appendChild(wrap);
192
+ setTimeout(() => input.focus(), 30);
193
+ }
194
+
195
+ async function submitLogin() {
196
+ const pw = document.getElementById("pw").value;
197
+ const err = document.getElementById("loginErr");
198
+ err.textContent = "";
199
+ if (!pw) { err.textContent = "Password required."; return; }
200
+ try {
201
+ const r = await fetch("./api/admin/login", {
202
+ method: "POST", headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ password: pw }),
204
+ });
205
+ if (!r.ok) { err.textContent = "Wrong password."; return; }
206
+ setAdminPw(pw);
207
+ renderDashboard();
208
+ fetchSubmissions();
209
+ } catch (e) {
210
+ err.textContent = "Login failed: " + e.message;
211
+ }
212
+ }
213
+
214
+ /* ---------- dashboard ---------- */
215
+ function renderDashboard() {
216
+ const app = document.getElementById("app");
217
+ app.innerHTML = "";
218
+ app.appendChild(el("header", {},
219
+ el("h1", {}, "Admin · FrameWorker user study"),
220
+ el("span", { class: "meta", id: "summaryMeta" }, "loading…"),
221
+ el("div", { class: "right" },
222
+ el("a", { href: "./stats.html", target: "_blank",
223
+ style: "color:var(--accent);font-size:12px;text-decoration:none" },
224
+ "📊 aggregate stats →"),
225
+ el("button", { class: "btn",
226
+ onclick: () => { clearAdminPw(); renderLogin(); } },
227
+ "Sign out"),
228
+ ),
229
+ ));
230
+ const main = el("main");
231
+ // submissions list
232
+ const sec1 = el("section");
233
+ sec1.appendChild(el("h2", {}, "Submissions"));
234
+ const filterRow = el("div", { class: "filter-row" });
235
+ filterRow.appendChild(el("input", {
236
+ type: "search", placeholder: "Filter by name / email…",
237
+ oninput: e => filterTable(e.target.value.toLowerCase()),
238
+ }));
239
+ filterRow.appendChild(el("button", {
240
+ class: "btn", onclick: () => toggleSelectAll(true) }, "Select all"));
241
+ filterRow.appendChild(el("button", {
242
+ class: "btn", onclick: () => toggleSelectAll(false) }, "Clear"));
243
+ filterRow.appendChild(el("button", {
244
+ class: "btn primary", id: "vizBtn", onclick: visualizeSelected,
245
+ disabled: "" }, "Visualize selected"));
246
+ filterRow.appendChild(el("button", {
247
+ class: "btn", onclick: () => fetchSubmissions() }, "↻ Refresh"));
248
+ sec1.appendChild(filterRow);
249
+
250
+ sec1.appendChild(el("div", { id: "tableWrap" }));
251
+ main.appendChild(sec1);
252
+
253
+ // visualisation slot
254
+ const sec2 = el("div", { id: "viz", style: "display:none" });
255
+ main.appendChild(sec2);
256
+
257
+ app.appendChild(main);
258
+ }
259
+
260
+ async function fetchSubmissions() {
261
+ try {
262
+ const r = await fetch("./api/admin/submissions", {
263
+ headers: { "X-Admin-Password": adminPw() },
264
+ });
265
+ if (r.status === 401) { clearAdminPw(); renderLogin(); return; }
266
+ if (!r.ok) {
267
+ showToast(`Fetch failed (HTTP ${r.status})`, true);
268
+ return;
269
+ }
270
+ const j = await r.json();
271
+ SUBMISSIONS = j.submissions || [];
272
+ SELECTED = new Set();
273
+ renderTable();
274
+ document.getElementById("summaryMeta").textContent =
275
+ `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` +
276
+ `${new Set(SUBMISSIONS.map(s=>s.rater_email)).size} unique raters`;
277
+ } catch (e) {
278
+ showToast("Fetch failed: " + e.message, true);
279
+ }
280
+ }
281
+
282
+ function renderTable() {
283
+ const wrap = document.getElementById("tableWrap");
284
+ wrap.innerHTML = "";
285
+ const table = el("table");
286
+ const thead = el("thead");
287
+ thead.appendChild(el("tr", {},
288
+ el("th", { style: "width:32px" }, ""),
289
+ el("th", {}, "Rater"),
290
+ el("th", { class: "email" }, "Email"),
291
+ el("th", {}, "Status"),
292
+ el("th", {}, "Scores"),
293
+ el("th", {}, "Cases"),
294
+ el("th", {}, "Received (UTC)"),
295
+ el("th", { class: "path" }, "Path"),
296
+ ));
297
+ table.appendChild(thead);
298
+ const tbody = el("tbody", { id: "tbody" });
299
+ for (const s of SUBMISSIONS) {
300
+ tbody.appendChild(renderRow(s));
301
+ }
302
+ table.appendChild(tbody);
303
+ wrap.appendChild(table);
304
+ updateVizBtn();
305
+ }
306
+
307
+ function renderRow(s) {
308
+ const tr = el("tr", { "data-path": s.path });
309
+ const cb = el("input", { type: "checkbox" });
310
+ cb.addEventListener("change", () => {
311
+ if (cb.checked) SELECTED.add(s.path); else SELECTED.delete(s.path);
312
+ updateVizBtn();
313
+ });
314
+ if (SELECTED.has(s.path)) cb.checked = true;
315
+ tr.appendChild(el("td", {}, cb));
316
+ tr.appendChild(el("td", {},
317
+ s.rater_name || "(unnamed)",
318
+ s.is_admin ? el("span", { class: "badge admin" }, "admin") : null,
319
+ ));
320
+ tr.appendChild(el("td", { class: "email" }, s.rater_email || "—"));
321
+ const status = s.status || (s.path.includes("in_progress") ? "in_progress" : "final");
322
+ tr.appendChild(el("td", {},
323
+ el("span", { class: "badge " + status }, status)));
324
+ tr.appendChild(el("td", {}, String(s.n_scores ?? "—")));
325
+ tr.appendChild(el("td", {}, String(s.n_cases ?? "—")));
326
+ tr.appendChild(el("td", {},
327
+ (s.received_at_utc || "—").replace("T"," ").replace("Z","")));
328
+ // Path → link to dataset blob view
329
+ const pathLink = el("a", {
330
+ href: `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/blob/main/${s.path}`,
331
+ target: "_blank", rel: "noopener",
332
+ style: "color:var(--muted);text-decoration:none",
333
+ title: "open raw JSON on the dataset",
334
+ }, s.path);
335
+ tr.appendChild(el("td", { class: "path" }, pathLink));
336
+ return tr;
337
+ }
338
+
339
+ function filterTable(needle) {
340
+ for (const tr of document.querySelectorAll("#tbody tr")) {
341
+ const txt = tr.textContent.toLowerCase();
342
+ tr.style.display = txt.includes(needle) ? "" : "none";
343
+ }
344
+ }
345
+ function toggleSelectAll(on) {
346
+ SELECTED = new Set(on ? SUBMISSIONS.map(s=>s.path) : []);
347
+ renderTable();
348
+ }
349
+ function updateVizBtn() {
350
+ const btn = document.getElementById("vizBtn");
351
+ if (!btn) return;
352
+ btn.textContent = `Visualize selected (${SELECTED.size})`;
353
+ btn.disabled = SELECTED.size === 0;
354
+ }
355
+
356
+ /* ---------- visualization ---------- */
357
+ async function visualizeSelected() {
358
+ if (SELECTED.size === 0) return;
359
+ const viz = document.getElementById("viz");
360
+ viz.style.display = "block";
361
+ viz.innerHTML = "<h2>Loading selected submissions…</h2>";
362
+
363
+ const submissions = [];
364
+ for (const path of SELECTED) {
365
+ try {
366
+ const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${path}`;
367
+ const r = await fetch(url, { cache: "no-cache" });
368
+ if (r.ok) submissions.push(await r.json());
369
+ } catch (e) { /* skip */ }
370
+ }
371
+ renderViz(submissions);
372
+ }
373
+
374
+ function renderViz(payloads) {
375
+ // bag: (model, qkey) -> scores
376
+ const bag = {};
377
+ for (const p of payloads) {
378
+ for (const c of (p.cases || [])) {
379
+ for (const v of (c.videos || [])) {
380
+ const m = v.model || "unknown";
381
+ for (const [qk, score] of Object.entries(v.ratings || {})) {
382
+ if (typeof score === "number" && score >= 1 && score <= 5) {
383
+ (bag[`${m}|${qk}`] ||= []).push(score);
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ const models = Array.from(new Set(Object.keys(bag).map(k => k.split("|")[0])));
390
+ models.sort((a,b) => (a === "ours" ? -1 : b === "ours" ? 1 : a.localeCompare(b)));
391
+ const COLORS = ["#6aa9ff","#ff9f43","#28c76f","#ea5455","#b46aff"];
392
+ const mcolor = Object.fromEntries(models.map((m,i) => [m, COLORS[i % COLORS.length]]));
393
+
394
+ const viz = document.getElementById("viz");
395
+ viz.innerHTML = "";
396
+ viz.appendChild(el("h2", {},
397
+ `Aggregate over ${payloads.length} selected submission(s)`));
398
+
399
+ // Per dimension
400
+ const dimDiv = el("section", { style: "background:transparent;border:none;padding:0" });
401
+ dimDiv.appendChild(el("h2", {}, "Per dimension"));
402
+ const dimCanvas = el("canvas");
403
+ dimDiv.appendChild(dimCanvas);
404
+ viz.appendChild(dimDiv);
405
+
406
+ function mean(arr){ return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null; }
407
+ function statsFor(qkeys, m){
408
+ const all = [];
409
+ for (const qk of qkeys) all.push(...(bag[`${m}|${qk}`] || []));
410
+ return all;
411
+ }
412
+ const dimLabels = DIMENSIONS.map(d => d.label);
413
+ const dimData = models.map(m => ({
414
+ label: m,
415
+ data: DIMENSIONS.map(d => mean(statsFor(d.questions.map(q=>q[0]), m))),
416
+ backgroundColor: mcolor[m] + "cc",
417
+ borderColor: mcolor[m], borderWidth: 1,
418
+ }));
419
+ new Chart(dimCanvas, {
420
+ type: "bar",
421
+ data: { labels: dimLabels, datasets: dimData },
422
+ options: {
423
+ scales: {
424
+ y: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
425
+ x: { ticks: { color: "#e6e8ef" }, grid: { display: false } },
426
+ },
427
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
428
+ },
429
+ });
430
+
431
+ // Per question
432
+ viz.appendChild(el("h2", { style: "margin-top:14px" }, "Per sub-question"));
433
+ for (const d of DIMENSIONS) {
434
+ for (const [qk, qlabel] of d.questions) {
435
+ const block = el("div", { class: "q-block" });
436
+ const total = models.reduce((n,m) => n + (bag[`${m}|${qk}`]||[]).length, 0);
437
+ block.appendChild(el("div", { class: "q-title" }, qlabel));
438
+ block.appendChild(el("div", { class: "q-dim" }, d.label + (total ? ` · n=${total}` : " · (no data)")));
439
+ if (total) {
440
+ const c = el("canvas");
441
+ block.appendChild(c);
442
+ const datasets = models.map(m => ({
443
+ label: m,
444
+ data: [mean(bag[`${m}|${qk}`] || [])],
445
+ backgroundColor: mcolor[m] + "cc",
446
+ borderColor: mcolor[m], borderWidth: 1,
447
+ }));
448
+ new Chart(c, {
449
+ type: "bar",
450
+ data: { labels: [qlabel], datasets },
451
+ options: {
452
+ indexAxis: "y", scales: {
453
+ x: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
454
+ y: { ticks: { display: false }, grid: { display: false } },
455
+ },
456
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
457
+ },
458
+ });
459
+ }
460
+ viz.appendChild(block);
461
+ }
462
+ }
463
+ viz.scrollIntoView({ behavior: "smooth", block: "start" });
464
+ }
465
+
466
+ /* ---------- boot ---------- */
467
+ (function init(){
468
+ if (adminPw()) {
469
+ // Probe a cheap endpoint to validate the cached password.
470
+ fetch("./api/admin/submissions", {
471
+ headers: { "X-Admin-Password": adminPw() },
472
+ }).then(r => {
473
+ if (r.status === 401) { clearAdminPw(); renderLogin(); }
474
+ else { renderDashboard(); fetchSubmissions(); }
475
+ }).catch(() => renderLogin());
476
+ } else {
477
+ renderLogin();
478
+ }
479
+ })();
480
+ </script>
481
+ </body>
482
+ </html>
static/index.html CHANGED
@@ -599,10 +599,10 @@ const STRINGS = {
599
  "tips_header": "Tips",
600
  "tip_keys": "Use <kbd>Space</kbd> to play / pause all videos in sync, <kbd>R</kbd> to restart, <kbd>L</kbd> to toggle loop.",
601
  "tip_nav": "Use <kbd>←</kbd> / <kbd>→</kbd> (or <kbd>j</kbd> / <kbd>k</kbd>) to jump between cases.",
602
- "tip_autosave": "Ratings autosave to your browser's localStorage. Click <code>↓ Export JSON</code> when finished.",
603
  "tip_anon": "Videos are anonymized by default — model identity is hidden unless an admin toggles reveal.",
604
  "tip_missing": "If a tile shows \"no video file\", the manifest path doesn't exist on disk yet — drop a real <code>.mp4</code> at that path and refresh.",
605
- "tip_save_then_submit": "Click <code>💾</code> (Save progress) at any time — your in-progress ratings stay safe. When all questions are filled in, click <strong>Submit study</strong> to record the final submission.",
606
  "tip_audio": "For BGM / soundtrack / subtitle questions, listen to the video — toggle the speaker button to unmute.",
607
  "scale_1": "Very poor",
608
  "scale_2": "Poor",
@@ -726,10 +726,10 @@ const STRINGS = {
726
  "tips_header": "操作提示",
727
  "tip_keys": "<kbd>Space</kbd> 同步播放 / 暂停所有视频,<kbd>R</kbd> 重新开始,<kbd>L</kbd> 切换循环。",
728
  "tip_nav": "使用 <kbd>←</kbd> / <kbd>→</kbd> (或 <kbd>j</kbd> / <kbd>k</kbd>) 在案例间切换。",
729
- "tip_autosave": "评分会自动保存到浏览器的 localStorage。完成后点击 <code>↓ 导出 JSON</code>。",
730
  "tip_anon": "默认情况下视频已匿名 — 仅管理员可切换显示模型名称。",
731
  "tip_missing": "若卡片显示「没有视频文件」,说明清单中的路径在磁盘上不存在 — 请将真实的 <code>.mp4</code> 放置在该路径后刷新。",
732
- "tip_save_then_submit": "评分过程中可随时点击 <code>💾</code>(保存进度),已打分的内容会安全保留;全部完成后请点击<strong>提交研究</strong>记录最终结果。",
733
  "tip_audio": "涉及 BGM / 配乐 / 字幕等问题时,请打开视频声音聆听 — 点击扬声器按钮可取消静音。",
734
  "scale_1": "很差",
735
  "scale_2": "差",
@@ -853,10 +853,10 @@ const STRINGS = {
853
  "tips_header": "Съвети",
854
  "tip_keys": "С <kbd>Space</kbd> пускате / спирате всички видеа синхронно, <kbd>R</kbd> рестартира, <kbd>L</kbd> превключва цикъл.",
855
  "tip_nav": "С <kbd>←</kbd> / <kbd>→</kbd> (или <kbd>j</kbd> / <kbd>k</kbd>) минавате между случаите.",
856
- "tip_autosave": "Оценките се запазват автоматично в localStorage на браузъра. Щракнете <code>↓ Изтегли JSON</code>, когато приключите.",
857
  "tip_anon": "По подразбиране видеата са анонимизирани — само администратор може да покаже имената на моделите.",
858
  "tip_missing": "Ако плочка показва „няма видео файл“, пътят от манифеста не съществува — поставете истинско <code>.mp4</code> там и презаредете.",
859
- "tip_save_then_submit": "Можете да щракнете <code>💾</code> (Запази прогрес) по всяко време — оценките ви остават запазени. Когато всички въпроси са попълнени, щракнете <strong>Изпрати проучването</strong>, за да запишете окончателното изпращане.",
860
  "tip_audio": "За въпроси относно BGM / музика / субтитри слушайте видео звука — превключете бутона за високоговорителя, за да включите звука.",
861
  "scale_1": "Много слабо",
862
  "scale_2": "Слабо",
@@ -1686,10 +1686,10 @@ function renderToolbar() {
1686
  }, anonymize ? t("anonymized") : t("reveal_models")));
1687
  tb.appendChild(el("a", {
1688
  class: "btn ghost",
1689
- href: "./stats.html", target: "_blank", rel: "noopener",
1690
  style: "text-decoration:none",
1691
- title: "open the aggregated study stats dashboard",
1692
- }, "📊 Stats"));
1693
  tb.appendChild(el("div", { class: "sep" }));
1694
  }
1695
 
 
599
  "tips_header": "Tips",
600
  "tip_keys": "Use <kbd>Space</kbd> to play / pause all videos in sync, <kbd>R</kbd> to restart, <kbd>L</kbd> to toggle loop.",
601
  "tip_nav": "Use <kbd>←</kbd> / <kbd>→</kbd> (or <kbd>j</kbd> / <kbd>k</kbd>) to jump between cases.",
602
+ "tip_autosave": "Ratings autosave to your browser as you go — but they only count once you click <strong>Submit study</strong>.",
603
  "tip_anon": "Videos are anonymized by default — model identity is hidden unless an admin toggles reveal.",
604
  "tip_missing": "If a tile shows \"no video file\", the manifest path doesn't exist on disk yet — drop a real <code>.mp4</code> at that path and refresh.",
605
+ "tip_save_then_submit": "Click <code>💾</code> (Save progress) at any time — in-progress ratings stay safe. <strong>You must click <code>Submit study</code> once every question is filled in</strong>; otherwise your scores do not count.",
606
  "tip_audio": "For BGM / soundtrack / subtitle questions, listen to the video — toggle the speaker button to unmute.",
607
  "scale_1": "Very poor",
608
  "scale_2": "Poor",
 
726
  "tips_header": "操作提示",
727
  "tip_keys": "<kbd>Space</kbd> 同步播放 / 暂停所有视频,<kbd>R</kbd> 重新开始,<kbd>L</kbd> 切换循环。",
728
  "tip_nav": "使用 <kbd>←</kbd> / <kbd>→</kbd> (或 <kbd>j</kbd> / <kbd>k</kbd>) 在案例间切换。",
729
+ "tip_autosave": "评分过程中会自动保存在浏览器内 — 但只有在点击<strong>提交研究</strong>之后才会被记录。",
730
  "tip_anon": "默认情况下视频已匿名 — 仅管理员可切换显示模型名称。",
731
  "tip_missing": "若卡片显示「没有视频文件」,说明清单中的路径在磁盘上不存在 — 请将真实的 <code>.mp4</code> 放置在该路径后刷新。",
732
+ "tip_save_then_submit": "评分过程中可随时点击 <code>💾</code>(保存进度),已打分的内容会安全保留。<strong>全部完成后必须点击「提交研究」</strong>,否则您的评分不会被计入。",
733
  "tip_audio": "涉及 BGM / 配乐 / 字幕等问题时,请打开视频声音聆听 — 点击扬声器按钮可取消静音。",
734
  "scale_1": "很差",
735
  "scale_2": "差",
 
853
  "tips_header": "Съвети",
854
  "tip_keys": "С <kbd>Space</kbd> пускате / спирате всички видеа синхронно, <kbd>R</kbd> рестартира, <kbd>L</kbd> превключва цикъл.",
855
  "tip_nav": "С <kbd>←</kbd> / <kbd>→</kbd> (или <kbd>j</kbd> / <kbd>k</kbd>) минавате между случаите.",
856
+ "tip_autosave": "Оценките се запазват автоматично в браузъра ви — но влизат в сметка чак след като щракнете <strong>Изпрати проучването</strong>.",
857
  "tip_anon": "По подразбиране видеата са анонимизирани — само администратор може да покаже имената на моделите.",
858
  "tip_missing": "Ако плочка показва „няма видео файл“, пътят от манифеста не съществува — поставете истинско <code>.mp4</code> там и презаредете.",
859
+ "tip_save_then_submit": "Можете да щракнете <code>💾</code> (Запази прогрес) по всяко време — оценките ви остават запазени. <strong>Когато всички въпроси са попълнени, трябва да щракнете „Изпрати проучването“</strong>, иначе оценките ви не се ��роят.",
860
  "tip_audio": "За въпроси относно BGM / музика / субтитри слушайте видео звука — превключете бутона за високоговорителя, за да включите звука.",
861
  "scale_1": "Много слабо",
862
  "scale_2": "Слабо",
 
1686
  }, anonymize ? t("anonymized") : t("reveal_models")));
1687
  tb.appendChild(el("a", {
1688
  class: "btn ghost",
1689
+ href: "./admin.html", target: "_blank", rel: "noopener",
1690
  style: "text-decoration:none",
1691
+ title: "open the admin dashboard (password-gated)",
1692
+ }, "🛠 Admin"));
1693
  tb.appendChild(el("div", { class: "sep" }));
1694
  }
1695