// LREC 2026 — LLM Annotator front-end logic (Alpine.js) function annotator() { return { // ----------- state ----------- paperLink: 'https://aclanthology.org/2026.loreslm-1.28/', loading: false, progressText: 'Annotating…', modal: null, cheatsheetHtml: '', toasts: [], nextToastId: 1, focus: {sent: null, tok: null}, selection: new Set(), ctxMenu: {open: false, x: 0, y: 0, s: null, t: null}, guideDismissed: false, moeBannerDismissed: false, moeHintDismissed: false, // Per-provider client-side keys; persisted in sessionStorage only localKeys: {openrouter: '', mistral: '', openai: '', ilaas: ''}, state: { schema: null, schema_hash: '', json_schema: {}, language: '', system_prompt: '', user_template: '', has_env_key: false, provider: 'openrouter', providers: ['openrouter', 'mistral', 'openai', 'ilaas'], curated_models_by_provider: {}, models: [], priority: [], temperature: 0, n_icl: 5, icl_pool: {version: 0, size: 0, entries: []}, sentences: [], presets: [], curated_models: [], aggregators: [], exercises: [], }, editor: { sidx: null, tidx: null, tok: null, original: null, // snapshot at modal-open, used to diff field changes perModel: {}, disagreementCells: [], search: {}, filtered: {}, autoAdvance: true, propagateToSimilar: false, }, taskEditor: {json: ''}, modelEditor: {custom: '', priority: ''}, keyEditor: {value: '', testing: false, result: '', ok: false}, pasteEditor: { text: '', tokenizer: 'whitespace', language: '', presetKey: 'ud_upos_morph', customTaskName: 'My custom task', customTagInput: '', customTags: [], includeNone: true, includeConfidence: true, includeComment: false, }, advEditor: {system_prompt: '', user_template: '', n_icl: 5, temperature: 0}, bulkEditor: {field: '', value: ''}, // ----------- derived ----------- get schema() { return this.state.schema; }, get schemaFields() { const f = (this.state.schema && this.state.schema.fields) || []; return f; }, get totalTokens() { return this.state.sentences.reduce((a, s) => a + s.tokens.length, 0); }, get totalDisagreements() { return this.state.sentences.reduce((a, s) => a + (s.n_disagreements || 0), 0); }, // ----------- key helpers (per-provider) ----------- get localKey() { return this.localKeys[this.state.provider] || ''; }, setLocalKey(value) { const p = this.state.provider; this.localKeys = {...this.localKeys, [p]: value}; try { sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys)); } catch (e) { } }, get hasKey() { return !!this.localKey || !!this.state.has_env_key; }, get canRun() { return this.hasKey && this.state.models.length > 0 && this.state.sentences.length > 0; }, keyHeaders() { const h = {'X-LLM-Provider': this.state.provider}; if (this.localKey) h['X-API-Key'] = this.localKey; return h; }, // ----------- init ----------- async init() { this.guideDismissed = localStorage.getItem('guideDismissed') === '1'; this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1'; this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1'; // Load per-provider keys; migrate legacy single-key key if present try { const raw = sessionStorage.getItem('llm_keys'); if (raw) { this.localKeys = { openrouter: '', mistral: '', openai: '', ilaas: '', ...JSON.parse(raw), }; } const legacy = sessionStorage.getItem('openrouter_key'); if (legacy && !this.localKeys.openrouter) { this.localKeys = {...this.localKeys, openrouter: legacy}; sessionStorage.setItem('llm_keys', JSON.stringify(this.localKeys)); sessionStorage.removeItem('openrouter_key'); } } catch (e) { } await this.refresh(); try { const r = await fetch('/api/cheatsheet'); const txt = await r.text(); this.cheatsheetHtml = this.markdownToHtml(txt); } catch (e) { } // sync editor mirrors this.taskEditor.json = JSON.stringify(this.state.schema, null, 2); this.modelEditor.priority = this.state.priority.join(', '); this.advEditor.system_prompt = this.state.system_prompt; this.advEditor.user_template = this.state.user_template; this.advEditor.n_icl = this.state.n_icl; this.advEditor.temperature = this.state.temperature; window.addEventListener('keydown', (e) => this.globalKey(e)); // persist dismissals this.$watch?.('moeBannerDismissed', v => localStorage.setItem('moeBannerDismissed', v ? '1' : '0')); this.$watch?.('moeHintDismissed', v => localStorage.setItem('moeHintDismissed', v ? '1' : '0')); }, // ----------- contextual guide ----------- get guide() { const s = this.state; if (s.sentences.length === 0) { return { step: 1, icon: '📜', title: 'Load a corpus to start', body: 'Pick a sandbox example in the left sidebar — Greek, Armenian or Syriac. They come with a task preset, a validated tagset, and 3–5 pre-loaded ICL examples (visible in the toolbar: ICL pool · v3 · 5 ex).', actions: [ {label: '📘 Try Armenian (HYE)', handler: 'loadExercise', arg: 1}, {label: 'Paste my own text', handler: 'modal', arg: 'paste'}, ], }; } if (!this.hasKey) { const providerLabel = s.provider || 'provider'; const body = s.provider === 'openrouter' ? 'One OpenRouter key gives you access to Claude, GPT, Mistral, Llama, Qwen, DeepSeek and more. The key is kept in this browser tab only...' : `Add your ${providerLabel} API key. The key is kept in this browser tab only and sent as an X-API-Key header.`; return { step: 2, icon: '🔑', title: `Add your ${providerLabel} API key`, body, actions: [ {label: 'Add API key', handler: 'modal', arg: 'key'}, ], }; } const anyDone = s.sentences.some(x => x.status === 'done'); const anyPending = s.sentences.some(x => x.status === 'pending'); const totalDis = this.totalDisagreements; const lastWasMoE = s.sentences.some(x => Object.keys(x.per_model || {}).length >= 2); if (!anyDone) { const moeNote = s.models.length >= 2 ? `MoE is ON — your ${s.models.length} models will be called in parallel and their answers voted token-by-token.` : `Single model mode. To enable Mixture-of-Experts (parallel models + per-token vote), add a 2nd model.`; return { step: 3, icon: '▶️', title: 'Run the first annotation', body: `Click Annotate all in the toolbar. The ${s.icl_pool.size} ICL examples already in the pool will be sent as few-shot context. ${moeNote}`, actions: [ {label: '▶ Annotate all', handler: 'annotateAll'}, {label: 'Add a 2nd model (MoE)', handler: 'modal', arg: 'models', show: s.models.length < 2}, ].filter(a => a.show !== false), }; } if (totalDis > 0) { const moeMsg = lastWasMoE ? `Each ⚠ amber token has at least one field where your models disagreed. Click it to see which model said what and pick the right answer (or click adopt next to one model).` : `Click a token to edit it: change its tag, lemma, or any field. With keyboard: e to edit, to save & auto-advance to the next ⚠.`; return { step: 4, icon: '⚠', title: `Review ${totalDis} disagreement${totalDis !== 1 ? 's' : ''}`, body: moeMsg, actions: [ {label: 'Open first ⚠', handler: 'jumpToFirstDisagreement'}, ], }; } if (s.icl_pool.entries.filter(e => e.source === 'corrected').length === 0 && anyDone) { return { step: 5, icon: '📥', title: 'Feed corrections back to ICL', body: 'Your sentences look consensual. To bootstrap: click 📥 to ICL on any sentence to add its (corrected) annotation to the few-shot pool. Subsequent runs will reuse it. This is how the loop closes. Then export, or load more sentences.', actions: [ {label: '⬇ Export the corpus', handler: 'modal', arg: 'exports'}, ], }; } return { step: 5, icon: '✅', title: 'Loop closed — export or continue', body: `Your ICL pool now has ${s.icl_pool.size} entries (version v${s.icl_pool.version}). Re-run on more sentences and they will benefit from your corrections. Or export the corpus in TSV / JSON / CoNLL-U / JSONL.`, actions: [ {label: '⬇ Export', handler: 'modal', arg: 'exports'}, {label: 'Paste more text', handler: 'modal', arg: 'paste'}, ], }; }, runGuideAction(a) { if (a.handler === 'modal') { this.modal = a.arg; return; } if (a.handler === 'loadExercise') { this.loadExercise(a.arg); return; } if (a.handler === 'annotateAll') { this.annotateAll(); return; } if (a.handler === 'openExternal') { window.open(a.arg, '_blank'); return; } if (a.handler === 'jumpToFirstDisagreement') { for (let i = 0; i < this.state.sentences.length; i++) { const ds = this.state.sentences[i].disagreements || []; if (ds.length > 0) { const t = ds.sort((x, y) => x.token_idx - y.token_idx)[0].token_idx; this.openTokenEditor(i, t); return; } } } }, allDisplayableModels() { const set = new Set(this.state.curated_models); for (const m of this.state.models) set.add(m); return Array.from(set); }, tokenTooltip(sent, tidx) { const tok = sent.tokens[tidx]; const lines = [`${tok.surface}`]; for (const f of (this.state.schema?.fields || [])) { const v = tok[f.name]; if (v && typeof v !== 'object') lines.push(`${f.name}: ${v}`); } const dis = (sent.disagreements || []).filter(d => d.token_idx === tidx); if (dis.length > 0 && Object.keys(sent.per_model || {}).length > 0) { lines.push(''); lines.push('Per-model votes:'); for (const [m, ann] of Object.entries(sent.per_model)) { const t = (ann.tokens || [])[tidx] || {}; const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence'); const tag = enums[0] ? (t[enums[0].name] ?? '∅') : ''; const lemma = t.lemma ? ` ${t.lemma}` : ''; lines.push(` • ${this.modelShort(m)}: ${tag}${lemma}`); } } else if (tok._corrected) { lines.push('(corrected by you)'); } return lines.join('\n'); }, async refresh() { const r = await fetch('/api/state'); const data = await r.json(); this.applyState(data); }, rev: 0, // bumped on every state mutation; used as x-for :key suffix to force re-render // Mutate state property-by-property and replace nested arrays with fresh references, // so Alpine reactivity detects every change (replacing `state` wholesale can silently // miss deep updates in x-for / :class bindings). applyState(newState) { if (!newState) return; for (const k of Object.keys(newState)) { const v = newState[k]; if (k === 'sentences') { this.state.sentences = (v || []).map(s => ({ ...s, tokens: (s.tokens || []).map(t => ({...t})), disagreements: [...(s.disagreements || [])], per_model: {...(s.per_model || {})}, })); } else if (Array.isArray(v)) { this.state[k] = [...v]; } else if (v && typeof v === 'object') { this.state[k] = {...v}; } else { this.state[k] = v; } } for (const sent of this.state.sentences || []) { if (sent.validated) { sent._accuracy = this.modelAccuracy(sent); } } this.rev++; }, isFullStatePayload(data) { return data && Array.isArray(data.sentences) && data.icl_pool !== undefined; }, applyTokenSavePayload(sidx, data) { if (!data) return; // Nouveau backend : payload = _public_state() if (this.isFullStatePayload(data)) { this.applyState(data); return; } // Ancien backend : payload = sentence uniquement if (data.tokens && Array.isArray(data.tokens)) { this.replaceSentence(sidx, data); return; } console.warn('Unexpected token-save payload:', data); }, replaceSentence(sidx, sent) { const arr = [...this.state.sentences]; arr[sidx] = { ...sent, tokens: (sent.tokens || []).map(t => ({...t})), disagreements: [...(sent.disagreements || [])], per_model: {...(sent.per_model || {})}, }; this.state.sentences = arr; this.rev++; }, // ----------- helpers ----------- primaryTag(tok) { // pick the most informative field for the chip label if (!this.state.schema) return ''; const enums = this.state.schema.fields.filter(f => f.type === 'enum' && f.name !== 'confidence'); if (enums.length > 0) { const v = tok[enums[0].name]; if (v) return v; } // fallback to lemma if string-typed if (tok.lemma) return tok.lemma; return ''; }, tokenClass(sent, sidx, tidx, tok) { const isFocus = this.focus.sent === sidx && this.focus.tok === tidx; const isSelected = this.selection.has(`${sidx}:${tidx}`); const hasDisagreement = (sent.disagreements || []).some(d => d.token_idx === tidx); const hasContent = this.primaryTag(tok); const corrected = !!tok._corrected; let cls = 'token-base '; if (hasDisagreement) cls += 'token-warn '; else if (corrected) cls += 'token-corrected '; else if (hasContent) cls += 'token-done '; else cls += 'token-pending '; if (isFocus) cls += 'token-focus '; if (isSelected) cls += 'token-selected '; return cls; }, modelShort(m) { const parts = m.split('/'); return parts[parts.length - 1]; }, // Per-model accuracy on a single sentence, ONLY shown after the user has // confirmed the annotation as gold (sent.validated === true). Skips // confidence/comment (same as disagreement counting). modelAccuracy(sent) { if (!sent || sent.status !== 'done' || !sent.validated) return []; const perModel = sent.per_model || {}; const modelNames = Object.keys(perModel); if (modelNames.length === 0) return []; const quiet = new Set(['min', 'priority']); const fields = (this.state.schema?.fields || []).filter(f => !quiet.has(f.aggregator)); const out = []; for (const m of modelNames) { const tokens = perModel[m].tokens || []; let total = 0, correct = 0; const n = Math.min(tokens.length, sent.tokens.length); for (let i = 0; i < n; i++) { const got = tokens[i] || {}; const ref = sent.tokens[i] || {}; for (const f of fields) { if (f.type === 'object') { for (const sub of (f.subfields || [])) { const a = (got[f.name] || {})[sub.name] ?? null; const b = (ref[f.name] || {})[sub.name] ?? null; total++; if (a === b) correct++; } } else { const a = got[f.name] ?? null; const b = ref[f.name] ?? null; total++; if (a === b) correct++; } } } out.push({model: m, pct: total > 0 ? Math.round(100 * correct / total) : 0, correct, total}); } return out.sort((a, b) => b.pct - a.pct); }, accuracyClass(pct) { if (pct >= 90) return 'accuracy-pill-high'; if (pct >= 70) return 'accuracy-pill-mid'; return 'accuracy-pill-low'; }, modelTokenSummary(ann, tidx) { const t = (ann.tokens || [])[tidx] || {}; const enums = (this.state.schema?.fields || []).filter(f => f.type === 'enum' && f.name !== 'confidence'); const lemma = t.lemma ? ` · lemma=${t.lemma}` : ''; const tag = enums[0] ? ` · ${enums[0].name}=${t[enums[0].name] ?? '∅'}` : ''; const conf = t.confidence ? ` · ${t.confidence}` : ''; return `${tag}${lemma}${conf}`; }, currentPresetMatches(key) { return this.state.schema?.task_name?.toLowerCase().includes(key.replace(/_/g, ' ').replace('tagset', '').trim()); }, // ----------- mutations: task / settings / models ----------- async setPreset(key) { const r = await fetch('/api/task/preset', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({key}) }); this.applyState(await r.json()); this.taskEditor.json = JSON.stringify(this.state.schema, null, 2); this.toast('Task: ' + this.state.schema.task_name, 'ok'); }, async applyTaskJson() { try { const annotation_schema = JSON.parse(this.taskEditor.json); const r = await fetch('/api/task/schema', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({annotation_schema}) }); if (!r.ok) throw new Error((await r.json()).detail); this.applyState(await r.json()); this.toast('Custom schema applied.', 'ok'); } catch (e) { this.toast('Invalid schema JSON: ' + e.message, 'error'); } }, async saveSettings(partial) { const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(partial) }); this.applyState(await r.json()); }, async setProvider(p) { if (!this.state.providers.includes(p)) return; await this.saveSettings({provider: p}); this.toast(`Provider: ${p}. Models reset to its defaults.`, 'ok'); }, saveKey() { const k = (this.keyEditor.value || '').trim(); if (!k) { if (this.localKey) { this.toast('No new key entered. Existing key kept.', 'warn'); } else { this.toast('Paste a key first.', 'warn'); } return; } this.setLocalKey(k); this.keyEditor.value = ''; this.keyEditor.result = ''; this.keyEditor.ok = false; this.toast(`✓ ${this.state.provider} key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok'); this.closeModal(); }, clearKey() { this.setLocalKey(''); this.keyEditor.value = ''; this.keyEditor.result = ''; this.keyEditor.ok = false; this.toast(`${this.state.provider} key cleared from this tab.`, 'ok'); }, async testKey(autoSaveOnSuccess = false) { const k = (this.keyEditor.value || this.localKey || '').trim(); if (!k) { this.keyEditor.result = 'Paste a key first.'; this.keyEditor.ok = false; return; } this.keyEditor.testing = true; this.keyEditor.result = ''; try { const r = await fetch('/api/settings/test_key', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({api_key: k, provider: this.state.provider}) }); const j = await r.json(); this.keyEditor.ok = j.ok; this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message; if (j.ok && autoSaveOnSuccess) { this.setLocalKey(k); this.keyEditor.value = ''; this.toast(`✓ ${this.state.provider} key tested & saved (${k.length} chars).`, 'ok'); } } catch (e) { this.keyEditor.ok = false; this.keyEditor.result = '✗ ' + e.message; } this.keyEditor.testing = false; }, async toggleModel(m) { const set = new Set(this.state.models); if (set.has(m)) set.delete(m); else set.add(m); await this.saveSettings({models: Array.from(set)}); }, async addCustomModel() { const slug = (this.modelEditor.custom || '').trim(); if (!slug) return; const set = new Set(this.state.models); set.add(slug); await this.saveSettings({models: Array.from(set)}); this.modelEditor.custom = ''; }, async saveAdvanced() { await this.saveSettings({ n_icl: this.advEditor.n_icl, temperature: this.advEditor.temperature, system_prompt: this.advEditor.system_prompt, user_template: this.advEditor.user_template, }); this.toast('Advanced settings saved.', 'ok'); this.closeModal(); }, // ----------- corpus loading ----------- async loadExercise(idx) { this.loading = true; try { const r = await fetch('/api/corpus/exercise', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({idx}) }); this.applyState(await r.json()); this.taskEditor.json = JSON.stringify(this.state.schema, null, 2); this.toast(`Loaded: ${this.state.exercises[idx].title}`, 'ok'); } finally { this.loading = false; } }, onTagKeydown(e) { if (e.key === 'Enter' || e.key === ',' || e.key === ';') { e.preventDefault(); this.addCustomTag(); } }, addCustomTag() { const raw = (this.pasteEditor.customTagInput || '').trim(); if (!raw) return; const parts = raw.split(/[,;\n\t]+/).map(t => t.trim()).filter(Boolean); for (const t of parts) { if (!this.pasteEditor.customTags.includes(t)) { this.pasteEditor.customTags.push(t); } } this.pasteEditor.customTagInput = ''; }, buildCustomSchema() { const fields = []; const baseTags = this.pasteEditor.customTags.slice(); const values = this.pasteEditor.includeNone ? (baseTags.includes('O') ? baseTags : ['O', ...baseTags]) : baseTags; fields.push({ name: 'tag', type: 'enum', values, nullable: false, aggregator: 'vote', subfields: [], }); if (this.pasteEditor.includeConfidence) { fields.push({ name: 'confidence', type: 'enum', values: ['low', 'medium', 'high'], nullable: false, aggregator: 'min', subfields: [] }); } if (this.pasteEditor.includeComment) { fields.push({ name: 'comment', type: 'string', values: [], nullable: true, aggregator: 'priority', subfields: [] }); } return { task_name: this.pasteEditor.customTaskName || 'Custom task', language: this.pasteEditor.language || '', description: '', fields, }; }, async loadPaste() { // flush any pending tag still in the input if ((this.pasteEditor.customTagInput || '').trim()) this.addCustomTag(); this.loading = true; try { // 1) Set the task BEFORE loading text (so the ICL pool & state are consistent) if (this.pasteEditor.presetKey === 'custom') { if (this.pasteEditor.customTags.length === 0) { this.toast('Add at least one tag in the custom set.', 'warn'); return; } const annotation_schema = this.buildCustomSchema(); const r0 = await fetch('/api/task/schema', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({annotation_schema}) }); if (!r0.ok) { this.toast('Schema rejected: ' + (await r0.json()).detail, 'error'); return; } this.applyState(await r0.json()); } else if (this.pasteEditor.presetKey) { const r0 = await fetch('/api/task/preset', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({key: this.pasteEditor.presetKey}) }); if (r0.ok) this.applyState(await r0.json()); } // 2) Load the text const r = await fetch('/api/corpus/paste', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ text: this.pasteEditor.text, tokenizer: this.pasteEditor.tokenizer, language: this.pasteEditor.language, }) }); this.applyState(await r.json()); this.closeModal(); this.toast(`Loaded ${this.state.sentences.length} sentence(s). Task: ${this.state.schema?.task_name}.`, 'ok'); } finally { this.loading = false; } }, async clearCorpus() { const r = await fetch('/api/corpus/clear', {method: 'POST'}); this.applyState(await r.json()); }, async resetAll() { if (!confirm('Reset everything? This wipes:\n• loaded corpus\n• annotations\n• ICL pool\n• custom task/schema\n• prompt overrides\n\nYour API key (browser-only) is kept.')) return; this.loading = true; try { const r = await fetch('/api/reset', {method: 'POST'}); this.applyState(await r.json()); this.selection = new Set(); this.focus = {sent: null, tok: null}; this.modal = null; this.toast('Workspace reset.', 'ok'); } finally { this.loading = false; } }, async clearIcl() { const r = await fetch('/api/icl/clear', {method: 'POST'}); this.applyState(await r.json()); }, // ----------- annotation ----------- async annotateAll() { if (!this.canRun || this.loading) return; this.loading = true; this.progressText = `Annotating ${this.state.sentences.length} sentences…`; // Optimistic UI: nouvelle référence pour Alpine this.state.sentences = this.state.sentences.map(s => s.status !== 'done' ? {...s, status: 'annotating'} : s ); try { const r = await fetch('/api/annotate', { method: 'POST', headers: {'Content-Type': 'application/json', ...this.keyHeaders()}, body: JSON.stringify({}) }); if (!r.ok) throw new Error((await r.json()).detail); const data = await r.json(); this.applyState(data); const dis = this.totalDisagreements; this.toast( `Done. ${dis} disagreement${dis !== 1 ? 's' : ''} to review.`, dis > 0 ? 'warn' : 'ok' ); } catch (e) { this.toast(e.message, 'error'); } finally { this.loading = false; } }, async annotateOne(sidx) { if (!this.hasKey) { this.modal = 'key'; return; } this.loading = true; this.state.sentences = this.state.sentences.map((s, i) => i === sidx ? {...s, status: 'annotating'} : s ); try { const r = await fetch('/api/annotate', { method: 'POST', headers: {'Content-Type': 'application/json', ...this.keyHeaders()}, body: JSON.stringify({sentence_idxs: [sidx]}) }); if (!r.ok) throw new Error((await r.json()).detail); this.applyState(await r.json()); const s = this.state.sentences[sidx]; this.toast(`Sentence ${s.id}: ${s.n_disagreements} disagreement(s).`, s.n_disagreements > 0 ? 'warn' : 'ok'); } catch (e) { this.toast(e.message, 'error'); } this.loading = false; }, async addSentenceToIcl(sidx) { const r = await fetch(`/api/sentence/${sidx}/add_to_icl`, {method: 'POST'}); if (!r.ok) { this.toast('Could not add to ICL pool.', 'error'); return; } const data = await r.json(); this.applyState(data); if (data.icl_add_result === 'unchanged') { this.toast( `Already in ICL pool — unchanged (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, 'warn' ); } else if (data.icl_add_result === 'updated') { this.toast( `Updated existing ICL example after correction (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, 'ok' ); } else { this.toast( `Added to ICL pool (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, 'ok' ); } }, async setValidated(sidx, value) { const r = await fetch(`/api/sentence/${sidx}/sent_score`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({value}), }); if (!r.ok) { this.toast('Could not toggle scoring.', 'error'); return; } const sent = await r.json(); sent._accuracy = this.modelAccuracy(sent); this.replaceSentence(sidx, sent); this.toast(value ? '📊 Showing per-model accuracy vs your current annotation.' : 'Scores hidden.', 'ok'); }, // ----------- token editor ----------- onTokenClick(ev, sidx, tidx) { if (ev.shiftKey) { this.toggleSelectionIdx(sidx, tidx); return; } this.openTokenEditor(sidx, tidx); }, openTokenEditor(sidx, tidx) { const sent = this.state.sentences[sidx]; const tok = JSON.parse(JSON.stringify(sent.tokens[tidx] || {})); this.editor.sidx = sidx; this.editor.tidx = tidx; this.editor.tok = tok; this.editor.original = JSON.parse(JSON.stringify(tok)); // snapshot for diff this.editor.search = {}; this.editor.filtered = {}; this.editor.perModel = sent.per_model || {}; this.editor.disagreementCells = (sent.disagreements || []).filter(d => d.token_idx === tidx); this.editor.propagateToSimilar = false; this.focus = {sent: sidx, tok: tidx}; this.modal = 'token'; }, matchingTokenCount() { if (!this.editor.tok) return 0; const surf = this.editor.tok.surface; let n = 0; for (let s = 0; s < this.state.sentences.length; s++) { for (let t = 0; t < this.state.sentences[s].tokens.length; t++) { if (s === this.editor.sidx && t === this.editor.tidx) continue; if (this.state.sentences[s].tokens[t].surface === surf) n++; } } return n; }, fieldChanges() { const out = {}; if (!this.editor.tok || !this.editor.original) return out; for (const k of Object.keys(this.editor.tok)) { if (k === 'surface' || k.startsWith('_')) continue; const a = JSON.stringify(this.editor.tok[k] ?? null); const b = JSON.stringify(this.editor.original[k] ?? null); if (a !== b) out[k] = this.editor.tok[k]; } return out; }, fieldChangesSummary() { const c = this.fieldChanges(); return Object.entries(c).map(([k, v]) => { const val = (v === null || v === undefined) ? '∅' : (typeof v === 'object' ? JSON.stringify(v) : String(v)); return `${k}=${val}`; }).join(', '); }, refreshFilter(name, values) { const q = (this.editor.search[name] || '').toLowerCase(); this.editor.filtered[name] = values.filter(v => v.toLowerCase().includes(q)); }, adoptFromModel(model) { const sent = this.state.sentences[this.editor.sidx]; const modelTok = (sent.per_model?.[model]?.tokens || [])[this.editor.tidx]; if (!modelTok || !this.editor.tok) return; const surface = this.editor.tok.surface; const adopted = JSON.parse(JSON.stringify(modelTok)); for (const [k, v] of Object.entries(adopted)) { if (k === 'surface') continue; this.editor.tok[k] = v; } this.editor.tok.surface = surface; this.editor.tok._corrected = true; // Force Alpine reactivity this.editor.tok = {...this.editor.tok}; this.toast(`Adopted from ${this.modelShort(model)}.`, 'ok'); }, async reaskOneToken(model) { try { const r = await fetch('/api/annotate/token', { method: 'POST', headers: {'Content-Type': 'application/json', ...this.keyHeaders()}, body: JSON.stringify({ sent: this.editor.sidx, tok: this.editor.tidx, model, }) }); if (!r.ok) throw new Error((await r.json()).detail); const sent = await r.json(); this.replaceSentence(this.editor.sidx, sent); // re-open with the new token this.openTokenEditor(this.editor.sidx, this.editor.tidx); this.toast(`Re-asked ${this.modelShort(model)}.`, 'ok'); } catch (e) { this.toast(e.message, 'error'); } }, async reaskOneTokenAt(sidx, tidx, model) { this.editor.sidx = sidx; this.editor.tidx = tidx; await this.reaskOneToken(model); }, async saveToken() { const sidx = this.editor.sidx; const tidx = this.editor.tidx; const surface = this.editor.tok.surface; const changes = this.fieldChanges(); const wantPropagate = this.editor.propagateToSimilar && Object.keys(changes).length > 0 && this.matchingTokenCount() > 0; this.editor.tok._corrected = true; const r = await fetch(`/api/sentence/${sidx}/token/${tidx}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({token: this.editor.tok}) }); if (!r.ok) { this.toast('Save failed.', 'error'); return; } const data = await r.json(); this.applyTokenSavePayload(sidx, data); let propagatedCount = 0; if (wantPropagate) { try { const r2 = await fetch('/api/bulk_similar', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ surface, updates: changes, exclude: [{s: sidx, t: tidx}], }), }); if (r2.ok) { const j = await r2.json(); for (const item of (j.sentences || [])) { this.replaceSentence(item.idx, item.sentence); } propagatedCount = (j.affected || []).length; } } catch (e) { this.toast('Propagation failed: ' + e.message, 'error'); } } let iclMsg = ''; if (data.icl_add_result === 'updated') { iclMsg = ` + updated ICL v${this.state.icl_pool.version}`; } else if (data.icl_add_result === 'inserted') { iclMsg = ` + added to ICL v${this.state.icl_pool.version}`; } else if (data.icl_add_result === 'unchanged') { iclMsg = ` + ICL unchanged`; } // auto-advance if (this.editor.autoAdvance) { const next = this.findNextDisagreement(sidx, tidx); if (next) { this.openTokenEditor(next.s, next.t); this.toast( propagatedCount > 0 ? `✓ Saved + propagated to ${propagatedCount} other "${surface}"${iclMsg}.` : `✓ Saved${iclMsg}.`, 'ok' ); return; } } this.closeModal(); this.toast( propagatedCount > 0 ? `✓ Saved + propagated to ${propagatedCount} other "${surface}"${iclMsg}.` : `✓ Saved${iclMsg}.`, 'ok' ); }, findNextDisagreement(sidx, tidx) { const sents = this.state.sentences; // search rest of current sentence const sent = sents[sidx]; const more = (sent.disagreements || []).filter(d => d.token_idx > tidx).sort((a, b) => a.token_idx - b.token_idx); if (more.length > 0) return {s: sidx, t: more[0].token_idx}; // next sentences for (let i = sidx + 1; i < sents.length; i++) { const ds = sents[i].disagreements || []; if (ds.length > 0) { const t = ds.sort((a, b) => a.token_idx - b.token_idx)[0].token_idx; return {s: i, t}; } } return null; }, moveToken(delta) { const sent = this.state.sentences[this.editor.sidx]; const next = this.editor.tidx + delta; if (next < 0 || next >= sent.tokens.length) return; this.openTokenEditor(this.editor.sidx, next); }, // ----------- selection / bulk ----------- toggleSelectionIdx(sidx, tidx) { const k = `${sidx}:${tidx}`; if (this.selection.has(k)) this.selection.delete(k); else this.selection.add(k); // alpine reactivity: replace Set this.selection = new Set(this.selection); }, clearSelection() { this.selection = new Set(); }, bulkSelectedField() { return this.schemaFields.find(f => f.name === this.bulkEditor.field); }, async applyBulk() { const bySent = {}; for (const k of this.selection) { const [s, t] = k.split(':').map(Number); if (!bySent[s]) bySent[s] = []; bySent[s].push(t); } for (const [s, idxs] of Object.entries(bySent)) { const r = await fetch(`/api/sentence/${s}/bulk`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ token_idxs: idxs, field: this.bulkEditor.field, value: this.bulkEditor.value, }) }); if (r.ok) this.replaceSentence(Number(s), await r.json()); } this.clearSelection(); this.closeModal(); this.toast('Bulk applied.', 'ok'); }, // ----------- context menu ----------- openTokenContext(ev, sidx, tidx) { this.ctxMenu = {open: true, x: ev.clientX, y: ev.clientY, s: sidx, t: tidx}; }, // ----------- modals ----------- closeModal() { this.modal = null; this.$nextTick(() => { this.editor.sidx = null; this.editor.tidx = null; this.editor.tok = null; this.editor.original = null; this.editor.perModel = {}; this.editor.disagreementCells = []; }); }, // ----------- keyboard ----------- globalKey(e) { // editor-modal: route to editor keys if (this.modal === 'token' && this.editor.tok) { if (e.key === 'Escape') { this.closeModal(); e.preventDefault(); return; } if (e.key === 'Enter' && !(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) { this.saveToken(); e.preventDefault(); return; } if (e.key === 'ArrowLeft') { this.moveToken(-1); e.preventDefault(); return; } if (e.key === 'ArrowRight') { this.moveToken(1); e.preventDefault(); return; } // 1-9 → assign primary enum const num = parseInt(e.key); if (!isNaN(num) && num >= 1 && num <= 9) { const enums = this.schemaFields.filter(f => f.type === 'enum' && f.name !== 'confidence'); if (enums.length > 0) { const f = enums[0]; const visible = this.editor.filtered[f.name] || f.values; const v = visible[num - 1]; if (v) { this.editor.tok[f.name] = v; e.preventDefault(); } } } return; } if (this.modal) { if (e.key === 'Escape') { this.closeModal(); e.preventDefault(); } return; } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; // global shortcuts if (e.key === 'j') { this.moveFocus(1); e.preventDefault(); } else if (e.key === 'k') { this.moveFocus(-1); e.preventDefault(); } else if (e.key === 'e' || e.key === 'Enter') { if (this.focus.sent !== null) { this.openTokenEditor(this.focus.sent, this.focus.tok); e.preventDefault(); } } else if (e.key === 'x') { if (this.focus.sent !== null) { this.toggleSelectionIdx(this.focus.sent, this.focus.tok); e.preventDefault(); } } else if (e.key === 'r') { if (this.focus.sent !== null) { this.annotateOne(this.focus.sent); e.preventDefault(); } } else if (e.key === 'Escape') { this.clearSelection(); } }, handleKey(e) { /* main panel passthrough — globalKey handles all */ }, moveFocus(delta) { const sents = this.state.sentences; if (sents.length === 0) return; if (this.focus.sent === null) { this.focus = {sent: 0, tok: 0}; return; } let s = this.focus.sent, t = this.focus.tok + delta; while (s >= 0 && s < sents.length) { if (t < 0) { s -= 1; if (s < 0) return; t = sents[s].tokens.length - 1; continue; } if (t >= sents[s].tokens.length) { s += 1; t = 0; continue; } this.focus = {sent: s, tok: t}; // scroll into view this.$nextTick(() => { const el = document.querySelector(`button.token-base[data-sent="${s}"][data-tok="${t}"]`); if (el) el.scrollIntoView({block: 'center', behavior: 'smooth'}); }); return; } }, // ----------- toasts ----------- toast(msg, kind = 'ok') { const id = this.nextToastId++; this.toasts.push({id, msg, kind}); setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, 3500); }, // ----------- markdown -> html (minimal) ----------- markdownToHtml(md) { // very lightweight; safe enough for trusted local content let h = md .replace(/&/g, '&').replace(//g, '>') .replace(/^### (.*)$/gm, '

$1

') .replace(/^## (.*)$/gm, '

$1

') .replace(/^# (.*)$/gm, '

$1

') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/^- (.*)$/gm, '
  • $1
  • ') .replace(/^\d+\. (.*)$/gm, '
  • $1
  • '); h = h.replace(/(
  • .*<\/li>\n?)+/g, m => ''); h = h.split(/\n{2,}/).map(p => /^<[hul]/.test(p) ? p : '

    ' + p.replace(/\n/g, '
    ') + '

    ').join('\n'); return h; }, }; }