lterriel's picture
fix_bug_adopt_suggest (#3)
0aaf5f7
// 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: <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) {
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 <strong>in this browser tab only</strong>...'
: `Add your <strong>${providerLabel}</strong> API key. The key is kept <strong>in this browser tab only</strong> and sent as an <code>X-API-Key</code> 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
? `<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];
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, '&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;
},
};
}