Spaces:
Running
Running
| """FastAPI backend for the LFM2.5 Spellchecker demo (Docker Space). | |
| 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 | |
| def _effective_cpus(): | |
| """Cores actually granted to this container (cgroup quota), not the host core count. | |
| os.cpu_count() returns the host's cores, so torch would oversubscribe the 2-vCPU Space.""" | |
| try: # cgroup v2 | |
| raw = open("/sys/fs/cgroup/cpu.max").read().split() | |
| if raw and raw[0] != "max": | |
| return max(1, round(int(raw[0]) / int(raw[1]))) | |
| except (OSError, ValueError): | |
| pass | |
| try: # cgroup v1 | |
| q = int(open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read()) | |
| p = int(open("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read()) | |
| if q > 0: | |
| return max(1, q // p) | |
| except (OSError, ValueError): | |
| pass | |
| try: | |
| return max(1, len(os.sched_getaffinity(0))) | |
| except AttributeError: | |
| return os.cpu_count() or 1 | |
| _CPUS = _effective_cpus() | |
| torch.set_num_threads(_CPUS) | |
| try: | |
| torch.set_num_interop_threads(1) | |
| except RuntimeError: | |
| pass | |
| MODEL_ID = os.environ.get("SPELLCHECKER_MODEL", "LiquidAI/LFM2.5-Encoder-350M-Spellchecker") | |
| MODEL_REV = os.environ.get("SPELLCHECKER_REVISION", "main") | |
| STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") | |
| print(f"[server] loading {MODEL_ID}@{MODEL_REV} on {_CPUS} CPU thread(s) ...", flush=True) | |
| _model = AutoModel.from_pretrained(MODEL_ID, revision=MODEL_REV, trust_remote_code=True, | |
| token=os.environ.get("HF_TOKEN")).float().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() | |
| _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") | |
| 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}} | |
| 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} | |
| def index(): | |
| return FileResponse(os.path.join(STATIC, "index.html")) | |
| app.mount("/", StaticFiles(directory=STATIC), name="static") | |