Spaces:
Running
Running
Download review.html from rafmacalaba/data-use-annotate: direct link, hf CLI and curl.
- Browser
- Download file 28.2 kB
-
https://huggingface.co/spaces/rafmacalaba/data-use-annotate/resolve/main/review.html
- Command line
-
hf download hf://spaces/rafmacalaba/data-use-annotate/review.html
-
curl -L -o review.html https://huggingface.co/spaces/rafmacalaba/data-use-annotate/resolve/main/review.html
28.2 kB
| <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 => ({'&':'&','<':'<','>':'>'}[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 = `“${esc(sp.surface)}” (#${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> | |