Files
webmail-client/backend/app/api/spellcheck.py
T

188 lines
6.6 KiB
Python
Raw Normal View History

"""Spelling and grammar check for the compose window, backed by LanguageTool.
The browser sends the text it wants checked here, never to LanguageTool
itself: the engine stays on the internal network, only signed-in users reach
it, and nothing leaves the server. When no engine is configured (or it is
down), the check answers 503 and the compose window simply goes without.
"""
from __future__ import annotations
import asyncio
import time
from collections import Counter
from typing import Any
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.errors import raise_api_error
from app.config import get_settings
from app.identity.deps import CurrentUser, require_csrf
router = APIRouter(prefix="/spellcheck", tags=["compose"])
_TIMEOUT = 10.0
# A message body is checked as a whole; past this it is a document, not mail.
MAX_TEXT = 20_000
_MAX_SUGGESTIONS = 6
# With "auto", LanguageTool settles on ONE language per request, so a message
# mixing Polish and English paragraphs would have every English word flagged as
# a Polish typo. Auto-detected text is therefore checked in chunks, each in its
# own language. A chunk is at least this many characters of consecutive lines:
# a greeting alone ("Witam,") is too short to detect reliably, so it rides with
# the lines after it.
_MIN_CHUNK = 60
_PARALLEL = 4
class CheckIn(BaseModel):
text: str = Field(max_length=MAX_TEXT)
# "auto" or a LanguageTool code ("pl-PL", "en-GB", "de-DE" ...).
language: str = Field(default="auto", max_length=16)
class Issue(BaseModel):
offset: int
length: int
message: str
short_message: str
replacements: list[str]
# spelling | grammar | style: what the underline colour follows.
kind: str
rule_id: str
class CheckOut(BaseModel):
language: str # the language checked (detected, with "auto")
language_name: str
issues: list[Issue]
class LanguageOut(BaseModel):
code: str
name: str
def _base() -> str:
url = get_settings().languagetool_url.rstrip("/")
if not url:
raise_api_error(503, "spellcheck_unavailable", "spell checking is not set up")
return url
def _kind(issue_type: str) -> str:
if issue_type == "misspelling":
return "spelling"
if issue_type in ("style", "typographical", "whitespace", "register", "locale-violation"):
return "style"
return "grammar"
def chunks(text: str) -> list[tuple[int, str]]:
"""Split ``text`` into (offset, chunk) runs of whole lines, each at least
_MIN_CHUNK characters where the text allows; a blank line always ends one."""
out: list[tuple[int, str]] = []
start = pos = 0
for line in text.split("\n"):
end = pos + len(line)
if not line.strip():
if text[start:pos].strip():
# Up to the line break before the blank line, not including it.
out.append((start, text[start:pos].rstrip("\n")))
start = end + 1
elif end - start >= _MIN_CHUNK:
out.append((start, text[start:end]))
start = end + 1
pos = end + 1
if text[start:].strip():
out.append((start, text[start:]))
return out
async def _check_one(
client: httpx.AsyncClient, base: str, text: str, language: str
) -> dict[str, Any]:
resp = await client.post(f"{base}/v2/check", data={"text": text, "language": language})
resp.raise_for_status()
data: dict[str, Any] = resp.json()
return data
def _issues(data: dict[str, Any], shift: int) -> list[Issue]:
return [
Issue(
offset=int(m["offset"]) + shift,
length=int(m["length"]),
message=str(m.get("message", "")),
short_message=str(m.get("shortMessage", "")),
replacements=[str(r["value"]) for r in (m.get("replacements") or [])][
:_MAX_SUGGESTIONS
],
kind=_kind(str((m.get("rule") or {}).get("issueType", ""))),
rule_id=str((m.get("rule") or {}).get("id", "")),
)
for m in data.get("matches") or []
]
def _detected(data: dict[str, Any]) -> tuple[str, str]:
lang = data.get("language") or {}
detected = lang.get("detectedLanguage") or lang
return str(detected.get("code", "")), str(detected.get("name", ""))
@router.post("", response_model=CheckOut, dependencies=[Depends(require_csrf)])
async def check(body: CheckIn, user: CurrentUser) -> CheckOut:
base = _base()
if not body.text.strip():
return CheckOut(language=body.language, language_name="", issues=[])
parts = chunks(body.text) if body.language == "auto" else [(0, body.text)]
gate = asyncio.Semaphore(_PARALLEL)
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
async def one(text: str) -> dict[str, Any]:
async with gate:
return await _check_one(client, base, text, body.language)
results = await asyncio.gather(*(one(text) for _, text in parts))
except (httpx.HTTPError, ValueError):
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
issues = [
i for (shift, _), data in zip(parts, results, strict=True) for i in _issues(data, shift)
]
# The language reported is the one most of the text was written in.
weight: Counter[tuple[str, str]] = Counter()
for (_, text), data in zip(parts, results, strict=True):
weight[_detected(data)] += len(text)
(code, name), _ = weight.most_common(1)[0]
return CheckOut(language=code or body.language, language_name=name, issues=issues)
_languages_cache: tuple[float, list[LanguageOut]] | None = None
@router.get("/languages", response_model=list[LanguageOut])
async def languages(user: CurrentUser) -> list[LanguageOut]:
"""What the engine can check, for the language picker in Settings."""
global _languages_cache
if _languages_cache and time.monotonic() - _languages_cache[0] < 3600:
return _languages_cache[1]
base = _base()
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(f"{base}/v2/languages")
resp.raise_for_status()
rows: list[dict[str, Any]] = resp.json()
except (httpx.HTTPError, ValueError):
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
seen: dict[str, str] = {}
for row in rows:
seen.setdefault(str(row["longCode"]), str(row["name"]))
out = sorted((LanguageOut(code=c, name=n) for c, n in seen.items()), key=lambda x: x.name)
_languages_cache = (time.monotonic(), out)
return out