spellchecker / server.py
mlabonne's picture
Rename spellchecker and normalize apostrophes
f89393b verified
Raw
History Blame
6.93 kB
"""FastAPI backend for the LFM2.5 Spellchecker demo (Docker Space).
Loads the published model from the Hub (pinned to `main`, so the Space always serves the current best
checkpoint), exposes POST /api/correct, and serves the static frontend in static/. No Gradio.
The model repo is private, so HF_TOKEN (a Space secret) is needed to pull it. Pinned library versions
(see requirements.txt) match the environment the model was validated against — the encoder's custom
bidirectional-mask code is sensitive to the transformers version.
uvicorn server:app --host 0.0.0.0 --port 7860
"""
import difflib
import os
import re
import torch
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from transformers import AutoModel
MODEL_ID = os.environ.get("SPELLCHECKER_MODEL", "LiquidAI/LFM2.5-Spellchecker-350M")
# Pin to the EXACT published commit so the container can never serve stale cached weights/remote-code
# (the bug we hit: a rebuild kept serving old, tagger-only behaviour). Bump on each publish, or override.
MODEL_REV = os.environ.get("SPELLCHECKER_REVISION", "65a4a90af31205d2f7ef66b6a68d7b3d276adfdd")
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
print(f"[server] loading {MODEL_ID}@{MODEL_REV} ...", flush=True)
# fp16 (half the memory; the published weights are fp16). Casting the whole model uniformly avoids the
# mixed-dtype error — torch 2.12 runs fp16 matmul on CPU fine.
_model = AutoModel.from_pretrained(MODEL_ID, revision=MODEL_REV, trust_remote_code=True,
token=os.environ.get("HF_TOKEN")).half().eval()
_mem_bytes = sum(t.numel() * t.element_size() for t in (*_model.parameters(), *_model.buffers()))
_mem_human = (f"{_mem_bytes / 1024**3:.2f} GB" if _mem_bytes >= 1024**3
else f"{_mem_bytes / 1024**2:.0f} MB")
print(f"[server] model ready ({_mem_human} in memory)", flush=True)
# The model is trained on spaCy-tokenized text (punctuation separated by spaces AND contractions split:
# "let's"->"let 's", "don't"->"do n't"). We tokenize natural input into that form before the model and
# detokenize for display, so users type/read natural text while the model sees its training form. Feeding
# JOINED contractions is out-of-distribution and made the model edit them erratically (the "let 's" /
# "That's 's" artifacts) — splitting+rejoining contractions is the fix.
_PUNCT = re.compile(r'([.,!?;:()\[\]{}"«»…])')
_ATTACH_LEFT = re.compile(r'\s+([.,!?;:%)\]}»…])')
_ATTACH_RIGHT = re.compile(r'([(\[{«])\s+')
_NT = re.compile(r"(\w+?)(n['’]t)\b", re.I) # don't->do n't, can't->ca n't
_CONTR = re.compile(r"(\w)(['’](?:s|re|ve|ll|d|m))\b", re.I) # let's->let 's, I'm->I 'm
_REJOIN_NT = re.compile(r"\s+(n['’]t)\b", re.I) # do n't->don't
_REJOIN_CONTR = re.compile(r"\s+(['’](?:s|re|ve|ll|d|m))\b", re.I) # let 's->let's
_REJOIN_SPLIT_APOSTROPHE = re.compile(r"(?<=\w)\s+(['’])\s+(?=(?:s|t|d|m|re|ve|ll)\b)", re.I)
def tokenize(text: str) -> str:
text = _PUNCT.sub(r" \1 ", text)
text = _NT.sub(r"\1 \2", text) # split n't (own apostrophe) first
text = _CONTR.sub(r"\1 \2", text) # then 's/'re/'ve/'ll/'d/'m
return re.sub(r"\s+", " ", text).strip()
def detok(text: str) -> str:
text = _ATTACH_LEFT.sub(r"\1", text)
text = _ATTACH_RIGHT.sub(r"\1", text)
text = _REJOIN_NT.sub(r"\1", text) # do n't->don't
text = _REJOIN_CONTR.sub(r"\1", text) # let 's->let's
text = _REJOIN_SPLIT_APOSTROPHE.sub(r"\1", text) # don ' t->don't
return re.sub(r"\s+", " ", text).strip()
# Startup self-test over the REAL user path (tokenize -> correct -> detok), logged + exposed at
# /api/health: a correctly-deployed full system leaves this clean sentence UNCHANGED. If it changes,
# the deploy is wrong (stale model, reranker inactive, or contraction handling broken) — no guessing.
_PROBE_IN = "That's a fair point, let's discuss it tomorrow."
try:
_PROBE_OUT = detok(_model.correct([tokenize(_PROBE_IN)], max_iter=3)[0])
_RERANK_ACTIVE = (_PROBE_OUT == _PROBE_IN)
except Exception as e: # pragma: no cover
_PROBE_OUT, _RERANK_ACTIVE = f"ERROR: {e}", None
print(f"[server] self-test ok={_RERANK_ACTIVE}: {_PROBE_IN!r} -> {_PROBE_OUT!r}", flush=True)
def diff_segments(source: str, corrected: str):
"""Word-level diff -> [{text, kind}] segments, kind in {keep, edit, del}, for inline rendering of
the corrected text: `keep` unchanged, `edit` inserted/replaced (highlight green), `del` removed
(shown struck-through red — these words are NOT part of the corrected text)."""
s, c = source.split(), corrected.split()
seg = []
for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s, c, autojunk=False).get_opcodes():
if op == "equal":
seg.append({"text": " ".join(c[j1:j2]), "kind": "keep"})
elif op == "insert":
seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
elif op == "delete":
seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
elif op == "replace": # new words highlighted; removed words shown struck
seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
return seg or [{"text": corrected, "kind": "keep"}]
class CorrectRequest(BaseModel):
text: str
min_error_prob: float = 0.0
max_iter: int = 3
app = FastAPI(title="LFM2.5 Spellchecker")
@app.get("/api/health")
def health():
return {"status": "ok", "model": MODEL_ID, "revision": MODEL_REV,
"mem_bytes": _mem_bytes, "mem_human": _mem_human,
"rerank_active": _RERANK_ACTIVE, "self_test": {"in": _PROBE_IN, "out": _PROBE_OUT}}
@app.post("/api/correct")
@torch.no_grad()
def correct(req: CorrectRequest):
text = (req.text or "").strip()
if not text:
return {"corrected": "", "segments": [], "changed": False}
src = tokenize(text) # natural -> spaced (the model's form)
out = _model.correct([src], min_error_prob=float(req.min_error_prob),
max_iter=int(req.max_iter))[0]
# diff on spaced tokens (accurate, word-level); the frontend detokenizes spacing for display.
return {"corrected": detok(out), "segments": diff_segments(src, out), "changed": out != src}
@app.get("/")
def index():
return FileResponse(os.path.join(STATIC, "index.html"))
app.mount("/", StaticFiles(directory=STATIC), name="static")