Spaces:
Running
Running
Pass-1 verdicts Space; conversion locks until pass 1 is complete
Browse files- README.md +22 -5
- app.js +559 -0
- config.js +61 -0
- fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- fonts/source-serif-4-latin-400-normal.woff2 +0 -0
- fonts/source-serif-4-latin-600-normal.woff2 +0 -0
- hub-2.15.0.bundle.mjs +0 -0
- index.html +25 -17
- style.css +173 -20
README.md
CHANGED
|
@@ -1,10 +1,27 @@
|
|
| 1 |
---
|
| 2 |
-
title: Audit
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: LocalGate Audit — Conversion Pass 1
|
| 3 |
+
emoji: ✅
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
hf_oauth_expiration_minutes: 1440
|
| 12 |
+
hf_oauth_scopes:
|
| 13 |
+
- read-repos
|
| 14 |
+
- contribute-repos
|
| 15 |
+
hf_oauth_authorized_org: localgate
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# LocalGate audit — conversion pass 1 (blind verdicts)
|
| 19 |
+
|
| 20 |
+
Private static annotation Space. Use the **direct URL**
|
| 21 |
+
(https://localgate-audit-verdicts.static.hf.space) — the embedded view links out.
|
| 22 |
+
Sign in with your HF account; judgments are committed to a private dataset in
|
| 23 |
+
**your own namespace** (`{you}/localgate-audit-results`) under your own token,
|
| 24 |
+
so every grade is attributed by commit author. Progress saves continuously and
|
| 25 |
+
resumes across sessions. Incident response: revoke a leaked token at
|
| 26 |
+
https://huggingface.co/settings/tokens and remove this app under Settings →
|
| 27 |
+
Connected Applications.
|
app.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* LocalGate audit engine — shared by both audit Spaces.
|
| 2 |
+
*
|
| 3 |
+
* Design contract (deliverables/AUDIT_SPACES_BUILD.md, FINAL SPEC):
|
| 4 |
+
* - Same-tab OAuth on the direct *.static.hf.space URL; embedded views link out.
|
| 5 |
+
* - Token lives in sessionStorage only; the hub library persists nothing itself.
|
| 6 |
+
* - Scopes are read-repos + contribute-repos: results go to a private dataset in
|
| 7 |
+
* the ANNOTATOR'S OWN namespace ({user}/localgate-audit-results), created on
|
| 8 |
+
* first login. Nobody can write anyone else's results at the permission layer.
|
| 9 |
+
* - Append-only event log: every judgment/revision is a new row; analysis takes
|
| 10 |
+
* the LAST row per key in file order (position is the authority — client
|
| 11 |
+
* clocks are metadata, immune to skew). Every push is read-merge-write: the
|
| 12 |
+
* remote file is pulled and unioned first, so a push can only grow the file.
|
| 13 |
+
* - One tab at a time: a localStorage heartbeat blocks a second tab of the
|
| 14 |
+
* same task in the same browser (two browsers merge safely, last-write races
|
| 15 |
+
* are bounded by the merge-before-push).
|
| 16 |
+
* - All content rendered via textContent. No innerHTML anywhere in this file.
|
| 17 |
+
* - localStorage holds the event log per (task, user) — loss bound is zero;
|
| 18 |
+
* uploads every SAVE_EVERY judgments / on 's' / on tab-hide / at the end.
|
| 19 |
+
*/
|
| 20 |
+
import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile,
|
| 21 |
+
createRepo } from "./hub-2.15.0.bundle.mjs";
|
| 22 |
+
|
| 23 |
+
/* global CONFIG */
|
| 24 |
+
const ORG_ID = "6a7af86a89612db0d39b0b14"; // localgate — forces the org grant
|
| 25 |
+
const ITEMS_REPO = { type: "dataset", name: "localgate/audit-items" };
|
| 26 |
+
const SAVE_EVERY = 5;
|
| 27 |
+
|
| 28 |
+
const $ = (sel) => document.querySelector(sel);
|
| 29 |
+
const el = (tag, cls, text) => {
|
| 30 |
+
const node = document.createElement(tag);
|
| 31 |
+
if (cls) node.className = cls;
|
| 32 |
+
if (text !== undefined) node.textContent = text;
|
| 33 |
+
return node;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// ── rubric fingerprint (same discipline as convert.py's PROMPT_VERSION) ──────
|
| 37 |
+
async function rubricVersion() {
|
| 38 |
+
const data = new TextEncoder().encode(CONFIG.rubric + "\0" + JSON.stringify(CONFIG.fields));
|
| 39 |
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
| 40 |
+
return [...new Uint8Array(hash)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ── auth ─────────────────────────────────────────────────────────────────────
|
| 44 |
+
function storedAuth() {
|
| 45 |
+
try {
|
| 46 |
+
const raw = sessionStorage.getItem("oauth");
|
| 47 |
+
if (!raw) return null;
|
| 48 |
+
const auth = JSON.parse(raw);
|
| 49 |
+
if (new Date(auth.accessTokenExpiresAt) <= new Date()) return null;
|
| 50 |
+
return auth;
|
| 51 |
+
} catch { return null; }
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function ensureAuth() {
|
| 55 |
+
let auth = storedAuth();
|
| 56 |
+
if (auth) return auth;
|
| 57 |
+
const fresh = await oauthHandleRedirectIfPresent();
|
| 58 |
+
if (fresh) {
|
| 59 |
+
sessionStorage.setItem("oauth", JSON.stringify(fresh));
|
| 60 |
+
history.replaceState(null, "", location.pathname); // ?code is single-use; keep it out of history
|
| 61 |
+
return fresh;
|
| 62 |
+
}
|
| 63 |
+
return null;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
async function signIn() {
|
| 67 |
+
const url = await oauthLoginUrl(); // reads window.huggingface.variables in a Space
|
| 68 |
+
window.location.href = url + "&orgIds=" + ORG_ID; // no param passthrough in the helper; append
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ── state ────────────────────────────────────────────────────────────────────
|
| 72 |
+
const state = {
|
| 73 |
+
auth: null, user: null, items: [], order: [], idx: 0,
|
| 74 |
+
events: [], // append-only, mirrored to localStorage
|
| 75 |
+
unsaved: 0, sessionId: crypto.randomUUID().slice(0, 8),
|
| 76 |
+
shownAt: 0, rubricVersion: "", resultsRepo: null, last: null,
|
| 77 |
+
breakShownAt: Date.now(), stickyBanner: null, hbTimer: null,
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const logKey = () => `audit:${CONFIG.task}:${state.user}`;
|
| 81 |
+
const latestByKey = (events) => {
|
| 82 |
+
const map = new Map();
|
| 83 |
+
for (const ev of events) map.set(ev.key, ev); // log order: later position wins
|
| 84 |
+
return map;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// Union preserving log order: remote rows first (their order is the published
|
| 88 |
+
// history), then local rows not yet in remote, in local append order. No ts
|
| 89 |
+
// sort — a skewed clock must never reorder a revision before its original.
|
| 90 |
+
function mergeEvents(remote, local) {
|
| 91 |
+
const seen = new Set(remote.map((ev) => ev.key + "|" + ev.ts));
|
| 92 |
+
return [...remote, ...local.filter((ev) => !seen.has(ev.key + "|" + ev.ts))];
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function loadLocalEvents() {
|
| 96 |
+
try { return JSON.parse(localStorage.getItem(logKey()) ?? "[]"); }
|
| 97 |
+
catch { return []; }
|
| 98 |
+
}
|
| 99 |
+
function persistLocal() {
|
| 100 |
+
localStorage.setItem(logKey(), JSON.stringify(state.events));
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Seeded per-user order (fatigue decorrelation) — mulberry32 over a string hash.
|
| 104 |
+
function seededOrder(n, seed) {
|
| 105 |
+
let h = 1779033703;
|
| 106 |
+
for (const ch of seed) { h = Math.imul(h ^ ch.charCodeAt(0), 3432918353); h = (h << 13) | (h >>> 19); }
|
| 107 |
+
const rand = () => {
|
| 108 |
+
h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909);
|
| 109 |
+
return ((h ^= h >>> 16) >>> 0) / 4294967296;
|
| 110 |
+
};
|
| 111 |
+
const order = [...Array(n).keys()];
|
| 112 |
+
for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
|
| 113 |
+
return order;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Warm-ups always come first, in file order; real items follow in seeded order.
|
| 117 |
+
function buildOrder() {
|
| 118 |
+
const warm = [], real = [];
|
| 119 |
+
state.items.forEach((item, i) => (item.warmup ? warm : real).push(i));
|
| 120 |
+
const shuffled = seededOrder(real.length, `${CONFIG.task}:${state.user}`).map((i) => real[i]);
|
| 121 |
+
state.order = [...warm, ...shuffled];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function keyOf(item) { return String(item.id ?? item.key); }
|
| 125 |
+
|
| 126 |
+
function firstUngraded() {
|
| 127 |
+
const done = latestByKey(state.events);
|
| 128 |
+
for (let pos = 0; pos < state.order.length; pos++) {
|
| 129 |
+
const item = state.items[state.order[pos]];
|
| 130 |
+
const ev = done.get(keyOf(item));
|
| 131 |
+
if (!ev || !CONFIG.isComplete(ev)) return pos;
|
| 132 |
+
}
|
| 133 |
+
return state.order.length;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// ── results repo (own namespace; contribute-repos scope) ─────────────────────
|
| 137 |
+
async function ensureResultsRepo() {
|
| 138 |
+
const name = `${state.user}/localgate-audit-results`;
|
| 139 |
+
state.resultsRepo = { type: "dataset", name };
|
| 140 |
+
try {
|
| 141 |
+
await createRepo({ repo: state.resultsRepo, accessToken: state.auth.accessToken,
|
| 142 |
+
private: true });
|
| 143 |
+
} catch (err) {
|
| 144 |
+
// Already exists (any prior session) is the expected steady state; the hub's
|
| 145 |
+
// message says "You already created this dataset repo" with no status code.
|
| 146 |
+
if (!/409|exist|already/i.test(String(err))) throw err;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const remotePath = () => `${CONFIG.task}/${state.user}.jsonl`;
|
| 151 |
+
|
| 152 |
+
async function pullRemote() {
|
| 153 |
+
const blob = await downloadFile({ repo: state.resultsRepo, path: remotePath(),
|
| 154 |
+
accessToken: state.auth.accessToken });
|
| 155 |
+
if (blob === null) return []; // no file yet — first session
|
| 156 |
+
return (await blob.text()).split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
async function push() {
|
| 160 |
+
if (!state.events.length) return;
|
| 161 |
+
// Read-merge-write: never upload without having just read the remote head.
|
| 162 |
+
// A pull failure aborts the push — an unreadable remote must not be replaced.
|
| 163 |
+
const remote = await pullRemote();
|
| 164 |
+
state.events = mergeEvents(remote, state.events);
|
| 165 |
+
persistLocal();
|
| 166 |
+
const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n";
|
| 167 |
+
await uploadFiles({
|
| 168 |
+
repo: state.resultsRepo, accessToken: state.auth.accessToken,
|
| 169 |
+
commitTitle: `${CONFIG.task}: ${state.user} — ${state.events.length} events`,
|
| 170 |
+
files: [{ path: remotePath(), content: new Blob([jsonl]) }],
|
| 171 |
+
});
|
| 172 |
+
state.unsaved = 0;
|
| 173 |
+
banner("", true); // clear any stale failure notice
|
| 174 |
+
renderStatus();
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async function pushGuarded() {
|
| 178 |
+
try { await push(); }
|
| 179 |
+
catch (err) {
|
| 180 |
+
banner(/403|401/.test(String(err))
|
| 181 |
+
? "Saved locally, but publishing failed (permissions). Nothing is lost — press s to retry after it's fixed."
|
| 182 |
+
: `Saved locally; publishing failed: ${String(err).slice(0, 120)} — press s to retry.`, true);
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// ── one-tab guard (heartbeat in localStorage; pagehide releases it) ──────────
|
| 187 |
+
const HEARTBEAT_MS = 5000;
|
| 188 |
+
const heartbeatKey = () => `audit:hb:${CONFIG.task}`;
|
| 189 |
+
|
| 190 |
+
function foreignTabAlive() {
|
| 191 |
+
try {
|
| 192 |
+
const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
|
| 193 |
+
return !!hb && hb.session !== state.sessionId && Date.now() - hb.at < HEARTBEAT_MS * 3;
|
| 194 |
+
} catch { return false; }
|
| 195 |
+
}
|
| 196 |
+
function readHeartbeat() {
|
| 197 |
+
try { return JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); }
|
| 198 |
+
catch { return null; }
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// Ownership is an epoch: taking over bumps it, and a tab stands down only to a
|
| 202 |
+
// fresh foreign heartbeat of an equal-or-newer epoch — so a seizure is
|
| 203 |
+
// one-directional even while the old tab is still beating.
|
| 204 |
+
function startHeartbeat(force = false) {
|
| 205 |
+
const stored = readHeartbeat();
|
| 206 |
+
state.hbEpoch = (stored?.epoch ?? 0) + (force ? 1 : 0);
|
| 207 |
+
const beat = () => {
|
| 208 |
+
const hb = readHeartbeat();
|
| 209 |
+
const foreignFresh = hb && hb.session !== state.sessionId
|
| 210 |
+
&& Date.now() - hb.at < HEARTBEAT_MS * 3;
|
| 211 |
+
if (foreignFresh && (hb.epoch ?? 0) >= state.hbEpoch) { supersede(); return; }
|
| 212 |
+
localStorage.setItem(heartbeatKey(),
|
| 213 |
+
JSON.stringify({ session: state.sessionId, at: Date.now(), epoch: state.hbEpoch }));
|
| 214 |
+
};
|
| 215 |
+
beat();
|
| 216 |
+
clearInterval(state.hbTimer);
|
| 217 |
+
state.hbTimer = setInterval(beat, HEARTBEAT_MS);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function supersede() {
|
| 221 |
+
clearInterval(state.hbTimer);
|
| 222 |
+
commitCurrent();
|
| 223 |
+
if (state.unsaved) pushGuarded(); // merge-safe: the other tab re-pulls
|
| 224 |
+
$("#controls").hidden = true;
|
| 225 |
+
$("#main").replaceChildren(el("p", null,
|
| 226 |
+
"Grading moved to another tab. This tab is paused — you can close it; everything here was saved."));
|
| 227 |
+
}
|
| 228 |
+
function releaseHeartbeat() {
|
| 229 |
+
try {
|
| 230 |
+
const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
|
| 231 |
+
if (hb?.session === state.sessionId) localStorage.removeItem(heartbeatKey());
|
| 232 |
+
} catch { /* releasing is best-effort */ }
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// ── recording ────────────────────────────────────────────────────────────────
|
| 236 |
+
function stampClass(values) {
|
| 237 |
+
// The stamp inks in the verdict's own color; any failing answer turns it red.
|
| 238 |
+
const vals = Object.values(values);
|
| 239 |
+
if (vals.includes("no_match") || vals.includes("no")) return "v-no_match";
|
| 240 |
+
if (vals.includes("no_answer") || vals.includes("borderline")) return "v-no_answer";
|
| 241 |
+
return "v-match";
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function record(item, values, unsure, note) {
|
| 245 |
+
const key = keyOf(item);
|
| 246 |
+
const prior = [...state.events].reverse().find((ev) => ev.key === key);
|
| 247 |
+
state.events.push({
|
| 248 |
+
key, ...values,
|
| 249 |
+
unsure: !!unsure, note: note || "",
|
| 250 |
+
seq: state.events.length, // position cross-check for the loader
|
| 251 |
+
user: state.user, ts: new Date().toISOString(),
|
| 252 |
+
order_index: state.idx, elapsed_ms: Date.now() - state.shownAt,
|
| 253 |
+
edit_count: prior ? (prior.edit_count ?? 0) + 1 : 0,
|
| 254 |
+
session_id: state.sessionId, rubric_version: state.rubricVersion,
|
| 255 |
+
client: `${CONFIG.task}@${CONFIG.build}`,
|
| 256 |
+
...(item.warmup ? { warmup: true } : {}),
|
| 257 |
+
});
|
| 258 |
+
persistLocal();
|
| 259 |
+
state.last = { key, text: CONFIG.summarize(values), unsure: !!unsure,
|
| 260 |
+
cls: stampClass(values) };
|
| 261 |
+
state.unsaved += 1;
|
| 262 |
+
if (state.unsaved >= SAVE_EVERY) pushGuarded();
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// ── UI ───────────────────────────────────────────────────────────────────────
|
| 266 |
+
// Sticky banners (save failures) survive item renders until a push succeeds;
|
| 267 |
+
// transient ones (break reminder) clear on the next item.
|
| 268 |
+
function banner(text, sticky = false) {
|
| 269 |
+
if (sticky) state.stickyBanner = text || null;
|
| 270 |
+
const box = $("#banner");
|
| 271 |
+
const shown = text || state.stickyBanner || "";
|
| 272 |
+
box.textContent = shown;
|
| 273 |
+
box.hidden = !shown;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function renderStatus() {
|
| 277 |
+
const done = [...latestByKey(state.events).values()]
|
| 278 |
+
.filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length;
|
| 279 |
+
const total = state.items.filter((item) => !item.warmup).length;
|
| 280 |
+
$("#progress").textContent =
|
| 281 |
+
`${done}/${total} graded · ${state.unsaved} unsaved` +
|
| 282 |
+
(state.items[state.order[state.idx]]?.warmup ? " · WARM-UP" : "");
|
| 283 |
+
$("#bar-fill").style.width = `${(100 * done) / total}%`;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function contentBlock(label, text, opts = {}) {
|
| 287 |
+
const wrap = el("section", "field" + (opts.scroll ? " response" : "")
|
| 288 |
+
+ (opts.ref ? " reference" : ""));
|
| 289 |
+
const head = el("b", null, label + (opts.count ? ` · ${text.length} chars` : ""));
|
| 290 |
+
wrap.append(head);
|
| 291 |
+
const body = el("div", "content");
|
| 292 |
+
body.textContent = text; // textContent ONLY — never innerHTML
|
| 293 |
+
wrap.append(body);
|
| 294 |
+
if (opts.scroll) wrap.append(el("div", "endmark", "· · · end of response · · ·"));
|
| 295 |
+
return wrap;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function renderItem() {
|
| 299 |
+
const main = $("#main");
|
| 300 |
+
main.replaceChildren();
|
| 301 |
+
banner("");
|
| 302 |
+
|
| 303 |
+
if (state.idx >= state.order.length) {
|
| 304 |
+
main.append(el("h2", null, "All items graded — thank you!"));
|
| 305 |
+
main.append(el("p", null, "Final save in progress. You can close this tab once the counter reads 0 unsaved."));
|
| 306 |
+
pushGuarded();
|
| 307 |
+
renderStatus();
|
| 308 |
+
$("#controls").hidden = true;
|
| 309 |
+
return;
|
| 310 |
+
}
|
| 311 |
+
const item = state.items[state.order[state.idx]];
|
| 312 |
+
if (item.warmup) {
|
| 313 |
+
main.append(el("p", "warmup-note",
|
| 314 |
+
"Warm-up item — discussable with the others; real items start after these and must be graded independently."));
|
| 315 |
+
}
|
| 316 |
+
for (const block of CONFIG.blocks(item)) {
|
| 317 |
+
main.append(contentBlock(block.label, block.text, block));
|
| 318 |
+
}
|
| 319 |
+
state.shownAt = Date.now();
|
| 320 |
+
renderControls(item);
|
| 321 |
+
renderStatus();
|
| 322 |
+
if (Date.now() - state.breakShownAt > 45 * 60 * 1000) {
|
| 323 |
+
banner("You've been at this a while — good moment for a break. Everything up to the counter is saved.");
|
| 324 |
+
state.breakShownAt = Date.now();
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function renderControls(item) {
|
| 329 |
+
const controls = $("#controls");
|
| 330 |
+
controls.hidden = false;
|
| 331 |
+
controls.replaceChildren();
|
| 332 |
+
const current = latestByKey(state.events).get(keyOf(item));
|
| 333 |
+
const selection = { ...(current ? CONFIG.valuesOf(current) : {}) };
|
| 334 |
+
let unsure = current?.unsure ?? false;
|
| 335 |
+
|
| 336 |
+
const groups = [];
|
| 337 |
+
for (const field of CONFIG.fields) {
|
| 338 |
+
const group = el("div", "grp");
|
| 339 |
+
group.append(el("span", "lbl", field.label));
|
| 340 |
+
for (const [value, hotkey] of field.options) {
|
| 341 |
+
const button = el("button", null, value);
|
| 342 |
+
button.dataset.v = value; // semantic verdict color hook
|
| 343 |
+
button.append(el("kbd", null, hotkey));
|
| 344 |
+
if (selection[field.name] === value) button.classList.add("sel");
|
| 345 |
+
button.addEventListener("click", () => choose(field.name, value));
|
| 346 |
+
group.append(button);
|
| 347 |
+
}
|
| 348 |
+
controls.append(group);
|
| 349 |
+
groups.push(group);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
const unsureBtn = el("button", "unsure" + (unsure ? " sel" : ""), "unsure");
|
| 353 |
+
unsureBtn.append(el("kbd", null, "u"));
|
| 354 |
+
unsureBtn.title = "marks this judgment as uncertain — it still counts, the flag is analysis metadata";
|
| 355 |
+
unsureBtn.addEventListener("click", () => { unsure = !unsure; unsureBtn.classList.toggle("sel", unsure); });
|
| 356 |
+
controls.append(unsureBtn);
|
| 357 |
+
|
| 358 |
+
const note = el("input", null);
|
| 359 |
+
note.id = "note"; note.placeholder = CONFIG.notePlaceholder;
|
| 360 |
+
note.value = current?.note ?? "";
|
| 361 |
+
controls.append(note);
|
| 362 |
+
|
| 363 |
+
const nav = el("div", "nav");
|
| 364 |
+
const prev = el("button", null, "← prev"); prev.append(el("kbd", null, "j"));
|
| 365 |
+
prev.addEventListener("click", () => move(-1));
|
| 366 |
+
const skip = el("button", null, "next →"); skip.append(el("kbd", null, "k"));
|
| 367 |
+
skip.addEventListener("click", () => move(1));
|
| 368 |
+
const save = el("button", null, "save"); save.append(el("kbd", null, "s"));
|
| 369 |
+
save.addEventListener("click", () => { commitCurrent(); pushGuarded(); });
|
| 370 |
+
nav.append(prev, skip, save);
|
| 371 |
+
controls.append(nav);
|
| 372 |
+
|
| 373 |
+
if (state.last) {
|
| 374 |
+
const last = el("div", "last");
|
| 375 |
+
last.append(el("span", null, `last · ${state.last.key}`));
|
| 376 |
+
last.append(el("span", "stamp " + state.last.cls,
|
| 377 |
+
state.last.text + (state.last.unsure ? " · unsure" : "")));
|
| 378 |
+
last.append(el("span", null, "j to revisit"));
|
| 379 |
+
controls.append(last);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function choose(name, value) {
|
| 383 |
+
selection[name] = value;
|
| 384 |
+
CONFIG.fields.forEach((field, fi) => {
|
| 385 |
+
groups[fi].querySelectorAll("button").forEach((button) => {
|
| 386 |
+
const label = button.childNodes[0].textContent;
|
| 387 |
+
button.classList.toggle("sel", selection[field.name] === label);
|
| 388 |
+
});
|
| 389 |
+
});
|
| 390 |
+
if (CONFIG.fields.every((field) => selection[field.name])) move(1);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// The one recording path: leaving an item (nav, save, tab-hide) commits a
|
| 394 |
+
// complete selection whose verdicts, unsure flag, or note differ from the
|
| 395 |
+
// last recorded event — so a note typed or unsure toggled after the final
|
| 396 |
+
// verdict click is never lost.
|
| 397 |
+
controls._commitIfDirty = () => {
|
| 398 |
+
if (!CONFIG.fields.every((field) => selection[field.name])) return;
|
| 399 |
+
const now = latestByKey(state.events).get(keyOf(item));
|
| 400 |
+
const values = Object.fromEntries(CONFIG.fields.map((f) => [f.name, selection[f.name]]));
|
| 401 |
+
const dirty = !now
|
| 402 |
+
|| CONFIG.fields.some((field) => now[field.name] !== selection[field.name])
|
| 403 |
+
|| (now.unsure ?? false) !== unsure
|
| 404 |
+
|| (now.note ?? "") !== (note.value || "");
|
| 405 |
+
if (dirty) record(item, values, unsure, note.value);
|
| 406 |
+
};
|
| 407 |
+
controls._choose = choose; // for the keyboard handler
|
| 408 |
+
controls._toggleUnsure = () => unsureBtn.click();
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
function commitCurrent() { $("#controls")._commitIfDirty?.(); }
|
| 412 |
+
|
| 413 |
+
function move(delta) {
|
| 414 |
+
commitCurrent();
|
| 415 |
+
state.idx = Math.max(0, Math.min(state.order.length, state.idx + delta));
|
| 416 |
+
renderItem();
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
document.addEventListener("keydown", (event) => {
|
| 420 |
+
if (event.target.tagName === "INPUT" || $("#controls").hidden) return;
|
| 421 |
+
const controls = $("#controls");
|
| 422 |
+
if (event.key === "j") { event.preventDefault(); move(-1); return; }
|
| 423 |
+
if (event.key === "k") { event.preventDefault(); move(1); return; }
|
| 424 |
+
if (event.key === "s") { event.preventDefault(); commitCurrent(); pushGuarded(); return; }
|
| 425 |
+
if (event.key === "u") { event.preventDefault(); controls._toggleUnsure?.(); return; }
|
| 426 |
+
if (event.key === "e") {
|
| 427 |
+
event.preventDefault();
|
| 428 |
+
const resp = document.querySelector(".response .content");
|
| 429 |
+
if (resp) resp.parentElement.scrollTop = resp.parentElement.scrollHeight;
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
const binding = CONFIG.hotkeys[event.key];
|
| 433 |
+
if (binding) { event.preventDefault(); controls._choose?.(binding[0], binding[1]); }
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
+
document.addEventListener("visibilitychange", () => {
|
| 437 |
+
if (document.visibilityState !== "hidden") return;
|
| 438 |
+
commitCurrent(); // capture a trailing note/unsure edit
|
| 439 |
+
if (state.unsaved) pushGuarded(); // best-effort
|
| 440 |
+
});
|
| 441 |
+
window.addEventListener("pagehide", () => {
|
| 442 |
+
commitCurrent();
|
| 443 |
+
if (state.unsaved) pushGuarded(); // best-effort; localStorage is the backstop
|
| 444 |
+
releaseHeartbeat(); // so a reload doesn't trip the one-tab guard
|
| 445 |
+
});
|
| 446 |
+
|
| 447 |
+
// ── boot ─────────────────────────────────────────────────────────────────────
|
| 448 |
+
async function boot() {
|
| 449 |
+
$("#task-title").textContent = CONFIG.title;
|
| 450 |
+
state.rubricVersion = await rubricVersion();
|
| 451 |
+
$("#rubric-body").textContent = CONFIG.rubric;
|
| 452 |
+
// Wide screens hold the rubric in a side rail — open it so it reads at a glance.
|
| 453 |
+
if (matchMedia("(min-width: 1240px)").matches) $("#rubric").open = true;
|
| 454 |
+
|
| 455 |
+
if (window.self !== window.top) {
|
| 456 |
+
// Embedded in hf.co: OAuth storage is partitioned here — link out instead.
|
| 457 |
+
const main = $("#main");
|
| 458 |
+
main.replaceChildren(el("p", null, "Open this Space in its own tab to sign in:"));
|
| 459 |
+
const link = el("a", "open-out", location.href);
|
| 460 |
+
link.href = location.href; link.target = "_blank"; link.rel = "noopener";
|
| 461 |
+
main.append(link);
|
| 462 |
+
return;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
state.auth = await ensureAuth();
|
| 466 |
+
if (!state.auth) {
|
| 467 |
+
const main = $("#main");
|
| 468 |
+
main.replaceChildren(el("p", null, CONFIG.landing));
|
| 469 |
+
main.append(el("p", "independence", CONFIG.independence));
|
| 470 |
+
const button = el("button", "signin", "Sign in with Hugging Face");
|
| 471 |
+
button.addEventListener("click", signIn);
|
| 472 |
+
main.append(button);
|
| 473 |
+
return;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
const username = state.auth.userInfo.preferred_username;
|
| 477 |
+
const inOrg = (state.auth.userInfo.orgs ?? []).some((org) => org.preferred_username === "localgate");
|
| 478 |
+
if (!inOrg) {
|
| 479 |
+
$("#main").replaceChildren(el("p", null,
|
| 480 |
+
`@${username} is not on this study's annotator list — ask Samuel to add you to the localgate org.`));
|
| 481 |
+
return;
|
| 482 |
+
}
|
| 483 |
+
state.user = username;
|
| 484 |
+
$("#whoami").textContent = `@${username}`;
|
| 485 |
+
|
| 486 |
+
if (!state.hbTimer && foreignTabAlive()) {
|
| 487 |
+
const main = $("#main");
|
| 488 |
+
main.replaceChildren(el("p", null,
|
| 489 |
+
"This audit is already open in another tab of this browser. Two open copies " +
|
| 490 |
+
"can overwrite each other's work, so grading is paused here."));
|
| 491 |
+
const takeOver = el("button", "signin", "Continue in this tab instead");
|
| 492 |
+
takeOver.addEventListener("click", () => { startHeartbeat(true); boot(); });
|
| 493 |
+
main.append(takeOver);
|
| 494 |
+
return;
|
| 495 |
+
}
|
| 496 |
+
if (!state.hbTimer) startHeartbeat();
|
| 497 |
+
|
| 498 |
+
try {
|
| 499 |
+
const blob = await downloadFile({ repo: ITEMS_REPO, path: CONFIG.itemsPath,
|
| 500 |
+
accessToken: state.auth.accessToken });
|
| 501 |
+
if (blob === null) throw new Error("items file missing from localgate/audit-items");
|
| 502 |
+
state.items = JSON.parse(await blob.text());
|
| 503 |
+
} catch (err) {
|
| 504 |
+
$("#main").replaceChildren(el("p", null, `Could not load items: ${String(err).slice(0, 140)}`));
|
| 505 |
+
const retry = el("button", null, "retry");
|
| 506 |
+
retry.addEventListener("click", boot);
|
| 507 |
+
$("#main").append(retry);
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
await ensureResultsRepo();
|
| 512 |
+
|
| 513 |
+
// Ordering gate: a task can require another task's completion first (the
|
| 514 |
+
// conversion fidelity pass must not open before the blind verdict pass,
|
| 515 |
+
// because it reveals which items the filter kept).
|
| 516 |
+
if (CONFIG.requires) {
|
| 517 |
+
let done = 0;
|
| 518 |
+
try {
|
| 519 |
+
const blob = await downloadFile({
|
| 520 |
+
repo: state.resultsRepo, accessToken: state.auth.accessToken,
|
| 521 |
+
path: `${CONFIG.requires.task}/${state.user}.jsonl`,
|
| 522 |
+
});
|
| 523 |
+
if (blob !== null) {
|
| 524 |
+
const rows = (await blob.text()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
| 525 |
+
done = [...latestByKey(rows).values()]
|
| 526 |
+
.filter((ev) => !ev.warmup && CONFIG.requires.fields.every((f) => ev[f])).length;
|
| 527 |
+
}
|
| 528 |
+
} catch { /* unreadable counts as not done — the gate stays shut */ }
|
| 529 |
+
if (done < CONFIG.requires.count) {
|
| 530 |
+
$("#main").replaceChildren(el("p", null,
|
| 531 |
+
`${CONFIG.requires.label} must be finished first — you have graded ` +
|
| 532 |
+
`${done} of ${CONFIG.requires.count} items there. This pass reveals ` +
|
| 533 |
+
"information that must not color that one, so it stays locked until " +
|
| 534 |
+
"you are done."));
|
| 535 |
+
const link = el("a", "open-out", CONFIG.requires.url);
|
| 536 |
+
link.href = CONFIG.requires.url; link.rel = "noopener";
|
| 537 |
+
$("#main").append(link);
|
| 538 |
+
return;
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
// Merge remote history with the local log, remote order first (mergeEvents).
|
| 543 |
+
// A pull FAILURE is not "no file yet": grading continues from the local log,
|
| 544 |
+
// and because every push re-pulls first, nothing can be overwritten blind.
|
| 545 |
+
let remote = [];
|
| 546 |
+
try { remote = await pullRemote(); }
|
| 547 |
+
catch (err) {
|
| 548 |
+
banner(`Could not read your previous progress (${String(err).slice(0, 80)}) — ` +
|
| 549 |
+
"grading continues and is kept in this browser; publishing retries on the next save.", true);
|
| 550 |
+
}
|
| 551 |
+
state.events = mergeEvents(remote, loadLocalEvents());
|
| 552 |
+
persistLocal();
|
| 553 |
+
|
| 554 |
+
buildOrder();
|
| 555 |
+
state.idx = firstUngraded();
|
| 556 |
+
renderItem();
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
boot();
|
config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
window.CONFIG = {
|
| 2 |
+
task: "verdicts",
|
| 3 |
+
title: "Conversion audit, pass 1 — 248 items",
|
| 4 |
+
itemsPath: "verdict_items.json",
|
| 5 |
+
build: "ef8b962-p1space",
|
| 6 |
+
landing: "Pass 1 of the conversion audit. You will see original exam questions "
|
| 7 |
+
+ "with their answer options and judge whether each could survive losing its "
|
| 8 |
+
+ "options — you are never told what the automated filter decided. 248 items; "
|
| 9 |
+
+ "expect 2–3 hours, in as many sittings as you like — progress saves "
|
| 10 |
+
+ "continuously. Please stick to one browser: work is guarded per browser, "
|
| 11 |
+
+ "and switching browsers mid-audit merges on save rather than instantly.",
|
| 12 |
+
independence: "Grade independently: no discussing items until everyone has "
|
| 13 |
+
+ "finished both conversion passes. Do not try to guess or track what the "
|
| 14 |
+
+ "filter kept — every item is shown the same way on purpose.",
|
| 15 |
+
rubric: "CONVERTS — imagine the options deleted and the question reworded to "
|
| 16 |
+
+ "stand alone. Would a knowledgeable person give the reference answer, "
|
| 17 |
+
+ "rather than a different, equally correct one?\n\nyes — the reference is "
|
| 18 |
+
+ "the single correct answer. no — stripped of options it admits many "
|
| 19 |
+
+ "correct answers, or stops meaning anything. borderline — you genuinely "
|
| 20 |
+
+ "cannot decide; use sparingly.\n\nAlways 'no': exclusion questions "
|
| 21 |
+
+ "('which is NOT', 'EXCEPT', 'least likely') and answers that only mean "
|
| 22 |
+
+ "something beside the other options ('all of the above') — except when "
|
| 23 |
+
+ "the stem itself lists the alternatives ('i) … ii) … iii)'). Common "
|
| 24 |
+
+ "failures: asking for one member of a large category; ranking the "
|
| 25 |
+
+ "options ('best', 'closest'); an answer true among these ten but false "
|
| 26 |
+
+ "about the world; leaning on a unit or convention only the options "
|
| 27 |
+
+ "supplied. A superlative is not automatically a ranking — if the stem "
|
| 28 |
+
+ "fixes the quantity, 'best approximates' has nothing left to rank.\n\n"
|
| 29 |
+
+ "Not failures: multi-part questions (judge uniqueness, not simplicity), "
|
| 30 |
+
+ "working the answer out from supplied figures, clumsy phrasing (a "
|
| 31 |
+
+ "rewrite fixes that), and the reference's wording (a later stage "
|
| 32 |
+
+ "accepts any phrasing of the same fact).\n\nANSWER STANDS ALONE — judge "
|
| 33 |
+
+ "this independently: a grader will see the question, this reference, "
|
| 34 |
+
+ "and a model response. yes — usable target. no — units or magnitude "
|
| 35 |
+
+ "stripped ('1.12' for a speed), a fragment ('greater and grander'), it "
|
| 36 |
+
+ "does not answer what was asked, or it names option labels ('I and II "
|
| 37 |
+
+ "only'). borderline — sparingly.\n\nWHY — half a line on what decided "
|
| 38 |
+
+ "it, written for the other two annotators when you disagree. Do not "
|
| 39 |
+
+ "track your own yes/no ratio: the sample is deliberately not a picture "
|
| 40 |
+
+ "of the corpus.",
|
| 41 |
+
fields: [
|
| 42 |
+
{ name: "converts", label: "converts?",
|
| 43 |
+
options: [["yes","1"],["no","2"],["borderline","3"]] },
|
| 44 |
+
{ name: "answer_stands_alone", label: "answer stands alone?",
|
| 45 |
+
options: [["yes","4"],["no","5"],["borderline","6"]] },
|
| 46 |
+
],
|
| 47 |
+
hotkeys: { "1":["converts","yes"], "2":["converts","no"],
|
| 48 |
+
"3":["converts","borderline"],
|
| 49 |
+
"4":["answer_stands_alone","yes"], "5":["answer_stands_alone","no"],
|
| 50 |
+
"6":["answer_stands_alone","borderline"] },
|
| 51 |
+
notePlaceholder: "why (half a line)",
|
| 52 |
+
isComplete: (ev) => !!(ev.converts && ev.answer_stands_alone),
|
| 53 |
+
valuesOf: (ev) => ({ converts: ev.converts,
|
| 54 |
+
answer_stands_alone: ev.answer_stands_alone }),
|
| 55 |
+
summarize: (v) => `converts:${v.converts} alone:${v.answer_stands_alone}`,
|
| 56 |
+
blocks: (item) => [
|
| 57 |
+
{ label: "original (with options)",
|
| 58 |
+
text: item.original + "\n\n" + item.options.map((o,i)=>String.fromCharCode(65+i)+". "+o).join("\n") },
|
| 59 |
+
{ label: "correct answer", text: item.reference_answer, ref: true },
|
| 60 |
+
],
|
| 61 |
+
};
|
fonts/ibm-plex-mono-latin-400-normal.woff2
ADDED
|
Binary file (14.7 kB). View file
|
|
|
fonts/ibm-plex-mono-latin-600-normal.woff2
ADDED
|
Binary file (15.6 kB). View file
|
|
|
fonts/source-serif-4-latin-400-normal.woff2
ADDED
|
Binary file (20.1 kB). View file
|
|
|
fonts/source-serif-4-latin-600-normal.woff2
ADDED
|
Binary file (21.5 kB). View file
|
|
|
hub-2.15.0.bundle.mjs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
index.html
CHANGED
|
@@ -1,19 +1,27 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src https://huggingface.co https://*.hf.co; img-src 'self' data:; base-uri 'none'; form-action 'none'">
|
| 7 |
+
<title>LocalGate audit</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css?v=ef8b962-p1space">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<header>
|
| 12 |
+
<h1 id="task-title">Loading…</h1>
|
| 13 |
+
<span id="whoami"></span>
|
| 14 |
+
<span id="progress"></span>
|
| 15 |
+
</header>
|
| 16 |
+
<div id="bar"><div id="bar-fill"></div></div>
|
| 17 |
+
<div id="banner" hidden></div>
|
| 18 |
+
<div id="layout">
|
| 19 |
+
<main id="main"><p>Loading…</p></main>
|
| 20 |
+
<details id="rubric"><summary>Rubric & edge rules (keep open while grading)</summary>
|
| 21 |
+
<pre id="rubric-body"></pre></details>
|
| 22 |
+
</div>
|
| 23 |
+
<div id="controls" hidden></div>
|
| 24 |
+
<script src="config.js?v=ef8b962-p1space"></script>
|
| 25 |
+
<script type="module" src="app.js?v=ef8b962-p1space"></script>
|
| 26 |
+
</body>
|
| 27 |
</html>
|
style.css
CHANGED
|
@@ -1,28 +1,181 @@
|
|
| 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 |
}
|
|
|
|
|
|
|
|
|
| 1 |
+
/* LocalGate audit instrument — grading-desk design.
|
| 2 |
+
Chroma is semantic only: verdict inks + the reference yardstick. Everything
|
| 3 |
+
instrumental speaks mono; everything read speaks serif. */
|
| 4 |
+
|
| 5 |
+
@font-face { font-family: "Source Serif 4"; src: url("fonts/source-serif-4-latin-400-normal.woff2") format("woff2");
|
| 6 |
+
font-weight: 400; font-display: swap; }
|
| 7 |
+
@font-face { font-family: "Source Serif 4"; src: url("fonts/source-serif-4-latin-600-normal.woff2") format("woff2");
|
| 8 |
+
font-weight: 600; font-display: swap; }
|
| 9 |
+
@font-face { font-family: "IBM Plex Mono"; src: url("fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
|
| 10 |
+
font-weight: 400; font-display: swap; }
|
| 11 |
+
@font-face { font-family: "IBM Plex Mono"; src: url("fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2");
|
| 12 |
+
font-weight: 600; font-display: swap; }
|
| 13 |
|
| 14 |
+
:root {
|
| 15 |
+
--ground: #f3f5f4; /* cool exam paper, not cream */
|
| 16 |
+
--field: #ffffff;
|
| 17 |
+
--ink: #1c2430; /* blue-black */
|
| 18 |
+
--dim: #5c6672;
|
| 19 |
+
--line: #d4dad8;
|
| 20 |
+
--line-strong: #aeb8b4;
|
| 21 |
+
--ref-ground: #fbf7e6; /* annotation-yellow: the yardstick, nothing else */
|
| 22 |
+
--ref-line: #e3d9ae;
|
| 23 |
+
--match: #2f7a4d;
|
| 24 |
+
--nomatch: #a63d2a;
|
| 25 |
+
--noanswer: #58657a;
|
| 26 |
+
--unsure: #9a7b1f;
|
| 27 |
+
--yes: #2f7a4d;
|
| 28 |
+
--no: #a63d2a;
|
| 29 |
+
--serif: "Source Serif 4", Charter, "Iowan Old Style", Georgia, serif;
|
| 30 |
+
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
| 31 |
+
}
|
| 32 |
+
@media (prefers-color-scheme: dark) {
|
| 33 |
+
:root {
|
| 34 |
+
--ground: #171b1f; --field: #1f242a; --ink: #dde3e8; --dim: #8b95a0;
|
| 35 |
+
--line: #323a41; --line-strong: #49535c;
|
| 36 |
+
--ref-ground: #2b2618; --ref-line: #4a3f1e;
|
| 37 |
+
--match: #5cb885; --nomatch: #d9765f; --noanswer: #93a2b8; --unsure: #cfa93e;
|
| 38 |
+
--yes: #5cb885; --no: #d9765f;
|
| 39 |
+
}
|
| 40 |
}
|
| 41 |
|
| 42 |
+
* { box-sizing: border-box; }
|
| 43 |
+
html { height: 100%; }
|
| 44 |
+
body { margin: 0; min-height: 100%; background: var(--ground); color: var(--ink);
|
| 45 |
+
font: 16.5px/1.62 var(--serif); }
|
| 46 |
+
|
| 47 |
+
/* ── header: instrument panel ── */
|
| 48 |
+
header { display: flex; gap: 1.2rem; align-items: baseline; flex-wrap: wrap;
|
| 49 |
+
padding: .65rem 1.2rem; background: var(--field);
|
| 50 |
+
border-bottom: 2px solid var(--ink); font-family: var(--mono); }
|
| 51 |
+
header h1 { margin: 0; font-size: .8rem; font-weight: 600; letter-spacing: .14em;
|
| 52 |
+
text-transform: uppercase; }
|
| 53 |
+
#whoami { color: var(--dim); font-size: .75rem; }
|
| 54 |
+
#progress { margin-left: auto; color: var(--dim); font-size: .75rem;
|
| 55 |
+
font-variant-numeric: tabular-nums; }
|
| 56 |
+
|
| 57 |
+
/* gauge with ticks every 10% */
|
| 58 |
+
#bar { height: 6px; background:
|
| 59 |
+
repeating-linear-gradient(to right,
|
| 60 |
+
transparent 0, transparent calc(10% - 1px),
|
| 61 |
+
var(--line-strong) calc(10% - 1px), var(--line-strong) 10%),
|
| 62 |
+
var(--line); }
|
| 63 |
+
#bar-fill { height: 100%; width: 0; background: var(--ink); transition: width .25s ease; }
|
| 64 |
+
|
| 65 |
+
#banner { padding: .55rem 1.2rem; font-family: var(--mono); font-size: .78rem;
|
| 66 |
+
color: var(--unsure); background: color-mix(in srgb, var(--unsure) 9%, var(--field));
|
| 67 |
+
border-bottom: 1px solid var(--line); }
|
| 68 |
+
|
| 69 |
+
/* ── layout: reading column + rubric rail on wide screens ── */
|
| 70 |
+
#layout { max-width: 100rem; margin: 0 auto; }
|
| 71 |
+
main { max-width: 46rem; margin: 1.1rem auto 12rem; padding: 0 1.2rem; }
|
| 72 |
+
@media (min-width: 1240px) {
|
| 73 |
+
#layout { display: grid; grid-template-columns: 1fr 21rem; gap: 0; }
|
| 74 |
+
main { grid-column: 1; margin-left: auto; margin-right: 3vw; }
|
| 75 |
+
details#rubric { grid-column: 2; position: sticky; top: 0; align-self: start;
|
| 76 |
+
max-height: calc(100vh - 8rem); border-left: 1px solid var(--line);
|
| 77 |
+
border-top: none; background: transparent; }
|
| 78 |
+
details#rubric[open] summary { border-bottom: 1px solid var(--line); }
|
| 79 |
}
|
| 80 |
|
| 81 |
+
/* ── fields ── */
|
| 82 |
+
.field { background: var(--field); border: 1px solid var(--line);
|
| 83 |
+
border-left: 3px solid var(--line-strong); padding: .8rem 1.1rem;
|
| 84 |
+
margin: .8rem 0; }
|
| 85 |
+
.field b { display: block; font-family: var(--mono); font-weight: 600;
|
| 86 |
+
font-size: .66rem; letter-spacing: .12em; text-transform: uppercase;
|
| 87 |
+
color: var(--dim); margin-bottom: .4rem; }
|
| 88 |
+
.field .content { white-space: pre-wrap; overflow-wrap: anywhere; }
|
| 89 |
+
|
| 90 |
+
/* the yardstick: only the reference wears the annotation highlight */
|
| 91 |
+
.field.reference { background: var(--ref-ground); border-color: var(--ref-line);
|
| 92 |
+
border-left-color: var(--unsure); }
|
| 93 |
+
|
| 94 |
+
.response { max-height: 26rem; overflow-y: auto; border-left-color: var(--ink); }
|
| 95 |
+
.endmark { text-align: center; color: var(--dim); font-family: var(--mono);
|
| 96 |
+
font-size: .68rem; letter-spacing: .18em; padding-top: .6rem; }
|
| 97 |
+
|
| 98 |
+
.warmup-note { font-family: var(--mono); font-size: .78rem; padding: .55rem .9rem;
|
| 99 |
+
border: 1px dashed var(--line-strong); color: var(--dim); }
|
| 100 |
+
.independence { color: var(--dim); font-style: italic; }
|
| 101 |
+
|
| 102 |
+
button.signin { font: 600 .95rem var(--mono); padding: .7rem 1.5rem; cursor: pointer;
|
| 103 |
+
border: 2px solid var(--ink); background: var(--ink); color: var(--ground); }
|
| 104 |
+
button.signin:hover { background: transparent; color: var(--ink); }
|
| 105 |
+
|
| 106 |
+
/* ── rubric ── */
|
| 107 |
+
details#rubric { background: var(--field); border-top: 1px solid var(--line);
|
| 108 |
+
font-family: var(--mono); font-size: .74rem; line-height: 1.55;
|
| 109 |
+
overflow-y: auto; }
|
| 110 |
+
details#rubric summary { cursor: pointer; padding: .55rem 1.1rem; font-weight: 600;
|
| 111 |
+
letter-spacing: .08em; text-transform: uppercase;
|
| 112 |
+
font-size: .66rem; color: var(--dim); list-style: none; }
|
| 113 |
+
details#rubric summary::before { content: "▸ "; }
|
| 114 |
+
details#rubric[open] summary::before { content: "▾ "; }
|
| 115 |
+
details#rubric pre { white-space: pre-wrap; font: inherit; margin: 0;
|
| 116 |
+
padding: .3rem 1.1rem 1rem; }
|
| 117 |
+
@media (max-width: 1239px) {
|
| 118 |
+
details#rubric { position: fixed; bottom: 5.9rem; left: 0; right: 0; max-height: 38vh;
|
| 119 |
+
border-top: 2px solid var(--line-strong); }
|
| 120 |
}
|
| 121 |
|
| 122 |
+
/* ── controls: the bench ── */
|
| 123 |
+
#controls { position: fixed; bottom: 0; left: 0; right: 0; background: var(--field);
|
| 124 |
+
border-top: 2px solid var(--ink); padding: .65rem 1.2rem;
|
| 125 |
+
display: flex; gap: .7rem; align-items: center; flex-wrap: wrap;
|
| 126 |
+
font-family: var(--mono); }
|
| 127 |
+
#controls .grp { display: flex; gap: .4rem; align-items: center; }
|
| 128 |
+
#controls .lbl { font-size: .68rem; letter-spacing: .08em; text-transform: uppercase;
|
| 129 |
+
color: var(--dim); }
|
| 130 |
+
#controls button { font: 600 .82rem var(--mono); padding: .48rem .8rem; cursor: pointer;
|
| 131 |
+
background: var(--field); color: var(--ink);
|
| 132 |
+
border: 2px solid var(--line-strong); }
|
| 133 |
+
#controls button:hover { border-color: var(--ink); }
|
| 134 |
+
|
| 135 |
+
/* verdict identity: a colored stamp edge; full ink when committed */
|
| 136 |
+
#controls button[data-v] { border-left-width: 5px; }
|
| 137 |
+
#controls button[data-v="match"], #controls button[data-v="yes"] { border-left-color: var(--match); }
|
| 138 |
+
#controls button[data-v="no_match"], #controls button[data-v="no"] { border-left-color: var(--nomatch); }
|
| 139 |
+
#controls button[data-v="no_answer"] { border-left-color: var(--noanswer); }
|
| 140 |
+
#controls button[data-v].sel { color: var(--field); }
|
| 141 |
+
#controls button[data-v="match"].sel, #controls button[data-v="yes"].sel
|
| 142 |
+
{ background: var(--match); border-color: var(--match); }
|
| 143 |
+
#controls button[data-v="no_match"].sel, #controls button[data-v="no"].sel
|
| 144 |
+
{ background: var(--nomatch); border-color: var(--nomatch); }
|
| 145 |
+
#controls button[data-v="no_answer"].sel
|
| 146 |
+
{ background: var(--noanswer); border-color: var(--noanswer); }
|
| 147 |
+
|
| 148 |
+
#controls button.unsure { border-style: dashed; color: var(--dim); }
|
| 149 |
+
#controls button.unsure.sel { background: var(--unsure); border-color: var(--unsure);
|
| 150 |
+
border-style: solid; color: var(--field); }
|
| 151 |
+
|
| 152 |
+
#controls kbd { display: inline-block; font-size: .62rem; font-weight: 400;
|
| 153 |
+
padding: 0 .3em; margin-left: .45em; border: 1px solid currentColor;
|
| 154 |
+
border-bottom-width: 2px; border-radius: 3px; opacity: .65; }
|
| 155 |
+
|
| 156 |
+
#controls #note { font: .8rem var(--mono); padding: .45rem .6rem; width: 13rem;
|
| 157 |
+
border: 1px solid var(--line); background: var(--ground);
|
| 158 |
+
color: var(--ink); }
|
| 159 |
+
#controls #note::placeholder { color: var(--dim); }
|
| 160 |
+
#controls .nav { margin-left: auto; display: flex; gap: .4rem; }
|
| 161 |
+
|
| 162 |
+
/* ── the signature: the verdict stamp ── */
|
| 163 |
+
#controls .last { width: 100%; color: var(--dim); font-size: .72rem;
|
| 164 |
+
display: flex; align-items: center; gap: .5rem; }
|
| 165 |
+
#controls .last .stamp { display: inline-block; font-weight: 600; font-size: .7rem;
|
| 166 |
+
letter-spacing: .06em; padding: .12rem .5rem;
|
| 167 |
+
border: 2px solid var(--stamp, var(--dim));
|
| 168 |
+
color: var(--stamp, var(--dim));
|
| 169 |
+
transform: rotate(-1.2deg);
|
| 170 |
+
animation: stamp .14s ease-out; }
|
| 171 |
+
#controls .last .stamp.v-match { --stamp: var(--match); }
|
| 172 |
+
#controls .last .stamp.v-no_match { --stamp: var(--nomatch); }
|
| 173 |
+
#controls .last .stamp.v-no_answer { --stamp: var(--noanswer); }
|
| 174 |
+
@keyframes stamp { from { transform: rotate(-1.2deg) scale(1.12); opacity: .4; }
|
| 175 |
+
to { transform: rotate(-1.2deg) scale(1); opacity: 1; } }
|
| 176 |
+
@media (prefers-reduced-motion: reduce) {
|
| 177 |
+
.last .stamp { animation: none; }
|
| 178 |
+
#bar-fill { transition: none; }
|
| 179 |
}
|
| 180 |
+
|
| 181 |
+
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|