data-use-annotate / review.html
rafmacalaba's picture
wrap long tokens in passage
d932c80 verified
Raw History Blame Contribute Delete
28.2 kB
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>annotation review</title>
<style>
:root, [data-theme="light"] { --bg: #f7f8fa; --card: #ffffff; --fg: #232a31; --mut: #6b7683; --line: #dfe4ea;
--keep: #2e7d46; --keep-bg: #ddf2e3; --keep-bg2: #c9e9d2; --keep-line: #8fd0a6;
--drop: #c96a5e; --drop-bg: #fbe7e4; --drop-bg2: #f6d5d0; --drop-line: #ecb3ac;
--hl: #7c8cf8; --chip: #eef1f4; --mark-bg: #fdf0d3; --mark-ink: #6b4a08; }
[data-theme="dark"] { --bg: #12161c; --card: #1a2029; --fg: #dbe2ea; --mut: #8d99a8; --line: #2c3440;
--keep: #8fdcab; --keep-bg: rgba(63,185,80,.20); --keep-bg2: rgba(63,185,80,.30); --keep-line: rgba(143,220,171,.55);
--drop: #f0a49b; --drop-bg: rgba(248,113,113,.18); --drop-bg2: rgba(248,113,113,.28); --drop-line: rgba(240,164,155,.55);
--hl: #9aa7ff; --chip: #222a35; --mark-bg: rgba(222,171,55,.28); --mark-ink: #ffd88a; }
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, system-ui, sans-serif; margin: 0; }
header { 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; }
header .prog { font-variant-numeric: tabular-nums; white-space: nowrap; }
#bar { flex: 1; height: 8px; background: var(--chip); border-radius: 4px; overflow: hidden; }
#bar > div { height: 100%; background: var(--hl); width: 0; }
select, button { font: inherit; color: var(--fg); }
main { max-width: 860px; margin: 0 auto; padding: 20px 16px 90px; }
.meta { color: var(--mut); font-size: 13px; margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
#passage { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; font-size: 16px; overflow-wrap: anywhere; overflow-x: clip; }
#passage mark { background: var(--mark-bg); color: var(--mark-ink); border-radius: 4px; padding: 0 2px; cursor: pointer; }
#passage mark.active { outline: 2px solid var(--hl); }
#passage mark.done-k { background: linear-gradient(180deg, var(--keep-bg), var(--keep-bg2)); color: var(--fg); }
#passage mark.done-d { background: linear-gradient(180deg, var(--drop-bg), var(--drop-bg2)); color: var(--fg); }
#passage mark.active.done-k { outline-color: var(--keep-line); }
#passage mark.active.done-d { outline-color: var(--drop-line); }
#chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.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; }
.chip.active { outline: 2px solid var(--hl); }
.chip.active.k { outline-color: var(--keep-line); } .chip.active.d { outline-color: var(--drop-line); }
.chip.k { border-color: var(--keep-line); background: var(--keep-bg); } .chip.d { border-color: var(--drop-line); background: var(--drop-bg); }
#ref { margin-top: 10px; font-size: 13px; color: var(--mut); background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
#ref b.k { color: var(--keep); } #ref b.d { color: var(--drop); }
.controls { display: flex; gap: 10px; margin-top: 14px; }
button { cursor: pointer; border-radius: 8px; border: 1px solid var(--line); background: var(--card); padding: 10px 22px; }
button.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); }
button.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); }
button.nav { padding: 10px 14px; }
#mine { color: var(--mut); font-size: 13px; margin-top: 8px; min-height: 20px; }
kbd { background: var(--chip); border-radius: 4px; padding: 1px 6px; font-size: 12px; }
select { appearance: none; -webkit-appearance: none; background-color: var(--card);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 30px 8px 12px; cursor: pointer;
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");
background-repeat: no-repeat; background-position: right 10px center; max-width: 220px; }
select:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--hl); outline-offset: 1px; }
button { transition: background .12s ease, transform .05s ease; }
button:active:not(:disabled) { transform: scale(.97); }
#toast.warn { border-color: var(--drop); }
input[type="text"], input:not([type]) { font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
#who { max-width: 170px; }
#who.locked { background: var(--chip); color: var(--mut); border-style: dashed; }
#lockBtn { padding: 8px 10px; }
#toast { position: fixed; top: 110px; left: 50%; transform: translateX(-50%); background: var(--card);
border: 1px solid var(--hl); border-radius: 12px; padding: 12px 16px; display: none; gap: 12px;
align-items: center; z-index: 50; box-shadow: 0 8px 30px rgba(0,0,0,.25); font-size: 14px; }
footer { 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); }
footer .sp { flex: 1; }
</style>
</head>
<body>
<header>
<span class="prog" id="prog">–/–</span>
<div id="bar"><div></div></div>
<select id="ds" onchange="loadDs()">
<option value="sample" selected>annotate sample · 60</option>
<option value="gold">gold · 473</option>
<option value="paddy">paddy · 240</option>
<option value="aj">aj · 240</option>
<option value="aivin">aivin · 240</option>
<option value="rafael">rafael · 240</option>
<option value="paddy_part2">paddy part2 · 240</option>
<option value="aj_part2">aj part2 · 240</option>
<option value="aivin_part2">aivin part2 · 240</option>
<option value="rafael_part2">rafael part2 · 240</option>
</select>
<select id="src" onchange="reset()"></select>
<input id="who" placeholder="your name to annotate" autocomplete="off">
<button id="lockBtn" onclick="toggleLock()" title="lock / change name">unlock</button>
<button onclick="exp()" title="export rulings now">export</button>
<button onclick="theme()" title="toggle theme">◐</button>
</header>
<div id="toast" role="status"></div>
<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)">
<span id="ruledLab" style="white-space:nowrap">0 ruled</span>
<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>
<span id="ruledPct"></span>
</div>
<main>
<div class="meta"><span id="key"></span><span id="surface"></span></div>
<div id="passage"></div>
<div id="chips"></div>
<div id="ref"></div>
<div class="controls">
<button class="nav" onclick="move(-1)">← prev</button>
<button class="keep" id="bKeep" onclick="rule('DATA_MENTION')">keep <kbd>k</kbd></button>
<button class="drop" id="bDrop" onclick="rule('NON_MENTION')">drop <kbd>d</kbd></button>
<button class="nav" onclick="move(1)">next →</button>
</div>
<input id="note" placeholder="note (optional, saved with your ruling)" autocomplete="off"
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">
<div id="mine"></div>
<footer>
<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>
<span class="sp"></span><span id="count"></span>
<button id="hubBtn" onclick="exp()">export</button>
<button onclick="$('impFile').click()" title="load a previous export to resume">import</button>
<input id="impFile" type="file" accept=".jsonl,.json" style="display:none">
</footer>
<script>
let SINK = null, HFFUSER = null, SHAREDTOK = null;
fetch('./sink.json').then(r => r.ok ? r.json() : null).then(async j => {
if (!j) return;
// Optional shared push token (see README: public repo = public token,
// scope it to data-use-annotations writes only, rotate after the round).
if (j.pushToken) {
SHAREDTOK = j.pushToken;
const h = $('hubtok');
if (h) h.style.display = 'none';
}
if (!j.url) return;
SINK = j.url.replace(/\/$/, '');
paintLogin();
try {
const m = await fetch(SINK + '/me', {credentials: 'include'});
if (m.ok) { HFFUSER = (await m.json()).user; paintLogin(); }
} catch (e) {}
});
function paintLogin() {
const b = $('hfBtn');
if (!b) return;
b.textContent = HFFUSER ? 'HF: ' + HFFUSER : 'log in with HF';
b.title = HFFUSER ? 'logged in — rulings commit as you' : 'log in to push rulings to the Hub as yourself';
}
function hfLogin() {
if (!SINK) return;
location.href = SINK + '/login?next=' + encodeURIComponent(location.href.split('?')[0] + location.search);
}
const DS = { sample: './queue_annotate_sample.json', gold: './queue_review473.json',
paddy: './queue_annotate_paddy.json', aj: './queue_annotate_aj.json',
aivin: './queue_annotate_aivin.json', rafael: './queue_annotate_rafael.json',
paddy_part2: './queue_annotate_paddy_part2.json', aj_part2: './queue_annotate_aj_part2.json',
aivin_part2: './queue_annotate_aivin_part2.json', rafael_part2: './queue_annotate_rafael_part2.json' };
const USERS = ['paddy', 'aj', 'aivin', 'rafael'];
const LS = 'review_rulings';
let R = JSON.parse(localStorage.getItem(LS) || '{}');
let RQ = JSON.parse(localStorage.getItem('review_queues') || '{}');
let RN = JSON.parse(localStorage.getItem('review_notes') || '{}');
function saveNotes() { localStorage.setItem('review_notes', JSON.stringify(RN)); }
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const QD = document.documentElement;
QD.dataset.theme = localStorage.getItem('review_theme') || 'light';
function theme() { QD.dataset.theme = QD.dataset.theme === 'light' ? 'dark' : 'light'; localStorage.setItem('review_theme', QD.dataset.theme); }
const who = () => ($('who').value || '').trim();
// normalize legacy single-span items -> spans[]
function norm(o) {
if (o.spans) return o;
return {...o, spans: [{key: o.key, start: o.start, end: o.end, surface: o.surface,
luna_label: o.ref ? (o.ref.luna_label === 1 ? 1 : 0) : undefined,
human_verdict: o.ref && o.ref.human_verdict, human_note: o.ref && o.ref.human_note,
probe_tag: undefined, probe_score: o.ref && o.ref.head_score, luna_reason: o.ref && o.ref.luna_reason,
pred: o.ref && o.ref.pred}]};
}
function loadWho() {
$('who').value = localStorage.getItem('review_who') || '';
$('who').addEventListener('input', () => {
localStorage.setItem('review_who', who());
paintGate();
const w = who().toLowerCase();
if (USERS.includes(w) && $('ds').value !== w) { $('ds').value = w; loadDs(); }
});
$('note').addEventListener('input', () => {
if (!ITEMS.length) return;
const v = $('note').value;
if (v) RN[span().key] = v; else delete RN[span().key];
saveNotes();
});
const ht = $('hubtok');
if (ht) { ht.value = localStorage.getItem('review_hubtok') || ''; ht.addEventListener('input', () => localStorage.setItem('review_hubtok', ht.value.trim())); }
if (localStorage.getItem('review_locked') === '1' && who()) setLocked(true);
paintGate();
}
function setLocked(on) {
$('who').disabled = on;
$('who').classList.toggle('locked', on);
$('lockBtn').textContent = on ? 'locked' : 'unlock';
localStorage.setItem('review_locked', on ? '1' : '0');
}
function toggleLock() {
if ($('who').disabled) {
if (confirm('Change annotator name? Existing rulings stay under the old name.')) {
setLocked(false); $('who').focus();
}
return;
}
if (!who()) { needName(); return; }
setLocked(true);
}
function paintGate() {
const ok = who().length > 0;
$('who').style.borderColor = ok ? '' : 'var(--drop)';
$('bKeep').disabled = $('bDrop').disabled = !ok;
$('bKeep').style.opacity = $('bDrop').style.opacity = ok ? '' : '0.45';
}
function needName() {
if (who().length > 0) return false;
$('mine').textContent = 'enter your name above before annotating';
$('who').focus();
return true;
}
async function loadDs() {
const q = await (await fetch(DS[$('ds').value])).json();
ALL = q.map(norm);
const srcs = [...new Set(ALL.map(o => o.source))].sort();
const total = ALL.length;
$('src').innerHTML = `<option value="all">all · ${total}</option>` +
srcs.map(x => `<option value="${esc(x)}">${esc(x)} · ${ALL.filter(o => o.source === x).length}</option>`).join('');
reset();
}
function reset() {
const f = $('src').value;
ITEMS = ALL.filter(o => f === 'all' || o.source === f);
i = 0; s = 0; needCenter = true; render();
}
function cur() { return ITEMS[i]; }
function span() { return cur().spans[s]; }
let WSIZES = [400, 800, 1600, Infinity], wi = 1, wc = null, plo = 0, phi = 0, needCenter = true;
let lastSaved = '';
function wlabel() { return WSIZES[wi] === Infinity ? 'full' : '±' + WSIZES[wi] / 2; }
function render() {
const o = cur();
if (!o) return;
s = Math.min(s, o.spans.length - 1);
const sp = span();
$('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;
document.querySelector('#bar > div').style.width = (100 * (i + 1) / ITEMS.length) + '%';
$('key').textContent = sp.key;
$('surface').textContent = '“' + sp.surface + '”';
if (document.activeElement !== $('note')) $('note').value = RN[sp.key] || '';
// sliding assessment window around the active span
if (needCenter || wc === null) { wc = (sp.start + sp.end) / 2; needCenter = false; }
const W = WSIZES[wi], L = o.text.length;
let lo = 0, hi = L;
if (W !== Infinity && L > W) {
lo = Math.max(0, Math.floor(wc - W / 2)); hi = Math.min(L, lo + W);
if (hi - lo < W) lo = Math.max(0, hi - W);
const snap = (n, dir) => { while (n > 0 && n < L && o.text[n] !== ' ' && o.text[n] !== '\n') n += dir; return n; };
lo = snap(lo, -1); hi = snap(hi, 1);
lo = Math.min(lo, sp.start); hi = Math.max(hi, sp.end);
}
const pts = [];
if (lo > 0) pts.push('<span style="color:var(--mut)">…</span>');
let p = lo;
const ordered = [...o.spans].sort((a, b) => a.start - b.start).filter(x => x.end > lo && x.start < hi);
for (const x of ordered) {
const a = Math.max(x.start, lo), b = Math.min(x.end, hi);
pts.push(esc(o.text.slice(p, a)));
const cls = [x.key === sp.key ? 'active' : '', R[x.key] === 'DATA_MENTION' ? 'done-k' : '', R[x.key] === 'NON_MENTION' ? 'done-d' : ''].join(' ');
pts.push(`<mark data-k="${esc(x.key)}" class="${cls}">${esc(o.text.slice(a, b))}</mark>`);
p = b;
}
pts.push(esc(o.text.slice(p, hi)));
if (hi < L) pts.push('<span style="color:var(--mut)">…</span>');
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>`);
plo = lo; phi = hi;
$('passage').innerHTML = pts.join('');
bindMarks(o);
renderChips(o);
renderRef(span());
lightMeta();
}
function renderChips(o) {
$('chips').innerHTML = o.spans.length > 1 ? o.spans.map((x, n) =>
`<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('') : '';
document.querySelectorAll('.chip').forEach(c => c.onclick = () => show(i, +c.dataset.n));
}
function renderRef(sp) {
const mine = R[sp.key];
const tag = sp.probe_tag || (sp.pred === 'DATA_MENTION' ? 'keep' : 'drop');
if (!mine) {
// blind first look: difficulty only, no verdicts, no scores
$('ref').innerHTML = (tag === 'confusion')
? `probe: <b>confusion</b> — needs a look`
: `probe: <b>easy</b>`;
return;
}
// revealed after ruling, with user-vs-Luna agreement flag
const youKeep = mine === 'DATA_MENTION', lunaKeep = sp.luna_label === 1;
const n = cur().spans.findIndex(x => x.key === sp.key) + 1;
const subj = `&ldquo;${esc(sp.surface)}&rdquo; (#${n})`;
const flag = (youKeep === lunaKeep)
? `<div>${subj}: <span style="color:var(--mut)">you agree with Luna</span></div>`
: `<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>`;
let detail;
if (sp.luna_label !== undefined && sp.human_verdict) {
const g = lunaKeep ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';
detail = `gold ${g} · human ${esc(sp.human_verdict)}${sp.human_note ? ' (' + esc(sp.human_note) + ')' : ''} · model ${sp.pred} @ ${sp.probe_score}`;
} else {
const g = lunaKeep ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';
detail = `luna ${g} · probe ${esc(sp.probe_tag)} @ ${sp.probe_score}`;
}
const why = sp.luna_reason ? `<div style="color:var(--mut)">Luna's reasoning: “${esc(sp.luna_reason)}”</div>` : '';
$('ref').innerHTML = `${flag}<div>${detail}</div>${why}`;
}
function lightMeta() {
const mine = R[span().key];
$('bKeep').className = 'keep' + (mine === 'DATA_MENTION' ? ' on' : '');
$('bDrop').className = 'drop' + (mine === 'NON_MENTION' ? ' on' : '');
$('count').textContent = Object.keys(R).length + ' ruled' + (lastSaved ? ' · last save: ' + lastSaved : '');
paintRuled();
}
function paintRuled() {
let tot = 0, done = 0;
for (const o of ITEMS) for (const x of o.spans) { tot++; if (R[x.key]) done++; }
$('ruledFill').style.width = (tot ? 100 * done / tot : 0) + '%';
$('ruledLab').textContent = `${done}/${tot} mentions ruled`;
const pct = tot ? Math.round(100 * done / tot) : 0;
$('ruledPct').textContent = tot ? pct + '%' : '';
// checkpoint nudge at each new 10% mark (per dataset)
const mark = Math.floor(pct / 10) * 10;
const mem = 'review_decile_' + $('ds').value;
if (tot && mark >= 10 && +(localStorage.getItem(mem) || 0) < mark) {
localStorage.setItem(mem, String(mark));
toast(`${mark}% annotated — export a checkpoint?`, true);
}
}
let toastTimer = null;
function hideToast() { $('toast').style.display = 'none'; $('toast').classList.remove('warn'); }
function toast(msg, withExport) {
const t = $('toast');
t.classList.remove('warn');
t.innerHTML = `<span>${esc(msg)}</span>` +
(withExport ? `<button onclick="exp();hideToast()">export now</button>` : '') +
`<button onclick="hideToast()">dismiss</button>`;
t.style.display = 'flex';
clearTimeout(toastTimer);
toastTimer = setTimeout(hideToast, 3000);
}
function warnToast(msg) {
const t = $('toast');
t.innerHTML = `<span>${esc(msg)}</span><button onclick="hideToast()">dismiss</button>`;
t.classList.add('warn');
t.style.display = 'flex';
clearTimeout(toastTimer);
toastTimer = setTimeout(hideToast, 3000);
}
function bindMarks(o) {
document.querySelectorAll('#passage mark').forEach(m => m.onclick = () => {
show(i, o.spans.findIndex(x => x.key === m.dataset.k));
});
}
function light() {
// same passage, text stays fixed: only the active highlight changes
const o = cur(), sp = span();
$('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;
$('key').textContent = sp.key;
$('surface').textContent = '“' + sp.surface + '”';
if (document.activeElement !== $('note')) $('note').value = RN[sp.key] || '';
document.querySelectorAll('#passage mark').forEach(m => {
m.classList.toggle('active', m.dataset.k === sp.key);
m.classList.toggle('done-k', R[m.dataset.k] === 'DATA_MENTION');
m.classList.toggle('done-d', R[m.dataset.k] === 'NON_MENTION');
});
renderChips(o);
renderRef(sp);
lightMeta();
}
function show(ni, ns) {
// recenter only when the target span lies outside the shown window.
if (ni !== i) { i = ni; s = ns; needCenter = true; render(); return; }
s = Math.max(0, Math.min(ns, cur().spans.length - 1));
const c = (span().start + span().end) / 2;
if (c < plo || c > phi) { needCenter = true; render(); } else light();
}
function move(d) { show((i + d + ITEMS.length) % ITEMS.length, 0); }
function smove(d) {
const o = cur();
if (s + d >= 0 && s + d < o.spans.length) show(i, s + d);
else if (d > 0 && i < ITEMS.length - 1) show(i + 1, 0);
else if (d < 0 && i > 0) show(i - 1, ITEMS[i - 1].spans.length - 1);
else show(i, d > 0 ? o.spans.length - 1 : 0);
}
async function rule(v) {
if (needName()) return;
const k = span().key;
R[k] = v;
RQ[k] = $('ds').value;
localStorage.setItem(LS, JSON.stringify(R));
localStorage.setItem('review_queues', JSON.stringify(RQ));
setLocked(true);
paintGate();
const note = ($('note').value || '').trim();
if (note) { RN[k] = note; saveNotes(); }
const entry = {key: k, ruling: v, queue: $('ds').value, annotator: who(), note: note || undefined};
let saved = 'this browser only';
try {
const r = await fetch('/api/rulings', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(entry)});
if (r.ok) saved = (await r.json()).pushed ? 'server + Hub' : 'server';
else saved = await sinkCommit(entry);
} catch (e) {
saved = await sinkCommit(entry);
}
lastSaved = saved;
// warning toast on disagreement (stays until dismissed)
const sp0 = span(), dk = (v === 'DATA_MENTION') !== (sp0.luna_label === 1);
light();
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}”` : ''));
}
async function sinkCommit(entry) {
// shared sink (Cloudflare Worker) if configured, else the annotator's
// own token, else local-only. The sink holds no shared secret: with HF
// login it commits with the annotator's own OAuth token.
if (SINK) {
try {
const r = await fetch(SINK + '/rulings', {method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(entry)});
if (r.ok) return 'Hub (' + ((await r.json()).as || 'you') + ')';
} catch (e) {}
}
return hubCommit(entry);
}
async function hubCommit(entry) {
// commit straight to the public dataset: personal token first,
// else the shared push token from sink.json (public repo = public
// token: scope it to data-use-annotations writes only).
const tok = (($('hubtok') || {}).value || '').trim() || (typeof SHAREDTOK !== 'undefined' && SHAREDTOK) || '';
if (!tok) return 'this browser only (add HF token to sync)';
try {
const repo = 'rafmacalaba/data-use-annotations';
const who = entry.annotator.replace(/[^a-zA-Z0-9-_]/g, '_');
const path = `rulings/${who}.jsonl`;
let rows = [];
try {
const r = await fetch(`https://huggingface.co/datasets/${repo}/resolve/main/${path}`);
if (r.ok) rows = (await r.text()).split('\n').filter(Boolean).map(JSON.parse);
} catch (e) {}
rows = rows.filter(x => !(x.queue === entry.queue && x.key === entry.key));
rows.push({...entry, ts: new Date().toISOString().replace(/\.\d+Z$/, '+00:00')});
const content = btoa(unescape(encodeURIComponent(rows.map(x => JSON.stringify(x)).join('\n') + '\n')));
const nd = [{key: 'header', value: {summary: `ruling ${who}: ${entry.key}=${entry.ruling} (web)`, description: ''}},
{key: 'file', value: {content, path, encoding: 'base64'}}].map(o => JSON.stringify(o)).join('\n');
const res = await fetch(`https://huggingface.co/api/datasets/${repo}/commit/main`, {
method: 'POST', headers: {Authorization: `Bearer ${tok}`, 'Content-Type': 'application/x-ndjson'}, body: nd});
if (!res.ok) throw new Error('hub ' + res.status);
return 'Hub';
} catch (e) { return 'this browser only (Hub sync failed)'; }
}
function exp() {
// rulings carry their own context so they survive queue changes:
// passage text, span offsets, origin, probe + luna fields.
const idx = {};
for (const o of ALL) for (const x of o.spans)
idx[x.key] = {o, x};
const rows = Object.entries(R).map(([key, ruling]) => {
const hit = idx[key];
const base = {key, ruling, queue: RQ[key] || $('ds').value, annotator: who(),
note: RN[key] || undefined,
ts: new Date().toISOString().replace(/\.\d+Z$/, '+00:00')};
if (!hit) return base;
const {o, x} = hit;
return {...base, surface: x.surface, start: x.start, end: x.end,
text: o.text, origin: o.source,
probe_tag: x.probe_tag, probe_score: x.probe_score,
luna_label: x.luna_label, luna_reason: x.luna_reason || null};
});
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([rows.map(r => JSON.stringify(r)).join('\n')], {type: 'application/json'}));
a.download = `rulings-${who() || 'anon'}-${$('ds').value}.jsonl`; a.click();
}
document.getElementById('impFile').addEventListener('change', async e => {
const f = e.target.files[0];
if (!f) return;
try {
const lines = (await f.text()).split('\n').filter(Boolean);
let n = 0;
for (const line of lines) {
let r;
try { r = JSON.parse(line); } catch { continue; }
const items = r.items && Array.isArray(r.items) ? r.items : [r];
for (const it of items) {
if (!it.key || !['DATA_MENTION', 'NON_MENTION'].includes(it.ruling)) continue;
R[it.key] = it.ruling;
if (it.queue) RQ[it.key] = it.queue;
if (it.note) RN[it.key] = it.note;
n++;
}
}
localStorage.setItem(LS, JSON.stringify(R));
localStorage.setItem('review_queues', JSON.stringify(RQ));
saveNotes();
// jump to first unruled span
let done = false;
for (let ni = 0; ni < ITEMS.length && !done; ni++)
for (let ns = 0; ns < ITEMS[ni].spans.length; ns++)
if (!R[ITEMS[ni].spans[ns].key]) { i = ni; needCenter = true; show(ni, ns); done = true; break; }
if (!done) render();
$('mine').textContent = `imported ${n} rulings — resuming at first unreviewed mention`;
} catch (err) { $('mine').textContent = 'could not read that file'; }
e.target.value = '';
});
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'ArrowRight') smove(1);
else if (e.key === 'ArrowLeft') smove(-1);
else if (e.key === '[') { wc = Math.max(0, (wc ?? 0) - WSIZES[wi] / 2); render(); }
else if (e.key === ']') { wc = (wc ?? 0) + WSIZES[wi] / 2; render(); }
else if (e.key === '-' || e.key === '_') { wi = Math.max(0, wi - 1); render(); }
else if (e.key === '=' || e.key === '+') { wi = Math.min(WSIZES.length - 1, wi + 1); render(); }
else if (e.key === 'k' || e.key === 'K') rule('DATA_MENTION');
else if (e.key === 'd' || e.key === 'D') rule('NON_MENTION');
else if (e.key === ' ') { e.preventDefault(); skip(); }
else if (e.key === 'e' || e.key === 'E') exp();
});
function skip() {
const o = cur();
const nx = o.spans.findIndex((x, n) => n > s && !R[x.key]);
if (nx >= 0) show(i, nx);
else if (i < ITEMS.length - 1) show(i + 1, 0);
}
(function initParams() {
const q = new URLSearchParams(location.search);
const du = (q.get('user') || '').toLowerCase(), dd = q.get('ds');
if (du) localStorage.setItem('review_who', du);
if (du && USERS.includes(du)) $('ds').value = du;
else if (dd && DS[dd]) $('ds').value = dd;
})();
loadWho();
loadDs();
</script>
</body>
</html>