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

View file

@ -0,0 +1,400 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
import 'package:halkyon_learn_server/src/grammar/language_tool.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
/// Some of the codes LanguageTool 6.8 lists (`GET /v2/languages`).
const _codes = [
'ar', 'ca-ES', 'ca-ES-valencia', 'de', 'de-AT', 'de-CH', 'de-DE', //
'el-GR', 'en', 'en-GB', 'en-US', 'es', 'es-AR', 'es-ES', 'fr', 'fr-CA', //
'fr-FR', 'it', 'it-IT', 'ja-JP', 'pt', 'pt-BR', 'pt-PT', 'sv', 'sv-SE', //
'zh-CN',
];
/// A match as LanguageTool's `/v2/check` writes it, shortened.
Map<String, Object?> _match({
int offset = 5,
int length = 8,
List<String> replacements = const ['sentence'],
}) => {
'message': 'Possible spelling mistake found.',
'shortMessage': 'Spelling mistake',
'replacements': [
for (final value in replacements) {'value': value},
],
'offset': offset,
'length': length,
'context': {'text': 'This sentance has a mistake.', 'offset': 5, 'length': 8},
'sentence': 'This sentance has a mistake.',
'rule': {
'id': 'MORFOLOGIK_RULE_EN_US',
'description': 'Possible Typo',
'issueType': 'misspelling',
'category': {'id': 'TYPOS', 'name': 'Possible Typo'},
},
};
Map<String, Object?> _answer(
List<Map<String, Object?>> matches, {
String code = 'en-US',
}) => {
'software': {'name': 'LanguageTool', 'version': '6.8'},
'language': {
'name': 'English (US)',
'code': code,
'detectedLanguage': {'name': 'English (US)', 'code': code},
},
'matches': matches,
};
void main() {
group('A language tag becomes the code LanguageTool checks it in', () {
test('as it stands where LanguageTool has it', () {
expect(languageToolCode('en-GB', _codes), 'en-GB');
expect(languageToolCode('de-at', _codes), 'de-AT');
});
test('a language alone in the variant that spells (LanguageTool checks '
'no spelling in `en` or `de` without one)', () {
expect(languageToolCode('en', _codes), 'en-US');
expect(languageToolCode('de', _codes), 'de-DE');
expect(languageToolCode('pt', _codes), 'pt-PT');
expect(languageToolCode('ca', _codes), 'ca-ES');
});
test('a language with one variant in it, else as itself', () {
expect(languageToolCode('el', _codes), 'el-GR');
expect(languageToolCode('ja', _codes), 'ja-JP');
expect(languageToolCode('sv', _codes), 'sv-SE');
expect(languageToolCode('fr', _codes), 'fr');
expect(languageToolCode('es', _codes), 'es');
expect(languageToolCode('ar', _codes), 'ar');
});
test('simplified Chinese in its one variant, traditional in none, as '
'that is simplified', () {
expect(languageToolCode('zh', _codes), 'zh-CN');
expect(languageToolCode('zh-Hans', _codes), 'zh-CN');
expect(languageToolCode('zh-Hant', _codes), isNull);
expect(languageToolCode('zh-TW', _codes), isNull);
});
test('none for a language it lacks, and the one recognised for none', () {
expect(languageToolCode('ko', _codes), isNull);
expect(languageToolCode(null, _codes), 'auto');
expect(languageToolCode(' ', _codes), 'auto');
});
test('the languages it checks, each once, without variants', () {
expect(checkedLanguages(_codes), [
'ar', 'ca', 'de', 'el', 'en', 'es', 'fr', 'it', 'ja', 'pt', 'sv', //
'zh',
]);
});
});
group('What LanguageTool answers', () {
test('its matches, with their suggestions and rules', () {
final check = grammarCheckOf(_answer([_match()]));
expect(check.language, 'en-US');
expect(check.truncated, isFalse);
final match = check.matches.single;
expect((match.offset, match.length), (5, 8));
expect(match.message, 'Possible spelling mistake found.');
expect(match.shortMessage, 'Spelling mistake');
expect(match.replacements, ['sentence']);
expect((match.rule, match.category), ('MORFOLOGIK_RULE_EN_US', 'TYPOS'));
});
test('at most 200 matches and 8 suggestions each, saying so', () {
final check = grammarCheckOf(
_answer([
for (var i = 0; i < 205; i++)
_match(
offset: i,
length: 1,
replacements: [for (var j = 0; j < 12; j++) 's$j'],
),
]),
);
expect(check.matches, hasLength(200));
expect(check.truncated, isTrue);
expect(check.matches.first.replacements, hasLength(8));
});
test('the language it recognised where none was asked for', () {
final check = grammarCheckOf({
'language': {
'code': 'auto',
'detectedLanguage': {'code': 'de-DE'},
},
'matches': <Object?>[],
});
expect(check.language, 'de-DE');
});
test('matches without what they need are left out, empty suggestions '
'too', () {
final check = grammarCheckOf(
_answer([
{'offset': 1},
_match(replacements: ['', 'a']),
]),
);
expect(check.matches.single.replacements, ['a']);
});
});
group('Talking to LanguageTool', () {
late HttpServer server;
late LanguageTool languageTool;
final asked = <Map<String, String>>[];
var status = HttpStatus.ok;
Object? answer;
setUp(() async {
asked.clear();
status = HttpStatus.ok;
answer = null;
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
languageTool = LanguageTool(
Uri.parse('http://127.0.0.1:${server.port}/'),
);
server.listen((request) async {
final body = await utf8.decodeStream(request);
asked.add({
'method': request.method,
'path': request.uri.path,
'type': request.headers.contentType?.mimeType ?? '',
...Uri.splitQueryString(body),
});
request.response
..statusCode = status
..headers.contentType = ContentType.json
..write(jsonEncode(answer));
await request.response.close();
});
});
tearDown(() => server.close(force: true));
test('asks for its languages', () async {
answer = [
{'name': 'German (Germany)', 'code': 'de', 'longCode': 'de-DE'},
{'name': 'Japanese', 'code': 'ja', 'longCode': 'ja-JP'},
];
expect(await languageTool.languages(), ['de-DE', 'ja-JP']);
expect(asked.single, containsPair('path', '/v2/languages'));
});
test('sends the text form-encoded with its language, and with the '
'variants that spell where it is to recognise it', () async {
answer = _answer([_match()]);
final check = await languageTool.check('This sentance', 'en-US');
await languageTool.check('Ein Satz', 'auto');
expect(check.matches.single.replacements, ['sentence']);
expect(asked.first, {
'method': 'POST',
'path': '/v2/check',
'type': 'application/x-www-form-urlencoded',
'text': 'This sentance',
'language': 'en-US',
});
expect(asked.last, containsPair('language', 'auto'));
expect(
asked.last,
containsPair('preferredVariants', 'en-US,de-DE,pt-PT,ca-ES'),
);
});
test('fails when it answers with an error, or with what it never '
'writes', () async {
status = HttpStatus.badRequest;
await expectLater(
languageTool.check('x', 'xx'),
throwsA(isA<LanguageToolFailed>()),
);
status = HttpStatus.ok;
answer = 'not a check';
await expectLater(
languageTool.check('x', 'en-US'),
throwsA(isA<LanguageToolFailed>()),
);
await expectLater(
languageTool.languages(),
throwsA(isA<LanguageToolFailed>()),
);
});
test('fails when nothing answers', () async {
await server.close(force: true);
await expectLater(
languageTool.check('x', 'en-US'),
throwsA(isA<LanguageToolFailed>()),
);
});
});
group('Checking for a person', () {
final alice = UuidValue.fromString('0192d8a0-0000-7000-8000-000000000001');
final bob = UuidValue.fromString('0192d8a0-0000-7000-8000-000000000002');
test('none on an instance without LanguageTool, which names no '
'languages', () async {
final checking = GrammarChecking();
await checking.look();
expect(checking.languages, isNull);
await expectLater(
checking.check(alice, 'A text.'),
throwsA(_refused(GrammarRefusal.unavailable)),
);
});
test('names the languages its LanguageTool checks, looking again an '
'hour later', () async {
final fake = _FakeLanguageTool();
var now = DateTime.utc(2026, 10, 26, 12);
await withClock(Clock(() => now), () async {
final checking = GrammarChecking(languageTool: fake);
await checking.look();
expect(checking.languages, ['de', 'en', 'ja']);
fake.codes = ['de-DE', 'en-US', 'ja-JP', 'el-GR'];
now = now.add(const Duration(minutes: 59));
expect(checking.languages, ['de', 'en', 'ja']);
now = now.add(const Duration(minutes: 2));
// The ones it knows, while it looks again.
expect(checking.languages, ['de', 'en', 'ja']);
await checking.look();
expect(checking.languages, ['de', 'el', 'en', 'ja']);
});
});
test('checks in the code the language becomes, nothing for an empty '
'text', () async {
final fake = _FakeLanguageTool();
final checking = GrammarChecking(languageTool: fake);
await checking.check(alice, 'Ein Satz.', language: 'de');
await checking.check(alice, 'A sentence.');
final empty = await checking.check(alice, ' \n');
expect(fake.checked, [('Ein Satz.', 'de-DE'), ('A sentence.', 'auto')]);
expect(empty.matches, isEmpty);
});
test('refuses a language its LanguageTool lacks, before sending '
'anything', () async {
final fake = _FakeLanguageTool();
final checking = GrammarChecking(languageTool: fake);
await expectLater(
checking.check(alice, '안녕하세요.', language: 'ko'),
throwsA(_refused(GrammarRefusal.unknownLanguage)),
);
expect(fake.checked, isEmpty);
});
test('refuses a text longer than one check takes', () async {
final fake = _FakeLanguageTool();
final checking = GrammarChecking(languageTool: fake);
await checking.check(alice, 'a' * GrammarChecking.maxText);
await expectLater(
checking.check(alice, 'a' * (GrammarChecking.maxText + 1)),
throwsA(_refused(GrammarRefusal.tooLong)),
);
expect(fake.checked, hasLength(1));
});
test('refuses the 61st check in a minute, of that person alone, and '
'takes them again once the minute has passed', () async {
final fake = _FakeLanguageTool();
var now = DateTime.utc(2026, 10, 26, 12);
await withClock(Clock(() => now), () async {
final checking = GrammarChecking(languageTool: fake);
for (var i = 0; i < GrammarChecking.perMinute; i++) {
await checking.check(alice, 'Text $i.');
now = now.add(const Duration(milliseconds: 500));
}
await expectLater(
checking.check(alice, 'One more.'),
throwsA(_refused(GrammarRefusal.tooOften)),
);
await checking.check(bob, 'Bob’s first.');
now = now.add(const Duration(seconds: 31));
await checking.check(alice, 'Later.');
});
expect(fake.checked.last.$1, 'Later.');
});
test('says it is unavailable when LanguageTool fails, and looks for its '
'languages again on the next check', () async {
final fake = _FakeLanguageTool()..fails = true;
final checking = GrammarChecking(languageTool: fake);
await checking.look();
expect(checking.languages, isNull);
await expectLater(
checking.check(alice, 'A text.'),
throwsA(_refused(GrammarRefusal.unavailable)),
);
fake.fails = false;
await checking.check(alice, 'A text.');
expect(checking.languages, ['de', 'en', 'ja']);
});
});
}
Matcher _refused(GrammarRefusal reason) => isA<GrammarRefusedException>()
.having((refused) => refused.reason, 'reason', reason);
/// A LanguageTool that remembers what it was asked to check and finds
/// nothing.
class _FakeLanguageTool implements LanguageTool {
List<String> codes = ['de-DE', 'en-US', 'ja-JP'];
bool fails = false;
final checked = <(String, String)>[];
@override
Uri get address => Uri.parse('http://languagetool:8081/');
@override
Future<List<String>> languages() async {
if (fails) {
throw const LanguageToolFailed('down');
}
return codes;
}
@override
Future<GrammarCheck> check(String text, String language) async {
if (fails) {
throw const LanguageToolFailed('down');
}
checked.add((text, language));
return GrammarCheck(
matches: const [],
language: language == 'auto' ? 'en-US' : language,
truncated: false,
);
}
}