Files changed (4) hide show
  1. .dockerignore +0 -6
  2. README.md +21 -12
  3. server.py +28 -44
  4. static/index.html +15 -9
.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
@@ -7,22 +7,29 @@
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>
@@ -33,7 +40,9 @@
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">
@@ -157,13 +166,10 @@ const EXAMPLES = [
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");
 
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>
 
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">
 
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");