Compose: spelling and grammar check with LanguageTool

A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by
`make up`) checks what the user writes in the compose window, through
POST /api/spellcheck — the browser never talks to the engine directly.

- Marks are CSS highlights over the text, never written into the message: red
  wavy for spelling, blue for grammar/style. Clicking one shows the message,
  up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore.
  The popup, like the link popup, opens above its anchor when there is no
  room below.
- Checked after a pause in typing; the signature and quoted original are
  skipped; marks on words just edited drop at once; a check overtaken by a
  newer one, by turning checking off, or by closing the window is discarded.
- With "auto", text is checked in chunks of lines, each in its own detected
  language — LanguageTool settles on one language per request, so a mixed
  Polish/English message had every English word flagged as a Polish typo.
- Settings > Appearance: on/off and language (Auto-detect or any of the
  engine's languages); the compose window's More menu overrides it for one
  message. The browser's own spellcheck steps aside while ours runs, and comes
  back when it is off or the server has no engine (503 from the endpoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 23:51:31 +02:00
co-authored by Claude Opus 5.5
parent b157c5c9bb
commit 1f2e539eb2
16 changed files with 1000 additions and 9 deletions
+182
View File
@@ -0,0 +1,182 @@
"""Spelling/grammar check proxy (compose window -> LanguageTool).
LanguageTool is replaced by a stub transport, so these run without the engine.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import spellcheck
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
LT_REPLY: dict[str, Any] = {
"language": {
"code": "auto",
"name": "Auto",
"detectedLanguage": {"code": "pl-PL", "name": "Polish"},
},
"matches": [
{
"offset": 12,
"length": 6,
"message": "Wyraz „prosze” to najczęściej literówka",
"shortMessage": "Literówka",
"replacements": [{"value": "proszę"}],
"rule": {"id": "PL_PROSZE", "issueType": "misspelling"},
},
{
"offset": 40,
"length": 11,
"message": "Powtórzony wyraz",
"shortMessage": "",
"replacements": [{"value": v} for v in "abcdefgh"],
"rule": {"id": "WORD_REPEAT", "issueType": "duplication"},
},
],
}
@pytest_asyncio.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"sp{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
async with httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
) as c:
yield c
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _stub(monkeypatch: pytest.MonkeyPatch, handler: Any) -> list[httpx.Request]:
"""Route the module's outbound calls to `handler`; return what was sent."""
seen: list[httpx.Request] = []
real = httpx.AsyncClient
def wrapped(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
monkeypatch.setattr(
spellcheck.httpx,
"AsyncClient",
lambda **kw: real(transport=httpx.MockTransport(wrapped), **kw),
)
monkeypatch.setattr(get_settings(), "languagetool_url", "http://lt.test:8010")
return seen
async def test_check_returns_trimmed_typed_issues(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
resp = await client.post(
"/api/spellcheck",
json={"text": "Dzień dobry prosze o pliki ważne ważne", "language": "auto"},
)
assert resp.status_code == 200
body = resp.json()
assert (body["language"], body["language_name"]) == ("pl-PL", "Polish")
first, second = body["issues"]
assert first["kind"] == "spelling" and first["replacements"] == ["proszę"]
assert second["kind"] == "grammar" and len(second["replacements"]) == 6
assert b"language=auto" in sent[0].content
async def test_unconfigured_or_down_engine_is_a_503(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def down(_r: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
_stub(monkeypatch, down)
resp = await client.post("/api/spellcheck", json={"text": "hello"})
assert resp.json()["error"]["code"] == "spellcheck_unavailable"
monkeypatch.setattr(get_settings(), "languagetool_url", "")
resp = await client.post("/api/spellcheck", json={"text": "hello"})
assert resp.status_code == 503
async def test_empty_text_never_reaches_the_engine(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
resp = await client.post("/api/spellcheck", json={"text": " \n "})
assert resp.json()["issues"] == [] and sent == []
async def test_settings_store_the_spellcheck_choice(client: httpx.AsyncClient) -> None:
out = (await client.patch("/api/settings", json={"spellcheck_language": "pl-PL"})).json()
assert out["spellcheck_language"] == "pl-PL" and out["spellcheck_enabled"] is True
out = (await client.patch("/api/settings", json={"spellcheck_enabled": False})).json()
assert out["spellcheck_enabled"] is False
bad = (await client.patch("/api/settings", json={"spellcheck_language": "<script>"})).json()
assert bad["spellcheck_language"] == "pl-PL" # rejected, previous value kept
def test_auto_text_is_split_into_language_sized_chunks() -> None:
text = "Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.\n\nHi\n\nThanks!"
parts = spellcheck.chunks(text)
# The greeting rides with the long line after it; blank lines end a chunk.
assert [t for _, t in parts] == [
"Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.",
"Hi",
"Thanks!",
]
# Offsets point back into the original text.
assert all(text[o : o + len(t)] == t for o, t in parts)
async def test_auto_checks_each_chunk_and_shifts_offsets(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def per_chunk(request: httpx.Request) -> httpx.Response:
body = request.content.decode()
lang = ("pl-PL", "Polish") if "Witam" in body else ("en-US", "English (US)")
return httpx.Response(
200,
json={
"language": {"detectedLanguage": {"code": lang[0], "name": lang[1]}},
"matches": [
{
"offset": 0,
"length": 2,
"message": "m",
"replacements": [],
"rule": {"id": "R", "issueType": "misspelling"},
}
],
},
)
sent = _stub(monkeypatch, per_chunk)
text = "Witam, to jest długi polski akapit pełen słów i zdań.\n\nHello there friend"
out = (await client.post("/api/spellcheck", json={"text": text})).json()
assert len(sent) == 2
assert [i["offset"] for i in out["issues"]] == [0, text.index("Hello")]
assert out["language_name"] == "Polish" # most of the text