Spaces:
Running
Running
File size: 42,511 Bytes
2e3db49 490790a 2e3db49 e27289c 8b45cc8 e27289c 2e3db49 0db4d7f 2e3db49 5f1aaf7 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 9126057 f0bfa49 2f47f3f 0db4d7f 5da77ee 2e3db49 490790a 2e3db49 e27289c 2e3db49 e27289c 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 0db4d7f 2e3db49 e27289c 2e3db49 e27289c 490790a e27289c 0db4d7f e27289c 0db4d7f f0bfa49 e27289c 8d19d30 e27289c 2e3db49 e27289c 2e3db49 5f1aaf7 0db4d7f e27289c 0db4d7f e27289c 0db4d7f 5cb6969 0db4d7f 5cb6969 766336e f0bfa49 2e3db49 490790a e27289c 490790a e27289c 490790a e27289c 490790a 2e3db49 490790a 2e3db49 9126057 2e3db49 f0bfa49 0db4d7f 2e3db49 490790a f0bfa49 e27289c f0bfa49 e27289c 2e3db49 490790a e27289c f0bfa49 490790a 2e3db49 f0bfa49 2e3db49 e27289c 2e3db49 9126057 2e3db49 2f47f3f 2e3db49 5da77ee 2e3db49 8d19d30 f0bfa49 e27289c 8d19d30 f0bfa49 2e3db49 8d19d30 2e3db49 8d19d30 2e3db49 8d19d30 8b45cc8 f0bfa49 8b45cc8 2e3db49 f0bfa49 2e3db49 f0bfa49 2f47f3f f0bfa49 2e3db49 f0bfa49 2e3db49 f0bfa49 9126057 2e3db49 f0bfa49 2e3db49 490790a 2e3db49 9126057 f0bfa49 9126057 f0bfa49 9126057 2e3db49 5da77ee 2e3db49 f0bfa49 2e3db49 5da77ee 2e3db49 490790a f0bfa49 2e3db49 490790a 2e3db49 f0bfa49 490790a 2e3db49 f0bfa49 0db4d7f f0bfa49 2e3db49 f0bfa49 490790a 2e3db49 5da77ee 2e3db49 5f1aaf7 490790a 2e3db49 4ca2363 2e3db49 4ca2363 2e3db49 f0bfa49 2e3db49 9126057 2e3db49 766336e 2e3db49 490790a 2e3db49 4ca2363 2e3db49 0db4d7f 4ca2363 0db4d7f 4ca2363 060cc4d 0db4d7f 060cc4d 8d19d30 060cc4d e27289c 8d19d30 f0bfa49 060cc4d f0bfa49 0db4d7f f0bfa49 0db4d7f f0bfa49 0db4d7f f0bfa49 490790a 2e3db49 490790a 2e3db49 5f1aaf7 2e3db49 f0bfa49 2f47f3f 0db4d7f f0bfa49 2e3db49 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 | /* LocalGate audit engine — shared by both audit Spaces.
*
* Design contract (deliverables/AUDIT_SPACES_BUILD.md, FINAL SPEC):
* - Same-tab OAuth on the direct *.static.hf.space URL; embedded views link out.
* - Token lives in sessionStorage only; the hub library persists nothing itself.
* - Scopes are read-repos + contribute-repos: results go to a private dataset in
* the ANNOTATOR'S OWN namespace ({user}/localgate-audit-results), created on
* first login. Nobody can write anyone else's results at the permission layer.
* - Append-only event log: every judgment/revision is a new row; analysis takes
* the LAST row per key in file order (position is the authority — client
* clocks are metadata, immune to skew). Every push is read-merge-write: the
* remote file is pulled and unioned first, so a push can only grow the file.
* - One tab at a time: a localStorage heartbeat blocks a second tab of the
* same task in the same browser (two browsers merge safely, last-write races
* are bounded by the merge-before-push).
* - All content rendered via textContent. No innerHTML anywhere in this file.
* - localStorage holds the event log per (task, user) — loss bound is zero;
* uploads every SAVE_EVERY judgments / on 's' / on tab-hide / at the end.
*/
import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile,
createRepo } from "./hub-2.15.0.bundle.mjs";
// The version query keeps the nested import cache-coherent with app.js itself:
// static Spaces send no Cache-Control, and only the top-level script tags get
// the ?v= stamp — an unversioned nested import could load a stale module.
import { keyOf, latestByKey, mergeEvents, orderItems, firstUngraded as
firstUngradedPure, countComplete, stampClass, nextEpoch,
standsDownTo, statusOf, classifyProbe, hasScopes, displayId }
from "./logic.mjs?v=d2b2018-space";
// Test seam: Playwright installs window.__testHub (an in-memory hub) before
// any page script runs; production never defines it, so this is one inert
// window read per call. The fake mirrors the real contracts exactly —
// downloadFile: null on missing file, THROW on any other failure.
const hub = () => window.__testHub ?? {
oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile,
createRepo, fetch: (...args) => fetch(...args),
};
/* global CONFIG */
const ORG_ID = "6a7af86a89612db0d39b0b14"; // localgate — forces the org grant
// write-repos: commit to the shared repo whichever app created it;
// contribute-repos: create it on a brand-new annotator's first login.
const REQUIRED_SCOPES = ["read-repos", "write-repos", "contribute-repos"];
const ITEMS_REPO = { type: "dataset", name: "localgate/audit-items" };
const SAVE_EVERY = 3; // phones kill background fetches; keep the window small
const $ = (sel) => document.querySelector(sel);
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
};
// ── rubric fingerprint (same discipline as convert.py's PROMPT_VERSION) ──────
async function rubricVersion() {
const data = new TextEncoder().encode(CONFIG.rubric + "\0" + JSON.stringify(CONFIG.fields));
const hash = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hash)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// ── auth ─────────────────────────────────────────────────────────────────────
function storedAuth() {
try {
const raw = sessionStorage.getItem("oauth");
if (!raw) return null;
const auth = JSON.parse(raw);
if (new Date(auth.accessTokenExpiresAt) <= new Date()) return null;
// A token minted before a scope change still "works" for reads but fails
// at publish — treat it as absent so the user re-consents cleanly.
if (!hasScopes(auth.scope, REQUIRED_SCOPES)) return null;
return auth;
} catch { return null; }
}
async function ensureAuth() {
// A fresh authorization redirect ALWAYS outranks the cache: the user just
// consented, possibly to new scopes — ignoring it kept stale tokens alive.
if (new URLSearchParams(location.search).has("code")) {
const fresh = await hub().oauthHandleRedirectIfPresent();
if (fresh) {
sessionStorage.setItem("oauth", JSON.stringify(fresh));
history.replaceState(null, "", location.pathname); // ?code is single-use
return fresh;
}
}
return storedAuth();
}
async function signIn() {
sessionStorage.removeItem("oauth"); // never carry a stale grant
const url = await hub().oauthLoginUrl(); // reads window.huggingface.variables in a Space
// prompt=consent re-shows the consent screen (the HF client-side-oauth
// idiom) so scope changes are actually granted, not silently skipped.
window.location.href = url + "&orgIds=" + ORG_ID + "&prompt=consent";
}
// ── state ────────────────────────────────────────────────────────────────────
const state = {
auth: null, user: null, items: [], order: [], idx: 0,
events: [], // append-only, mirrored to localStorage
unsaved: 0, sessionId: crypto.randomUUID().slice(0, 8),
shownAt: 0, rubricVersion: "", resultsRepo: null, last: null,
breakShownAt: Date.now(), stickyBanner: null, stickyActions: [], stickyKind: null,
gradedSinceBreak: 0, infoNavsLeft: 0,
hbTimer: null, pushing: null, pushQueued: false, publishBlocked: null,
onPushSuccess: null, lastVerdictKey: null, lastVerdictAt: 0,
};
const logKey = () => `audit:${CONFIG.task}:${state.user}`;
function loadLocalEvents() {
try { return JSON.parse(localStorage.getItem(logKey()) ?? "[]"); }
catch { return []; }
}
function persistLocal() {
localStorage.setItem(logKey(), JSON.stringify(state.events));
}
function buildOrder() {
state.order = orderItems(state.items, `${CONFIG.task}:${state.user}`);
}
function firstUngraded() {
return firstUngradedPure(state.order, state.items, state.events,
(ev) => CONFIG.isComplete(ev));
}
// ── results repo (own namespace) ─────────────────────────────────────────────
// GET /api/datasets/{name} with the user's token: 200 = exists and visible.
async function repoProbe() {
try {
const res = await hub().fetch(
`https://huggingface.co/api/datasets/${state.resultsRepo.name}`,
{ headers: { Authorization: `Bearer ${state.auth.accessToken}` },
cache: "no-store" });
return classifyProbe(res.ok ? 200 : res.status);
} catch { return "network"; }
}
// huggingface_hub's own exist_ok logic, ported: create, treat the
// already-exists 409 as success, retry the concurrency 409, and on a
// permission 401/403 probe whether the repo exists anyway (write-repos can
// commit to it even when creation is not granted). Returns a status object —
// boot decides policy; grading must never be blocked by this.
async function ensureResultsRepo() {
const name = `${state.user}/localgate-audit-results`;
state.resultsRepo = { type: "dataset", name };
for (let attempt = 0; attempt < 3; attempt++) {
try {
await hub().createRepo({ repo: state.resultsRepo,
accessToken: state.auth.accessToken, private: true });
return { ok: true, repo: "created" };
} catch (err) {
const status = statusOf(err);
if (status === 409 || /already/i.test(String(err))) {
if (/conflicting operation/i.test(String(err))) continue; // create race
return { ok: true, repo: "present" };
}
if (status === 401 || status === 403) {
const probe = await repoProbe();
if (probe === "present") return { ok: true, repo: "present" };
if (probe === "absent") return { ok: false, kind: "cannot-create" };
return { ok: false, kind: probe === "denied" ? "stale-token" : "network" };
}
return { ok: false, kind: "network", detail: String(err).slice(0, 120) };
}
}
return { ok: false, kind: "network", detail: "create kept conflicting" };
}
const remotePath = () => `${CONFIG.task}/${state.user}.jsonl`;
// Read one of the user's own log files with honest outcomes: an absent repo
// or file is "absent" (legitimately not started); permission problems are
// "denied" (stale token — NOT the same as zero progress); anything else is
// "error". Callers must never render "0 graded" for denied/error.
async function readOwnLog(task) {
try {
const blob = await hub().downloadFile({
repo: state.resultsRepo, accessToken: state.auth.accessToken,
path: `${task}/${state.user}.jsonl` });
if (blob === null) return { kind: "absent", rows: [] };
const rows = (await blob.text()).split("\n").filter(Boolean)
.map((line) => JSON.parse(line));
return { kind: "rows", rows };
} catch (err) {
const status = statusOf(err);
if (status === 404) return { kind: "absent", rows: [] }; // repo not created yet
if (status === 401 || status === 403) return { kind: "denied", rows: [] };
return { kind: "error", rows: [], detail: String(err).slice(0, 100) };
}
}
async function pullRemote() {
const blob = await hub().downloadFile({ repo: state.resultsRepo, path: remotePath(),
accessToken: state.auth.accessToken });
if (blob === null) return []; // no file yet — first session
return (await blob.text()).split("\n").filter(Boolean).map((line) => JSON.parse(line));
}
// Head commit at pull time — passed as parentCommit so the upload is
// compare-and-swap: a concurrent device's commit makes ours 412 instead of
// silently clobbering it (the one cross-device loss the merge can't prevent).
async function headCommit() {
const res = await hub().fetch(
`https://huggingface.co/api/datasets/${state.resultsRepo.name}/revision/main`,
{ headers: { Authorization: `Bearer ${state.auth.accessToken}` },
cache: "no-store" });
if (!res.ok) throw new Error(`head lookup failed: HTTP ${res.status}`);
return (await res.json()).sha;
}
async function pushOnce() {
// Read-merge-write: never upload without having just read the remote head.
// A pull failure aborts the push — an unreadable remote must not be replaced.
for (let attempt = 0; ; attempt++) {
const parent = await headCommit();
const remote = await pullRemote();
state.events = mergeEvents(remote, state.events);
persistLocal();
const snapshot = state.events.length;
const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n";
try {
await hub().uploadFiles({
repo: state.resultsRepo, accessToken: state.auth.accessToken,
commitTitle: `${CONFIG.task}: ${state.user} — ${snapshot} events`,
files: [{ path: remotePath(), content: new Blob([jsonl]) }],
parentCommit: parent,
});
} catch (err) {
if (attempt < 3 && (statusOf(err) === 412
|| /412|precondition/i.test(String(err)))) continue;
throw err; // someone kept committing, or a real failure
}
// Events recorded while the upload was in flight are still unsaved —
// assigning 0 here would make the pagehide flush skip them.
state.unsaved = state.events.length - snapshot;
state.publishBlocked = null; // publishing works again
clearErrorBanner(); // never eats an info banner
renderStatus();
state.onPushSuccess?.();
return;
}
}
// One push at a time: concurrent triggers (threshold, 's', tab-hide) queue a
// follow-up cycle instead of racing two whole-file commits against each other.
async function push() {
if (!state.events.length) return;
if (state.pushing) { state.pushQueued = true; return state.pushing; }
state.pushing = (async () => {
try {
do { state.pushQueued = false; await pushOnce(); } while (state.pushQueued);
} finally { state.pushing = null; }
})();
return state.pushing;
}
function downloadLog() {
// Escape hatch when publishing is stuck: a file in the user's hands cannot
// be evicted by the browser.
const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n";
const url = URL.createObjectURL(new Blob([jsonl], { type: "application/json" }));
const link = el("a", null, "");
link.href = url;
link.download = `${CONFIG.task}-${state.user}.jsonl`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
async function pushGuarded() {
try { await push(); }
catch (err) {
if (document.visibilityState === "hidden") return; // browser killed the
// fetch on backgrounding — expected on phones; retried on return/boot
const status = statusOf(err);
if (!storedAuth()) {
// Tokens expire (no refresh) and stale-scope tokens are rejected — a
// retry can never succeed. Re-login; localStorage keeps everything.
banner("Your login needs refreshing — all your work is saved in this "
+ "browser. Sign in again to publish it; you will continue "
+ "exactly where you left off.", true,
[{ label: "Sign in again (refreshes permissions)", onClick: signIn },
{ label: "Download your log", onClick: downloadLog }]);
return;
}
if (status === 401 || status === 403 || /forbidden/i.test(String(err))) {
state.publishBlocked = "permissions"; // stop burning auto-pushes
banner("Your work is saved in this browser, but publishing was refused "
+ "(permissions). Sign in again to refresh your access — you will "
+ "continue exactly where you left off.", true,
[{ label: "Sign in again (refreshes permissions)", onClick: signIn },
{ label: "Download your log", onClick: downloadLog }]);
return;
}
banner("Your work is saved in this browser, but publishing to the server failed "
+ `(${String(err).slice(0, 80)}). Use the save button to retry (s on a keyboard).`,
true, [{ label: "Download your log (send it to Samuel if this persists)",
onClick: downloadLog }]);
}
}
// ── one-tab guard (heartbeat in localStorage; pagehide releases it) ──────────
const HEARTBEAT_MS = 5000;
const heartbeatKey = () => `audit:hb:${CONFIG.task}`;
function foreignTabAlive() {
try {
const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
return !!hb && hb.session !== state.sessionId && Date.now() - hb.at < HEARTBEAT_MS * 3;
} catch { return false; }
}
function readHeartbeat() {
try { return JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); }
catch { return null; }
}
// Ownership is an epoch: taking over bumps it, and a tab stands down only to a
// fresh foreign heartbeat of an equal-or-newer epoch — so a seizure is
// one-directional even while the old tab is still beating. (Predicates live
// in logic.mjs and are unit-tested there.)
function startHeartbeat(force = false) {
state.hbEpoch = nextEpoch(readHeartbeat(), force);
const beat = () => {
const hb = readHeartbeat();
if (standsDownTo(hb, state.sessionId, state.hbEpoch, Date.now(),
HEARTBEAT_MS * 3)) { supersede(); return; }
localStorage.setItem(heartbeatKey(),
JSON.stringify({ session: state.sessionId, at: Date.now(), epoch: state.hbEpoch }));
};
beat();
clearInterval(state.hbTimer);
state.hbTimer = setInterval(beat, HEARTBEAT_MS);
}
function supersede() {
clearInterval(state.hbTimer);
commitCurrent();
if (state.unsaved) pushGuarded(); // merge-safe: the other tab re-pulls
$("#controls").hidden = true;
$("#main").replaceChildren(el("p", null,
"Grading moved to another tab. This tab is paused — you can close it; everything here was saved."));
}
function releaseHeartbeat() {
try {
const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
if (hb?.session === state.sessionId) localStorage.removeItem(heartbeatKey());
} catch { /* releasing is best-effort */ }
}
// ── recording ────────────────────────────────────────────────────────────────
function record(item, values, unsure, note) {
const key = keyOf(item);
const prior = [...state.events].reverse().find((ev) => ev.key === key);
state.events.push({
key, ...values,
unsure: !!unsure, note: note || "",
seq: state.events.length, // position cross-check for the loader
user: state.user, ts: new Date().toISOString(),
order_index: state.idx, elapsed_ms: Date.now() - state.shownAt,
edit_count: prior ? (prior.edit_count ?? 0) + 1 : 0,
session_id: state.sessionId, rubric_version: state.rubricVersion,
client: `${CONFIG.task}@${CONFIG.build}`,
...(item.warmup ? { warmup: true } : {}),
});
persistLocal();
state.last = { key, text: CONFIG.summarize(values), unsure: !!unsure,
cls: stampClass(values) };
state.unsaved += 1;
if (!item.warmup) state.gradedSinceBreak += 1;
if (state.unsaved >= SAVE_EVERY && !state.publishBlocked) pushGuarded();
}
// ── UI ───────────────────────────────────────────────────────────────────────
// Sticky banners (save failures) survive item renders until a push succeeds;
// transient ones (break reminder) clear on the next item.
// kind "err" (save failures — outlives everything until a push succeeds) or
// "info" (break reminders, resume notice — cleared after a couple more grades
// so it survives item transitions without nagging forever).
function banner(text, sticky = false, actions = [], kind = "err") {
if (sticky) {
state.stickyBanner = text || null;
state.stickyActions = text ? actions : [];
state.stickyKind = text ? kind : null;
}
const box = $("#banner");
const shown = text || state.stickyBanner || "";
const shownActions = text ? actions : state.stickyActions;
box.replaceChildren();
if (shown) box.append(document.createTextNode(shown + " "));
for (const action of shownActions) {
const button = el("button", "banner-act", action.label);
button.addEventListener("click", action.onClick);
box.append(button);
}
box.classList.toggle("err", (text ? kind : state.stickyKind) === "err");
box.hidden = !shown;
}
// A successful push clears failure notices but must not eat an info banner.
function clearErrorBanner() {
if (state.stickyKind !== "info") { state.stickyBanner = null; state.stickyActions = []; }
banner("");
}
function renderStatus() {
const done = [...latestByKey(state.events).values()]
.filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length;
const total = state.items.filter((item) => !item.warmup).length;
$("#progress").textContent =
`${done}/${total} graded · ${state.unsaved} unsaved` +
(state.items[state.order[state.idx]]?.warmup ? " · WARM-UP" : "");
$("#bar-fill").style.width = total ? `${(100 * done) / total}%` : "0%";
}
function contentBlock(label, text, opts = {}) {
const wrap = el("section", "field" + (opts.scroll ? " response" : "")
+ (opts.ref ? " reference" : ""));
const head = el("b", null, label + (opts.count ? ` · ${text.length} chars` : ""));
wrap.append(head);
const body = el("div", "content");
body.textContent = text; // textContent ONLY — never innerHTML
wrap.append(body);
if (opts.scroll) wrap.append(el("div", "endmark", "· · · end of response · · ·"));
return wrap;
}
function renderItem() {
const main = $("#main");
main.replaceChildren();
if (state.stickyKind === "info") {
state.infoNavsLeft = (state.infoNavsLeft ?? 1) - 1;
if (state.infoNavsLeft <= 0) {
state.stickyBanner = null; state.stickyActions = []; state.stickyKind = null;
}
}
banner("");
if (state.idx >= state.order.length) {
const pos = firstUngraded();
if (pos < state.order.length) {
// Not actually done — walking past the last item must never claim
// completion. Jump back to the first ungraded item instead.
state.idx = pos;
renderItem();
banner(`Not finished yet — jumped back to the next ungraded item.`,
false, [], "info");
return;
}
main.append(el("h2", null, "All items graded — thank you!"));
const status = el("p", null, "Publishing your final save…");
status.setAttribute("role", "status");
main.append(status);
let nextShown = false, retryBtn = null;
const refresh = () => {
if (state.unsaved === 0) {
status.textContent = "Everything is published. You can close this tab.";
if (retryBtn) { retryBtn.remove(); retryBtn = null; }
if (CONFIG.next && !nextShown) {
nextShown = true;
const link = el("a", "open-out", CONFIG.next.label);
link.href = CONFIG.next.url; link.rel = "noopener";
main.append(link);
}
} else {
status.textContent = "Some judgments are still only in this browser — "
+ "see the notice above; do not clear this browser's storage.";
if (!retryBtn) {
retryBtn = el("button", "signin", "Retry publishing");
retryBtn.addEventListener("click", () => pushGuarded());
main.append(retryBtn);
}
}
renderStatus();
};
$("#controls").hidden = true;
// Any later successful push (retry button, tab-return heal) re-renders
// the truth — the screen must never stay on a stale failure claim.
state.onPushSuccess = refresh;
if (state.unsaved) pushGuarded().then(refresh);
else refresh();
return;
}
state.onPushSuccess = null;
const item = state.items[state.order[state.idx]];
window.scrollTo(0, 0); // a new item always starts at its top
const shownId = item.warmup && !CONFIG.opaqueIds
? "warm-up" // raw corpus qids never reach the screen
: displayId(keyOf(item), state.idx, CONFIG.opaqueIds);
main.append(el("div", "item-id",
`${shownId} · ${state.idx + 1}/${state.order.length}`));
if (item.warmup) {
main.append(el("p", "warmup-note",
"Warm-up item — discussable with the others; real items start after these and must be graded independently."));
}
for (const block of CONFIG.blocks(item)) {
main.append(contentBlock(block.label, block.text, block));
}
// Math: items carry \( \) / \[ \] delimiters (normalized at build time);
// KaTeX walks the text nodes of our textContent-built DOM. trust:false and
// throwOnError:false — a malformed expression shows as source, never breaks.
if (window.renderMathInElement) {
window.renderMathInElement(main, {
throwOnError: false, trust: false,
delimiters: [{ left: "\\(", right: "\\)", display: false },
{ left: "\\[", right: "\\]", display: true },
{ left: "$$", right: "$$", display: true }],
});
}
state.shownAt = Date.now();
renderControls(item);
renderStatus();
// Break cadence: ~40 items or 45 minutes, whichever first. Sticky info —
// survives item changes and autosaves, expires two grades later.
if (state.gradedSinceBreak >= 40 || Date.now() - state.breakShownAt > 45 * 60 * 1000) {
state.infoNavsLeft = 3; // this item + two more
banner("Good stopping point — consider ending this sitting here. Everything "
+ "graded so far is saved; you will resume exactly where you left off.",
true, [], "info");
state.breakShownAt = Date.now();
state.gradedSinceBreak = 0;
}
}
function renderControls(item) {
const controls = $("#controls");
controls.hidden = false;
controls.replaceChildren();
const current = latestByKey(state.events).get(keyOf(item));
const selection = { ...(current ? CONFIG.valuesOf(current) : {}) };
let unsure = current?.unsure ?? false;
const groups = [];
for (const field of CONFIG.fields) {
const group = el("div", "grp");
group.append(el("span", "lbl", field.label));
for (const [value, hotkey] of field.options) {
const button = el("button", null, value.replaceAll("_", " "));
button.dataset.v = value; // semantic verdict color hook
button.append(el("kbd", null, hotkey));
if (selection[field.name] === value) button.classList.add("sel");
button.addEventListener("click", () => choose(field.name, value));
group.append(button);
}
controls.append(group);
groups.push(group);
}
const unsureBtn = el("button", "unsure" + (unsure ? " sel" : ""), "unsure");
unsureBtn.append(el("kbd", null, "u"));
unsureBtn.title = "marks this judgment as uncertain — it still counts, the flag is analysis metadata";
unsureBtn.addEventListener("click", () => { unsure = !unsure; unsureBtn.classList.toggle("sel", unsure); });
controls.append(unsureBtn);
const note = el("input", null);
note.id = "note"; note.placeholder = CONFIG.notePlaceholder;
note.value = current?.note ?? "";
note.addEventListener("keydown", (ev) => {
// Enter/Escape hand the keyboard back to grading; hotkeys otherwise type
// here by design (the global handler ignores INPUT targets).
if (ev.key === "Enter" || ev.key === "Escape") { ev.preventDefault(); note.blur(); }
});
controls.append(note);
const nav = el("div", "nav");
const prev = el("button", null, "← prev"); prev.append(el("kbd", null, "j"));
prev.addEventListener("click", () => move(-1));
const skip = el("button", null, "next →"); skip.append(el("kbd", null, "k"));
skip.addEventListener("click", () => move(1));
const save = el("button", null, "save"); save.append(el("kbd", null, "s"));
save.addEventListener("click", () => { commitCurrent(); pushGuarded(); });
nav.append(prev, skip, save);
controls.append(nav);
if (state.last) {
// The previous verdict is deliberately NOT shown (sequential anchoring);
// the colored dot confirms it recorded, hover reveals it if needed.
const last = el("div", "last");
last.append(el("span", null, `last · ${state.last.key}`));
const stamp = el("span", "stamp " + state.last.cls, "recorded ▪");
stamp.title = state.last.text + (state.last.unsure ? " · unsure" : "");
last.append(stamp);
last.append(el("span", null, "j to revisit"));
controls.append(last);
}
function choose(name, value) {
const wasComplete = CONFIG.fields.every((field) => selection[field.name]);
selection[name] = value;
CONFIG.fields.forEach((field, fi) => {
groups[fi].querySelectorAll("button").forEach((button) => {
button.classList.toggle("sel", selection[field.name] === button.dataset.v);
});
});
// Advance only when this choice COMPLETES the item. A revisited item is
// already complete, so changing one answer must never jump away — the
// change commits on j/k/s/tab-hide via _commitIfDirty.
if (!wasComplete && CONFIG.fields.every((field) => selection[field.name])) move(1);
}
// The one recording path: leaving an item (nav, save, tab-hide) commits a
// complete selection whose verdicts, unsure flag, or note differ from the
// last recorded event — so a note typed or unsure toggled after the final
// verdict click is never lost.
controls._commitIfDirty = () => {
if (!CONFIG.fields.every((field) => selection[field.name])) return;
const now = latestByKey(state.events).get(keyOf(item));
const values = Object.fromEntries(CONFIG.fields.map((f) => [f.name, selection[f.name]]));
const dirty = !now
|| CONFIG.fields.some((field) => now[field.name] !== selection[field.name])
|| (now.unsure ?? false) !== unsure
|| (now.note ?? "") !== (note.value || "");
if (dirty) record(item, values, unsure, note.value);
};
// the mobile rubric pins itself just above the bench's real height
document.documentElement.style.setProperty("--bench-h", `${controls.offsetHeight}px`);
controls._noteDiscarded = () =>
!CONFIG.fields.every((field) => selection[field.name]) && !!note.value;
controls._choose = choose; // for the keyboard handler
controls._toggleUnsure = () => unsureBtn.click();
}
function commitCurrent() { $("#controls")._commitIfDirty?.(); }
function move(delta) {
const discarded = $("#controls")._noteDiscarded?.();
commitCurrent();
state.idx = Math.max(0, Math.min(state.order.length, state.idx + delta));
renderItem();
if (discarded) {
banner("Heads up: the note on the item you just left was not recorded — "
+ "notes only save once both of its questions are answered.",
false, [], "info");
}
}
document.addEventListener("keydown", (event) => {
if (event.target.tagName === "INPUT" || $("#controls").hidden) return;
const controls = $("#controls");
if (event.key === "j" || event.key === "ArrowLeft") { event.preventDefault(); move(-1); return; }
if (event.key === "k" || event.key === "ArrowRight") { event.preventDefault(); move(1); return; }
if (event.key === "s") { event.preventDefault(); commitCurrent(); pushGuarded(); return; }
if (event.key === "u") { event.preventDefault(); controls._toggleUnsure?.(); return; }
if (event.key === "e") {
event.preventDefault();
const resp = document.querySelector(".response .content");
if (resp) resp.parentElement.scrollTop = resp.parentElement.scrollHeight;
return;
}
const binding = CONFIG.hotkeys[event.key];
if (binding) {
event.preventDefault();
// Double-press bounce: the SAME key repeating into the next item's first
// 150ms is the keystroke that just advanced the previous item. A
// different key (or a deliberate press later) always lands.
const now = Date.now();
if (event.key === state.lastVerdictKey && now - state.shownAt < 150
&& now - (state.lastVerdictAt ?? 0) < 300) return;
state.lastVerdictKey = event.key;
state.lastVerdictAt = now;
controls._choose?.(binding[0], binding[1]);
}
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (state.unsaved) pushGuarded(); // heal a killed background push
return;
}
commitCurrent(); // capture a trailing note/unsure edit
if (state.unsaved) pushGuarded(); // best-effort
});
window.addEventListener("pagehide", () => {
commitCurrent();
if (state.unsaved) pushGuarded(); // best-effort; localStorage is the backstop
releaseHeartbeat(); // so a reload doesn't trip the one-tab guard
});
// ── boot ─────────────────────────────────────────────────────────────────────
const withTimeout = (promise, seconds, label) => Promise.race([
promise,
new Promise((_, reject) => setTimeout(
() => reject(new Error(`${label} timed out after ${seconds}s`)), seconds * 1000)),
]);
async function boot() {
// Watchdog: if nothing has replaced the shell after 30s, the network
// stalled mid-boot (common on phones) — offer a reload instead of an
// eternal "Loading...".
setTimeout(() => {
const main = $("#main");
if (main && main.textContent.includes("Loading")) {
main.replaceChildren(el("p", null,
"Loading stalled — the connection may have dropped mid-request. "
+ "Nothing is lost."));
const retry = el("button", "signin", "Reload");
retry.addEventListener("click", () => location.reload());
main.append(retry);
}
}, 30000);
$("#task-title").textContent = CONFIG.title;
document.title = CONFIG.title; // three distinguishable tabs
$("#banner").setAttribute("aria-live", "assertive");
$("#progress").setAttribute("aria-live", "polite");
state.rubricVersion = await rubricVersion();
$("#rubric-body").textContent = CONFIG.rubric;
// Wide screens hold the rubric in a side rail — open it so it reads at a glance.
if (matchMedia("(min-width: 1240px)").matches) $("#rubric").open = true;
if (window.self !== window.top) {
// Embedded in hf.co: OAuth storage is partitioned here — link out instead.
const main = $("#main");
main.replaceChildren(el("p", null, "Open this Space in its own tab to sign in:"));
const link = el("a", "open-out", location.href);
link.href = location.href; link.target = "_blank"; link.rel = "noopener";
main.append(link);
return;
}
state.auth = await ensureAuth();
if (!state.auth) {
const main = $("#main");
main.replaceChildren(el("p", null, CONFIG.landing));
// A returning annotator (new tab, expired login, reclaimed mobile tab)
// must never mistake this screen for a fresh start: their log is here.
let stored = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith(`audit:${CONFIG.task}:`)) continue;
try {
const events = JSON.parse(localStorage.getItem(key) ?? "[]");
stored += [...latestByKey(events).values()]
.filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length;
} catch { /* unreadable log — claim nothing */ }
}
if (stored) {
main.append(el("p", "resume-note",
`Welcome back — ${stored} of your judgments are safely stored in this `
+ "browser. Sign in with the same Hugging Face account to pick up "
+ "exactly where you left off."));
}
main.append(el("p", "independence", CONFIG.independence));
const button = el("button", "signin", "Sign in with Hugging Face");
button.addEventListener("click", signIn);
main.append(button);
return;
}
const username = state.auth.userInfo.preferred_username;
const inOrg = (state.auth.userInfo.orgs ?? []).some((org) => org.preferred_username === "localgate");
if (!inOrg) {
$("#main").replaceChildren(el("p", null,
`@${username} is not on this study's annotator list — ask Samuel to add you to the localgate org.`));
return;
}
state.user = username;
$("#whoami").textContent = `@${username}`;
if (!state.hbTimer && foreignTabAlive()) {
const main = $("#main");
main.replaceChildren(el("p", null,
"This audit is already open in another tab of this browser. Two open copies " +
"can overwrite each other's work, so grading is paused here."));
const takeOver = el("button", "signin", "Continue in this tab instead");
takeOver.addEventListener("click", () => { startHeartbeat(true); boot(); });
main.append(takeOver);
return;
}
if (!state.hbTimer) startHeartbeat();
try {
const blob = await withTimeout(
hub().downloadFile({ repo: ITEMS_REPO, path: CONFIG.itemsPath,
accessToken: state.auth.accessToken }),
60, "loading the items");
if (blob === null) throw new Error("items file missing from localgate/audit-items");
state.items = JSON.parse(await blob.text());
} catch (err) {
$("#main").replaceChildren(el("p", null, `Could not load items: ${String(err).slice(0, 140)}`));
const retry = el("button", null, "retry");
retry.addEventListener("click", boot);
$("#main").append(retry);
return;
}
let prep;
try {
prep = await withTimeout(ensureResultsRepo(), 60, "preparing your results dataset");
} catch { prep = { ok: false, kind: "network" }; }
if (!prep.ok) {
// NEVER block grading on this: every judgment lands in localStorage and
// the backlog auto-publishes once publishing works. Only the "published"
// claim is blocked (completion screen + counters stay honest).
state.publishBlocked = prep.kind;
const text = prep.kind === "stale-token"
? "Your access needs refreshing before results can publish. You can "
+ "grade now — everything is kept in this browser — but do sign in "
+ "again soon so it uploads."
: prep.kind === "cannot-create"
? "Your results dataset does not exist yet and this login cannot "
+ "create it. You can grade now — everything is kept in this "
+ "browser — then sign in again to set it up."
: "Could not reach your results dataset (network). You can grade — "
+ "everything is kept in this browser and publishing retries.";
banner(text, true,
[{ label: "Sign in again (refreshes permissions)", onClick: signIn },
{ label: "Download your log", onClick: downloadLog }]);
}
// Ordering gate: a task can require another task's completion first (the
// conversion fidelity pass must not open before the blind verdict pass,
// because it reveals which items the filter kept).
if (CONFIG.requires) {
const log = await readOwnLog(CONFIG.requires.task);
if (log.kind === "denied" || log.kind === "error") {
// Saying "you have graded 0 items" here would be FALSE — the read
// failed; their work may be complete. Fail closed with the truth.
$("#main").replaceChildren(el("p", null,
`Could not read your ${CONFIG.requires.label} progress`
+ (log.kind === "denied" ? " (permissions — your login may need refreshing)."
: " (network)." )
+ " This pass stays locked until it can be verified."));
const again = el("button", "signin", log.kind === "denied"
? "Sign in again (refreshes permissions)" : "Retry");
again.addEventListener("click",
log.kind === "denied" ? signIn : () => location.reload());
$("#main").append(again);
$("#controls").hidden = true;
return;
}
const done = countComplete(log.rows, CONFIG.requires.fields);
if (done < CONFIG.requires.count) {
const ownWork = loadLocalEvents().filter((ev) => !ev.warmup).length;
$("#main").replaceChildren(el("p", null,
`${CONFIG.requires.label} must be finished first — you have graded ` +
`${done} of ${CONFIG.requires.count} items there. This pass reveals ` +
"information that must not color that one, so it stays locked until " +
"you are done. (Just finished it? Make sure its tab says everything "
+ "is published, then reload this page.)"
+ (typeof ownWork !== "undefined" && ownWork
? ` Your ${ownWork} judgments already recorded here are safe and `
+ "will be waiting when this pass reopens."
: "")));
const link = el("a", "open-out",
CONFIG.requires.linkLabel ?? CONFIG.requires.url);
link.href = CONFIG.requires.url; link.rel = "noopener";
$("#main").append(link);
return;
}
}
// Reciprocal lock: once the FOLLOWING pass has begun, this one's blindness
// window has ended — it closes for revision. Fail CLOSED on a read error:
// wrongly locking is a reload; wrongly unlocking is an invisible validity
// hole (post-unblinding edits are only detectable, not preventable).
if (CONFIG.lockWhen) {
const log = await readOwnLog(CONFIG.lockWhen.task);
const started = log.kind === "rows" && log.rows.some((ev) => !ev.warmup);
const unreadable = log.kind === "denied" || log.kind === "error";
if (started || unreadable) {
$("#main").replaceChildren(el("p", null, unreadable
? "Could not verify whether the next pass has already started, so this "
+ "one stays closed to be safe."
+ (log.kind === "denied" ? " Your login may need refreshing." : "")
: `${CONFIG.lockWhen.label} has begun, so this pass is closed for `
+ "revision — its blindness window has ended. Every verdict you "
+ "recorded here is safe and counted. If a correction is genuinely "
+ "needed, tell Samuel; corrections after unblinding are flagged in "
+ "the analysis rather than silently applied."));
if (unreadable) {
const again = el("button", "signin", log.kind === "denied"
? "Sign in again (refreshes permissions)" : "Retry");
again.addEventListener("click",
log.kind === "denied" ? signIn : () => location.reload());
$("#main").append(again);
}
$("#controls").hidden = true;
return;
}
}
// Merge remote history with the local log, remote order first (mergeEvents).
// A pull FAILURE is not "no file yet": grading continues from the local log,
// and because every push re-pulls first, nothing can be overwritten blind.
let remote = [];
try { remote = await pullRemote(); }
catch (err) {
banner(`Could not read your previous progress (${String(err).slice(0, 80)}) — ` +
"grading continues and is kept in this browser; publishing retries on the next save.", true);
}
state.events = mergeEvents(remote, loadLocalEvents());
persistLocal();
state.unsaved = state.events.length - remote.length;
if (state.unsaved > 0) pushGuarded(); // publish the backlog now,
// while the page is visible
buildOrder();
state.idx = firstUngraded();
renderItem();
const done = [...latestByKey(state.events).values()]
.filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length;
if (done > 0 && state.idx < state.order.length) {
// Transient on purpose: mobile browsers reload the tab on every app
// switch, so a sticky notice re-arms forever and reads as stuck.
banner(`Welcome back — ${done} already recorded. `
+ "Continuing exactly where you left off.", false, [], "info");
}
}
boot();
|