dhuser's picture
Clean
9ce2aaa
raw
history blame
35.2 kB
// 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,
localKey: '', // client-side OpenRouter key; never sent to /api/state, never persisted on server
state: {
schema: null,
schema_hash: '',
json_schema: {},
language: '',
system_prompt: '',
user_template: '',
has_env_key: false,
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);
},
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() {
// Send the client-side key as a header. Server never stores it.
return this.localKey ? { 'X-OpenRouter-Key': this.localKey } : {};
},
// ----------- init -----------
async init() {
this.guideDismissed = localStorage.getItem('guideDismissed') === '1';
this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1';
this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1';
this.localKey = sessionStorage.getItem('openrouter_key') || '';
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: <strong>ICL pool · v3 · 5 ex</strong>).',
actions: [
{ label: '📘 Try Armenian (HYE)', handler: 'loadExercise', arg: 1 },
{ label: 'Paste my own text', handler: 'modal', arg: 'paste' },
],
};
}
if (!this.hasKey) {
return {
step: 2, icon: '🔑', title: 'Add your OpenRouter API key',
body: 'One key gives you access to Claude, GPT, Mistral, Llama, Qwen, DeepSeek and more. The key is kept <strong>in this browser tab only</strong> (sessionStorage) and never sent to or stored on the server — you can wipe it with the <strong>Clear key</strong> button at any time.',
actions: [
{ label: 'Add API key', handler: 'modal', arg: 'key' },
{ label: 'Get a key →', handler: 'openExternal', arg: 'https://openrouter.ai/keys' },
],
};
}
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
? `<strong>MoE is ON</strong> — your ${s.models.length} models will be called in parallel and their answers voted token-by-token.`
: `Single model mode. To enable <strong>Mixture-of-Experts</strong> (parallel models + per-token vote), add a 2nd model.`;
return {
step: 3, icon: '▶️', title: 'Run the first annotation',
body: `Click <strong>Annotate all</strong> 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 <em>which model said what</em> and pick the right answer (or click <kbd>adopt</kbd> next to one model).`
: `Click a token to edit it: change its tag, lemma, or any field. With keyboard: <kbd>e</kbd> to edit, <kbd>↵</kbd> 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 <strong>📥 to ICL</strong> on any sentence to add its (corrected) annotation to the few-shot pool. Subsequent runs will reuse it. <strong>This is how the loop closes.</strong> 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 <strong>${s.icl_pool.size}</strong> entries (version <strong>v${s.icl_pool.version}</strong>). 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];
this.state[k] = Array.isArray(v) ? v.slice() : v;
}
this.rev++;
},
replaceSentence(sidx, sent) {
const arr = this.state.sentences.slice();
arr[sidx] = sent;
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];
},
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());
},
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.localKey = k;
try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
this.keyEditor.value = '';
this.keyEditor.result = '';
this.keyEditor.ok = false;
this.toast(`✓ Key saved in this tab (${k.length} chars). Pill above should turn green.`, 'ok');
this.closeModal();
},
clearKey() {
this.localKey = '';
try { sessionStorage.removeItem('openrouter_key'); } catch (e) {}
this.keyEditor.value = '';
this.keyEditor.result = '';
this.keyEditor.ok = false;
this.toast('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 }) });
const j = await r.json();
this.keyEditor.ok = j.ok;
this.keyEditor.result = (j.ok ? '✓ ' : '✗ ') + j.message;
if (j.ok && autoSaveOnSuccess) {
this.localKey = k;
try { sessionStorage.setItem('openrouter_key', k); } catch (e) {}
this.keyEditor.value = '';
this.toast(`✓ 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: mark all pending sentences as annotating
this.state.sentences.forEach(s => { if (s.status !== 'done') s.status = 'annotating'; });
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);
this.applyState(await r.json());
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[sidx].status = 'annotating';
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' });
this.applyState(await r.json());
this.toast(`Added to ICL pool (v${this.state.icl_pool.version}, ${this.state.icl_pool.size} entries).`, '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 t = (sent.per_model[model]?.tokens || [])[this.editor.tidx];
if (!t) return;
// copy all fields except surface
const surface = this.editor.tok.surface;
this.editor.tok = { ...t, surface };
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, 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 sent = await r.json();
this.replaceSentence(sidx, sent);
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();
this.applyState(j.state);
propagatedCount = (j.affected || []).length;
}
} catch (e) {
this.toast('Propagation failed: ' + e.message, 'error');
}
}
// auto-advance
if (this.editor.autoAdvance) {
const next = this.findNextDisagreement(sidx, tidx);
if (next) {
this.openTokenEditor(next.s, next.t);
if (propagatedCount > 0) this.toast(`✓ Saved + propagated to ${propagatedCount} other "${surface}".`, 'ok');
return;
}
}
this.closeModal();
this.toast(propagatedCount > 0 ? `✓ Saved + propagated to ${propagatedCount} other "${surface}".` : '✓ Saved.', '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.editor.sidx = null; this.editor.tidx = null; this.editor.tok = null;
},
// ----------- 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^- (.*)$/gm, '<li>$1</li>')
.replace(/^\d+\. (.*)$/gm, '<li>$1</li>');
h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
h = h.split(/\n{2,}/).map(p => /^<[hul]/.test(p) ? p : '<p>' + p.replace(/\n/g, '<br>') + '</p>').join('\n');
return h;
},
};
}