Spaces:
Running
Running
Download app.js from Gabrielkph/alias: direct link, hf CLI and curl.
- Browser
- Download file 11.6 kB
-
https://huggingface.co/spaces/Gabrielkph/alias/resolve/main/app.js
- Command line
-
hf download hf://spaces/Gabrielkph/alias/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Gabrielkph/alias/resolve/main/app.js
11.6 kB
| // Alias demo page: loads the tokenizer and the ONNX model from Hugging Face once (the browser | |
| // caches them), then masks the pasted text locally with alias.js. The text is never sent: the | |
| // only requests are for the model files and the ONNX runtime, and the page's CSP allows no other | |
| // host. Text is only ever inserted with textContent, never as HTML. | |
| import { | |
| AutoModelForTokenClassification, | |
| AutoTokenizer, | |
| Tensor, | |
| env, | |
| } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.2/dist/transformers.min.js"; | |
| import { TAGS, detect, mask, restore } from "./alias.js"; | |
| const MODEL = "Gabrielkph/alias-mmbert-small"; | |
| // The model version the demo was tested with: a tag on the model repository, so a later upload | |
| // of new weights cannot change the demo without a new release of it. | |
| const MODEL_REVISION = "v1.0"; | |
| const VERSION = "0.3.0"; // x-release-please-version | |
| // What the first visit downloads: the ONNX model (563 MB) and the tokenizer (34 MB). | |
| const EXPECTED_BYTES = 597e6; | |
| const LABELS = { | |
| PERSON: "Person", | |
| ADDRESS: "Address", | |
| EMAIL: "E-mail", | |
| PHONE: "Phone", | |
| IBAN: "IBAN", | |
| NATIONAL_ID: "National ID", | |
| BIRTH_DATE: "Birth date", | |
| IDENTIFIER: "Identifier", | |
| }; | |
| // Fictitious values only (tests/README.md): reserved e-mail domain, phone range for fiction, | |
| // specification IBAN, invented NIR. | |
| const EXAMPLE = `Objet : remboursement du dossier n° 2024/00421 | |
| Madame, Monsieur, | |
| Je soussigné Jean Dupont, né le 12/03/1985, domicilié 14 rue des Lilas, 69003 Lyon, | |
| demande le remboursement de mes frais. Vous pouvez me joindre au 01 99 00 12 34 ou à | |
| jean.dupont@example.com. Numéro de sécurité sociale : 1 85 05 78 006 084 91. | |
| Merci de verser la somme sur le compte FR76 3000 6000 0112 3456 7890 189. | |
| Jean Dupont`; | |
| // What an LLM could answer to "Summarise this document" about the masked example: written with | |
| // the aliases the example gets, so the restore step can be tried without an LLM. | |
| const EXAMPLE_ANSWER = `Résumé : [PERSON_1], né le [BIRTH_DATE_1] et domicilié [ADDRESS_1], demande le remboursement de ses frais pour le dossier n° [IDENTIFIER_1]. | |
| - Versement demandé sur le compte [IBAN_1]. | |
| - Numéro de sécurité sociale indiqué : [NATIONAL_ID_1]. | |
| - Contact : [PHONE_1] ou [EMAIL_1].`; | |
| const $ = (id) => document.getElementById(id); | |
| let detector = null; // { tokenize, predict, bos, eos } once the model is loaded | |
| let last = null; | |
| function show(element, visible) { | |
| element.hidden = !visible; | |
| } | |
| function setError(message) { | |
| $("error").textContent = message || ""; | |
| show($("error"), Boolean(message)); | |
| } | |
| function setStatus(message) { | |
| $("status").textContent = message; | |
| } | |
| async function load() { | |
| env.allowLocalModels = false; | |
| const files = new Map(); // file -> [loaded, total] | |
| let started = null; | |
| const progress = (info) => { | |
| if (info.status !== "progress" || !info.total) return; | |
| files.set(info.file, [info.loaded, info.total]); | |
| const [loaded, known] = [...files.values()].reduce((a, b) => [a[0] + b[0], a[1] + b[1]]); | |
| // The tokenizer comes first: until the model file starts, its size is not known yet. | |
| const total = Math.max(known, EXPECTED_BYTES); | |
| started ??= performance.now(); | |
| const seconds = (performance.now() - started) / 1000; | |
| const bar = $("progress"); | |
| show(bar, true); | |
| bar.value = loaded / total; | |
| let left = ""; | |
| if (seconds > 3 && loaded > 0) { | |
| const minutes = Math.ceil(((total - loaded) / (loaded / seconds)) / 60); | |
| left = minutes <= 1 ? ", about a minute left" : `, about ${minutes} minutes left`; | |
| } | |
| setStatus( | |
| `Downloading the model, first visit only: ${Math.round(loaded / 1e6)} / ` + | |
| `${Math.round(total / 1e6)} MB${left}. The browser keeps it: next visits start at once.`, | |
| ); | |
| }; | |
| setStatus("Loading the model…"); | |
| const tokenizer = await AutoTokenizer.from_pretrained(MODEL, { | |
| revision: MODEL_REVISION, | |
| progress_callback: progress, | |
| }); | |
| const model = await AutoModelForTokenClassification.from_pretrained(MODEL, { | |
| revision: MODEL_REVISION, | |
| dtype: "fp32", // int8 destroys this model's precision (docs/TRAINING.md) | |
| device: "wasm", | |
| progress_callback: progress, | |
| }); | |
| const labels = model.config.id2label; | |
| if (TAGS.some((tag, i) => labels[i] !== tag)) throw new Error("unexpected model labels"); | |
| const encode = (text) => { | |
| if (tokenizer._tokenizer?.encode) { | |
| const { ids, tokens } = tokenizer._tokenizer.encode(text, { add_special_tokens: false }); | |
| return { ids, tokens }; | |
| } | |
| const tokens = tokenizer.tokenize(text); | |
| return { ids: tokenizer.convert_tokens_to_ids(tokens), tokens }; | |
| }; | |
| const predict = async (ids) => { | |
| const shape = [1, ids.length]; | |
| const inputIds = new Tensor("int64", BigInt64Array.from(ids, BigInt), shape); | |
| const mask = new Tensor("int64", new BigInt64Array(ids.length).fill(1n), shape); | |
| const { logits } = await model({ input_ids: inputIds, attention_mask: mask }); | |
| const [, length, classes] = logits.dims; | |
| const data = logits.data; | |
| const tags = []; | |
| for (let t = 0; t < length; t++) { | |
| let best = 0; | |
| for (let c = 1; c < classes; c++) { | |
| if (data[t * classes + c] > data[t * classes + best]) best = c; | |
| } | |
| tags.push(best); | |
| } | |
| return tags; | |
| }; | |
| detector = { | |
| tokenize: encode, | |
| predict, | |
| bos: tokenizer.convert_tokens_to_ids("<bos>"), | |
| eos: tokenizer.convert_tokens_to_ids("<eos>"), | |
| }; | |
| show($("progress"), false); | |
| setStatus("Ready. Detector: rules + mmBERT-small, running in this browser."); | |
| $("mask").disabled = false; | |
| } | |
| function highlight(text, entities) { | |
| const box = $("highlighted"); | |
| box.replaceChildren(); | |
| let cursor = 0; | |
| for (const entity of entities) { | |
| box.append(document.createTextNode(text.slice(cursor, entity.start))); | |
| const mark = document.createElement("mark"); | |
| mark.className = `t-${entity.type}`; | |
| mark.title = `${LABELS[entity.type] || entity.type} → ${entity.alias}`; | |
| mark.append(document.createTextNode(text.slice(entity.start, entity.end))); | |
| const tag = document.createElement("span"); | |
| tag.className = "tag"; | |
| tag.textContent = entity.alias; | |
| mark.append(tag); | |
| box.append(mark); | |
| cursor = entity.end; | |
| } | |
| box.append(document.createTextNode(text.slice(cursor))); | |
| } | |
| function legend(report) { | |
| const box = $("legend"); | |
| box.replaceChildren(); | |
| for (const type of Object.keys(LABELS)) { | |
| if (!report[type]) continue; | |
| const chip = document.createElement("span"); | |
| chip.className = `t-${type}`; | |
| chip.textContent = LABELS[type]; | |
| box.append(chip); | |
| } | |
| } | |
| function table(report) { | |
| const body = $("report").querySelector("tbody"); | |
| body.replaceChildren(); | |
| const rows = Object.keys(LABELS).filter((type) => report[type]); | |
| rows.push("total"); | |
| for (const type of rows) { | |
| const row = document.createElement("tr"); | |
| const name = document.createElement("td"); | |
| name.textContent = type === "total" ? "Total" : LABELS[type]; | |
| const count = document.createElement("td"); | |
| count.textContent = String(report[type] || 0); | |
| row.append(name, count); | |
| body.append(row); | |
| } | |
| } | |
| async function maskText() { | |
| const text = $("input").value; | |
| setError(""); | |
| if (!detector) return; | |
| if (!text.trim()) { | |
| setError("Paste a text first."); | |
| return; | |
| } | |
| const button = $("mask"); | |
| button.disabled = true; | |
| button.textContent = "Masking…"; | |
| await new Promise((resolve) => setTimeout(resolve, 20)); // let the button repaint | |
| try { | |
| const started = performance.now(); | |
| const result = mask(text, await detect(text, detector)); | |
| last = result; | |
| highlight(text, result.entities); | |
| legend(result.report); | |
| $("masked").value = result.masked; | |
| table(result.report); | |
| $("copied").textContent = ""; | |
| show($("results"), true); | |
| // A new masked text: the previous answer and its restored version no longer apply. | |
| $("answer").value = ""; | |
| $("restored").value = ""; | |
| $("restore-report").textContent = ""; | |
| show($("restored"), false); | |
| show($("copy-restored"), false); | |
| show($("back"), true); | |
| // The example answer only fits the example, and only if every alias it uses was given. | |
| const fits = [...EXAMPLE_ANSWER.matchAll(/\[[A-Z_]+_\d+\]/g)].every((m) => | |
| result.aliases.includes(m[0]), | |
| ); | |
| show($("example-answer"), text === EXAMPLE && fits); | |
| setStatus(`Masked in ${((performance.now() - started) / 1000).toFixed(1)} s, in this browser.`); | |
| } catch (error) { | |
| console.error(error); | |
| setError("The detector failed on this text."); | |
| } finally { | |
| button.disabled = false; | |
| button.textContent = "Mask"; | |
| } | |
| } | |
| function restoreAnswer() { | |
| if (!last) return; | |
| const answer = $("answer").value; | |
| const report = $("restore-report"); | |
| if (!answer.trim()) { | |
| report.textContent = "Paste the LLM's answer first."; | |
| return; | |
| } | |
| const back = restore(answer, last); | |
| $("restored").value = back.text; | |
| show($("restored"), true); | |
| show($("copy-restored"), true); | |
| $("copied-restored").textContent = ""; | |
| const parts = [`${back.restored.length} alias(es) replaced by their value.`]; | |
| if (back.missing.length) parts.push(`Not in the answer: ${back.missing.join(", ")}.`); | |
| if (back.unknown.length) { | |
| parts.push(`Left as they are (not from this text): ${back.unknown.join(", ")}.`); | |
| } | |
| report.textContent = parts.join(" "); | |
| } | |
| async function copyRestored() { | |
| let copied = true; | |
| try { | |
| await navigator.clipboard.writeText($("restored").value); | |
| } catch { | |
| $("restored").select(); | |
| copied = document.execCommand("copy"); | |
| } | |
| $("copied-restored").textContent = copied ? "Copied." : "Not copied: select the text and copy it."; | |
| } | |
| async function copyMasked() { | |
| let copied = true; | |
| try { | |
| await navigator.clipboard.writeText($("masked").value); | |
| } catch { | |
| $("masked").select(); | |
| copied = document.execCommand("copy"); | |
| } | |
| $("copied").textContent = copied ? "Copied." : "Not copied: select the text and copy it."; | |
| } | |
| function downloadReport() { | |
| if (!last) return; | |
| // Types, positions (UTF-16 units), aliases and counts: never the values. | |
| const report = { | |
| generated: new Date().toISOString(), | |
| counts: last.report, | |
| entities: last.entities, | |
| }; | |
| const blob = new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }); | |
| const link = document.createElement("a"); | |
| link.href = URL.createObjectURL(blob); | |
| link.download = "alias-report.json"; | |
| link.click(); | |
| setTimeout(() => URL.revokeObjectURL(link.href), 1000); | |
| } | |
| function updateCount() { | |
| const n = $("input").value.length; | |
| $("count").textContent = n ? `${n.toLocaleString()} characters` : ""; | |
| } | |
| $("version").textContent = `Alias v${VERSION}, model ${MODEL_REVISION}.`; | |
| $("mask").addEventListener("click", maskText); | |
| $("example").addEventListener("click", () => { | |
| $("input").value = EXAMPLE; | |
| updateCount(); | |
| }); | |
| $("input").addEventListener("input", updateCount); | |
| $("input").addEventListener("keydown", (event) => { | |
| if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) maskText(); | |
| }); | |
| $("copy").addEventListener("click", copyMasked); | |
| $("download").addEventListener("click", downloadReport); | |
| $("restore").addEventListener("click", restoreAnswer); | |
| $("example-answer").addEventListener("click", () => { | |
| $("answer").value = EXAMPLE_ANSWER; | |
| }); | |
| $("copy-restored").addEventListener("click", copyRestored); | |
| load().catch((error) => { | |
| console.error(error); | |
| show($("progress"), false); | |
| setStatus("The model could not be loaded in this browser. Try a recent Chrome, Edge or Firefox."); | |
| $("status").classList.add("warn"); | |
| }); | |