mlabonne commited on
Commit
f89393b
·
verified ·
1 Parent(s): fae1f17

Rename spellchecker and normalize apostrophes

Browse files
Files changed (3) hide show
  1. README.md +22 -22
  2. server.py +128 -126
  3. static/index.html +324 -314
README.md CHANGED
@@ -1,22 +1,22 @@
1
- ---
2
- title: Liquid 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
- # Liquid 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
 
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,134 +1,136 @@
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
13
- import os
14
- import re
15
-
16
- import torch
17
- from fastapi import FastAPI
18
- from fastapi.responses import FileResponse
19
- from fastapi.staticfiles import StaticFiles
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")
37
- print(f"[server] model ready ({_mem_human} in memory)", flush=True)
38
-
39
-
40
- # The model is trained on spaCy-tokenized text (punctuation separated by spaces AND contractions split:
41
- # "let's"->"let 's", "don't"->"do n't"). We tokenize natural input into that form before the model and
42
- # detokenize for display, so users type/read natural text while the model sees its training form. Feeding
43
- # JOINED contractions is out-of-distribution and made the model edit them erratically (the "let 's" /
44
- # "That's 's" artifacts) — splitting+rejoining contractions is the fix.
45
- _PUNCT = re.compile(r'([.,!?;:()\[\]{}"«»…])')
46
- _ATTACH_LEFT = re.compile(r'\s+([.,!?;:%)\]}»…])')
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
-
53
-
54
- def tokenize(text: str) -> str:
55
- text = _PUNCT.sub(r" \1 ", text)
56
- text = _NT.sub(r"\1 \2", text) # split n't (own apostrophe) first
57
- text = _CONTR.sub(r"\1 \2", text) # then 's/'re/'ve/'ll/'d/'m
58
- return re.sub(r"\s+", " ", text).strip()
59
-
60
-
 
61
  def detok(text: str) -> str:
62
  text = _ATTACH_LEFT.sub(r"\1", text)
63
  text = _ATTACH_RIGHT.sub(r"\1", text)
64
  text = _REJOIN_NT.sub(r"\1", text) # do n't->don't
65
  text = _REJOIN_CONTR.sub(r"\1", text) # let 's->let's
 
66
  return re.sub(r"\s+", " ", text).strip()
67
-
68
-
69
- # Startup self-test over the REAL user path (tokenize -> correct -> detok), logged + exposed at
70
- # /api/health: a correctly-deployed full system leaves this clean sentence UNCHANGED. If it changes,
71
- # the deploy is wrong (stale model, reranker inactive, or contraction handling broken) — no guessing.
72
- _PROBE_IN = "That's a fair point, let's discuss it tomorrow."
73
- try:
74
- _PROBE_OUT = detok(_model.correct([tokenize(_PROBE_IN)], max_iter=3)[0])
75
- _RERANK_ACTIVE = (_PROBE_OUT == _PROBE_IN)
76
- except Exception as e: # pragma: no cover
77
- _PROBE_OUT, _RERANK_ACTIVE = f"ERROR: {e}", None
78
- print(f"[server] self-test ok={_RERANK_ACTIVE}: {_PROBE_IN!r} -> {_PROBE_OUT!r}", flush=True)
79
-
80
-
81
- def diff_segments(source: str, corrected: str):
82
- """Word-level diff -> [{text, kind}] segments, kind in {keep, edit, del}, for inline rendering of
83
- the corrected text: `keep` unchanged, `edit` inserted/replaced (highlight green), `del` removed
84
- (shown struck-through red — these words are NOT part of the corrected text)."""
85
- s, c = source.split(), corrected.split()
86
- seg = []
87
- for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s, c, autojunk=False).get_opcodes():
88
- if op == "equal":
89
- seg.append({"text": " ".join(c[j1:j2]), "kind": "keep"})
90
- elif op == "insert":
91
- seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
92
- elif op == "delete":
93
- seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
94
- elif op == "replace": # new words highlighted; removed words shown struck
95
- seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
96
- seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
97
- return seg or [{"text": corrected, "kind": "keep"}]
98
-
99
-
100
- class CorrectRequest(BaseModel):
101
- text: str
102
- min_error_prob: float = 0.0
103
- max_iter: int = 3
104
-
105
-
106
- app = FastAPI(title="LFM2.5 Spellchecker")
107
-
108
-
109
- @app.get("/api/health")
110
- def health():
111
- return {"status": "ok", "model": MODEL_ID, "revision": MODEL_REV,
112
- "mem_bytes": _mem_bytes, "mem_human": _mem_human,
113
- "rerank_active": _RERANK_ACTIVE, "self_test": {"in": _PROBE_IN, "out": _PROBE_OUT}}
114
-
115
-
116
- @app.post("/api/correct")
117
- @torch.no_grad()
118
- def correct(req: CorrectRequest):
119
- text = (req.text or "").strip()
120
- if not text:
121
- return {"corrected": "", "segments": [], "changed": False}
122
- src = tokenize(text) # natural -> spaced (the model's form)
123
- out = _model.correct([src], min_error_prob=float(req.min_error_prob),
124
- max_iter=int(req.max_iter))[0]
125
- # diff on spaced tokens (accurate, word-level); the frontend detokenizes spacing for display.
126
- return {"corrected": detok(out), "segments": diff_segments(src, out), "changed": out != src}
127
-
128
-
129
- @app.get("/")
130
- def index():
131
- return FileResponse(os.path.join(STATIC, "index.html"))
132
-
133
-
134
- app.mount("/", StaticFiles(directory=STATIC), name="static")
 
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
13
+ import os
14
+ import re
15
+
16
+ import torch
17
+ from fastapi import FastAPI
18
+ from fastapi.responses import FileResponse
19
+ from fastapi.staticfiles import StaticFiles
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")
37
+ print(f"[server] model ready ({_mem_human} in memory)", flush=True)
38
+
39
+
40
+ # The model is trained on spaCy-tokenized text (punctuation separated by spaces AND contractions split:
41
+ # "let's"->"let 's", "don't"->"do n't"). We tokenize natural input into that form before the model and
42
+ # detokenize for display, so users type/read natural text while the model sees its training form. Feeding
43
+ # JOINED contractions is out-of-distribution and made the model edit them erratically (the "let 's" /
44
+ # "That's 's" artifacts) — splitting+rejoining contractions is the fix.
45
+ _PUNCT = re.compile(r'([.,!?;:()\[\]{}"«»…])')
46
+ _ATTACH_LEFT = re.compile(r'\s+([.,!?;:%)\]}»…])')
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:
56
+ text = _PUNCT.sub(r" \1 ", text)
57
+ text = _NT.sub(r"\1 \2", text) # split n't (own apostrophe) first
58
+ text = _CONTR.sub(r"\1 \2", text) # then 's/'re/'ve/'ll/'d/'m
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])
77
+ _RERANK_ACTIVE = (_PROBE_OUT == _PROBE_IN)
78
+ except Exception as e: # pragma: no cover
79
+ _PROBE_OUT, _RERANK_ACTIVE = f"ERROR: {e}", None
80
+ print(f"[server] self-test ok={_RERANK_ACTIVE}: {_PROBE_IN!r} -> {_PROBE_OUT!r}", flush=True)
81
+
82
+
83
+ def diff_segments(source: str, corrected: str):
84
+ """Word-level diff -> [{text, kind}] segments, kind in {keep, edit, del}, for inline rendering of
85
+ the corrected text: `keep` unchanged, `edit` inserted/replaced (highlight green), `del` removed
86
+ (shown struck-through red — these words are NOT part of the corrected text)."""
87
+ s, c = source.split(), corrected.split()
88
+ seg = []
89
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s, c, autojunk=False).get_opcodes():
90
+ if op == "equal":
91
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "keep"})
92
+ elif op == "insert":
93
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
94
+ elif op == "delete":
95
+ seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
96
+ elif op == "replace": # new words highlighted; removed words shown struck
97
+ seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
98
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
99
+ return seg or [{"text": corrected, "kind": "keep"}]
100
+
101
+
102
+ class CorrectRequest(BaseModel):
103
+ text: str
104
+ min_error_prob: float = 0.0
105
+ max_iter: int = 3
106
+
107
+
108
+ app = FastAPI(title="LFM2.5 Spellchecker")
109
+
110
+
111
+ @app.get("/api/health")
112
+ def health():
113
+ return {"status": "ok", "model": MODEL_ID, "revision": MODEL_REV,
114
+ "mem_bytes": _mem_bytes, "mem_human": _mem_human,
115
+ "rerank_active": _RERANK_ACTIVE, "self_test": {"in": _PROBE_IN, "out": _PROBE_OUT}}
116
+
117
+
118
+ @app.post("/api/correct")
119
+ @torch.no_grad()
120
+ def correct(req: CorrectRequest):
121
+ text = (req.text or "").strip()
122
+ if not text:
123
+ return {"corrected": "", "segments": [], "changed": False}
124
+ src = tokenize(text) # natural -> spaced (the model's form)
125
+ out = _model.correct([src], min_error_prob=float(req.min_error_prob),
126
+ max_iter=int(req.max_iter))[0]
127
+ # diff on spaced tokens (accurate, word-level); the frontend detokenizes spacing for display.
128
+ return {"corrected": detok(out), "segments": diff_segments(src, out), "changed": out != src}
129
+
130
+
131
+ @app.get("/")
132
+ def index():
133
+ return FileResponse(os.path.join(STATIC, "index.html"))
134
+
135
+
136
+ app.mount("/", StaticFiles(directory=STATIC), name="static")
static/index.html CHANGED
@@ -1,324 +1,334 @@
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>Liquid 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
- document.write(`<link rel="stylesheet" href="./style.css${suffix}">`);
15
- })();
16
- </script>
17
- </head>
18
- <body>
19
- <div class="wrap">
20
- <header class="site-intro">
21
- <h1>Liquid <em>spellchecker</em></h1>
22
- <p class="sub">Correct English spelling and grammar privately with LFM2.5.</p>
23
- </header>
24
-
25
- <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
26
- <div class="model-mark" aria-hidden="true"><span></span></div>
27
- <div class="model-copy">
28
- <span class="model-kicker">Model</span>
29
- <div class="model-name-row">
30
- <a href="https://huggingface.co/LiquidAI/LFM2.5-Spellchecker-350M" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
31
- <span class="model-variant">350M parameters</span>
32
- </div>
33
- </div>
34
- <div class="load-block">
35
- <div class="load-meta"><span id="model-status">Connecting to model…</span><span id="model-ready">Checking</span></div>
36
- <div class="load-track" role="progressbar" aria-label="Model status" aria-valuemin="0" aria-valuemax="100" aria-valuenow="25"><span id="load-progress"></span></div>
37
- </div>
38
- </section>
39
-
40
- <main>
41
- <section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
42
- <article class="workspace-card input-panel">
43
- <div class="panel-header">
44
- <h2>Input</h2>
45
- </div>
46
-
47
- <div class="editor-stage input-stage">
48
- <label class="sr-only" for="input">Text to check</label>
49
- <textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
50
- <div class="editor-scan" aria-hidden="true"></div>
51
- </div>
52
-
53
- <div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
54
-
55
- <details class="settings-menu">
56
- <summary>Advanced settings</summary>
57
- <div class="settings-grid" aria-label="Correction settings">
58
- <label class="setting-card" for="mep">
59
- <span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
60
- <input type="range" id="mep" min="0" max="1" step="0.05" value="0">
61
- <span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
62
- </label>
63
- <label class="setting-card" for="mit">
64
- <span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
65
- <input type="range" id="mit" min="1" max="5" step="1" value="3">
66
- <span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
67
- </label>
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">Try an example</span>
99
- <div class="example-list" id="examples"></div>
100
- </section>
101
- </main>
102
- </div>
103
-
104
- <script>
105
- const EXAMPLES = [
106
- { label:"Agreement", text:"Their are many reason to study hard." },
107
- { label:"Verb forms", text:"I has went to the stor yesterday." },
108
- { label:"Pronouns", text:"Me and him was late for the meetting." },
109
- { label:"Repetition", text:"i want to to go home." },
110
- { label:"Comparison", text:"He is more taller than his brother." },
111
- { label:"Articles", text:"Can you give me a advice?" },
112
- { label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
113
- ];
114
-
115
- const $ = id => document.getElementById(id);
116
- const sign = new URLSearchParams(location.search).get("__sign");
117
- const signedUrl = path => {
118
- const url = new URL(path, location.href);
119
- if (sign) url.searchParams.set("__sign", sign);
120
- return url;
121
- };
122
- const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
123
  const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
124
  const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
 
 
125
  const OPEN = /^[(\[{«¿¡]+$/;
126
- const DEBOUNCE = 650;
127
- let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
128
-
129
- function setModelState(kind, status, meta) {
130
- const strip = $("model-strip");
131
- strip.className = `model-strip is-${kind}`;
132
- $("model-status").textContent = status;
133
- $("model-ready").textContent = meta;
134
- $("load-progress").style.width = kind === "ready" ? "100%" : kind === "error" ? "100%" : "34%";
135
- strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", kind === "ready" ? "100" : kind === "error" ? "100" : "34");
136
- }
137
-
138
- function setActivity(kind, label) {
139
- $("activity").className = `activity-pill is-${kind}`;
140
- $("activity-label").textContent = label;
141
- }
142
-
143
- function setRangeFill(range) {
144
- const pct = (range.value - range.min) / (range.max - range.min) * 100;
145
- range.style.setProperty("--pct", `${pct}%`);
146
- }
147
-
148
- function updateCount() {
149
- const count = input.value.length;
150
- $("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
151
- }
152
-
153
- function clearActiveExample() {
154
- if (activeExample) activeExample.classList.remove("active");
155
- activeExample = null;
156
- }
157
-
158
- function schedule(delay = DEBOUNCE) {
159
- clearTimeout(timer);
160
- timer = setTimeout(correct, delay);
161
- }
162
-
163
  function attachLeft(token) {
164
  return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
165
  }
166
 
167
- function renderOutput(segments) {
168
- const tokens = [];
169
- for (const segment of segments) {
170
- for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
171
- }
 
 
 
 
 
 
 
172
  output.innerHTML = "";
173
  output.classList.toggle("empty", tokens.length === 0);
174
  let previous = null, editIndex = 0;
175
- for (const token of tokens) {
176
- if (previous !== null && !attachLeft(token.text) && !OPEN.test(previous)) output.append(document.createTextNode(" "));
177
- let node;
178
- if (token.kind === "edit") {
179
- node = document.createElement("mark");
180
- node.style.setProperty("--edit-index", editIndex++);
181
- node.textContent = token.text;
182
- } else if (token.kind === "del") {
183
- node = document.createElement("span");
184
- node.className = "deleted";
185
- node.style.setProperty("--edit-index", editIndex++);
186
- node.textContent = token.text;
187
- } else {
188
- node = document.createTextNode(token.text);
189
- }
190
- output.append(node);
191
- previous = token.text;
192
- }
193
- }
194
-
195
- function countEdits(segments) {
196
- let edits = 0, inEdit = false;
197
- for (const segment of segments) {
198
- if (segment.kind === "keep") inEdit = false;
199
- else if (!inEdit) { edits++; inEdit = true; }
200
- }
201
- return edits;
202
- }
203
-
204
- function resetResult() {
205
- requestSequence++;
206
- lastCorrected = "";
207
- renderOutput([]);
208
- copy.disabled = true;
209
- setActivity("idle", "Ready");
210
- workspace.classList.remove("is-checking", "is-complete");
211
- $("stats").classList.remove("has-result");
212
- $("stat-edits").textContent = "";
213
- $("stat-latency").textContent = "—";
214
- $("stat-characters").textContent = "—";
215
- }
216
-
217
- async function correct() {
218
- const text = input.value.replace(/\s+/g, " ").trim();
219
- if (!text) { resetResult(); return; }
220
- const sequence = ++requestSequence;
221
- const started = performance.now();
222
- workspace.classList.remove("is-complete");
223
- workspace.classList.add("is-checking");
224
- setActivity("running", "Checking…");
225
-
226
- try {
227
- const response = await fetch(signedUrl("/api/correct"), {
228
- method:"POST",
229
- headers:{ "Content-Type":"application/json" },
230
- body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
231
- });
232
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
233
- const data = await response.json();
234
- if (sequence !== requestSequence) return;
235
-
236
- const latency = performance.now() - started;
237
- const edits = countEdits(data.segments);
238
- lastCorrected = data.corrected;
239
- renderOutput(data.segments);
240
- copy.disabled = !lastCorrected;
241
- workspace.classList.remove("is-checking");
242
- void workspace.offsetWidth;
243
- workspace.classList.add("is-complete");
244
- setActivity("done", data.changed ? "Corrected" : "Looks good");
245
-
246
- const stats = $("stats");
247
- stats.classList.remove("has-result");
248
- void stats.offsetWidth;
249
- stats.classList.add("has-result");
250
- $("stat-edits").textContent = String(edits);
251
- $("stat-latency").textContent = `${Math.round(latency)} ms`;
252
- $("stat-characters").textContent = String(text.length);
253
- setTimeout(() => workspace.classList.remove("is-complete"), 1000);
254
- } catch (error) {
255
- if (sequence !== requestSequence) return;
256
- workspace.classList.remove("is-checking");
257
- setActivity("error", "Check failed");
258
- console.error(error);
259
- }
260
- }
261
-
262
- input.addEventListener("input", () => {
263
- updateCount();
264
- clearActiveExample();
265
- schedule();
266
- });
267
-
268
- for (const range of [$("mep"), $("mit")]) {
269
- setRangeFill(range);
270
- range.addEventListener("input", event => {
271
- setRangeFill(event.target);
272
- $(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
273
- schedule(0);
274
- });
275
- }
276
-
277
- for (const example of EXAMPLES) {
278
- const button = document.createElement("button");
279
- button.type = "button";
280
- button.className = "ex";
281
- button.textContent = example.label;
282
- button.dataset.text = example.text;
283
- button.addEventListener("click", () => {
284
- clearActiveExample();
285
- button.classList.add("active");
286
- activeExample = button;
287
- input.value = example.text;
288
- updateCount();
289
- input.focus();
290
- schedule(0);
291
- });
292
- $("examples").append(button);
293
- }
294
-
295
- copy.addEventListener("click", async () => {
296
- if (!lastCorrected) return;
297
- await navigator.clipboard.writeText(lastCorrected);
298
- copy.textContent = "Copied";
299
- copy.classList.remove("copied");
300
- void copy.offsetWidth;
301
- copy.classList.add("copied");
302
- clearTimeout(copy._timer);
303
- copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
304
- });
305
-
306
- fetch(signedUrl("/api/health"))
307
- .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
308
- .then(data => {
309
- setModelState("ready", "Running on CPU", "Ready");
310
- $("model-ready").title = data.mem_human ? `${data.mem_human} in memory` : "";
311
- })
312
- .catch(() => setModelState("error", "Model unavailable", "Error"));
313
-
314
- (function init() {
315
- const first = $("examples").querySelector(".ex");
316
- first.classList.add("active");
317
- activeExample = first;
318
- input.value = first.dataset.text;
319
- updateCount();
320
- schedule(0);
321
- })();
322
- </script>
323
- </body>
324
- </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
+ document.write(`<link rel="stylesheet" href="./style.css${suffix}">`);
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="wrap">
20
+ <header class="site-intro">
21
+ <h1>English <em>spellchecker</em></h1>
22
+ <p class="sub">Correct English spelling and grammar privately with LFM2.5.</p>
23
+ </header>
24
+
25
+ <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
26
+ <div class="model-mark" aria-hidden="true"><span></span></div>
27
+ <div class="model-copy">
28
+ <span class="model-kicker">Model</span>
29
+ <div class="model-name-row">
30
+ <a href="https://huggingface.co/LiquidAI/LFM2.5-Spellchecker-350M" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
31
+ <span class="model-variant">350M parameters</span>
32
+ </div>
33
+ </div>
34
+ <div class="load-block">
35
+ <div class="load-meta"><span id="model-status">Connecting to model…</span><span id="model-ready">Checking</span></div>
36
+ <div class="load-track" role="progressbar" aria-label="Model status" aria-valuemin="0" aria-valuemax="100" aria-valuenow="25"><span id="load-progress"></span></div>
37
+ </div>
38
+ </section>
39
+
40
+ <main>
41
+ <section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
42
+ <article class="workspace-card input-panel">
43
+ <div class="panel-header">
44
+ <h2>Input</h2>
45
+ </div>
46
+
47
+ <div class="editor-stage input-stage">
48
+ <label class="sr-only" for="input">Text to check</label>
49
+ <textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
50
+ <div class="editor-scan" aria-hidden="true"></div>
51
+ </div>
52
+
53
+ <div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
54
+
55
+ <details class="settings-menu">
56
+ <summary>Advanced settings</summary>
57
+ <div class="settings-grid" aria-label="Correction settings">
58
+ <label class="setting-card" for="mep">
59
+ <span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
60
+ <input type="range" id="mep" min="0" max="1" step="0.05" value="0">
61
+ <span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
62
+ </label>
63
+ <label class="setting-card" for="mit">
64
+ <span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
65
+ <input type="range" id="mit" min="1" max="5" step="1" value="3">
66
+ <span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
67
+ </label>
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">Try an example</span>
99
+ <div class="example-list" id="examples"></div>
100
+ </section>
101
+ </main>
102
+ </div>
103
+
104
+ <script>
105
+ const EXAMPLES = [
106
+ { label:"Agreement", text:"Their are many reason to study hard." },
107
+ { label:"Verb forms", text:"I has went to the stor yesterday." },
108
+ { label:"Pronouns", text:"Me and him was late for the meetting." },
109
+ { label:"Repetition", text:"i want to to go home." },
110
+ { label:"Comparison", text:"He is more taller than his brother." },
111
+ { label:"Articles", text:"Can you give me a advice?" },
112
+ { label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
113
+ ];
114
+
115
+ const $ = id => document.getElementById(id);
116
+ const sign = new URLSearchParams(location.search).get("__sign");
117
+ const signedUrl = path => {
118
+ const url = new URL(path, location.href);
119
+ if (sign) url.searchParams.set("__sign", sign);
120
+ return url;
121
+ };
122
+ const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
123
  const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
124
  const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
125
+ const APOSTROPHE = /^['’]$/;
126
+ const CONTRACTION_TAIL = /^(?:s|t|d|m|re|ve|ll)$/i;
127
  const OPEN = /^[(\[{«¿¡]+$/;
128
+ const DEBOUNCE = 650;
129
+ let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
130
+
131
+ function setModelState(kind, status, meta) {
132
+ const strip = $("model-strip");
133
+ strip.className = `model-strip is-${kind}`;
134
+ $("model-status").textContent = status;
135
+ $("model-ready").textContent = meta;
136
+ $("load-progress").style.width = kind === "ready" ? "100%" : kind === "error" ? "100%" : "34%";
137
+ strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", kind === "ready" ? "100" : kind === "error" ? "100" : "34");
138
+ }
139
+
140
+ function setActivity(kind, label) {
141
+ $("activity").className = `activity-pill is-${kind}`;
142
+ $("activity-label").textContent = label;
143
+ }
144
+
145
+ function setRangeFill(range) {
146
+ const pct = (range.value - range.min) / (range.max - range.min) * 100;
147
+ range.style.setProperty("--pct", `${pct}%`);
148
+ }
149
+
150
+ function updateCount() {
151
+ const count = input.value.length;
152
+ $("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
153
+ }
154
+
155
+ function clearActiveExample() {
156
+ if (activeExample) activeExample.classList.remove("active");
157
+ activeExample = null;
158
+ }
159
+
160
+ function schedule(delay = DEBOUNCE) {
161
+ clearTimeout(timer);
162
+ timer = setTimeout(correct, delay);
163
+ }
164
+
165
  function attachLeft(token) {
166
  return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
167
  }
168
 
169
+ function needsSpace(previous, current, next) {
170
+ if (previous === null) return false;
171
+ const startsSplitContraction = APOSTROPHE.test(current) && CONTRACTION_TAIL.test(next || "");
172
+ const finishesSplitContraction = APOSTROPHE.test(previous) && CONTRACTION_TAIL.test(current);
173
+ return !attachLeft(current) && !startsSplitContraction && !finishesSplitContraction && !OPEN.test(previous);
174
+ }
175
+
176
+ function renderOutput(segments) {
177
+ const tokens = [];
178
+ for (const segment of segments) {
179
+ for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
180
+ }
181
  output.innerHTML = "";
182
  output.classList.toggle("empty", tokens.length === 0);
183
  let previous = null, editIndex = 0;
184
+ for (let index = 0; index < tokens.length; index++) {
185
+ const token = tokens[index];
186
+ if (needsSpace(previous, token.text, tokens[index + 1]?.text)) output.append(document.createTextNode(" "));
187
+ let node;
188
+ if (token.kind === "edit") {
189
+ node = document.createElement("mark");
190
+ node.style.setProperty("--edit-index", editIndex++);
191
+ node.textContent = token.text;
192
+ } else if (token.kind === "del") {
193
+ node = document.createElement("span");
194
+ node.className = "deleted";
195
+ node.style.setProperty("--edit-index", editIndex++);
196
+ node.textContent = token.text;
197
+ } else {
198
+ node = document.createTextNode(token.text);
199
+ }
200
+ output.append(node);
201
+ previous = token.text;
202
+ }
203
+ }
204
+
205
+ function countEdits(segments) {
206
+ let edits = 0, inEdit = false;
207
+ for (const segment of segments) {
208
+ if (segment.kind === "keep") inEdit = false;
209
+ else if (!inEdit) { edits++; inEdit = true; }
210
+ }
211
+ return edits;
212
+ }
213
+
214
+ function resetResult() {
215
+ requestSequence++;
216
+ lastCorrected = "";
217
+ renderOutput([]);
218
+ copy.disabled = true;
219
+ setActivity("idle", "Ready");
220
+ workspace.classList.remove("is-checking", "is-complete");
221
+ $("stats").classList.remove("has-result");
222
+ $("stat-edits").textContent = "—";
223
+ $("stat-latency").textContent = "—";
224
+ $("stat-characters").textContent = "—";
225
+ }
226
+
227
+ async function correct() {
228
+ const text = input.value.replace(/\s+/g, " ").trim();
229
+ if (!text) { resetResult(); return; }
230
+ const sequence = ++requestSequence;
231
+ const started = performance.now();
232
+ workspace.classList.remove("is-complete");
233
+ workspace.classList.add("is-checking");
234
+ setActivity("running", "Checking…");
235
+
236
+ try {
237
+ const response = await fetch(signedUrl("/api/correct"), {
238
+ method:"POST",
239
+ headers:{ "Content-Type":"application/json" },
240
+ body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
241
+ });
242
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
243
+ const data = await response.json();
244
+ if (sequence !== requestSequence) return;
245
+
246
+ const latency = performance.now() - started;
247
+ const edits = countEdits(data.segments);
248
+ lastCorrected = data.corrected;
249
+ renderOutput(data.segments);
250
+ copy.disabled = !lastCorrected;
251
+ workspace.classList.remove("is-checking");
252
+ void workspace.offsetWidth;
253
+ workspace.classList.add("is-complete");
254
+ setActivity("done", data.changed ? "Corrected" : "Looks good");
255
+
256
+ const stats = $("stats");
257
+ stats.classList.remove("has-result");
258
+ void stats.offsetWidth;
259
+ stats.classList.add("has-result");
260
+ $("stat-edits").textContent = String(edits);
261
+ $("stat-latency").textContent = `${Math.round(latency)} ms`;
262
+ $("stat-characters").textContent = String(text.length);
263
+ setTimeout(() => workspace.classList.remove("is-complete"), 1000);
264
+ } catch (error) {
265
+ if (sequence !== requestSequence) return;
266
+ workspace.classList.remove("is-checking");
267
+ setActivity("error", "Check failed");
268
+ console.error(error);
269
+ }
270
+ }
271
+
272
+ input.addEventListener("input", () => {
273
+ updateCount();
274
+ clearActiveExample();
275
+ schedule();
276
+ });
277
+
278
+ for (const range of [$("mep"), $("mit")]) {
279
+ setRangeFill(range);
280
+ range.addEventListener("input", event => {
281
+ setRangeFill(event.target);
282
+ $(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
283
+ schedule(0);
284
+ });
285
+ }
286
+
287
+ for (const example of EXAMPLES) {
288
+ const button = document.createElement("button");
289
+ button.type = "button";
290
+ button.className = "ex";
291
+ button.textContent = example.label;
292
+ button.dataset.text = example.text;
293
+ button.addEventListener("click", () => {
294
+ clearActiveExample();
295
+ button.classList.add("active");
296
+ activeExample = button;
297
+ input.value = example.text;
298
+ updateCount();
299
+ input.focus();
300
+ schedule(0);
301
+ });
302
+ $("examples").append(button);
303
+ }
304
+
305
+ copy.addEventListener("click", async () => {
306
+ if (!lastCorrected) return;
307
+ await navigator.clipboard.writeText(lastCorrected);
308
+ copy.textContent = "Copied";
309
+ copy.classList.remove("copied");
310
+ void copy.offsetWidth;
311
+ copy.classList.add("copied");
312
+ clearTimeout(copy._timer);
313
+ copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
314
+ });
315
+
316
+ fetch(signedUrl("/api/health"))
317
+ .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
318
+ .then(data => {
319
+ setModelState("ready", "Running on CPU", "Ready");
320
+ $("model-ready").title = data.mem_human ? `${data.mem_human} in memory` : "";
321
+ })
322
+ .catch(() => setModelState("error", "Model unavailable", "Error"));
323
+
324
+ (function init() {
325
+ const first = $("examples").querySelector(".ex");
326
+ first.classList.add("active");
327
+ activeExample = first;
328
+ input.value = first.dataset.text;
329
+ updateCount();
330
+ schedule(0);
331
+ })();
332
+ </script>
333
+ </body>
334
+ </html>