| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>LREC 2026 — LLM Annotator</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| fontFamily: { |
| sans: ['Inter', 'system-ui', 'sans-serif'], |
| mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], |
| }, |
| colors: { |
| ink: { 950: '#0b0f19', 900: '#111827', 800: '#1f2937', 700: '#374151', 500: '#6b7280', 400: '#9ca3af', 300: '#d1d5db', 200: '#e5e7eb', 100: '#f3f4f6', 50: '#f9fafb' }, |
| accent: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }, |
| } |
| } |
| } |
| } |
| </script> |
| <link rel="stylesheet" href="/static/styles.css?v=20260516b"> |
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.10/dist/cdn.min.js"></script> |
| </head> |
| <body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="annotator()" x-init="init()" x-cloak> |
|
|
| |
| |
| |
| <header class="sticky top-0 z-40 bg-white/95 backdrop-blur border-b border-ink-200"> |
| <div class="max-w-[1400px] mx-auto px-5 py-3 flex items-center gap-4"> |
| <div class="flex items-center gap-2"> |
| <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center text-white text-lg">✒</div> |
| <div> |
| <h1 class="font-semibold text-ink-900 leading-tight">LLM-as-Annotator</h1> |
| <div class="text-[11px] text-ink-500 leading-tight">LREC 2026 · <a class="hover:text-accent-600 inline-flex items-center gap-1 align-middle" target="_blank" href="https://aclanthology.org/2026.loreslm-1.28/" title="Read on ACL Anthology"><img src="/static/logos/acl-anthology.svg" alt="ACL Anthology" class="inline-block h-3 w-3 align-middle">paper: Vidal-Gorène et al. 2026, LoResLM →</a></div> |
| </div> |
| </div> |
|
|
| <span class="text-ink-300">·</span> |
|
|
| |
| <button @click="modal='task'" class="pill" :title="schema?.task_name"> |
| <span class="pill-icon">📋</span> |
| <span class="pill-label">Task</span> |
| <span class="pill-value truncate max-w-[160px]" x-text="schema?.task_name || 'choose…'"></span> |
| </button> |
|
|
| |
| <button @click="modal='models'" class="pill" |
| :class="state.models.length >= 2 ? 'pill-moe' : ''" |
| :title="state.models.join(', ')"> |
| <span class="pill-icon" x-text="state.models.length >= 2 ? '🧠' : '🤖'"></span> |
| <span class="pill-label" x-text="state.models.length >= 2 ? 'MoE' : 'Model'"></span> |
| <span class="pill-value" x-text="state.models.length + (state.models.length >= 2 ? ' active' : '')"></span> |
| </button> |
|
|
| |
| <button @click="modal='key'" class="pill" :class="hasKey ? 'pill-ok' : 'pill-warn'" |
| :title="localKey ? 'Key stored in this tab only (sessionStorage). Click to manage.' : (state.has_env_key ? 'Using server-side OPENROUTER_API_KEY env.' : 'No key yet.')"> |
| <span class="pill-icon" x-text="hasKey ? '🔑' : '⚠'"></span> |
| <span class="pill-label">OpenRouter</span> |
| <span class="pill-value" x-text="localKey ? 'tab-only' : (state.has_env_key ? 'shared env' : 'add key')"></span> |
| </button> |
|
|
| <div class="flex-1"></div> |
|
|
| |
| <template x-if="selection.size > 0"> |
| <div class="flex items-center gap-2 mr-2"> |
| <span class="text-xs text-ink-500" x-text="selection.size + ' selected'"></span> |
| <button @click="modal='bulk'" class="btn btn-secondary text-xs">Apply tag…</button> |
| <button @click="clearSelection()" class="btn btn-ghost text-xs">Clear</button> |
| </div> |
| </template> |
|
|
| <button @click="modal='help'" class="btn btn-ghost" title="Keyboard shortcuts & help">?</button> |
| <button @click="modal='advanced'" class="btn btn-ghost" title="Advanced settings">⚙</button> |
|
|
| <button @click="annotateAll()" |
| :disabled="!canRun || loading" |
| class="btn btn-primary" |
| :class="{'opacity-50 cursor-not-allowed': !canRun || loading}"> |
| <span x-show="!loading">▶ Annotate all</span> |
| <span x-show="loading" class="flex items-center gap-2"><span class="spinner"></span><span x-text="progressText"></span></span> |
| </button> |
| </div> |
|
|
| |
| <div class="max-w-[1400px] mx-auto px-5 pb-2 flex items-center gap-3 text-xs text-ink-500"> |
| <span x-show="state.language">📍 <span class="text-ink-700 font-medium" x-text="state.language"></span></span> |
| <span x-show="state.sentences.length > 0">·</span> |
| <span x-show="state.sentences.length > 0"> |
| <span class="text-ink-700 font-medium" x-text="state.sentences.length"></span> sentences, |
| <span class="text-ink-700 font-medium" x-text="totalTokens"></span> tokens |
| </span> |
| <span x-show="totalDisagreements > 0">· ⚠ <span class="text-amber-700 font-medium" x-text="totalDisagreements"></span> disagreements</span> |
| <span class="flex-1"></span> |
| <span>ICL pool · v<span class="text-ink-700 font-medium" x-text="state.icl_pool.version"></span> · <span class="text-ink-700 font-medium" x-text="state.icl_pool.size"></span> ex</span> |
| <button @click="modal='icl'" class="text-accent-600 hover:underline">view</button> |
| <span>·</span> |
| <button @click="modal='exports'" class="text-accent-600 hover:underline">export</button> |
| </div> |
| </header> |
|
|
| |
| |
| |
| <div class="max-w-[1400px] mx-auto px-5 py-6 grid grid-cols-12 gap-6"> |
|
|
| |
| <aside class="col-span-3 space-y-5"> |
|
|
| <section class="card"> |
| <h3 class="card-title">🚀 Quick start</h3> |
| <p class="text-xs text-ink-500 mb-3">Load a sandbox corpus from the LREC sandbox in one click.</p> |
| <div class="space-y-2"> |
| <template x-for="ex in state.exercises" :key="ex.idx"> |
| <button @click="loadExercise(ex.idx)" |
| class="w-full text-left p-3 rounded-lg border border-ink-200 hover:border-accent-300 hover:bg-accent-50 transition"> |
| <div class="font-medium text-sm" x-text="ex.title.split('—')[1]?.trim() || ex.title"></div> |
| <div class="text-[11px] text-ink-500 mt-0.5" x-text="ex.summary.slice(0,90)+'…'"></div> |
| </button> |
| </template> |
| </div> |
| </section> |
|
|
| <section class="card"> |
| <h3 class="card-title">📝 Your text</h3> |
| <button @click="modal='paste'" class="btn btn-secondary w-full">+ Paste text</button> |
| <button @click="clearCorpus()" x-show="state.sentences.length > 0" class="btn btn-ghost w-full mt-2 text-xs">Clear corpus only</button> |
| <button @click="resetAll()" class="btn btn-ghost w-full mt-1 text-xs text-red-600 hover:!bg-red-50"> |
| 🗑 Reset everything |
| </button> |
| </section> |
|
|
| <section class="card" x-show="state.sentences.length > 0"> |
| <h3 class="card-title">⚡ Shortcuts</h3> |
| <ul class="text-[11px] text-ink-500 space-y-1 font-mono"> |
| <li><kbd>j</kbd> / <kbd>k</kbd> next / prev token</li> |
| <li><kbd>e</kbd> edit focused token</li> |
| <li><kbd>1</kbd>–<kbd>9</kbd> assign tag (in editor)</li> |
| <li><kbd>x</kbd> toggle selection</li> |
| <li><kbd>r</kbd> re-annotate sentence</li> |
| <li><kbd>esc</kbd> close popup</li> |
| </ul> |
| </section> |
| </aside> |
|
|
| |
| <main class="col-span-9 space-y-4" tabindex="0" @keydown="handleKey($event)" x-ref="main"> |
|
|
| |
| <section x-show="guide && !guideDismissed" class="rounded-xl border border-accent-200 bg-gradient-to-br from-accent-50 to-purple-50 p-4 flex items-start gap-4" x-transition> |
| <div class="text-2xl shrink-0" x-text="guide?.icon || '📘'"></div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex items-center gap-2 flex-wrap"> |
| <span class="text-[11px] font-semibold uppercase tracking-wider text-accent-700" x-text="`Step ${guide?.step}/5`"></span> |
| <span class="text-[11px] text-ink-500">·</span> |
| <span class="text-sm font-semibold text-ink-900" x-text="guide?.title"></span> |
| </div> |
| <p class="text-xs text-ink-700 mt-1 leading-relaxed" x-html="guide?.body"></p> |
| <div class="flex flex-wrap gap-2 mt-3" x-show="guide?.actions?.length > 0"> |
| <template x-for="(a, i) in (guide?.actions || [])" :key="i"> |
| <button @click="runGuideAction(a)" |
| class="text-xs" |
| :class="i === 0 ? 'btn btn-primary' : 'btn btn-secondary'" |
| x-text="a.label"></button> |
| </template> |
| </div> |
| </div> |
| <div class="flex flex-col gap-1 shrink-0"> |
| <button @click="guideDismissed=true; localStorage.setItem('guideDismissed','1')" class="btn btn-ghost btn-icon" title="Hide guide">✕</button> |
| </div> |
| </section> |
|
|
| |
| <section x-show="state.models.length >= 2 && !moeBannerDismissed && state.sentences.length > 0" |
| class="rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 flex items-center gap-3" x-transition> |
| <span class="text-base">🧠</span> |
| <div class="flex-1"> |
| <strong>Mixture-of-Experts is ON.</strong> |
| <span x-text="state.models.length"></span> models will run in parallel — answers are voted per token, and contested tokens (⚠) are highlighted for your review. |
| </div> |
| <button @click="moeBannerDismissed=true" class="btn btn-ghost btn-icon">✕</button> |
| </section> |
| <section x-show="state.models.length === 1 && state.sentences.length > 0 && !moeHintDismissed" |
| class="rounded-xl border border-ink-200 bg-white px-4 py-2.5 text-xs text-ink-700 flex items-center gap-3" x-transition> |
| <span class="text-base">💡</span> |
| <div class="flex-1"> |
| Single-model mode. Add a 2nd model in <button class="text-accent-600 underline" @click="modal='models'">Models</button> to compare per token (Mixture-of-Experts). |
| </div> |
| <button @click="moeHintDismissed=true" class="btn btn-ghost btn-icon">✕</button> |
| </section> |
|
|
| |
| <template x-if="state.sentences.length === 0"> |
| <div class="card text-center py-16"> |
| <div class="text-5xl mb-4">📜</div> |
| <h2 class="text-xl font-semibold mb-2">No corpus loaded</h2> |
| <p class="text-ink-500 mb-6 max-w-md mx-auto">Pick a sandbox example from the sidebar, or paste your own text.</p> |
| <div class="flex justify-center gap-2"> |
| <button @click="loadExercise(0)" class="btn btn-primary">Try Ancient Greek</button> |
| <button @click="modal='paste'" class="btn btn-secondary">Paste my text</button> |
| </div> |
| </div> |
| </template> |
|
|
| |
| <template x-for="(sent, sidx) in state.sentences" :key="sidx + '-' + rev"> |
| <article class="card !p-0 overflow-hidden"> |
| <header class="px-4 py-2.5 bg-ink-50 border-b border-ink-200 flex items-center gap-3"> |
| <span class="font-mono text-xs text-ink-500" x-text="sent.id"></span> |
| <span class="text-xs" x-show="sent.n_disagreements > 0"> |
| <span class="dot dot-warn"></span> |
| <span class="text-amber-700" x-text="sent.n_disagreements + ' disagree'"></span> |
| </span> |
| <span class="text-xs" x-show="sent.status === 'done' && sent.n_disagreements === 0"> |
| <span class="dot dot-ok"></span> |
| <span class="text-emerald-700">consensus</span> |
| </span> |
| <span class="text-xs" x-show="sent.status === 'annotating'"> |
| <span class="spinner-sm"></span> |
| <span class="text-accent-600 ml-1">annotating…</span> |
| </span> |
| <span class="text-xs" x-show="sent.status === 'pending'"> |
| <span class="dot dot-pending"></span> |
| <span class="text-ink-500">pending</span> |
| </span> |
| <span class="text-xs text-red-600" x-show="sent.status === 'error'" x-text="'⚠ ' + (sent.error || 'error').slice(0,80)"></span> |
|
|
| <div class="flex-1"></div> |
| <button @click="annotateOne(sidx)" :disabled="loading" class="btn btn-secondary text-xs"> |
| <span x-show="sent.status === 'done'">↻ Re-annotate</span> |
| <span x-show="sent.status !== 'done'">▶ Annotate</span> |
| </button> |
| <button @click="addSentenceToIcl(sidx)" :disabled="sent.status !== 'done'" |
| class="btn btn-ghost text-xs" |
| :class="{'opacity-50': sent.status !== 'done'}" |
| title="Add this sentence's corrected annotation to the ICL pool"> |
| 📥 to ICL |
| </button> |
| </header> |
|
|
| <div class="p-4"> |
| <div class="tokens-flow"> |
| <template x-for="(tok, tidx) in sent.tokens" :key="tidx + '-' + rev"> |
| <button class="token" |
| :class="tokenClass(sent, sidx, tidx, tok)" |
| :title="tokenTooltip(sent, tidx)" |
| @click="onTokenClick($event, sidx, tidx)" |
| @contextmenu.prevent="openTokenContext($event, sidx, tidx)" |
| :data-sent="sidx" :data-tok="tidx" |
| x-ref="`tok-${sidx}-${tidx}`"> |
| <span class="token-surface" x-text="tok.surface"></span> |
| <span class="token-tag" x-text="primaryTag(tok)" x-show="primaryTag(tok)"></span> |
| </button> |
| </template> |
| </div> |
| </div> |
| </article> |
| </template> |
| </main> |
| </div> |
|
|
| |
| |
| |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'token' && editor.tok" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-2xl"> |
| <header class="modal-header"> |
| <div> |
| <div class="text-xs text-ink-500">Editing token</div> |
| <div class="font-mono text-xl mt-0.5" x-text="editor.tok?.surface"></div> |
| </div> |
| <div class="flex items-center gap-2"> |
| <button @click="moveToken(-1)" class="btn btn-ghost btn-icon" title="Previous (←)">←</button> |
| <button @click="moveToken(1)" class="btn btn-ghost btn-icon" title="Next (→)">→</button> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon" title="Close (Esc)">✕</button> |
| </div> |
| </header> |
|
|
| <div class="grid grid-cols-2 gap-5 p-5"> |
| |
| <div class="space-y-4"> |
| <template x-for="f in schemaFields" :key="f.name"> |
| <div> |
| <label class="lbl" x-text="f.name + (f.nullable ? ' (optional)' : '')"></label> |
|
|
| |
| <template x-if="f.type === 'enum'"> |
| <div> |
| <input class="input" type="text" :placeholder="'Search ' + f.name + '…'" |
| x-model="editor.search[f.name]" |
| @input.debounce.50ms="refreshFilter(f.name, f.values)"> |
| <div class="chips mt-2 max-h-44 overflow-y-auto"> |
| <template x-for="v in (editor.filtered[f.name] || f.values)" :key="v"> |
| <button type="button" |
| class="chip" |
| :class="editor.tok[f.name] === v ? 'chip-active' : ''" |
| @click="editor.tok[f.name] = v" |
| x-text="v"></button> |
| </template> |
| </div> |
| </div> |
| </template> |
|
|
| |
| <template x-if="f.type === 'string'"> |
| <input class="input" type="text" x-model="editor.tok[f.name]" :placeholder="f.name"> |
| </template> |
|
|
| |
| <template x-if="f.type === 'object'"> |
| <div class="space-y-2"> |
| <template x-for="sub in f.subfields" :key="sub.name"> |
| <div class="flex items-center gap-2"> |
| <label class="text-xs text-ink-500 w-24" x-text="sub.name"></label> |
| <input class="input flex-1" type="text" |
| :value="(editor.tok[f.name] || {})[sub.name] || ''" |
| @input="editor.tok[f.name] = {...(editor.tok[f.name]||{}), [sub.name]: $event.target.value}"> |
| </div> |
| </template> |
| </div> |
| </template> |
| </div> |
| </template> |
| </div> |
|
|
| |
| <div class="space-y-4"> |
| <div> |
| <label class="lbl">Per-model output</label> |
| <div class="text-xs space-y-1.5 mt-1.5" x-show="editor.perModel && Object.keys(editor.perModel).length > 0"> |
| <template x-for="[m, ann] in Object.entries(editor.perModel || {})" :key="m"> |
| <div class="flex items-start gap-2 p-2 rounded bg-ink-50"> |
| <span class="font-mono text-[11px] text-ink-700 min-w-[110px] truncate" :title="m" x-text="modelShort(m)"></span> |
| <span class="text-[11px] text-ink-900" x-text="modelTokenSummary(ann, editor.tidx)"></span> |
| <button class="ml-auto text-[10px] text-accent-600 hover:underline" |
| @click="adoptFromModel(m)">adopt</button> |
| </div> |
| </template> |
| </div> |
| <p class="text-[11px] text-ink-500 mt-1.5" x-show="!editor.perModel || Object.keys(editor.perModel || {}).length === 0"> |
| (run annotation first to see per-model output) |
| </p> |
| </div> |
|
|
| <div> |
| <label class="lbl">Disagreements on this token</label> |
| <div class="text-xs space-y-1 mt-1.5"> |
| <template x-for="d in editor.disagreementCells" :key="d.field_path"> |
| <div class="text-amber-700">⚠ <span class="font-mono" x-text="d.field_path"></span>: agreement <span x-text="(d.agreement_ratio*100).toFixed(0)+'%'"></span></div> |
| </template> |
| <div x-show="!editor.disagreementCells || editor.disagreementCells.length === 0" class="text-ink-500">none — consensus reached.</div> |
| </div> |
| </div> |
|
|
| <div> |
| <label class="lbl">Re-ask one model</label> |
| <div class="flex flex-wrap gap-1.5 mt-1.5"> |
| <template x-for="m in state.models" :key="m"> |
| <button class="chip chip-sm" @click="reaskOneToken(m)" x-text="modelShort(m)"></button> |
| </template> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="px-5 pb-3" x-show="matchingTokenCount() > 0 && Object.keys(fieldChanges()).length > 0" x-transition> |
| <label class="flex items-start gap-2 p-3 rounded-lg border border-accent-200 bg-accent-50 cursor-pointer"> |
| <input type="checkbox" x-model="editor.propagateToSimilar" class="mt-0.5"> |
| <div class="text-xs leading-relaxed flex-1"> |
| <div>Also apply to <strong x-text="matchingTokenCount()"></strong> other |
| <code class="font-mono bg-white px-1.5 py-0.5 rounded border border-accent-200" x-text="editor.tok.surface"></code> |
| token<span x-show="matchingTokenCount() > 1">s</span> in the corpus. |
| </div> |
| <div class="text-ink-500 mt-1"> |
| Changes: <span class="font-mono text-ink-700" x-text="fieldChangesSummary()"></span> |
| </div> |
| </div> |
| </label> |
| </div> |
|
|
| <footer class="modal-footer"> |
| <span class="text-xs text-ink-500" x-show="editor.autoAdvance">↵ saves & auto-advances to next disagreement</span> |
| <span class="flex-1"></span> |
| <label class="text-xs text-ink-500 mr-3 flex items-center gap-1.5"><input type="checkbox" x-model="editor.autoAdvance"> auto-advance</label> |
| <button class="btn btn-ghost" @click="closeModal()">Cancel</button> |
| <button class="btn btn-primary" @click="saveToken()">Save (↵)</button> |
| </footer> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'task'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-3xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Task & label schema</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-5"> |
| <div> |
| <label class="lbl">Preset</label> |
| <div class="grid grid-cols-2 gap-2 mt-2"> |
| <template x-for="p in state.presets" :key="p.key"> |
| <button class="preset-card" |
| :class="currentPresetMatches(p.key) ? 'preset-card-active' : ''" |
| @click="setPreset(p.key)"> |
| <div class="font-medium text-sm" x-text="p.label"></div> |
| </button> |
| </template> |
| </div> |
| </div> |
| <div> |
| <label class="lbl">Current schema fields (advanced)</label> |
| <details class="mt-1"> |
| <summary class="text-xs text-ink-500 cursor-pointer">edit fields…</summary> |
| <textarea class="input font-mono text-xs mt-2" rows="14" x-model="taskEditor.json" |
| @change="applyTaskJson()"></textarea> |
| <p class="text-[11px] text-ink-500 mt-1">JSON of the internal schema (fields with name/type/values/nullable/aggregator). Click outside to apply.</p> |
| </details> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'models'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-2xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Models</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-4"> |
| <p class="text-xs text-ink-500">Pick one model for single inference, or 2+ to enable <strong>Mixture-of-Experts</strong> (parallel calls + majority vote, with disagreements highlighted on each token).</p> |
| <div class="space-y-1.5 max-h-72 overflow-y-auto"> |
| <template x-for="m in allDisplayableModels()" :key="m"> |
| <label class="flex items-center gap-3 p-2 rounded hover:bg-ink-50 cursor-pointer"> |
| <input type="checkbox" :checked="state.models.includes(m)" @change="toggleModel(m)"> |
| <span class="font-mono text-sm" x-text="m"></span> |
| <span class="badge badge-uploaded text-[10px]" x-show="!state.curated_models.includes(m)">custom</span> |
| </label> |
| </template> |
| </div> |
| <div> |
| <label class="lbl">Custom OpenRouter slug</label> |
| <div class="flex gap-2 mt-1"> |
| <input class="input flex-1" type="text" x-model="modelEditor.custom" placeholder="provider/model-id"> |
| <button class="btn btn-secondary" @click="addCustomModel()">Add</button> |
| </div> |
| </div> |
| <div> |
| <label class="lbl">MoE priority (tie-break order)</label> |
| <input class="input" type="text" x-model="modelEditor.priority" |
| @change="saveSettings({priority: modelEditor.priority.split(',').map(s => s.trim()).filter(Boolean)})" |
| placeholder="model1, model2, model3"> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'key'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-md"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">OpenRouter API key</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-3"> |
| <div class="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-xs text-emerald-900"> |
| <strong>🔒 Tab-scoped storage.</strong> Your key lives only in this browser tab's <code>sessionStorage</code>. It is sent as an <code>X-OpenRouter-Key</code> header on each annotation request and <strong>never stored on the server</strong>. Closing the tab wipes it. Use <strong>Clear key</strong> below at any time. |
| </div> |
|
|
| <template x-if="localKey"> |
| <div class="rounded-lg bg-ink-50 border border-ink-200 p-3 text-xs flex items-center gap-2"> |
| <span class="text-emerald-700">✓</span> |
| <span class="text-ink-700 flex-1">A key is set in this tab.</span> |
| <button class="btn btn-ghost text-xs text-red-600" @click="clearKey()">Clear key</button> |
| </div> |
| </template> |
|
|
| <label class="lbl" x-text="localKey ? 'Replace with a new key' : 'Paste your key, then press Enter (or click Save)'"></label> |
| <input class="input" type="password" placeholder="sk-or-v1-…" |
| x-model="keyEditor.value" |
| @keydown.enter.prevent="saveKey()" |
| x-ref="keyInput" |
| autocomplete="off" |
| autocorrect="off" |
| spellcheck="false"> |
|
|
| <div class="flex items-center gap-2"> |
| <button class="btn btn-primary flex-1" @click="saveKey()" x-text="localKey ? 'Replace key' : 'Save key (this tab only)'"></button> |
| <button class="btn btn-secondary" @click="testKey(true)" :disabled="keyEditor.testing" |
| title="Test against OpenRouter and save if it works"> |
| <span x-show="!keyEditor.testing">Test & save</span> |
| <span x-show="keyEditor.testing" class="spinner-sm"></span> |
| </button> |
| </div> |
| <div class="text-xs" x-show="keyEditor.result" :class="keyEditor.ok ? 'text-emerald-700' : 'text-red-600'" x-text="keyEditor.result"></div> |
|
|
| <template x-if="state.has_env_key"> |
| <div class="text-[11px] text-ink-500 border-t border-ink-100 pt-2"> |
| ⓘ A server-side <code>OPENROUTER_API_KEY</code> is also configured. If you don't set a tab-key, the server fallback is used. |
| </div> |
| </template> |
|
|
| <p class="text-[11px] text-ink-400">Get a key → <a class="text-accent-600 underline" target="_blank" href="https://openrouter.ai/keys">openrouter.ai/keys</a></p> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'paste'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-3xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Load your own corpus</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-5"> |
|
|
| |
| <div> |
| <label class="lbl">① Your text</label> |
| <textarea class="input font-mono text-sm" rows="6" x-model="pasteEditor.text" |
| placeholder="One sentence per line. With 'one token per line', put each token on its own line and separate sentences with a blank line."></textarea> |
| <div class="grid grid-cols-2 gap-3 mt-2"> |
| <div> |
| <label class="lbl">Tokenization</label> |
| <select class="input" x-model="pasteEditor.tokenizer"> |
| <option value="whitespace">Split on whitespace</option> |
| <option value="as_is">One token per line</option> |
| <option value="newline">Newline-separated</option> |
| </select> |
| </div> |
| <div> |
| <label class="lbl">Language (free text)</label> |
| <input class="input" type="text" x-model="pasteEditor.language" placeholder="e.g. Ancient Greek"> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div> |
| <label class="lbl">② What should the LLM annotate?</label> |
| <div class="grid grid-cols-2 sm:grid-cols-3 gap-2"> |
| <template x-for="p in state.presets" :key="p.key"> |
| <button class="preset-card text-xs" |
| :class="pasteEditor.presetKey === p.key ? 'preset-card-active' : ''" |
| @click="pasteEditor.presetKey = p.key"> |
| <div class="font-medium" x-text="p.label"></div> |
| </button> |
| </template> |
| </div> |
| </div> |
|
|
| |
| <div x-show="pasteEditor.presetKey === 'custom'" class="rounded-lg border border-accent-200 bg-accent-50/60 p-3 space-y-3" x-transition> |
| <div> |
| <label class="lbl">Task name</label> |
| <input class="input" type="text" x-model="pasteEditor.customTaskName" placeholder="e.g. Medieval NER, Diplomatic tag…"> |
| </div> |
| <div> |
| <label class="lbl">Your tag set (the LLM picks one per token)</label> |
| <div class="flex flex-wrap gap-1.5 mb-2" x-show="pasteEditor.customTags.length > 0"> |
| <template x-for="(t, i) in pasteEditor.customTags" :key="t + '-' + i"> |
| <span class="chip chip-active inline-flex items-center gap-1.5"> |
| <span x-text="t"></span> |
| <button type="button" @click="pasteEditor.customTags.splice(i, 1)" class="hover:opacity-70 leading-none">✕</button> |
| </span> |
| </template> |
| </div> |
| <div class="flex gap-2"> |
| <input class="input flex-1" type="text" |
| x-model="pasteEditor.customTagInput" |
| @keydown="onTagKeydown($event)" |
| @blur="if ((pasteEditor.customTagInput || '').trim()) addCustomTag()" |
| placeholder="Type a tag, then Enter or comma…"> |
| <button type="button" class="btn btn-secondary text-xs whitespace-nowrap" |
| @click="addCustomTag()" |
| :disabled="!(pasteEditor.customTagInput || '').trim()">+ Add</button> |
| </div> |
| <p class="text-[11px] text-ink-500 mt-1">Press <kbd>Enter</kbd>, <kbd>,</kbd> or <kbd>;</kbd> to add. You can also paste <code>PER, LOC, ORG, WORK</code> and press Enter to add them all at once.</p> |
| <label class="flex items-center gap-2 mt-3 text-sm cursor-pointer"> |
| <input type="checkbox" x-model="pasteEditor.includeNone"> |
| <span>Add an <strong>"O"</strong> tag for tokens that match <em>none</em> of your labels |
| <span class="text-ink-500">(recommended — without it, the LLM is forced to pick a label for every token)</span> |
| </span> |
| </label> |
| </div> |
| <div> |
| <label class="lbl">Self-reported metadata, per token</label> |
| <div class="flex flex-wrap gap-x-4 gap-y-1.5 mt-1"> |
| <label class="flex items-center gap-2 text-sm cursor-pointer"> |
| <input type="checkbox" x-model="pasteEditor.includeConfidence"> |
| <span>Confidence <span class="text-ink-500">(low / medium / high)</span></span> |
| </label> |
| <label class="flex items-center gap-2 text-sm cursor-pointer"> |
| <input type="checkbox" x-model="pasteEditor.includeComment"> |
| <span>Optional comment</span> |
| </label> |
| </div> |
| </div> |
| <p class="text-[11px] text-ink-500"> |
| Need lemmatization, morphology, or several tag columns? Use one of the POS / Lemma presets above, or open <strong>📋 Task</strong> after loading for finer control. |
| </p> |
| </div> |
|
|
| <div class="flex justify-end gap-2 pt-1"> |
| <button class="btn btn-ghost" @click="closeModal()">Cancel</button> |
| <button class="btn btn-primary" |
| @click="loadPaste()" |
| :disabled="!pasteEditor.text || (pasteEditor.presetKey === 'custom' && pasteEditor.customTags.length === 0)"> |
| Load corpus |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'icl'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-2xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">ICL pool · v<span x-text="state.icl_pool.version"></span></h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-3"> |
| <p class="text-xs text-ink-500">Corrected sentences live here and are re-injected as few-shot examples on the next annotation run. The pool filters by <code>(language, schema_hash)</code> so cross-task contamination cannot happen.</p> |
| <div class="max-h-72 overflow-y-auto border border-ink-200 rounded"> |
| <table class="w-full text-xs"> |
| <thead class="bg-ink-50 sticky top-0"> |
| <tr><th class="th">#</th><th class="th">lang</th><th class="th">source</th><th class="th">preview</th></tr> |
| </thead> |
| <tbody> |
| <template x-for="e in state.icl_pool.entries" :key="e.idx"> |
| <tr class="border-t border-ink-100"> |
| <td class="td font-mono" x-text="e.idx"></td> |
| <td class="td" x-text="e.language"></td> |
| <td class="td"> |
| <span class="badge" :class="'badge-' + e.source" x-text="e.source"></span> |
| </td> |
| <td class="td font-mono text-ink-700" x-text="e.preview"></td> |
| </tr> |
| </template> |
| <tr x-show="state.icl_pool.size === 0"> |
| <td colspan="4" class="td text-center text-ink-500 py-6">empty — correct a sentence and click 📥 to ICL.</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| <div class="flex gap-2"> |
| <a class="btn btn-secondary" href="/api/icl/download" download>⬇ Download .jsonl</a> |
| <button class="btn btn-ghost" @click="clearIcl()">Clear pool</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'exports'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-md"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Export corpus</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-2"> |
| <p class="text-xs text-ink-500 mb-3">All sentences in the corpus, with their current (possibly corrected) annotations.</p> |
| <a class="btn btn-secondary w-full justify-start" href="/api/export/tsv" download> |
| <span class="mr-2">⬇</span>TSV <span class="text-ink-400 text-xs ml-auto">round-trip with PIE baseline</span> |
| </a> |
| <a class="btn btn-secondary w-full justify-start" href="/api/export/json" download> |
| <span class="mr-2">⬇</span>JSON <span class="text-ink-400 text-xs ml-auto">schema-conformant</span> |
| </a> |
| <a class="btn btn-secondary w-full justify-start" href="/api/export/conllu" download> |
| <span class="mr-2">⬇</span>CoNLL-U <span class="text-ink-400 text-xs ml-auto">UD-standard</span> |
| </a> |
| <a class="btn btn-secondary w-full justify-start" href="/api/export/jsonl" download> |
| <span class="mr-2">⬇</span>JSONL <span class="text-ink-400 text-xs ml-auto">fine-tune format</span> |
| </a> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'bulk'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-md"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Apply to <span x-text="selection.size"></span> tokens</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-3"> |
| <div> |
| <label class="lbl">Field</label> |
| <select class="input" x-model="bulkEditor.field"> |
| <template x-for="f in schemaFields" :key="f.name"> |
| <option :value="f.name" x-text="f.name + ' (' + f.type + ')'"></option> |
| </template> |
| </select> |
| </div> |
| <div> |
| <label class="lbl">Value</label> |
| <template x-if="bulkSelectedField()?.type === 'enum'"> |
| <select class="input" x-model="bulkEditor.value"> |
| <template x-for="v in (bulkSelectedField()?.values || [])" :key="v"> |
| <option :value="v" x-text="v"></option> |
| </template> |
| </select> |
| </template> |
| <template x-if="bulkSelectedField()?.type !== 'enum'"> |
| <input class="input" type="text" x-model="bulkEditor.value"> |
| </template> |
| </div> |
| <div class="flex justify-end gap-2"> |
| <button class="btn btn-ghost" @click="closeModal()">Cancel</button> |
| <button class="btn btn-primary" @click="applyBulk()">Apply</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'advanced'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-3xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">⚙ Advanced settings</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-4"> |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="lbl">N few-shot examples (per request)</label> |
| <input class="input" type="number" min="0" max="50" x-model.number="advEditor.n_icl"> |
| </div> |
| <div> |
| <label class="lbl">Temperature</label> |
| <input class="input" type="number" min="0" max="1" step="0.05" x-model.number="advEditor.temperature"> |
| </div> |
| </div> |
| <div> |
| <label class="lbl">System prompt</label> |
| <textarea class="input font-mono text-xs" rows="6" x-model="advEditor.system_prompt"></textarea> |
| </div> |
| <div> |
| <label class="lbl">User template (variables: {tokens}, {tagset}, {schema}, {few_shot_examples}, {language}, …)</label> |
| <textarea class="input font-mono text-xs" rows="14" x-model="advEditor.user_template"></textarea> |
| </div> |
| <div class="flex justify-end gap-2"> |
| <button class="btn btn-ghost" @click="closeModal()">Cancel</button> |
| <button class="btn btn-primary" @click="saveAdvanced()">Save</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-backdrop" x-show="modal === 'help'" @click.self="closeModal()" x-transition> |
| <div class="modal-card max-w-2xl"> |
| <header class="modal-header"> |
| <h2 class="font-semibold">Help</h2> |
| <button @click="closeModal()" class="btn btn-ghost btn-icon">✕</button> |
| </header> |
| <div class="p-5 space-y-4"> |
| <div> |
| <h3 class="font-semibold text-sm mb-2">Keyboard shortcuts</h3> |
| <table class="text-xs w-full"> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>j</kbd> / <kbd>k</kbd></td><td>focus next / previous token</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>e</kbd> / <kbd>↵</kbd></td><td>edit focused token</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>1</kbd> – <kbd>9</kbd></td><td>(in editor) assign the i-th visible tag</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>x</kbd></td><td>toggle selection of focused token</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>r</kbd></td><td>re-annotate the focused sentence</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>↵</kbd></td><td>save token edit and advance to next disagreement</td></tr> |
| <tr class="border-t border-ink-100"><td class="py-1.5 font-mono"><kbd>Esc</kbd></td><td>close popup</td></tr> |
| </table> |
| </div> |
| <div> |
| <h3 class="font-semibold text-sm mb-2">Workflow</h3> |
| <ol class="text-xs space-y-1.5 list-decimal list-inside text-ink-700"> |
| <li>Pick a sandbox example (sidebar) or paste your text.</li> |
| <li>Choose a task preset (top bar → 📋 Task).</li> |
| <li>Set OpenRouter API key (top bar → 🔑) and one or more models.</li> |
| <li>Click <strong>▶ Annotate all</strong>. Disagreement tokens turn amber.</li> |
| <li>Click a token to fix it. Click <strong>📥 to ICL</strong> to feed corrections back.</li> |
| <li>Export TSV / JSON / CoNLL-U / JSONL when done.</li> |
| </ol> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <footer class="orgs-footer"> |
| <div class="max-w-[1400px] mx-auto px-5 py-2.5 flex flex-wrap items-center justify-center gap-x-5 gap-y-2"> |
| <img src="/static/logos/LREC2026.png" alt="LREC 2026" title="LREC 2026" class="org-logo"> |
| <img src="/static/logos/chartes.png" alt="École nationale des chartes — PSL" title="École nationale des chartes — PSL" class="org-logo"> |
| <img src="/static/logos/cjm.png" alt="Centre Jean-Mabillon" title="Centre Jean-Mabillon" class="org-logo"> |
| <img src="/static/logos/culturelab.png" alt="PSL CultureLab" title="PSL CultureLab (ANR-10-IDEX-0001)" class="org-logo"> |
| <img src="/static/logos/DALiH_logo_200.png" alt="DALiH" title="ANR DALiH (ANR-21-CE38-0006)" class="org-logo"> |
| </div> |
| </footer> |
|
|
| |
| <div x-show="ctxMenu.open" |
| class="fixed z-50 bg-white shadow-xl border border-ink-200 rounded-lg py-1.5 text-sm min-w-[180px]" |
| :style="`top:${ctxMenu.y}px; left:${ctxMenu.x}px`" |
| @click.outside="ctxMenu.open=false"> |
| <button class="ctx-item" @click="openTokenEditor(ctxMenu.s, ctxMenu.t); ctxMenu.open=false">✎ Edit token</button> |
| <button class="ctx-item" @click="toggleSelectionIdx(ctxMenu.s, ctxMenu.t); ctxMenu.open=false">⊕ Toggle selection</button> |
| <div class="border-t border-ink-100 my-1"></div> |
| <template x-for="m in state.models" :key="m"> |
| <button class="ctx-item text-xs" @click="reaskOneTokenAt(ctxMenu.s, ctxMenu.t, m); ctxMenu.open=false"> |
| ↻ Re-ask <span class="font-mono" x-text="modelShort(m)"></span> |
| </button> |
| </template> |
| </div> |
|
|
| |
| <div class="fixed bottom-4 right-4 z-50 space-y-2"> |
| <template x-for="t in toasts" :key="t.id"> |
| <div class="toast" :class="'toast-' + t.kind" x-text="t.msg"></div> |
| </template> |
| </div> |
|
|
| <script src="/static/app.js?v=20260516b"></script> |
| </body> |
| </html> |
|
|