feat(learn): LanguageTool as a sidecar, reached through the server

compose.languagetool.yaml runs LanguageTool's open-source server
(meyay/languagetool 6.8-13, pinned by digest) on `edge` with no site and
no published port, as user 61081 on a read-only filesystem, without a
log. The server alone reaches it (LANGUAGETOOL_URL): grammar.check is
for those signed in, takes 20,000 characters, 60 checks a minute each
(counted in memory), maps a studyset's language to LanguageTool's code
and keeps nothing. instance.info() names the languages it checks; the
privacy page says what goes there. sidecars/README.md has the steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 23:13:46 +01:00
commit f766a880ac
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 2235 additions and 5 deletions

View file

@ -0,0 +1,46 @@
# The LanguageTool sidecar (decided 2026-10-26, ADR 0024): LanguageTool's open-source
# server beside the instance, which checks spelling and grammar for Halkyon Learn (Check
# in the assistant's field, and underlines while writing). Switched on in .env:
#
# COMPOSE_FILE=compose.yaml:compose.languagetool.yaml
#
# It has no site, unlike Jellyfin: LanguageTool has no sign-in of its own, so only the
# server reaches it, which checks who asks and how often, and keeps nothing. Its one
# folder on the server, ./languagetool/fasttext (the model it tells languages apart
# with, fetched at its first start), is owned by a user ID of its own, which no person,
# no other container and not the CI runner uses: processes of one user ID can read
# each other's environment and files. Setting it up: sidecars/README.md.
services:
languagetool:
# meyay/languagetool 6.8-13: LanguageTool 6.8 built from its tag (LanguageTool's own
# downloads end at 6.6), the version Halkyon Learn's checks are tried with.
image: meyay/languagetool@sha256:062924c66c649d706d60aed80af6bab207bb50a053733e7ac1ca0d8446d150f1
restart: unless-stopped
user: "61081:61081"
read_only: true
tmpfs:
# The configuration it writes at each start, and the native libraries Java
# unpacks and loads.
- /tmp:exec,size=64m,uid=61081,gid=61081
environment:
# Java's heap: 1.2 GB resident with fastText beside it, five languages loaded.
JAVA_XMS: 256m
JAVA_XMX: 1g
volumes:
- ./languagetool/fasttext:/fasttext
# On `edge` alone, beside Caddy and the server's public API, never beside Postgres;
# no published port, and no site in Caddy.
networks:
- edge
mem_limit: 2g
# No log at all: LanguageTool's names each check's length and language only, but
# what is written must never be kept, and a failing rule could name its sentence.
# To look into a failure, switch to journald for a while.
logging:
driver: none
server:
environment:
# The instance checks writing here (ADR 0024).
LANGUAGETOOL_URL: http://languagetool:8081/

View file

@ -113,6 +113,9 @@ services:
# The Jellyfin server suggested to people; empty: jellyfin. on the
# instance's own domain, if one answers there; `none`: none.
JELLYFIN_URL: ${JELLYFIN_URL:-}
# The LanguageTool server checking spelling and grammar, reached by the server
# alone; empty: none (compose.languagetool.yaml sets its own).
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL:-}
# Where people's data and the backups are, which the app shows: `EU`, a
# country (`DE`) or a subdivision (`US-CA`); empty: it says nothing.
DATA_LOCATION: ${DATA_LOCATION:-}

View file

@ -18,6 +18,9 @@ site changes.
| Sidecar | Compose file | Site |
|---|---|---|
| Jellyfin, which people watch and listen to learn from | `compose.jellyfin.yaml` | `jellyfin.caddy` |
| LanguageTool, which checks spelling and grammar | `compose.languagetool.yaml` | none |
Both together: `COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`.
## Jellyfin
@ -96,3 +99,53 @@ Steps, in order; ✱ marks a step as root.
Switching it off: the compose file out of `COMPOSE_FILE`, then `docker compose up -d
--remove-orphans`; `deploy/jellyfin/` stays until removed by hand.
## LanguageTool
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling
and grammar for Halkyon Learn: Check in the assistant's field, and the underlines while
writing (the app's Settings › Writing). Only the instance's server reaches it
(`LANGUAGETOOL_URL`, set by the compose file): LanguageTool has no sign-in of its own,
so it gets no site, and the server checks who asks and how often (signed in, 60 checks a
minute each). Nothing written is kept or logged, LanguageTool's own log included: Docker
keeps none for it. It checks without n-gram data (LanguageTool's statistical checks of
words like their and there, tens of GB per language), in about 1.5 GB of memory.
Steps, in order; ✱ marks a step as root.
1. ✱ **Its user and folder** (the operator, on the instance's server, as a user with
sudo): the user and group ID 61081 are LanguageTool's alone, as the compose file runs
it (`useradd` warns that the ID lies above the system range, which is meant: it must
not meet a container's IDs).
```sh
sudo groupadd --system --gid 61081 languagetool
sudo useradd --system --uid 61081 --gid 61081 --no-create-home --shell /usr/sbin/nologin languagetool
cd ~deploy/halkyon-learn/deploy
sudo mkdir -p languagetool/fasttext
sudo chown -R languagetool:languagetool languagetool
```
2. **Switch it on** (the operator, on the instance's server, as `deploy`): in
`deploy/.env`, `COMPOSE_FILE=compose.yaml:compose.languagetool.yaml`.
3. **Start it** (the operator, on the instance's server, as `deploy`):
`cd ~/halkyon-learn/deploy && docker compose up -d`. The server is recreated with it
(its `LANGUAGETOOL_URL`); the instance is away for seconds. At its first start
LanguageTool fetches fastText's model of languages (`lid.176.bin`, 126 MB, from
Facebook's `dl.fbaipublicfiles.com`) into `deploy/languagetool/fasttext/`, which it
uses to tell which language a note is in; checks start once it is there.
4. **Check it**: on the instance's server, as `deploy`, `docker compose ps languagetool`
says `healthy` after a minute; then, from anywhere, the instance names the languages
it checks:
```sh
curl -s -X POST -H 'Content-Type: application/json' -d '{}' https://<domain>/instance/info
```
answers with `grammarLanguages`, `de-DE` and `en-US` among them.
Switching it off: the compose file out of `COMPOSE_FILE`, then `docker compose up -d
--remove-orphans`; the app's Writing settings then say the instance checks nothing, and
`deploy/languagetool/` stays until removed by hand.