rafmacalaba/data-use-annotate
0
1<!DOCTYPE html>2<html lang="en" data-theme="light">3<head>4<meta charset="utf-8">5<meta name="viewport" content="width=device-width, initial-scale=1">6<title>annotation review</title>7<style>8:root, [data-theme="light"] { --bg: #f7f8fa; --card: #ffffff; --fg: #232a31; --mut: #6b7683; --line: #dfe4ea;9 --keep: #2e7d46; --keep-bg: #ddf2e3; --keep-bg2: #c9e9d2; --keep-line: #8fd0a6;10 --drop: #c96a5e; --drop-bg: #fbe7e4; --drop-bg2: #f6d5d0; --drop-line: #ecb3ac;11 --hl: #7c8cf8; --chip: #eef1f4; --mark-bg: #fdf0d3; --mark-ink: #6b4a08; }12[data-theme="dark"] { --bg: #12161c; --card: #1a2029; --fg: #dbe2ea; --mut: #8d99a8; --line: #2c3440;13 --keep: #8fdcab; --keep-bg: rgba(63,185,80,.20); --keep-bg2: rgba(63,185,80,.30); --keep-line: rgba(143,220,171,.55);14 --drop: #f0a49b; --drop-bg: rgba(248,113,113,.18); --drop-bg2: rgba(248,113,113,.28); --drop-line: rgba(240,164,155,.55);15 --hl: #9aa7ff; --chip: #222a35; --mark-bg: rgba(222,171,55,.28); --mark-ink: #ffd88a; }16* { box-sizing: border-box; }17body { background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, system-ui, sans-serif; margin: 0; }18header { position: sticky; top: 0; background: var(--card); border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; gap: 10px; align-items: center; z-index: 5; }19header .prog { font-variant-numeric: tabular-nums; white-space: nowrap; }20#bar { flex: 1; height: 8px; background: var(--chip); border-radius: 4px; overflow: hidden; }21#bar > div { height: 100%; background: var(--hl); width: 0; }22select, button { font: inherit; color: var(--fg); }23main { max-width: 860px; margin: 0 auto; padding: 20px 16px 90px; }24.meta { color: var(--mut); font-size: 13px; margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; }25#passage { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; font-size: 16px; overflow-wrap: anywhere; overflow-x: clip; }26#passage mark { background: var(--mark-bg); color: var(--mark-ink); border-radius: 4px; padding: 0 2px; cursor: pointer; }27#passage mark.active { outline: 2px solid var(--hl); }28#passage mark.done-k { background: linear-gradient(180deg, var(--keep-bg), var(--keep-bg2)); color: var(--fg); }29#passage mark.done-d { background: linear-gradient(180deg, var(--drop-bg), var(--drop-bg2)); color: var(--fg); }30#passage mark.active.done-k { outline-color: var(--keep-line); }31#passage mark.active.done-d { outline-color: var(--drop-line); }32#chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }33.chip { background: var(--chip); border: 1px solid var(--line); border-radius: 14px; padding: 2px 12px; font-size: 13px; cursor: pointer; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }34.chip.active { outline: 2px solid var(--hl); }35.chip.active.k { outline-color: var(--keep-line); } .chip.active.d { outline-color: var(--drop-line); }36.chip.k { border-color: var(--keep-line); background: var(--keep-bg); } .chip.d { border-color: var(--drop-line); background: var(--drop-bg); }37#ref { margin-top: 10px; font-size: 13px; color: var(--mut); background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }38#ref b.k { color: var(--keep); } #ref b.d { color: var(--drop); }39.controls { display: flex; gap: 10px; margin-top: 14px; }40button { cursor: pointer; border-radius: 8px; border: 1px solid var(--line); background: var(--card); padding: 10px 22px; }41button.keep { border-color: var(--keep-line); } button.keep.on { background: linear-gradient(180deg, var(--keep-bg), var(--keep-bg2)); color: var(--fg); border-color: var(--keep); }42button.drop { border-color: var(--drop-line); } button.drop.on { background: linear-gradient(180deg, var(--drop-bg), var(--drop-bg2)); color: var(--fg); border-color: var(--drop); }43button.nav { padding: 10px 14px; }44#mine { color: var(--mut); font-size: 13px; margin-top: 8px; min-height: 20px; }45kbd { background: var(--chip); border-radius: 4px; padding: 1px 6px; font-size: 12px; }46select { appearance: none; -webkit-appearance: none; background-color: var(--card);47 border: 1px solid var(--line); border-radius: 8px; padding: 8px 30px 8px 12px; cursor: pointer;48 background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");49 background-repeat: no-repeat; background-position: right 10px center; max-width: 220px; }50select:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--hl); outline-offset: 1px; }51button { transition: background .12s ease, transform .05s ease; }52button:active:not(:disabled) { transform: scale(.97); }53#toast.warn { border-color: var(--drop); }54input[type="text"], input:not([type]) { font: inherit; color: var(--fg); background: var(--bg);55 border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }56#who { max-width: 170px; }57#who.locked { background: var(--chip); color: var(--mut); border-style: dashed; }58#lockBtn { padding: 8px 10px; }59#toast { position: fixed; top: 110px; left: 50%; transform: translateX(-50%); background: var(--card);60 border: 1px solid var(--hl); border-radius: 12px; padding: 12px 16px; display: none; gap: 12px;61 align-items: center; z-index: 50; box-shadow: 0 8px 30px rgba(0,0,0,.25); font-size: 14px; }62footer { position: fixed; bottom: 0; left: 0; right: 0; background: var(--card); border-top: 1px solid var(--line); padding: 8px 16px; display: flex; gap: 10px; align-items: center; font-size: 13px; color: var(--mut); }63footer .sp { flex: 1; }64</style>65</head>66<body>67<header>68 <span class="prog" id="prog">–/–</span>69 <div id="bar"><div></div></div>70 <select id="ds" onchange="loadDs()">71 <option value="sample" selected>annotate sample · 60</option>72 <option value="gold">gold · 473</option>73 <option value="paddy">paddy · 240</option>74 <option value="aj">aj · 240</option>75 <option value="aivin">aivin · 240</option>76 <option value="rafael">rafael · 240</option>77 <option value="paddy_part2">paddy part2 · 240</option>78 <option value="aj_part2">aj part2 · 240</option>79 <option value="aivin_part2">aivin part2 · 240</option>80 <option value="rafael_part2">rafael part2 · 240</option>81 </select>82 <select id="src" onchange="reset()"></select>83 <input id="who" placeholder="your name to annotate" autocomplete="off">84 <button id="lockBtn" onclick="toggleLock()" title="lock / change name">unlock</button>85 <button onclick="exp()" title="export rulings now">export</button>86 <button onclick="theme()" title="toggle theme">◐</button>87</header>88<div id="toast" role="status"></div>89<div style="display:flex;align-items:center;gap:10px;padding:6px 16px;background:var(--card);border-bottom:1px solid var(--line);font-size:13px;color:var(--mut)">90 <span id="ruledLab" style="white-space:nowrap">0 ruled</span>91 <div style="flex:1;height:6px;background:var(--chip);border-radius:3px;overflow:hidden"><div id="ruledFill" style="height:100%;width:0;background:var(--hl)"></div></div>92 <span id="ruledPct"></span>93</div>94<main>95 <div class="meta"><span id="key"></span><span id="surface"></span></div>96 <div id="passage"></div>97 <div id="chips"></div>98 <div id="ref"></div>99 <div class="controls">100 <button class="nav" onclick="move(-1)">← prev</button>101 <button class="keep" id="bKeep" onclick="rule('DATA_MENTION')">keep <kbd>k</kbd></button>102 <button class="drop" id="bDrop" onclick="rule('NON_MENTION')">drop <kbd>d</kbd></button>103 <button class="nav" onclick="move(1)">next →</button>104 </div>105 <input id="note" placeholder="note (optional, saved with your ruling)" autocomplete="off"106 style="width:100%;margin-top:10px;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 12px">107 <div id="mine"></div>108<footer>109 <span><kbd>←</kbd><kbd>→</kbd> prev/next mention · <kbd>k</kbd> keep · <kbd>d</kbd> drop · <kbd>space</kbd> skip · <kbd>[</kbd><kbd>]</kbd> slide · <kbd>-</kbd><kbd>+</kbd> size · <kbd>e</kbd> export</span>110 <span class="sp"></span><span id="count"></span>111 <button id="hubBtn" onclick="exp()">export</button>112 <button onclick="$('impFile').click()" title="load a previous export to resume">import</button>113 <input id="impFile" type="file" accept=".jsonl,.json" style="display:none">114</footer>115<script>116let SINK = null, HFFUSER = null, SHAREDTOK = null;117fetch('./sink.json').then(r => r.ok ? r.json() : null).then(async j => {118 if (!j) return;119 // Optional shared push token (see README: public repo = public token,120 // scope it to data-use-annotations writes only, rotate after the round).121 if (j.pushToken) {122 SHAREDTOK = j.pushToken;123 const h = $('hubtok');124 if (h) h.style.display = 'none';125 }126 if (!j.url) return;127 SINK = j.url.replace(/\/$/, '');128 paintLogin();129 try {130 const m = await fetch(SINK + '/me', {credentials: 'include'});131 if (m.ok) { HFFUSER = (await m.json()).user; paintLogin(); }132 } catch (e) {}133});134function paintLogin() {135 const b = $('hfBtn');136 if (!b) return;137 b.textContent = HFFUSER ? 'HF: ' + HFFUSER : 'log in with HF';138 b.title = HFFUSER ? 'logged in — rulings commit as you' : 'log in to push rulings to the Hub as yourself';139}140function hfLogin() {141 if (!SINK) return;142 location.href = SINK + '/login?next=' + encodeURIComponent(location.href.split('?')[0] + location.search);143}144const DS = { sample: './queue_annotate_sample.json', gold: './queue_review473.json',145 paddy: './queue_annotate_paddy.json', aj: './queue_annotate_aj.json',146 aivin: './queue_annotate_aivin.json', rafael: './queue_annotate_rafael.json',147 paddy_part2: './queue_annotate_paddy_part2.json', aj_part2: './queue_annotate_aj_part2.json',148 aivin_part2: './queue_annotate_aivin_part2.json', rafael_part2: './queue_annotate_rafael_part2.json' };149const USERS = ['paddy', 'aj', 'aivin', 'rafael'];150const LS = 'review_rulings';151let R = JSON.parse(localStorage.getItem(LS) || '{}');152let RQ = JSON.parse(localStorage.getItem('review_queues') || '{}');153let RN = JSON.parse(localStorage.getItem('review_notes') || '{}');154function saveNotes() { localStorage.setItem('review_notes', JSON.stringify(RN)); }155const $ = id => document.getElementById(id);156const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));157const QD = document.documentElement;158QD.dataset.theme = localStorage.getItem('review_theme') || 'light';159function theme() { QD.dataset.theme = QD.dataset.theme === 'light' ? 'dark' : 'light'; localStorage.setItem('review_theme', QD.dataset.theme); }160const who = () => ($('who').value || '').trim();161// normalize legacy single-span items -> spans[]162function norm(o) {163 if (o.spans) return o;164 return {...o, spans: [{key: o.key, start: o.start, end: o.end, surface: o.surface,165 luna_label: o.ref ? (o.ref.luna_label === 1 ? 1 : 0) : undefined,166 human_verdict: o.ref && o.ref.human_verdict, human_note: o.ref && o.ref.human_note,167 probe_tag: undefined, probe_score: o.ref && o.ref.head_score, luna_reason: o.ref && o.ref.luna_reason,168 pred: o.ref && o.ref.pred}]};169}170function loadWho() {171 $('who').value = localStorage.getItem('review_who') || '';172 $('who').addEventListener('input', () => {173 localStorage.setItem('review_who', who());174 paintGate();175 const w = who().toLowerCase();176 if (USERS.includes(w) && $('ds').value !== w) { $('ds').value = w; loadDs(); }177 });178 $('note').addEventListener('input', () => {179 if (!ITEMS.length) return;180 const v = $('note').value;181 if (v) RN[span().key] = v; else delete RN[span().key];182 saveNotes();183 });184 const ht = $('hubtok');185 if (ht) { ht.value = localStorage.getItem('review_hubtok') || ''; ht.addEventListener('input', () => localStorage.setItem('review_hubtok', ht.value.trim())); }186 if (localStorage.getItem('review_locked') === '1' && who()) setLocked(true);187 paintGate();188}189function setLocked(on) {190 $('who').disabled = on;191 $('who').classList.toggle('locked', on);192 $('lockBtn').textContent = on ? 'locked' : 'unlock';193 localStorage.setItem('review_locked', on ? '1' : '0');194}195function toggleLock() {196 if ($('who').disabled) {197 if (confirm('Change annotator name? Existing rulings stay under the old name.')) {198 setLocked(false); $('who').focus();199 }200 return;201 }202 if (!who()) { needName(); return; }203 setLocked(true);204}205function paintGate() {206 const ok = who().length > 0;207 $('who').style.borderColor = ok ? '' : 'var(--drop)';208 $('bKeep').disabled = $('bDrop').disabled = !ok;209 $('bKeep').style.opacity = $('bDrop').style.opacity = ok ? '' : '0.45';210}211function needName() {212 if (who().length > 0) return false;213 $('mine').textContent = 'enter your name above before annotating';214 $('who').focus();215 return true;216}217 218async function loadDs() {219 const q = await (await fetch(DS[$('ds').value])).json();220 ALL = q.map(norm);221 const srcs = [...new Set(ALL.map(o => o.source))].sort();222 const total = ALL.length;223 $('src').innerHTML = `<option value="all">all · ${total}</option>` +224 srcs.map(x => `<option value="${esc(x)}">${esc(x)} · ${ALL.filter(o => o.source === x).length}</option>`).join('');225 reset();226}227function reset() {228 const f = $('src').value;229 ITEMS = ALL.filter(o => f === 'all' || o.source === f);230 i = 0; s = 0; needCenter = true; render();231}232function cur() { return ITEMS[i]; }233function span() { return cur().spans[s]; }234let WSIZES = [400, 800, 1600, Infinity], wi = 1, wc = null, plo = 0, phi = 0, needCenter = true;235let lastSaved = '';236function wlabel() { return WSIZES[wi] === Infinity ? 'full' : '±' + WSIZES[wi] / 2; }237function render() {238 const o = cur();239 if (!o) return;240 s = Math.min(s, o.spans.length - 1);241 const sp = span();242 $('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;243 document.querySelector('#bar > div').style.width = (100 * (i + 1) / ITEMS.length) + '%';244 $('key').textContent = sp.key;245 $('surface').textContent = '“' + sp.surface + '”';246 if (document.activeElement !== $('note')) $('note').value = RN[sp.key] || '';247 // sliding assessment window around the active span248 if (needCenter || wc === null) { wc = (sp.start + sp.end) / 2; needCenter = false; }249 const W = WSIZES[wi], L = o.text.length;250 let lo = 0, hi = L;251 if (W !== Infinity && L > W) {252 lo = Math.max(0, Math.floor(wc - W / 2)); hi = Math.min(L, lo + W);253 if (hi - lo < W) lo = Math.max(0, hi - W);254 const snap = (n, dir) => { while (n > 0 && n < L && o.text[n] !== ' ' && o.text[n] !== '\n') n += dir; return n; };255 lo = snap(lo, -1); hi = snap(hi, 1);256 lo = Math.min(lo, sp.start); hi = Math.max(hi, sp.end);257 }258 const pts = [];259 if (lo > 0) pts.push('<span style="color:var(--mut)">…</span>');260 let p = lo;261 const ordered = [...o.spans].sort((a, b) => a.start - b.start).filter(x => x.end > lo && x.start < hi);262 for (const x of ordered) {263 const a = Math.max(x.start, lo), b = Math.min(x.end, hi);264 pts.push(esc(o.text.slice(p, a)));265 const cls = [x.key === sp.key ? 'active' : '', R[x.key] === 'DATA_MENTION' ? 'done-k' : '', R[x.key] === 'NON_MENTION' ? 'done-d' : ''].join(' ');266 pts.push(`<mark data-k="${esc(x.key)}" class="${cls}">${esc(o.text.slice(a, b))}</mark>`);267 p = b;268 }269 pts.push(esc(o.text.slice(p, hi)));270 if (hi < L) pts.push('<span style="color:var(--mut)">…</span>');271 pts.push(`<div style="color:var(--mut);font-size:12px;margin-top:8px">window ${lo}–${hi} of ${L} · ${wlabel()} · <kbd>[</kbd><kbd>]</kbd> slide · <kbd>-</kbd><kbd>+</kbd> size</div>`);272 plo = lo; phi = hi;273 $('passage').innerHTML = pts.join('');274 bindMarks(o);275 renderChips(o);276 renderRef(span());277 lightMeta();278}279function renderChips(o) {280 $('chips').innerHTML = o.spans.length > 1 ? o.spans.map((x, n) =>281 `<span class="chip${n === s ? ' active' : ''}${R[x.key] === 'DATA_MENTION' ? ' k' : ''}${R[x.key] === 'NON_MENTION' ? ' d' : ''}" data-n="${n}">#${n + 1} ${esc(x.surface.slice(0, 40))}</span>`).join('') : '';282 document.querySelectorAll('.chip').forEach(c => c.onclick = () => show(i, +c.dataset.n));283}284function renderRef(sp) {285 const mine = R[sp.key];286 const tag = sp.probe_tag || (sp.pred === 'DATA_MENTION' ? 'keep' : 'drop');287 if (!mine) {288 // blind first look: difficulty only, no verdicts, no scores289 $('ref').innerHTML = (tag === 'confusion')290 ? `probe: <b>confusion</b> — needs a look`291 : `probe: <b>easy</b>`;292 return;293 }294 // revealed after ruling, with user-vs-Luna agreement flag295 const youKeep = mine === 'DATA_MENTION', lunaKeep = sp.luna_label === 1;296 const n = cur().spans.findIndex(x => x.key === sp.key) + 1;297 const subj = `“${esc(sp.surface)}” (#${n})`;298 const flag = (youKeep === lunaKeep)299 ? `<div>${subj}: <span style="color:var(--mut)">you agree with Luna</span></div>`300 : `<div><b class="d">DISAGREE</b> <span style="color:var(--mut)">— ${subj}: Luna ${lunaKeep ? 'kept this, you\u2019re dropping' : 'dropped this, you\u2019re keeping'}</span></div>`;301 let detail;302 if (sp.luna_label !== undefined && sp.human_verdict) {303 const g = lunaKeep ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';304 detail = `gold ${g} · human ${esc(sp.human_verdict)}${sp.human_note ? ' (' + esc(sp.human_note) + ')' : ''} · model ${sp.pred} @ ${sp.probe_score}`;305 } else {306 const g = lunaKeep ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';307 detail = `luna ${g} · probe ${esc(sp.probe_tag)} @ ${sp.probe_score}`;308 }309 const why = sp.luna_reason ? `<div style="color:var(--mut)">Luna's reasoning: “${esc(sp.luna_reason)}”</div>` : '';310 $('ref').innerHTML = `${flag}<div>${detail}</div>${why}`;311}312function lightMeta() {313 const mine = R[span().key];314 $('bKeep').className = 'keep' + (mine === 'DATA_MENTION' ? ' on' : '');315 $('bDrop').className = 'drop' + (mine === 'NON_MENTION' ? ' on' : '');316 $('count').textContent = Object.keys(R).length + ' ruled' + (lastSaved ? ' · last save: ' + lastSaved : '');317 paintRuled();318}319function paintRuled() {320 let tot = 0, done = 0;321 for (const o of ITEMS) for (const x of o.spans) { tot++; if (R[x.key]) done++; }322 $('ruledFill').style.width = (tot ? 100 * done / tot : 0) + '%';323 $('ruledLab').textContent = `${done}/${tot} mentions ruled`;324 const pct = tot ? Math.round(100 * done / tot) : 0;325 $('ruledPct').textContent = tot ? pct + '%' : '';326 // checkpoint nudge at each new 10% mark (per dataset)327 const mark = Math.floor(pct / 10) * 10;328 const mem = 'review_decile_' + $('ds').value;329 if (tot && mark >= 10 && +(localStorage.getItem(mem) || 0) < mark) {330 localStorage.setItem(mem, String(mark));331 toast(`${mark}% annotated — export a checkpoint?`, true);332 }333}334let toastTimer = null;335function hideToast() { $('toast').style.display = 'none'; $('toast').classList.remove('warn'); }336function toast(msg, withExport) {337 const t = $('toast');338 t.classList.remove('warn');339 t.innerHTML = `<span>${esc(msg)}</span>` +340 (withExport ? `<button onclick="exp();hideToast()">export now</button>` : '') +341 `<button onclick="hideToast()">dismiss</button>`;342 t.style.display = 'flex';343 clearTimeout(toastTimer);344 toastTimer = setTimeout(hideToast, 3000);345}346function warnToast(msg) {347 const t = $('toast');348 t.innerHTML = `<span>${esc(msg)}</span><button onclick="hideToast()">dismiss</button>`;349 t.classList.add('warn');350 t.style.display = 'flex';351 clearTimeout(toastTimer);352 toastTimer = setTimeout(hideToast, 3000);353}354function bindMarks(o) {355 document.querySelectorAll('#passage mark').forEach(m => m.onclick = () => {356 show(i, o.spans.findIndex(x => x.key === m.dataset.k));357 });358}359function light() {360 // same passage, text stays fixed: only the active highlight changes361 const o = cur(), sp = span();362 $('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;363 $('key').textContent = sp.key;364 $('surface').textContent = '“' + sp.surface + '”';365 if (document.activeElement !== $('note')) $('note').value = RN[sp.key] || '';366 document.querySelectorAll('#passage mark').forEach(m => {367 m.classList.toggle('active', m.dataset.k === sp.key);368 m.classList.toggle('done-k', R[m.dataset.k] === 'DATA_MENTION');369 m.classList.toggle('done-d', R[m.dataset.k] === 'NON_MENTION');370 });371 renderChips(o);372 renderRef(sp);373 lightMeta();374}375function show(ni, ns) {376 // recenter only when the target span lies outside the shown window.377 if (ni !== i) { i = ni; s = ns; needCenter = true; render(); return; }378 s = Math.max(0, Math.min(ns, cur().spans.length - 1));379 const c = (span().start + span().end) / 2;380 if (c < plo || c > phi) { needCenter = true; render(); } else light();381}382function move(d) { show((i + d + ITEMS.length) % ITEMS.length, 0); }383function smove(d) {384 const o = cur();385 if (s + d >= 0 && s + d < o.spans.length) show(i, s + d);386 else if (d > 0 && i < ITEMS.length - 1) show(i + 1, 0);387 else if (d < 0 && i > 0) show(i - 1, ITEMS[i - 1].spans.length - 1);388 else show(i, d > 0 ? o.spans.length - 1 : 0);389}390async function rule(v) {391 if (needName()) return;392 const k = span().key;393 R[k] = v;394 RQ[k] = $('ds').value;395 localStorage.setItem(LS, JSON.stringify(R));396 localStorage.setItem('review_queues', JSON.stringify(RQ));397 setLocked(true);398 paintGate();399 const note = ($('note').value || '').trim();400 if (note) { RN[k] = note; saveNotes(); }401 const entry = {key: k, ruling: v, queue: $('ds').value, annotator: who(), note: note || undefined};402 let saved = 'this browser only';403 try {404 const r = await fetch('/api/rulings', {method: 'POST', headers: {'Content-Type': 'application/json'},405 body: JSON.stringify(entry)});406 if (r.ok) saved = (await r.json()).pushed ? 'server + Hub' : 'server';407 else saved = await sinkCommit(entry);408 } catch (e) {409 saved = await sinkCommit(entry);410 }411 lastSaved = saved;412 // warning toast on disagreement (stays until dismissed)413 const sp0 = span(), dk = (v === 'DATA_MENTION') !== (sp0.luna_label === 1);414 light();415 if (dk) warnToast(`DISAGREE — “${sp0.surface}”: Luna ${sp0.luna_label === 1 ? 'kept this, you’re dropping' : 'dropped this, you’re keeping'}` + (sp0.luna_reason ? ` — Luna's reasoning: “${sp0.luna_reason}”` : ''));416}417async function sinkCommit(entry) {418 // shared sink (Cloudflare Worker) if configured, else the annotator's419 // own token, else local-only. The sink holds no shared secret: with HF420 // login it commits with the annotator's own OAuth token.421 if (SINK) {422 try {423 const r = await fetch(SINK + '/rulings', {method: 'POST', credentials: 'include',424 headers: {'Content-Type': 'application/json'}, body: JSON.stringify(entry)});425 if (r.ok) return 'Hub (' + ((await r.json()).as || 'you') + ')';426 } catch (e) {}427 }428 return hubCommit(entry);429}430async function hubCommit(entry) {431 // commit straight to the public dataset: personal token first,432 // else the shared push token from sink.json (public repo = public433 // token: scope it to data-use-annotations writes only).434 const tok = (($('hubtok') || {}).value || '').trim() || (typeof SHAREDTOK !== 'undefined' && SHAREDTOK) || '';435 if (!tok) return 'this browser only (add HF token to sync)';436 try {437 const repo = 'rafmacalaba/data-use-annotations';438 const who = entry.annotator.replace(/[^a-zA-Z0-9-_]/g, '_');439 const path = `rulings/${who}.jsonl`;440 let rows = [];441 try {442 const r = await fetch(`https://huggingface.co/datasets/${repo}/resolve/main/${path}`);443 if (r.ok) rows = (await r.text()).split('\n').filter(Boolean).map(JSON.parse);444 } catch (e) {}445 rows = rows.filter(x => !(x.queue === entry.queue && x.key === entry.key));446 rows.push({...entry, ts: new Date().toISOString().replace(/\.\d+Z$/, '+00:00')});447 const content = btoa(unescape(encodeURIComponent(rows.map(x => JSON.stringify(x)).join('\n') + '\n')));448 const nd = [{key: 'header', value: {summary: `ruling ${who}: ${entry.key}=${entry.ruling} (web)`, description: ''}},449 {key: 'file', value: {content, path, encoding: 'base64'}}].map(o => JSON.stringify(o)).join('\n');450 const res = await fetch(`https://huggingface.co/api/datasets/${repo}/commit/main`, {451 method: 'POST', headers: {Authorization: `Bearer ${tok}`, 'Content-Type': 'application/x-ndjson'}, body: nd});452 if (!res.ok) throw new Error('hub ' + res.status);453 return 'Hub';454 } catch (e) { return 'this browser only (Hub sync failed)'; }455}456function exp() {457 // rulings carry their own context so they survive queue changes:458 // passage text, span offsets, origin, probe + luna fields.459 const idx = {};460 for (const o of ALL) for (const x of o.spans)461 idx[x.key] = {o, x};462 const rows = Object.entries(R).map(([key, ruling]) => {463 const hit = idx[key];464 const base = {key, ruling, queue: RQ[key] || $('ds').value, annotator: who(),465 note: RN[key] || undefined,466 ts: new Date().toISOString().replace(/\.\d+Z$/, '+00:00')};467 if (!hit) return base;468 const {o, x} = hit;469 return {...base, surface: x.surface, start: x.start, end: x.end,470 text: o.text, origin: o.source,471 probe_tag: x.probe_tag, probe_score: x.probe_score,472 luna_label: x.luna_label, luna_reason: x.luna_reason || null};473 });474 const a = document.createElement('a');475 a.href = URL.createObjectURL(new Blob([rows.map(r => JSON.stringify(r)).join('\n')], {type: 'application/json'}));476 a.download = `rulings-${who() || 'anon'}-${$('ds').value}.jsonl`; a.click();477}478document.getElementById('impFile').addEventListener('change', async e => {479 const f = e.target.files[0];480 if (!f) return;481 try {482 const lines = (await f.text()).split('\n').filter(Boolean);483 let n = 0;484 for (const line of lines) {485 let r;486 try { r = JSON.parse(line); } catch { continue; }487 const items = r.items && Array.isArray(r.items) ? r.items : [r];488 for (const it of items) {489 if (!it.key || !['DATA_MENTION', 'NON_MENTION'].includes(it.ruling)) continue;490 R[it.key] = it.ruling;491 if (it.queue) RQ[it.key] = it.queue;492 if (it.note) RN[it.key] = it.note;493 n++;494 }495 }496 localStorage.setItem(LS, JSON.stringify(R));497 localStorage.setItem('review_queues', JSON.stringify(RQ));498 saveNotes();499 // jump to first unruled span500 let done = false;501 for (let ni = 0; ni < ITEMS.length && !done; ni++)502 for (let ns = 0; ns < ITEMS[ni].spans.length; ns++)503 if (!R[ITEMS[ni].spans[ns].key]) { i = ni; needCenter = true; show(ni, ns); done = true; break; }504 if (!done) render();505 $('mine').textContent = `imported ${n} rulings — resuming at first unreviewed mention`;506 } catch (err) { $('mine').textContent = 'could not read that file'; }507 e.target.value = '';508});509document.addEventListener('keydown', e => {510 if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;511 if (e.key === 'ArrowRight') smove(1);512 else if (e.key === 'ArrowLeft') smove(-1);513 else if (e.key === '[') { wc = Math.max(0, (wc ?? 0) - WSIZES[wi] / 2); render(); }514 else if (e.key === ']') { wc = (wc ?? 0) + WSIZES[wi] / 2; render(); }515 else if (e.key === '-' || e.key === '_') { wi = Math.max(0, wi - 1); render(); }516 else if (e.key === '=' || e.key === '+') { wi = Math.min(WSIZES.length - 1, wi + 1); render(); }517 else if (e.key === 'k' || e.key === 'K') rule('DATA_MENTION');518 else if (e.key === 'd' || e.key === 'D') rule('NON_MENTION');519 else if (e.key === ' ') { e.preventDefault(); skip(); }520 else if (e.key === 'e' || e.key === 'E') exp();521});522function skip() {523 const o = cur();524 const nx = o.spans.findIndex((x, n) => n > s && !R[x.key]);525 if (nx >= 0) show(i, nx);526 else if (i < ITEMS.length - 1) show(i + 1, 0);527}528(function initParams() {529 const q = new URLSearchParams(location.search);530 const du = (q.get('user') || '').toLowerCase(), dd = q.get('ds');531 if (du) localStorage.setItem('review_who', du);532 if (du && USERS.includes(du)) $('ds').value = du;533 else if (dd && DS[dd]) $('ds').value = dd;534})();535loadWho();536 loadDs();537</script>538</body>539</html>540 