The developer's decisions of 26 Oct (D1-D5) and the calls made building it; notes in ADR 0017 (point 10) and ADR 0021 (D54) on what it amends; Learn's AGENTS.md on the sidecar and the checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
120 lines
8.3 KiB
Markdown
120 lines
8.3 KiB
Markdown
# ADR 0024: LanguageTool as a sidecar
|
||
|
||
- **Status:** decided 26 Oct 2026, by the developer (D1–D5 below); built on 26 Oct for
|
||
v0.21.0, with ADR 0021's step 3.
|
||
- **Amends:** ADR 0017, point 10 (a private studyset is never "checked by the instance's
|
||
LanguageTool"): what the person writes in one is checked once they agree for it (D5),
|
||
and what they type into the field's Check always (D4). ADR 0021, D54 (Check in the
|
||
field asks the assistant): LanguageTool checks first where it can (D3).
|
||
|
||
## Context
|
||
|
||
Halkyon Learn is used to write in the language being learnt: flashcards, notes, answers
|
||
in the field. A spelling and grammar checker helps there as much as anywhere, and
|
||
LanguageTool is the open-source one (LGPL 2.1) that runs on one's own server: some 60
|
||
languages, Korean not among them, Greek and Chinese unmaintained, German and English the
|
||
best kept. Its HTTP API checks a text in a language, or tells the language itself, and
|
||
answers with what it found: where, why, and what could stand there instead.
|
||
|
||
An instance may already run services beside itself (`deploy/sidecars/`, decided 14 Oct:
|
||
Jellyfin), each a compose file switched on in `deploy/.env`. On 26 Oct, before the push
|
||
of ADR 0021's three steps, the developer asked "to also include the open-source version
|
||
of languagetool as a potential sidecar, similar to Jellyfin being an optional sidecar".
|
||
|
||
LanguageTool's server has no sign-in. Jellyfin has its own accounts, so the instance
|
||
publishes it under a site of its own; LanguageTool published so would check anyone's
|
||
text on the operator's machine. And unlike a person's Jellyfin, which the app reaches
|
||
directly, a checker sees what people write while they write it.
|
||
|
||
## Decisions (26 Oct 2026, the developer's)
|
||
|
||
- **D1 The sidecar, a server endpoint, the field's Check and underlining in the editors**
|
||
(the developer's choice, over the sidecar with Check alone, which was recommended as
|
||
the smaller step, and over the sidecar with the endpoint alone): the checks reach every
|
||
place people write.
|
||
- **D2 The image is `meyay/languagetool`** 6.8-13, pinned by its digest (recommended, over
|
||
`erikvl87/languagetool`, a month behind, and over building our own from LanguageTool's
|
||
tag): LanguageTool's own downloads end at 6.6, and this image builds every release from
|
||
its tag, runs as any user ID on a read-only filesystem and has a health check.
|
||
- **D3 Check goes to LanguageTool first** (recommended, amending ADR 0021's D54): where the
|
||
instance checks the language, the field's Check opens a sheet with what LanguageTool
|
||
found, a tap taking a suggestion into the field, and "Ask <assistant> why" where one is
|
||
offered; where it doesn't, Check asks the assistant as before. LanguageTool answers in
|
||
a moment and costs the operator no tokens; the assistant explains, which LanguageTool
|
||
can't.
|
||
- **D4 What is typed into the field goes to LanguageTool in a private studyset too**
|
||
(recommended, over checking it nowhere there): the person typed it to have it checked,
|
||
as they paste a text to have flashcards proposed (D56), and the text is theirs, not the
|
||
studyset's.
|
||
- **D5 The editors of a private studyset check only once agreed, asked once per studyset**
|
||
(recommended, as D9 and D17 ask for an assistant; over never checking there, as ADR
|
||
0017's point 10 had it, and over the device's setting alone deciding): a banner above
|
||
the editor, not a dialog, while it has the focus, says that what is written there goes
|
||
to the instance readable, a paragraph at a time, and is kept nowhere, with "Check here"
|
||
and "Not here". The answer holds for the studyset and its topics, follows the person
|
||
(their sealed settings) and is taken back in the studyset's privacy dialog, which asks
|
||
again.
|
||
|
||
## What else was built so (the agent's calls, each with its reason)
|
||
|
||
- **Reached only through the server.** The sidecar joins `edge` without a site in Caddy
|
||
and publishes no port; the server's `grammar.check` (`requireLogin`) forwards a text and
|
||
its language (`LANGUAGETOOL_URL`, set by the sidecar's compose file). The server checks
|
||
who asks and how often (60 checks a minute per person, counted in memory), refuses a
|
||
text over 20,000 characters, and keeps and logs neither the text nor what was found; a
|
||
failure is logged without LanguageTool's answer. The endpoint is Learn's, not
|
||
halkyon_base's: only Learn writes text to check so far, and a module's endpoint is for
|
||
what every Halkyon app needs.
|
||
- **No log in the container** (`logging: none`): LanguageTool's log names each check's
|
||
length and language only, but a failing rule may quote its sentence.
|
||
- **Memory**: Java's heap at most 1 GB (1.2 GB resident with fastText beside it and five
|
||
languages loaded, measured), the container capped at 2 GB.
|
||
- **fastText on**: the model telling languages apart (126 MB, fetched at the first start
|
||
into `deploy/languagetool/fasttext`) makes the recognised language of a note far more
|
||
reliable than LanguageTool's own guess; it costs 135 MB of memory.
|
||
- **Languages**: the instance asks its LanguageTool which it checks as it starts and an
|
||
hour after, and `instance.info()` names them (`grammarLanguages`, primary subtags), so
|
||
the app offers checks only where they work. A tag maps to LanguageTool's code
|
||
(`languageToolCode`): `en` and `de` to `en-US` and `de-DE`, since the bare codes check no
|
||
spelling; traditional Chinese to none, as LanguageTool only knows simplified; an empty
|
||
one to `auto`, whose guess prefers `en-US`, `de-DE`, `pt-PT` and `ca-ES`.
|
||
- **What is checked in which language**: a card's front in the studyset's front language
|
||
(a character card's front not at all: one character is no text), its back and its
|
||
answers in the back's, its hints and explanation, notes, steps, a PDF page's note,
|
||
descriptions and discussions in the language LanguageTool recognises, since they mix
|
||
the studyset's two.
|
||
- **How the editors check** (`core/grammar/`): `GrammarCheckController` checks once the
|
||
writer pauses for 1.5 s and as the editor loses the focus, never at each key; a
|
||
paragraph at a time, so what is found maps to the node's offsets, and only paragraphs
|
||
whose text changed (the rest kept by their text); one check at a time; formulas and
|
||
code masked as spaces, so they are neither checked nor underlined. An edit drops what
|
||
was found at once, so an underline never points at the wrong word; moving the caret
|
||
doesn't. A language LanguageTool refuses ends the checking in that editor. Nothing is
|
||
written into the document: `GrammarOverlay` draws red wavy lines over the editor where
|
||
the editor would draw a selection, and follows its scrolling and wrapping; a tap opens
|
||
a menu of what is wrong and the suggestions, and the one chosen is an edit like any
|
||
other. A check that found nothing shows a muted check in the corner.
|
||
- **Settings › Writing**: "Check grammar while writing", a device setting, on unless
|
||
turned off (`checkGrammar`), saying so where the instance checks nothing yet. It
|
||
covers the editors; the field's Check is asked for each time.
|
||
- **Private studysets' answers**: an agreement is kept as an assistant's is
|
||
(`privateAgreements`, the key `grammar`), so two devices' settings fold it the same way
|
||
(only where both agreed); a refusal in `grammarDeclined` (settings schema 14), folded
|
||
where either refused, so an older row never sends what was refused since.
|
||
- **The privacy page** says that what is checked, and written while checking is on, goes
|
||
to the instance's LanguageTool, a paragraph at a time, in a private studyset only once
|
||
agreed, and that neither keeps it.
|
||
|
||
## Consequences
|
||
|
||
- An operator switches it on as Jellyfin: a user ID of its own (61081), its folder, the
|
||
compose file in `COMPOSE_FILE` (`deploy/sidecars/README.md`). Without it nothing
|
||
changes: the instance names no languages, the editors check nothing, and Check asks the
|
||
assistant.
|
||
- What people write while checking is on is sent to the instance as they write, readable
|
||
to it, as their studysets that aren't private are anyway; in a private studyset only
|
||
where they agreed. A modified instance could keep it: the agreement says where it goes,
|
||
not that the operator can't read it.
|
||
- A paragraph is checked whenever its text changes after a pause; a person typing a long
|
||
note sends some checks a minute. The limit of 60 a minute is above what writing needs
|
||
and below what would let one person keep the checker busy.
|