docs(adr): ADR 0024, LanguageTool as a sidecar

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 23:51:45 +01:00
commit c23c0c80a1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 131 additions and 2 deletions

File diff suppressed because one or more lines are too long

View file

@ -17,6 +17,10 @@
GitHub half, point 11's, the GitHub rows below and M1's end hold for nobody: an
account without an address sets no password (`wrongCredentials`), and the GitHub IDs
left are the operator's to delete (ADR 0020, D36, gives the SQL).
And [ADR 0024](0024-languagetool-sidecar.md), D4 and D5 (26 Oct 2026): point 10's
"checked by the instance's LanguageTool" holds no more. What the person types into the
field's Check goes to it, and what they write in a private studyset's editors once they
agreed for that studyset.
## Context

View file

@ -9,6 +9,10 @@
the developer amended on 26 Oct to what was built.
- **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020
(SimpleLogin), ADR 0022 (the hand-off and the connector gone).
- **Amended by:** [ADR 0024](0024-languagetool-sidecar.md), D3 (26 Oct 2026): the field's
Check (D54) goes to the instance's LanguageTool first where it checks the language,
whose sheet offers to ask the assistant why; elsewhere it asks the assistant, as D54
has it.
- **Changes:** the rule of 10 Oct 2026 that Halkyon calls no language model with an API
key, for an instance whose operator opts in.

View file

@ -0,0 +1,120 @@
# 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.