Files changed (4) hide show
  1. .dockerignore +0 -6
  2. README.md +21 -12
  3. server.py +28 -44
  4. static/index.html +388 -382
.dockerignore DELETED
@@ -1,6 +0,0 @@
1
- .git
2
- .gitattributes
3
- __pycache__/
4
- *.pyc
5
- .venv/
6
- *.md
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,22 @@
1
- ---
2
  title: English spellchecker
3
- emoji: ✍️
4
- colorFrom: purple
5
- colorTo: gray
6
- sdk: docker
7
- app_port: 7860
8
- header: mini
9
- pinned: false
10
- private: true
11
- short_description: Fix typos with a tiny local model on CPU
12
- ---
13
-
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
  title: English spellchecker
3
+ emoji: ✍️
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ header: mini
9
+ pinned: false
10
+ private: true
11
+ ---
12
+
13
+ # English spellchecker
14
+
15
+ A focused grammar and spelling correction demo powered by [`LiquidAI/LFM2.5-Spellchecker-350M`](https://huggingface.co/LiquidAI/LFM2.5-Spellchecker-350M).
16
+
17
+ The FastAPI backend loads the private model on CPU and exposes word-level corrections to the custom frontend. The interface checks text automatically, visualizes edits in context, provides confidence and iteration controls, and includes concise examples.
18
+
19
+ - `server.py` — model loading, correction API, health endpoint, and static serving
20
+ - `static/index.html` — application structure and interaction logic
21
+ - `static/style.css` — shared LiquidAI demo design and animations
22
+ - `Dockerfile` and `requirements.txt` — reproducible CPU runtime
server.py CHANGED
@@ -1,5 +1,12 @@
1
  """FastAPI backend for the LFM2.5 Spellchecker demo (Docker Space).
2
 
 
 
 
 
 
 
 
3
  uvicorn server:app --host 0.0.0.0 --port 7860
4
  """
5
  import difflib
@@ -13,43 +20,17 @@ from fastapi.staticfiles import StaticFiles
13
  from pydantic import BaseModel
14
  from transformers import AutoModel
15
 
16
-
17
- def _effective_cpus():
18
- """Cores actually granted to this container (cgroup quota), not the host core count.
19
- os.cpu_count() returns the host's cores, so torch would oversubscribe the 2-vCPU Space."""
20
- try: # cgroup v2
21
- raw = open("/sys/fs/cgroup/cpu.max").read().split()
22
- if raw and raw[0] != "max":
23
- return max(1, round(int(raw[0]) / int(raw[1])))
24
- except (OSError, ValueError):
25
- pass
26
- try: # cgroup v1
27
- q = int(open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read())
28
- p = int(open("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read())
29
- if q > 0:
30
- return max(1, q // p)
31
- except (OSError, ValueError):
32
- pass
33
- try:
34
- return max(1, len(os.sched_getaffinity(0)))
35
- except AttributeError:
36
- return os.cpu_count() or 1
37
-
38
-
39
- _CPUS = _effective_cpus()
40
- torch.set_num_threads(_CPUS)
41
- try:
42
- torch.set_num_interop_threads(1)
43
- except RuntimeError:
44
- pass
45
-
46
- MODEL_ID = os.environ.get("SPELLCHECKER_MODEL", "LiquidAI/LFM2.5-Encoder-350M-Spellchecker")
47
- MODEL_REV = os.environ.get("SPELLCHECKER_REVISION", "main")
48
  STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
49
 
50
- print(f"[server] loading {MODEL_ID}@{MODEL_REV} on {_CPUS} CPU thread(s) ...", flush=True)
 
 
51
  _model = AutoModel.from_pretrained(MODEL_ID, revision=MODEL_REV, trust_remote_code=True,
52
- token=os.environ.get("HF_TOKEN")).float().eval()
53
  _mem_bytes = sum(t.numel() * t.element_size() for t in (*_model.parameters(), *_model.buffers()))
54
  _mem_human = (f"{_mem_bytes / 1024**3:.2f} GB" if _mem_bytes >= 1024**3
55
  else f"{_mem_bytes / 1024**2:.0f} MB")
@@ -66,9 +47,9 @@ _ATTACH_LEFT = re.compile(r'\s+([.,!?;:%)\]}»…])')
66
  _ATTACH_RIGHT = re.compile(r'([(\[{«])\s+')
67
  _NT = re.compile(r"(\w+?)(n['’]t)\b", re.I) # don't->do n't, can't->ca n't
68
  _CONTR = re.compile(r"(\w)(['’](?:s|re|ve|ll|d|m))\b", re.I) # let's->let 's, I'm->I 'm
69
- _REJOIN_NT = re.compile(r"\s+(n['’]t)\b", re.I) # do n't->don't
70
- _REJOIN_CONTR = re.compile(r"\s+(['’](?:s|re|ve|ll|d|m))\b", re.I) # let 's->let's
71
- _REJOIN_SPLIT_APOSTROPHE = re.compile(r"(?<=\w)\s+(['’])\s+(?=(?:s|t|d|m|re|ve|ll)\b)", re.I)
72
 
73
 
74
  def tokenize(text: str) -> str:
@@ -78,15 +59,18 @@ def tokenize(text: str) -> str:
78
  return re.sub(r"\s+", " ", text).strip()
79
 
80
 
81
- def detok(text: str) -> str:
82
- text = _ATTACH_LEFT.sub(r"\1", text)
83
- text = _ATTACH_RIGHT.sub(r"\1", text)
84
- text = _REJOIN_NT.sub(r"\1", text) # do n't->don't
85
- text = _REJOIN_CONTR.sub(r"\1", text) # let 's->let's
86
- text = _REJOIN_SPLIT_APOSTROPHE.sub(r"\1", text) # don ' t->don't
87
- return re.sub(r"\s+", " ", text).strip()
88
 
89
 
 
 
 
90
  _PROBE_IN = "That's a fair point, let's discuss it tomorrow."
91
  try:
92
  _PROBE_OUT = detok(_model.correct([tokenize(_PROBE_IN)], max_iter=3)[0])
 
1
  """FastAPI backend for the LFM2.5 Spellchecker demo (Docker Space).
2
 
3
+ Loads the published model from the Hub (pinned to `main`, so the Space always serves the current best
4
+ checkpoint), exposes POST /api/correct, and serves the static frontend in static/. No Gradio.
5
+
6
+ The model repo is private, so HF_TOKEN (a Space secret) is needed to pull it. Pinned library versions
7
+ (see requirements.txt) match the environment the model was validated against — the encoder's custom
8
+ bidirectional-mask code is sensitive to the transformers version.
9
+
10
  uvicorn server:app --host 0.0.0.0 --port 7860
11
  """
12
  import difflib
 
20
  from pydantic import BaseModel
21
  from transformers import AutoModel
22
 
23
+ MODEL_ID = os.environ.get("SPELLCHECKER_MODEL", "LiquidAI/LFM2.5-Spellchecker-350M")
24
+ # Pin to the EXACT published commit so the container can never serve stale cached weights/remote-code
25
+ # (the bug we hit: a rebuild kept serving old, tagger-only behaviour). Bump on each publish, or override.
26
+ MODEL_REV = os.environ.get("SPELLCHECKER_REVISION", "65a4a90af31205d2f7ef66b6a68d7b3d276adfdd")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
28
 
29
+ print(f"[server] loading {MODEL_ID}@{MODEL_REV} ...", flush=True)
30
+ # fp16 (half the memory; the published weights are fp16). Casting the whole model uniformly avoids the
31
+ # mixed-dtype error — torch 2.12 runs fp16 matmul on CPU fine.
32
  _model = AutoModel.from_pretrained(MODEL_ID, revision=MODEL_REV, trust_remote_code=True,
33
+ token=os.environ.get("HF_TOKEN")).half().eval()
34
  _mem_bytes = sum(t.numel() * t.element_size() for t in (*_model.parameters(), *_model.buffers()))
35
  _mem_human = (f"{_mem_bytes / 1024**3:.2f} GB" if _mem_bytes >= 1024**3
36
  else f"{_mem_bytes / 1024**2:.0f} MB")
 
47
  _ATTACH_RIGHT = re.compile(r'([(\[{«])\s+')
48
  _NT = re.compile(r"(\w+?)(n['’]t)\b", re.I) # don't->do n't, can't->ca n't
49
  _CONTR = re.compile(r"(\w)(['’](?:s|re|ve|ll|d|m))\b", re.I) # let's->let 's, I'm->I 'm
50
+ _REJOIN_NT = re.compile(r"\s+(n['’]t)\b", re.I) # do n't->don't
51
+ _REJOIN_CONTR = re.compile(r"\s+(['’](?:s|re|ve|ll|d|m))\b", re.I) # let 's->let's
52
+ _REJOIN_SPLIT_APOSTROPHE = re.compile(r"(?<=\w)\s+(['’])\s+(?=(?:s|t|d|m|re|ve|ll)\b)", re.I)
53
 
54
 
55
  def tokenize(text: str) -> str:
 
59
  return re.sub(r"\s+", " ", text).strip()
60
 
61
 
62
+ def detok(text: str) -> str:
63
+ text = _ATTACH_LEFT.sub(r"\1", text)
64
+ text = _ATTACH_RIGHT.sub(r"\1", text)
65
+ text = _REJOIN_NT.sub(r"\1", text) # do n't->don't
66
+ text = _REJOIN_CONTR.sub(r"\1", text) # let 's->let's
67
+ text = _REJOIN_SPLIT_APOSTROPHE.sub(r"\1", text) # don ' t->don't
68
+ return re.sub(r"\s+", " ", text).strip()
69
 
70
 
71
+ # Startup self-test over the REAL user path (tokenize -> correct -> detok), logged + exposed at
72
+ # /api/health: a correctly-deployed full system leaves this clean sentence UNCHANGED. If it changes,
73
+ # the deploy is wrong (stale model, reranker inactive, or contraction handling broken) — no guessing.
74
  _PROBE_IN = "That's a fair point, let's discuss it tomorrow."
75
  try:
76
  _PROBE_OUT = detok(_model.correct([tokenize(_PROBE_IN)], max_iter=3)[0])
static/index.html CHANGED
@@ -1,382 +1,388 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>English spellchecker</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <link rel="stylesheet" href="style.css">
11
- </head>
12
- <body>
13
- <div class="wrap">
14
- <header class="site-intro">
15
- <h1>English <em>spellchecker</em></h1>
16
- <p class="sub">Correct English spelling and grammar with LFM2.5.</p>
17
- </header>
18
-
19
- <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
20
- <span class="model-logo-frame" aria-hidden="true">
21
- <img id="model-logo" class="model-logo" width="56" height="59" alt="" src="lliquid.gif">
22
- </span>
23
- <div class="model-copy">
24
- <div class="model-name-row">
25
- <a href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-Spellchecker" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
26
- <span class="model-variant">350M parameters</span>
27
- </div>
28
- </div>
29
- <div class="load-block">
30
- <div class="load-meta">
31
- <span id="status" role="status" aria-live="polite">Loading model…</span>
32
- <span id="load-percent">0%</span>
33
- </div>
34
- <div class="load-track" role="progressbar" aria-label="Model loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="load-progress"></span></div>
35
- </div>
36
- </section>
37
-
38
- <main>
39
- <section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
40
- <article class="workspace-card input-panel">
41
- <div class="panel-header">
42
- <h2>Input</h2>
43
- </div>
44
-
45
- <div class="editor-stage input-stage">
46
- <label class="sr-only" for="input">Text to check</label>
47
- <textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
48
- <div class="editor-scan" aria-hidden="true"></div>
49
- </div>
50
-
51
- <div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
52
-
53
- <details class="settings-menu">
54
- <summary>Advanced settings</summary>
55
- <div class="settings-reveal">
56
- <div class="settings-grid" aria-label="Correction settings">
57
- <label class="setting-card" for="mep">
58
- <span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
59
- <input type="range" id="mep" min="0" max="1" step="0.05" value="0">
60
- <span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
61
- </label>
62
- <label class="setting-card" for="mit">
63
- <span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
64
- <input type="range" id="mit" min="1" max="5" step="1" value="3">
65
- <span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
66
- </label>
67
- </div>
68
- </div>
69
- </details>
70
- </article>
71
-
72
- <article class="workspace-card output-panel">
73
- <div class="panel-header output-heading">
74
- <h2>Corrected</h2>
75
- <button type="button" class="copy-button" id="copy" disabled>Copy</button>
76
- </div>
77
-
78
- <div class="editor-stage output-stage" id="output-stage">
79
- <div id="output" class="output-text empty" aria-live="polite" data-placeholder="Your corrected text will appear here."></div>
80
- <div class="editor-scan" aria-hidden="true"></div>
81
- </div>
82
-
83
- <div class="legend" aria-label="Correction legend">
84
- <span><i class="legend-swatch modified"></i>Modified</span>
85
- <span><i class="legend-swatch removed"></i>Removed</span>
86
- </div>
87
-
88
- <div class="stats-grid" id="stats" aria-live="polite">
89
- <div class="result-slot"><div class="activity-pill is-idle" id="activity"><span class="activity-dot" aria-hidden="true"></span><span id="activity-label">Ready</span></div></div>
90
- <div class="stat-card"><span>Edits</span><strong id="stat-edits">—</strong></div>
91
- <div class="stat-card"><span>Latency</span><strong id="stat-latency">—</strong></div>
92
- <div class="stat-card"><span>Characters</span><strong id="stat-characters">—</strong></div>
93
- </div>
94
- </article>
95
- </section>
96
-
97
- <section class="examples-panel" aria-label="Examples">
98
- <span class="examples-label">Examples</span>
99
- <div class="example-list" id="examples"></div>
100
- </section>
101
- </main>
102
- </div>
103
-
104
- <script>
105
- {
106
- const settingsMenu = document.querySelector(".settings-menu");
107
- const settingsSummary = settingsMenu.querySelector("summary");
108
- const settingsReveal = settingsMenu.querySelector(".settings-reveal");
109
- settingsSummary.setAttribute("aria-expanded", "false");
110
-
111
- settingsSummary.addEventListener("click", async (event) => {
112
- event.preventDefault();
113
- if (settingsMenu.dataset.animating === "true") return;
114
-
115
- const opening = !settingsMenu.open;
116
- const duration = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
117
- if (!settingsReveal.animate || duration === 0) {
118
- settingsMenu.open = opening;
119
- settingsSummary.setAttribute("aria-expanded", String(opening));
120
- return;
121
- }
122
-
123
- settingsMenu.dataset.animating = "true";
124
- if (opening) settingsMenu.open = true;
125
- settingsSummary.setAttribute("aria-expanded", String(opening));
126
-
127
- const expandedHeight = settingsReveal.scrollHeight;
128
- const animation = settingsReveal.animate(
129
- opening
130
- ? [
131
- { height: "0px", opacity: 0, transform: "translateY(-6px)" },
132
- { height: `${expandedHeight}px`, opacity: 1, transform: "translateY(0)" },
133
- ]
134
- : [
135
- { height: `${settingsReveal.getBoundingClientRect().height}px`, opacity: 1, transform: "translateY(0)" },
136
- { height: "0px", opacity: 0, transform: "translateY(-6px)" },
137
- ],
138
- { duration, easing: "cubic-bezier(.2,.8,.2,1)" },
139
- );
140
-
141
- try { await animation.finished; } catch (_) {}
142
- if (!opening) settingsMenu.open = false;
143
- delete settingsMenu.dataset.animating;
144
- });
145
- }
146
- </script>
147
-
148
- <script>
149
- const EXAMPLES = [
150
- { label:"Agreement", text:"Their are many reason to study hard." },
151
- { label:"Verb forms", text:"I has went to the stor yesterday." },
152
- { label:"Pronouns", text:"Him and me was late for the meetting." },
153
- { label:"Repetition", text:"i want to go home home." },
154
- { label:"Comparison", text:"He is more taller than his brother." },
155
- { label:"Articles", text:"Can you give me a advice?" },
156
- { label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
157
- ];
158
-
159
- const $ = id => document.getElementById(id);
160
- // Private Space auth: HF signs the embedded iframe URL with ?__sign=<token>.
161
- // Incognito / Safari block the third-party .hf.space cookie, so carry that token
162
- // on same-origin API requests, else /api/* returns 404.
163
- const __sign = new URLSearchParams(location.search).get("__sign");
164
- const signedUrl = path => {
165
- const url = new URL(path, location.href);
166
- if (__sign) url.searchParams.set("__sign", __sign);
167
- return url;
168
- };
169
- const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
170
- const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
171
- const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
172
- const APOSTROPHE = /^['’]$/;
173
- const CONTRACTION_TAIL = /^(?:s|t|d|m|re|ve|ll)$/i;
174
- const OPEN = /^[(\[{«¿¡]+$/;
175
- const DEBOUNCE = 650;
176
- let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
177
-
178
- function setModelState(kind, status, percent) {
179
- const strip = $("model-strip");
180
- strip.className = `model-strip is-${kind}`;
181
- const value = kind === "ready" ? 100 : kind === "error" ? 100 : Math.max(0, Math.min(100, Number(percent) || 0));
182
- $("status").textContent = status;
183
- $("load-percent").textContent = kind === "error" ? "Error" : `${Math.round(value)}%`;
184
- $("load-progress").style.width = `${value}%`;
185
- strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", String(Math.round(value)));
186
- }
187
-
188
- function setActivity(kind, label) {
189
- $("activity").className = `activity-pill is-${kind}`;
190
- $("activity-label").textContent = label;
191
- }
192
-
193
- function setRangeFill(range) {
194
- const pct = (range.value - range.min) / (range.max - range.min) * 100;
195
- range.style.setProperty("--pct", `${pct}%`);
196
- }
197
-
198
- function updateCount() {
199
- const count = input.value.length;
200
- $("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
201
- }
202
-
203
- function clearActiveExample() {
204
- if (activeExample) activeExample.classList.remove("active");
205
- activeExample = null;
206
- }
207
-
208
- function schedule(delay = DEBOUNCE) {
209
- clearTimeout(timer);
210
- timer = setTimeout(correct, delay);
211
- }
212
-
213
- function attachLeft(token) {
214
- return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
215
- }
216
-
217
- function needsSpace(previous, current, next) {
218
- if (previous === null) return false;
219
- const startsSplitContraction = APOSTROPHE.test(current) && CONTRACTION_TAIL.test(next || "");
220
- const finishesSplitContraction = APOSTROPHE.test(previous) && CONTRACTION_TAIL.test(current);
221
- return !attachLeft(current) && !startsSplitContraction && !finishesSplitContraction && !OPEN.test(previous);
222
- }
223
-
224
- function renderOutput(segments) {
225
- const tokens = [];
226
- for (const segment of segments) {
227
- for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
228
- }
229
- output.innerHTML = "";
230
- output.classList.toggle("empty", tokens.length === 0);
231
- let previous = null, editIndex = 0;
232
- for (let index = 0; index < tokens.length; index++) {
233
- const token = tokens[index];
234
- if (needsSpace(previous, token.text, tokens[index + 1]?.text)) output.append(document.createTextNode(" "));
235
- let node;
236
- if (token.kind === "edit") {
237
- node = document.createElement("mark");
238
- node.style.setProperty("--edit-index", editIndex++);
239
- node.textContent = token.text;
240
- } else if (token.kind === "del") {
241
- node = document.createElement("span");
242
- node.className = "deleted";
243
- node.style.setProperty("--edit-index", editIndex++);
244
- node.textContent = token.text;
245
- } else {
246
- node = document.createTextNode(token.text);
247
- }
248
- output.append(node);
249
- previous = token.text;
250
- }
251
- }
252
-
253
- function countEdits(segments) {
254
- let edits = 0, inEdit = false;
255
- for (const segment of segments) {
256
- if (segment.kind === "keep") inEdit = false;
257
- else if (!inEdit) { edits++; inEdit = true; }
258
- }
259
- return edits;
260
- }
261
-
262
- function resetResult() {
263
- requestSequence++;
264
- lastCorrected = "";
265
- renderOutput([]);
266
- copy.disabled = true;
267
- setActivity("idle", "Ready");
268
- workspace.classList.remove("is-checking", "is-complete");
269
- $("stats").classList.remove("has-result");
270
- $("stat-edits").textContent = "";
271
- $("stat-latency").textContent = "—";
272
- $("stat-characters").textContent = "—";
273
- }
274
-
275
- async function correct() {
276
- const text = input.value.replace(/\s+/g, " ").trim();
277
- if (!text) { resetResult(); return; }
278
- const sequence = ++requestSequence;
279
- const started = performance.now();
280
- workspace.classList.remove("is-complete");
281
- workspace.classList.add("is-checking");
282
- setActivity("running", "Checking…");
283
-
284
- try {
285
- const response = await fetch(signedUrl("/api/correct"), {
286
- method:"POST",
287
- headers:{ "Content-Type":"application/json" },
288
- body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
289
- });
290
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
291
- const data = await response.json();
292
- if (sequence !== requestSequence) return;
293
-
294
- const latency = performance.now() - started;
295
- const edits = countEdits(data.segments);
296
- lastCorrected = data.corrected;
297
- renderOutput(data.segments);
298
- copy.disabled = !lastCorrected;
299
- workspace.classList.remove("is-checking");
300
- void workspace.offsetWidth;
301
- workspace.classList.add("is-complete");
302
- setActivity("done", data.changed ? "Corrected" : "Looks good");
303
-
304
- const stats = $("stats");
305
- stats.classList.remove("has-result");
306
- void stats.offsetWidth;
307
- stats.classList.add("has-result");
308
- $("stat-edits").textContent = String(edits);
309
- $("stat-latency").textContent = `${Math.round(latency)} ms`;
310
- $("stat-characters").textContent = String(text.length);
311
- setTimeout(() => workspace.classList.remove("is-complete"), 1000);
312
- } catch (error) {
313
- if (sequence !== requestSequence) return;
314
- workspace.classList.remove("is-checking");
315
- setActivity("error", "Check failed");
316
- console.error(error);
317
- }
318
- }
319
-
320
- input.addEventListener("input", () => {
321
- updateCount();
322
- clearActiveExample();
323
- schedule();
324
- });
325
-
326
- for (const range of [$("mep"), $("mit")]) {
327
- setRangeFill(range);
328
- range.addEventListener("input", event => {
329
- setRangeFill(event.target);
330
- $(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
331
- schedule(0);
332
- });
333
- }
334
-
335
- for (const example of EXAMPLES) {
336
- const button = document.createElement("button");
337
- button.type = "button";
338
- button.className = "ex";
339
- button.textContent = example.label;
340
- button.dataset.text = example.text;
341
- button.addEventListener("click", () => {
342
- clearActiveExample();
343
- button.classList.add("active");
344
- activeExample = button;
345
- input.value = example.text;
346
- updateCount();
347
- input.focus();
348
- schedule(0);
349
- });
350
- $("examples").append(button);
351
- }
352
-
353
- copy.addEventListener("click", async () => {
354
- if (!lastCorrected) return;
355
- await navigator.clipboard.writeText(lastCorrected);
356
- copy.textContent = "Copied";
357
- copy.classList.remove("copied");
358
- void copy.offsetWidth;
359
- copy.classList.add("copied");
360
- clearTimeout(copy._timer);
361
- copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
362
- });
363
-
364
- fetch(signedUrl("/api/health"))
365
- .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
366
- .then(data => {
367
- setModelState("ready", "Model ready", 100);
368
- $("status").title = data.mem_human ? `${data.mem_human} in memory` : "";
369
- })
370
- .catch(() => setModelState("error", "Model unavailable", 100));
371
-
372
- (function init() {
373
- const first = $("examples").querySelector(".ex");
374
- first.classList.add("active");
375
- activeExample = first;
376
- input.value = first.dataset.text;
377
- updateCount();
378
- schedule(0);
379
- })();
380
- </script>
381
- </body>
382
- </html>
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>English spellchecker</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <script>
11
+ (() => {
12
+ const sign = new URLSearchParams(location.search).get("__sign");
13
+ const suffix = sign ? `?__sign=${encodeURIComponent(sign)}` : "";
14
+ window.__STATIC_ASSET_SUFFIX = suffix;
15
+ document.write(`<link rel="stylesheet" href="./style.css${suffix}">`);
16
+ })();
17
+ </script>
18
+ </head>
19
+ <body>
20
+ <div class="wrap">
21
+ <header class="site-intro">
22
+ <h1>English <em>spellchecker</em></h1>
23
+ <p class="sub">Correct English spelling and grammar privately with LFM2.5.</p>
24
+ </header>
25
+
26
+ <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
27
+ <span class="model-logo-frame" aria-hidden="true">
28
+ <img id="model-logo" class="model-logo" width="56" height="59" alt="">
29
+ </span>
30
+ <div class="model-copy">
31
+ <div class="model-name-row">
32
+ <a href="https://huggingface.co/LiquidAI/LFM2.5-Spellchecker-350M" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
33
+ <span class="model-variant">350M parameters</span>
34
+ </div>
35
+ </div>
36
+ <div class="load-block">
37
+ <div class="load-meta">
38
+ <span id="status" role="status" aria-live="polite">Loading model…</span>
39
+ <span id="load-percent">0%</span>
40
+ </div>
41
+ <div class="load-track" role="progressbar" aria-label="Model loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="load-progress"></span></div>
42
+ </div>
43
+ <span id="gpu" hidden></span>
44
+ </section>
45
+ <script>document.getElementById("model-logo").src = `./lliquid.gif${window.__STATIC_ASSET_SUFFIX || ""}`;</script>
46
+
47
+ <main>
48
+ <section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
49
+ <article class="workspace-card input-panel">
50
+ <div class="panel-header">
51
+ <h2>Input</h2>
52
+ </div>
53
+
54
+ <div class="editor-stage input-stage">
55
+ <label class="sr-only" for="input">Text to check</label>
56
+ <textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
57
+ <div class="editor-scan" aria-hidden="true"></div>
58
+ </div>
59
+
60
+ <div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
61
+
62
+ <details class="settings-menu">
63
+ <summary>Advanced settings</summary>
64
+ <div class="settings-reveal">
65
+ <div class="settings-grid" aria-label="Correction settings">
66
+ <label class="setting-card" for="mep">
67
+ <span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
68
+ <input type="range" id="mep" min="0" max="1" step="0.05" value="0">
69
+ <span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
70
+ </label>
71
+ <label class="setting-card" for="mit">
72
+ <span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
73
+ <input type="range" id="mit" min="1" max="5" step="1" value="3">
74
+ <span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
75
+ </label>
76
+ </div>
77
+ </div>
78
+ </details>
79
+ </article>
80
+
81
+ <article class="workspace-card output-panel">
82
+ <div class="panel-header output-heading">
83
+ <h2>Corrected</h2>
84
+ <button type="button" class="copy-button" id="copy" disabled>Copy</button>
85
+ </div>
86
+
87
+ <div class="editor-stage output-stage" id="output-stage">
88
+ <div id="output" class="output-text empty" aria-live="polite" data-placeholder="Your corrected text will appear here."></div>
89
+ <div class="editor-scan" aria-hidden="true"></div>
90
+ </div>
91
+
92
+ <div class="legend" aria-label="Correction legend">
93
+ <span><i class="legend-swatch modified"></i>Modified</span>
94
+ <span><i class="legend-swatch removed"></i>Removed</span>
95
+ </div>
96
+
97
+ <div class="stats-grid" id="stats" aria-live="polite">
98
+ <div class="result-slot"><div class="activity-pill is-idle" id="activity"><span class="activity-dot" aria-hidden="true"></span><span id="activity-label">Ready</span></div></div>
99
+ <div class="stat-card"><span>Edits</span><strong id="stat-edits"></strong></div>
100
+ <div class="stat-card"><span>Latency</span><strong id="stat-latency">—</strong></div>
101
+ <div class="stat-card"><span>Characters</span><strong id="stat-characters">—</strong></div>
102
+ </div>
103
+ </article>
104
+ </section>
105
+
106
+ <section class="examples-panel" aria-label="Examples">
107
+ <span class="examples-label">Examples</span>
108
+ <div class="example-list" id="examples"></div>
109
+ </section>
110
+ </main>
111
+ </div>
112
+
113
+ <script>
114
+ {
115
+ const settingsMenu = document.querySelector(".settings-menu");
116
+ const settingsSummary = settingsMenu.querySelector("summary");
117
+ const settingsReveal = settingsMenu.querySelector(".settings-reveal");
118
+ settingsSummary.setAttribute("aria-expanded", "false");
119
+
120
+ settingsSummary.addEventListener("click", async (event) => {
121
+ event.preventDefault();
122
+ if (settingsMenu.dataset.animating === "true") return;
123
+
124
+ const opening = !settingsMenu.open;
125
+ const duration = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
126
+ if (!settingsReveal.animate || duration === 0) {
127
+ settingsMenu.open = opening;
128
+ settingsSummary.setAttribute("aria-expanded", String(opening));
129
+ return;
130
+ }
131
+
132
+ settingsMenu.dataset.animating = "true";
133
+ if (opening) settingsMenu.open = true;
134
+ settingsSummary.setAttribute("aria-expanded", String(opening));
135
+
136
+ const expandedHeight = settingsReveal.scrollHeight;
137
+ const animation = settingsReveal.animate(
138
+ opening
139
+ ? [
140
+ { height: "0px", opacity: 0, transform: "translateY(-6px)" },
141
+ { height: `${expandedHeight}px`, opacity: 1, transform: "translateY(0)" },
142
+ ]
143
+ : [
144
+ { height: `${settingsReveal.getBoundingClientRect().height}px`, opacity: 1, transform: "translateY(0)" },
145
+ { height: "0px", opacity: 0, transform: "translateY(-6px)" },
146
+ ],
147
+ { duration, easing: "cubic-bezier(.2,.8,.2,1)" },
148
+ );
149
+
150
+ try { await animation.finished; } catch (_) {}
151
+ if (!opening) settingsMenu.open = false;
152
+ delete settingsMenu.dataset.animating;
153
+ });
154
+ }
155
+ </script>
156
+
157
+ <script>
158
+ const EXAMPLES = [
159
+ { label:"Agreement", text:"Their are many reason to study hard." },
160
+ { label:"Verb forms", text:"I has went to the stor yesterday." },
161
+ { label:"Pronouns", text:"Him and me was late for the meetting." },
162
+ { label:"Repetition", text:"i want to go home home." },
163
+ { label:"Comparison", text:"He is more taller than his brother." },
164
+ { label:"Articles", text:"Can you give me a advice?" },
165
+ { label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
166
+ ];
167
+
168
+ const $ = id => document.getElementById(id);
169
+ const sign = new URLSearchParams(location.search).get("__sign");
170
+ const signedUrl = path => {
171
+ const url = new URL(path, location.href);
172
+ if (sign) url.searchParams.set("__sign", sign);
173
+ return url;
174
+ };
175
+ const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
176
+ const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
177
+ const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
178
+ const APOSTROPHE = /^['’]$/;
179
+ const CONTRACTION_TAIL = /^(?:s|t|d|m|re|ve|ll)$/i;
180
+ const OPEN = /^[(\[{«¿¡]+$/;
181
+ const DEBOUNCE = 650;
182
+ let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
183
+
184
+ function setModelState(kind, status, percent) {
185
+ const strip = $("model-strip");
186
+ strip.className = `model-strip is-${kind}`;
187
+ const value = kind === "ready" ? 100 : kind === "error" ? 100 : Math.max(0, Math.min(100, Number(percent) || 0));
188
+ $("status").textContent = status;
189
+ $("load-percent").textContent = kind === "error" ? "Error" : `${Math.round(value)}%`;
190
+ $("load-progress").style.width = `${value}%`;
191
+ strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", String(Math.round(value)));
192
+ }
193
+
194
+ function setActivity(kind, label) {
195
+ $("activity").className = `activity-pill is-${kind}`;
196
+ $("activity-label").textContent = label;
197
+ }
198
+
199
+ function setRangeFill(range) {
200
+ const pct = (range.value - range.min) / (range.max - range.min) * 100;
201
+ range.style.setProperty("--pct", `${pct}%`);
202
+ }
203
+
204
+ function updateCount() {
205
+ const count = input.value.length;
206
+ $("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
207
+ }
208
+
209
+ function clearActiveExample() {
210
+ if (activeExample) activeExample.classList.remove("active");
211
+ activeExample = null;
212
+ }
213
+
214
+ function schedule(delay = DEBOUNCE) {
215
+ clearTimeout(timer);
216
+ timer = setTimeout(correct, delay);
217
+ }
218
+
219
+ function attachLeft(token) {
220
+ return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
221
+ }
222
+
223
+ function needsSpace(previous, current, next) {
224
+ if (previous === null) return false;
225
+ const startsSplitContraction = APOSTROPHE.test(current) && CONTRACTION_TAIL.test(next || "");
226
+ const finishesSplitContraction = APOSTROPHE.test(previous) && CONTRACTION_TAIL.test(current);
227
+ return !attachLeft(current) && !startsSplitContraction && !finishesSplitContraction && !OPEN.test(previous);
228
+ }
229
+
230
+ function renderOutput(segments) {
231
+ const tokens = [];
232
+ for (const segment of segments) {
233
+ for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
234
+ }
235
+ output.innerHTML = "";
236
+ output.classList.toggle("empty", tokens.length === 0);
237
+ let previous = null, editIndex = 0;
238
+ for (let index = 0; index < tokens.length; index++) {
239
+ const token = tokens[index];
240
+ if (needsSpace(previous, token.text, tokens[index + 1]?.text)) output.append(document.createTextNode(" "));
241
+ let node;
242
+ if (token.kind === "edit") {
243
+ node = document.createElement("mark");
244
+ node.style.setProperty("--edit-index", editIndex++);
245
+ node.textContent = token.text;
246
+ } else if (token.kind === "del") {
247
+ node = document.createElement("span");
248
+ node.className = "deleted";
249
+ node.style.setProperty("--edit-index", editIndex++);
250
+ node.textContent = token.text;
251
+ } else {
252
+ node = document.createTextNode(token.text);
253
+ }
254
+ output.append(node);
255
+ previous = token.text;
256
+ }
257
+ }
258
+
259
+ function countEdits(segments) {
260
+ let edits = 0, inEdit = false;
261
+ for (const segment of segments) {
262
+ if (segment.kind === "keep") inEdit = false;
263
+ else if (!inEdit) { edits++; inEdit = true; }
264
+ }
265
+ return edits;
266
+ }
267
+
268
+ function resetResult() {
269
+ requestSequence++;
270
+ lastCorrected = "";
271
+ renderOutput([]);
272
+ copy.disabled = true;
273
+ setActivity("idle", "Ready");
274
+ workspace.classList.remove("is-checking", "is-complete");
275
+ $("stats").classList.remove("has-result");
276
+ $("stat-edits").textContent = "—";
277
+ $("stat-latency").textContent = "—";
278
+ $("stat-characters").textContent = "—";
279
+ }
280
+
281
+ async function correct() {
282
+ const text = input.value.replace(/\s+/g, " ").trim();
283
+ if (!text) { resetResult(); return; }
284
+ const sequence = ++requestSequence;
285
+ const started = performance.now();
286
+ workspace.classList.remove("is-complete");
287
+ workspace.classList.add("is-checking");
288
+ setActivity("running", "Checking…");
289
+
290
+ try {
291
+ const response = await fetch(signedUrl("/api/correct"), {
292
+ method:"POST",
293
+ headers:{ "Content-Type":"application/json" },
294
+ body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
295
+ });
296
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
297
+ const data = await response.json();
298
+ if (sequence !== requestSequence) return;
299
+
300
+ const latency = performance.now() - started;
301
+ const edits = countEdits(data.segments);
302
+ lastCorrected = data.corrected;
303
+ renderOutput(data.segments);
304
+ copy.disabled = !lastCorrected;
305
+ workspace.classList.remove("is-checking");
306
+ void workspace.offsetWidth;
307
+ workspace.classList.add("is-complete");
308
+ setActivity("done", data.changed ? "Corrected" : "Looks good");
309
+
310
+ const stats = $("stats");
311
+ stats.classList.remove("has-result");
312
+ void stats.offsetWidth;
313
+ stats.classList.add("has-result");
314
+ $("stat-edits").textContent = String(edits);
315
+ $("stat-latency").textContent = `${Math.round(latency)} ms`;
316
+ $("stat-characters").textContent = String(text.length);
317
+ setTimeout(() => workspace.classList.remove("is-complete"), 1000);
318
+ } catch (error) {
319
+ if (sequence !== requestSequence) return;
320
+ workspace.classList.remove("is-checking");
321
+ setActivity("error", "Check failed");
322
+ console.error(error);
323
+ }
324
+ }
325
+
326
+ input.addEventListener("input", () => {
327
+ updateCount();
328
+ clearActiveExample();
329
+ schedule();
330
+ });
331
+
332
+ for (const range of [$("mep"), $("mit")]) {
333
+ setRangeFill(range);
334
+ range.addEventListener("input", event => {
335
+ setRangeFill(event.target);
336
+ $(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
337
+ schedule(0);
338
+ });
339
+ }
340
+
341
+ for (const example of EXAMPLES) {
342
+ const button = document.createElement("button");
343
+ button.type = "button";
344
+ button.className = "ex";
345
+ button.textContent = example.label;
346
+ button.dataset.text = example.text;
347
+ button.addEventListener("click", () => {
348
+ clearActiveExample();
349
+ button.classList.add("active");
350
+ activeExample = button;
351
+ input.value = example.text;
352
+ updateCount();
353
+ input.focus();
354
+ schedule(0);
355
+ });
356
+ $("examples").append(button);
357
+ }
358
+
359
+ copy.addEventListener("click", async () => {
360
+ if (!lastCorrected) return;
361
+ await navigator.clipboard.writeText(lastCorrected);
362
+ copy.textContent = "Copied";
363
+ copy.classList.remove("copied");
364
+ void copy.offsetWidth;
365
+ copy.classList.add("copied");
366
+ clearTimeout(copy._timer);
367
+ copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
368
+ });
369
+
370
+ fetch(signedUrl("/api/health"))
371
+ .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
372
+ .then(data => {
373
+ setModelState("ready", "Model ready", 100);
374
+ $("status").title = data.mem_human ? `${data.mem_human} in memory` : "";
375
+ })
376
+ .catch(() => setModelState("error", "Model unavailable", 100));
377
+
378
+ (function init() {
379
+ const first = $("examples").querySelector(".ex");
380
+ first.classList.add("active");
381
+ activeExample = first;
382
+ input.value = first.dataset.text;
383
+ updateCount();
384
+ schedule(0);
385
+ })();
386
+ </script>
387
+ </body>
388
+ </html>