| |
|
|
| function annotator() { |
| return { |
| |
| 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, |
| |
| 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, |
| 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: ''}, |
|
|
| |
| 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 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; |
| }, |
|
|
| |
| async init() { |
| this.guideDismissed = localStorage.getItem('guideDismissed') === '1'; |
| this.moeBannerDismissed = localStorage.getItem('moeBannerDismissed') === '1'; |
| this.moeHintDismissed = localStorage.getItem('moeHintDismissed') === '1'; |
| |
| 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) { |
| } |
| |
| 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)); |
| |
| this.$watch?.('moeBannerDismissed', v => localStorage.setItem('moeBannerDismissed', v ? '1' : '0')); |
| this.$watch?.('moeHintDismissed', v => localStorage.setItem('moeHintDismissed', v ? '1' : '0')); |
| }, |
|
|
| |
| 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, |
| |
| |
| |
| 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; |
|
|
| |
| if (this.isFullStatePayload(data)) { |
| this.applyState(data); |
| return; |
| } |
|
|
| |
| 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++; |
| }, |
|
|
| |
| primaryTag(tok) { |
| |
| 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; |
| } |
| |
| 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]; |
| }, |
|
|
| |
| |
| |
| 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()); |
| }, |
|
|
| |
| 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(); |
| }, |
|
|
| |
| 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() { |
| |
| if ((this.pasteEditor.customTagInput || '').trim()) this.addCustomTag(); |
| this.loading = true; |
| try { |
| |
| 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()); |
| } |
|
|
| |
| 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()); |
| }, |
|
|
| |
| async annotateAll() { |
| if (!this.canRun || this.loading) return; |
|
|
| this.loading = true; |
| this.progressText = `Annotating ${this.state.sentences.length} sentences…`; |
|
|
| |
| 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'); |
| }, |
|
|
| |
| 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)); |
| 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; |
|
|
| |
| 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); |
| |
| 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`; |
| } |
|
|
| |
| 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; |
| |
| 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}; |
| |
| 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); |
| }, |
|
|
| |
| toggleSelectionIdx(sidx, tidx) { |
| const k = `${sidx}:${tidx}`; |
| if (this.selection.has(k)) this.selection.delete(k); |
| else this.selection.add(k); |
| |
| 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'); |
| }, |
|
|
| |
| openTokenContext(ev, sidx, tidx) { |
| this.ctxMenu = {open: true, x: ev.clientX, y: ev.clientY, s: sidx, t: tidx}; |
| }, |
|
|
| |
| 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 = []; |
| }); |
| }, |
|
|
| |
| globalKey(e) { |
| |
| 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; |
| } |
| |
| 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; |
|
|
| |
| 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) { |
| }, |
|
|
| 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}; |
| |
| this.$nextTick(() => { |
| const el = document.querySelector(`button.token-base[data-sent="${s}"][data-tok="${t}"]`); |
| if (el) el.scrollIntoView({block: 'center', behavior: 'smooth'}); |
| }); |
| return; |
| } |
| }, |
|
|
| |
| 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); |
| }, |
|
|
| |
| markdownToHtml(md) { |
| |
| let h = md |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| .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; |
| }, |
| }; |
| } |
|
|