mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-13 13:43:41 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808e97c9ec | ||
|
|
f54bf1c0db | ||
|
|
868bed0ed9 | ||
|
|
cce828e3d7 | ||
|
|
654191dcd4 | ||
|
|
c0d538fba2 | ||
|
|
e0ac6170f4 |
@@ -21,7 +21,14 @@ server {
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
location ^~ /git/ {
|
||||
# Only the git smart-HTTP endpoints of a project (/git/<project id>[.git]/...) are
|
||||
# public. git-bridge's other routes (/status, /health_check, /metrics, /diags,
|
||||
# /api/...) are meant for the internal network only and must not be reachable.
|
||||
location /git/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ "^/git/[0-9a-f]{24}(\.git)?(/|$)" {
|
||||
resolver_timeout 2s;
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
|
||||
@@ -63,6 +70,17 @@ server {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
# handle output.zip, which clsi builds on demand rather than writing to disk
|
||||
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
# handle output files for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _; # Catch all, see http://nginx.org/en/docs/http/server_names.html
|
||||
|
||||
root /overleaf/services/web/public/;
|
||||
|
||||
# block external access to prometheus /metrics
|
||||
location /metrics {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
location /socket.io {
|
||||
proxy_pass http://127.0.0.1:3026;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
location /stylesheets {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
location /minjs {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
location /img {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
# handle output.zip, which clsi builds on demand rather than writing to disk
|
||||
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
# handle output files for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
# handle output files for anonymous users
|
||||
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
# PDF range for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
# PDF range for anonymous users
|
||||
location ~ ^/project/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# block external access to metrics
|
||||
location ~* ^/metrics/?$ {
|
||||
return 404 'Not found';
|
||||
}
|
||||
|
||||
# block external access to all health checks /health_check, /health_check/full, etc
|
||||
location ~* ^/health_check {
|
||||
return 404 'Not found';
|
||||
}
|
||||
|
||||
# Load any extra configuration for this vhost
|
||||
include /etc/nginx/vhost-extras/overleaf/*.conf;
|
||||
}
|
||||
@@ -101,14 +101,14 @@ const GitBridgePATManager = {
|
||||
|
||||
if (!objToken?.user_id) return null
|
||||
|
||||
// does the user still exists and not deleted?
|
||||
// does the user still exists and not deleted and not suspended?
|
||||
// tokens of a deleted user are not deleted until user expires
|
||||
const user = await db.users.findOne(
|
||||
{ _id: new ObjectId(objToken.user_id) },
|
||||
{ projection: { _id: 1 } }
|
||||
{ projection: { _id: 1, suspended: 1 } }
|
||||
)
|
||||
|
||||
if (!user) return null
|
||||
if (!user || user.suspended) return null
|
||||
|
||||
// non-blocking
|
||||
db.oauthAccessTokens.updateOne(
|
||||
|
||||
@@ -96,7 +96,9 @@ async function exportUserProjectsToZip(userId, output) {
|
||||
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
|
||||
)
|
||||
|
||||
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false, null)
|
||||
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false)
|
||||
// NOTE: createZipStreamForMultipleProjects(projectIds, zipFromHistory, callback) has no
|
||||
// historyId slot (unlike createZipStreamForProject), so only one argument besides the ids.
|
||||
|
||||
zipStream.on('progress', progress => {
|
||||
updateProgress(progress.entries.total, allIds.length)
|
||||
|
||||
@@ -37,8 +37,11 @@ const TrackChangesController = {
|
||||
async getAllRanges(req, res, next) {
|
||||
try {
|
||||
const { project_id } = req.params
|
||||
const ranges = await DocumentUpdaterHandler.promises.getProjectRanges(project_id)
|
||||
res.json(ranges)
|
||||
// document-updater only knows the docs loaded in Redis. Flush them to
|
||||
// docstore (without unloading) and read every non-deleted doc from there.
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
|
||||
const docs = await DocstoreManager.promises.getAllRanges(project_id)
|
||||
res.json(docs.map(doc => ({ id: doc._id, ranges: doc.ranges ?? {} })))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -50,6 +53,9 @@ const TrackChangesController = {
|
||||
// provided here to assign names to authors who have left the project but still have unaccepted changes.
|
||||
try {
|
||||
const { project_id } = req.params
|
||||
// Same as getAllRanges: a tracked change still in document-updater is
|
||||
// not in docstore yet, so its author would be missing here.
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
|
||||
const memberIds = await DocstoreManager.promises.getTrackedChangesUserIds(project_id)
|
||||
const limit = pLimit(3)
|
||||
const users = await Promise.all(
|
||||
|
||||
@@ -68,6 +68,7 @@ async function refreshLinkedFile(
|
||||
parentFolderId,
|
||||
userId
|
||||
) {
|
||||
linkedFileData = _normalizeLegacyData(linkedFileData)
|
||||
logger.debug(
|
||||
{ projectId, userId, linkedFileData },
|
||||
'refreshing Zotero linked file'
|
||||
@@ -108,7 +109,7 @@ async function _getBibtex(linkedFileData) {
|
||||
return await ZoteroApiClient.getLibraryBibtex(
|
||||
userId,
|
||||
linkedFileData.group_id, // == null for main library
|
||||
linkedFileData.format || 'bibtex'
|
||||
linkedFileData.format
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -129,6 +130,24 @@ async function _getBibtex(linkedFileData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Files imported before "Zotero: fix for 6.3.0" use the old key names.
|
||||
function _normalizeLegacyData(data) {
|
||||
const {
|
||||
zoteroGroupId,
|
||||
importedByUserId,
|
||||
importedByName,
|
||||
bibFormat,
|
||||
...rest
|
||||
} = data
|
||||
return {
|
||||
...rest,
|
||||
group_id: rest.group_id ?? zoteroGroupId ?? null,
|
||||
importer_id: rest.importer_id ?? importedByUserId,
|
||||
importer_name: rest.importer_name ?? importedByName,
|
||||
format: rest.format ?? bibFormat,
|
||||
}
|
||||
}
|
||||
|
||||
function _sanitizeData(data) {
|
||||
return {
|
||||
provider: 'zotero',
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Credits & Attribution
|
||||
|
||||
This project wraps two upstream bodies of work. Neither is redistributed in
|
||||
unattributed form; the specifics of what comes from where are listed below.
|
||||
|
||||
## 1. grammar-llm — functional core
|
||||
|
||||
**Repo:** <https://github.com/whiteh4cker-tr/grammar-llm>
|
||||
**License:** MIT — Copyright (c) 2025 whiteh4cker-tr
|
||||
**What this project takes:**
|
||||
|
||||
- **The whole correction pipeline** — the pure engine under
|
||||
`grammar-llm/src/electron/core/`, vendored byte-for-byte into this repo's
|
||||
`core/` (7 files: `apply.ts`, `clean.ts`, `correction.ts`, `diff.ts`,
|
||||
`reconstruct.ts`, `sentences.ts`, `types.ts`): sentence splitting, model
|
||||
output cleaning, word-diff/word-fix extraction, suggestion reconstruction.
|
||||
Provenance (source commit `ceff65a` + per-file SHA-256) is recorded in
|
||||
`core/.sync-info`; refresh with `scripts/sync-core.sh`.
|
||||
- **LLM call semantics** — system prompt, stop sequences, sampling defaults
|
||||
(temperature 0.7 / topK 40 / topP 0.95 / minP 0.01), and
|
||||
`node-llama-cpp` session usage mirror
|
||||
`grammar-llm/src/electron/llamaService.ts` so container output stays
|
||||
byte-identical to the desktop app for a given model.
|
||||
- **Model** — GRMR-V3-G4B (fine-tuned by the grammar-llm project; GGUF
|
||||
weights are fetched from Hugging Face at
|
||||
`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M by default).
|
||||
- **Model downloader usage** — the download flow in `src/download-model.ts`
|
||||
follows `grammar-llm`'s use of `createModelDownloader` from
|
||||
`modelManager.ts`.
|
||||
|
||||
> grammar-llm is itself an Electron/React desktop migration of the original
|
||||
> GrammarLLM Python app (`whiteh4cker-tr/grammar-llm`, `python` branch); the
|
||||
> engine core it ships is what this container is built on.
|
||||
|
||||
Full MIT license text (retained with the vendored `core/` files):
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 whiteh4cker-tr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## 2. LanguageTool — API compatibility reference
|
||||
|
||||
**Project:** <https://languagetool.org> (source:
|
||||
<https://github.com/languagetool-org/languagetool>)
|
||||
**License:** GNU Lesser General Public License (LGPL) — see `COPYING.txt` in
|
||||
the LanguageTool source tree
|
||||
**What this project takes:** *the wire protocol, not the code.*
|
||||
|
||||
- **The v2 REST API contract** this container emulates: `GET /v2/languages`,
|
||||
`POST /v2/check`, and the response envelope (`software`, `warnings`,
|
||||
`language`, `matches[]`, `sentenceRanges`) — schema taken from
|
||||
`languagetool-core/src/main/java/org/languagetool/tools/RuleMatchesAsJsonSerializer.java`,
|
||||
`languagetool-server/.../ApiV2.java` and `TextChecker.java`, so that
|
||||
existing LanguageTool clients (Overleaf, editors, CI linters) work
|
||||
unmodified.
|
||||
- **Error/status semantics** (`400` missing text, `413` too long, `503`
|
||||
degraded, `warnings.incompleteResults`) follow LanguageTool's behavior so
|
||||
clients that special-case it keep working.
|
||||
- **Drop-in target packaging** — port `8010`, `--allow-origin '*'` behavior
|
||||
and the "run-a-side" admin story follow the widely deployed
|
||||
`erikvl87/languagetool` / `Erikvl87/docker-languagetool` images.
|
||||
|
||||
No LanguageTool source code is included in this repository; compatibility is
|
||||
at the HTTP/JSON level only, so the LGPL obligations of the Java source do
|
||||
not attach to this container. (If you embed this as a *library* rather than a
|
||||
service, review the LGPL implications yourself.)
|
||||
|
||||
## 3. Runtime dependencies
|
||||
|
||||
| Dependency | License | Role |
|
||||
|---|---|---|
|
||||
| [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) (Gilad S. / withcatai), v3.19.1 | MIT | GGUF inference engine (`llama.cpp` bindings) + `createModelDownloader` used for model fetches |
|
||||
| [esbuild](https://github.com/evanw/esbuild) | MIT | bundling `src/` → `dist/` |
|
||||
| Node.js LTS (≥ 20) | MIT (nodejs org) | runtime |
|
||||
|
||||
## 4. Integration context
|
||||
|
||||
Serves the Overleaf LanguageTool module in this repo
|
||||
(`services/web/modules/languagetool/`, based on upstream
|
||||
[yu-i-i/overleaf-cep#179](https://github.com/yu-i-i/overleaf-cep/pull/179))
|
||||
as a one-for-one replacement for a `languagetool` service container.
|
||||
@@ -0,0 +1,72 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# grammar-llm container — LanguageTool-compatible (v2 API) grammar checker.
|
||||
# Drop-in replacement for erikvl87/languagetool (docker-languagetool):
|
||||
# same port (8010), same /v2/languages + /v2/check wire API, backed by the
|
||||
# grammar-llm engine (GRMR-V3-G4B fine-tuned LLM via node-llama-cpp).
|
||||
#
|
||||
# Build (from this project directory):
|
||||
# docker build -t grammar-llm .
|
||||
#
|
||||
# Run (model auto-downloads on first start unless pre-provisioned):
|
||||
# docker run -p 8010:8010 --name grammar-llm -v grammar-llm-models:/models grammar-llm
|
||||
#
|
||||
# The model (~2.5 GB for Q4_K_M) is NOT baked into the image: put it in the
|
||||
# /models volume (file name GRMR-V3-G4B-Q4_K_M.gguf by default) or let the
|
||||
# container download it from Hugging Face on first start. See README.md.
|
||||
|
||||
ARG NODE_TAG=24
|
||||
|
||||
# ---------- build stage ----------
|
||||
FROM node:${NODE_TAG}-bookworm-slim AS build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /build
|
||||
|
||||
# Install all deps (needed for bundling: esbuild; runtime: node-llama-cpp).
|
||||
# The @node-llama-cpp/* prebuilt platform package for THIS build platform is
|
||||
# what makes the bundle loadable; we re-install runtime deps for the TARGET
|
||||
# platform in the next stage (docker build --platform handles cross-arch).
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY build.mjs ./
|
||||
COPY core/ core/
|
||||
COPY src/ src/
|
||||
RUN node build.mjs \
|
||||
&& npm prune --omit=dev
|
||||
|
||||
# Sanity: the bundle must resolve its only external (node-llama-cpp).
|
||||
RUN node --input-type=module -e "import('node-llama-cpp').then(() => process.exit(0), (e) => { console.error(e.message); process.exit(1); })"
|
||||
|
||||
# ---------- runtime stage ----------
|
||||
FROM node:${NODE_TAG}-bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_ENV=production \
|
||||
PORT=8010 \
|
||||
GRAMMAR_LLM_MODEL_DIR=/models \
|
||||
GRAMMAR_LLM_PRELOAD=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime-only deps (node-llama-cpp + prebuilt native lib) for the
|
||||
# target platform of the image.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& node -e "import('node-llama-cpp').then(() => process.exit(0)).catch(() => process.exit(1))"
|
||||
|
||||
COPY --from=build /build/dist ./dist
|
||||
COPY start.sh ./
|
||||
RUN chmod +x start.sh \
|
||||
&& useradd -m -d /home/grammarllm -s /nonexistent grammarllm 2>/dev/null || true \
|
||||
&& mkdir -p /models \
|
||||
&& chown -R grammarllm:grammarllm /models /app
|
||||
|
||||
USER grammarllm
|
||||
VOLUME ["/models"]
|
||||
EXPOSE 8010
|
||||
|
||||
# Model download can take a while on first start → generous start period.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=1800s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8010) + '/v2/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["./start.sh"]
|
||||
@@ -0,0 +1,133 @@
|
||||
# grammar-llm container — development & ops tasks
|
||||
#
|
||||
# Everything maps to the documented npm scripts / docs; pick whichever you
|
||||
# like. Run `make help` (the default) for the list.
|
||||
|
||||
SHELL := /bin/bash
|
||||
NODE ?= node
|
||||
DOCKER ?= docker
|
||||
NODE_TAG ?= 24
|
||||
|
||||
IMAGE ?= grammar-llm
|
||||
TAG ?= latest
|
||||
PORT ?= 8010
|
||||
CONTAINER_NAME := glm-run
|
||||
MODEL_VOLUME := glm-models
|
||||
MODEL_DIR ?= models
|
||||
MODEL_FILE ?= GRMR-V3-G4B-Q4_K_M.gguf
|
||||
MODEL_URL ?= https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
# BASE for the smoke script (server to smoke-test against)
|
||||
BASE ?= http://127.0.0.1:8010
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "grammar-llm container — make targets"
|
||||
@echo
|
||||
@echo " Local dev"
|
||||
@echo " install npm install (node-llama-cpp + esbuild)"
|
||||
@echo " build bundle src/ -> dist/ (esbuild)"
|
||||
@echo " test build + node --test test/ (no model needed)"
|
||||
@echo " start run the server locally (needs model; see download-model)"
|
||||
@echo " download-model fetch GRMR-V3-G4B GGUF into ./$(MODEL_DIR)/ (local server)"
|
||||
@echo " sync-core re-vendor core/ from grammar-llm (scripts/sync-core.sh)"
|
||||
@echo " smoke BASE=<url> e2e smoke against a RUNNING server (default $(BASE))"
|
||||
@echo
|
||||
@echo " Docker"
|
||||
@echo " docker-build docker build -t $(IMAGE):$(TAG) ."
|
||||
@echo " docker-smoke docker-build + run on :$(PORT) + smoke against it"
|
||||
@echo " docker-run start container on :$(PORT) (model volume, detached)"
|
||||
@echo " docker-stop stop + rm the container (volume kept)"
|
||||
@echo " docker-clobber docker-stop + delete the model volume"
|
||||
@echo
|
||||
@echo " Misc"
|
||||
@echo " all build the Docker image (compiles inside the node base image — no local Node needed)"
|
||||
@echo " docker-test run the unit tests inside a node base-image container (no local Node needed)"
|
||||
@echo " logs tail the running container's logs"
|
||||
@echo " clean rm -rf dist node_modules (keep core/, models)"
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install
|
||||
|
||||
.PHONY: ensure-deps
|
||||
ensure-deps:
|
||||
@if [ ! -d node_modules ]; then \
|
||||
echo "node_modules missing — running npm install…"; \
|
||||
npm install; \
|
||||
fi
|
||||
|
||||
.PHONY: build
|
||||
build: ensure-deps
|
||||
$(NODE) build.mjs
|
||||
|
||||
.PHONY: test
|
||||
test: ensure-deps
|
||||
$(NODE) build.mjs --quiet && $(NODE) --test test/*.test.mjs
|
||||
|
||||
.PHONY: start
|
||||
start: ensure-deps build
|
||||
GRAMMAR_LLM_MODEL_DIR=$(MODEL_DIR) PORT=$${PORT:-8010} $(NODE) dist/server.mjs
|
||||
|
||||
.PHONY: download-model
|
||||
download-model: ensure-deps build
|
||||
mkdir -p $(MODEL_DIR)
|
||||
$(NODE) dist/download-model.mjs --url "$(MODEL_URL)" --out "$(MODEL_DIR)/$(MODEL_FILE)"
|
||||
|
||||
.PHONY: sync-core
|
||||
sync-core:
|
||||
bash scripts/sync-core.sh $(GRAMMAR_LLM_SRC)
|
||||
|
||||
.PHONY: smoke
|
||||
smoke:
|
||||
BASE=$(BASE) bash test/smoke.sh
|
||||
|
||||
# --- docker ---------------------------------------------------------------
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build:
|
||||
$(DOCKER) build -t $(IMAGE):$(TAG) .
|
||||
|
||||
.PHONY: docker-test
|
||||
docker-test:
|
||||
@echo "running the test suite inside the $(NODE_TAG:24=24)-bookworm-slim base image (no local Node needed)"
|
||||
$(DOCKER) run --rm -v "$(PWD):/src:ro" -w /app node:$(NODE_TAG)-bookworm-slim sh -c \
|
||||
'cp -r /src/. /app && npm ci --no-audit --no-fund && node build.mjs --quiet && node --test test/*.test.mjs'
|
||||
|
||||
.PHONY: docker-smoke
|
||||
docker-smoke: docker-build
|
||||
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
|
||||
@echo "waiting for model load…"
|
||||
@for i in $$(seq 1 120); do \
|
||||
[ "$$($(DOCKER) logs $(CONTAINER_NAME) 2>&1 | grep -c 'model loaded and ready')" -ge 1 ] && break; \
|
||||
sleep 5; \
|
||||
done
|
||||
BASE=http://127.0.0.1:$(PORT) bash test/smoke.sh
|
||||
@echo "container $(CONTAINER_NAME) left running — stop with: make docker-stop"
|
||||
|
||||
.PHONY: docker-run
|
||||
docker-run:
|
||||
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
|
||||
@echo "$(CONTAINER_NAME) is up on :$(PORT) — check with: make smoke BASE=http://127.0.0.1:$(PORT)"
|
||||
|
||||
.PHONY: docker-stop
|
||||
docker-stop:
|
||||
$(DOCKER) stop $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) rm $(CONTAINER_NAME) 2>/dev/null || true
|
||||
|
||||
.PHONY: docker-clobber
|
||||
docker-clobber: docker-stop
|
||||
$(DOCKER) volume rm $(MODEL_VOLUME)
|
||||
|
||||
.PHONY: logs
|
||||
logs:
|
||||
$(DOCKER) logs -f $(CONTAINER_NAME)
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf dist node_modules
|
||||
|
||||
.PHONY: all
|
||||
all: docker-build
|
||||
@echo "all done — image: $(IMAGE):$(TAG) (compiled inside the node base image; no local Node needed)"
|
||||
@@ -0,0 +1,373 @@
|
||||
# grammar-llm container
|
||||
|
||||
A **drop-in replacement for the LanguageTool container**
|
||||
([docker-languagetool](https://github.com/Erikvl87/docker-languagetool),
|
||||
`erikvl87/languagetool`) for grammar checking — same port, same wire API —
|
||||
powered by the **grammar-llm** engine: a fine-tuned local LLM
|
||||
(`GRMR-V3-G4B`, [whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm))
|
||||
running through [`node-llama-cpp`](https://github.com/withcatai/node-llama-cpp).
|
||||
|
||||
The intent: one admin can run the classic `languagetool` container, another
|
||||
can run this container — and the rest of the stack (e.g. the Overleaf
|
||||
LanguageTool module) points at whichever one is up, without code changes.
|
||||
|
||||
```
|
||||
┌────────────┐ LanguageTool v2 API (port 8010) ┌───────────────────────────┐
|
||||
│ Overleaf │ ───────────────────────────────────▶ │ grammar-llm container │
|
||||
│ (or any │ ◀─────────────────────────────────── │ ┌─────────────────────┐ │
|
||||
│ LT client) │ { software, matches[] … } JSON │ │ HTTP server (node) │ │
|
||||
└────────────┘ │ │ grammar-llm core* │ │
|
||||
│ │ node-llama-cpp + │ │
|
||||
│ │ GRMR-V3-G4B model │ │
|
||||
│ └─────────────────────┘ │
|
||||
└───────────────────────────┘
|
||||
* vendored from /data_1/compare/grammar-llm/src/electron/core (see scripts/sync-core.sh)
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
# from this directory
|
||||
docker build -t grammar-llm .
|
||||
docker run -d --name grammar-llm -p 8010:8010 -v grammar-llm-models:/models grammar-llm
|
||||
|
||||
# or with compose
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
First start downloads the model (~2.4 GB) into the `/models` volume from
|
||||
Hugging Face (`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M). Subsequent starts use the
|
||||
cached file. The server answers immediately; `/v2/health` reports
|
||||
`state: loading/ready` and `/v2/check` returns `503` until the model is
|
||||
loaded — the same "silent linter" behavior Overleaf already has for timeouts.
|
||||
|
||||
Probe it like LanguageTool:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8010/v2/languages | jq '.[0]'
|
||||
curl -s -X POST http://localhost:8010/v2/check \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=auto' \
|
||||
--data-urlencode 'level=picky' \
|
||||
--data-urlencode 'text=I has two cats. They is very good.' | jq '.matches'
|
||||
```
|
||||
|
||||
Use it as the Overleaf LT backend (see `services/web/modules/languagetool`):
|
||||
|
||||
```sh
|
||||
# env for the overleaf web service — unchanged from the languagetool setup,
|
||||
# just point at this container:
|
||||
LANGUAGE_TOOL_ENABLED=true
|
||||
LANGUAGE_TOOL_HOST=grammar-llm # or 127.0.0.1 on the host
|
||||
LANGUAGE_TOOL_PORT=8010
|
||||
# …or explicitly:
|
||||
# LANGUAGE_TOOL_URL=http://grammar-llm:8010
|
||||
```
|
||||
|
||||
## API (LanguageTool v2 subset)
|
||||
|
||||
| Method & path | LanguageTool equivalent | Notes |
|
||||
|--------------------|-------------------------|-------|
|
||||
| `GET /v2/languages` | `GET /v2/languages` | English variants only (model is English-only) |
|
||||
| `POST /v2/check` | `POST /v2/check` | form-urlencoded **or** JSON body |
|
||||
| `GET /v2/version` | version info | container/version + engine state |
|
||||
| `GET /v2/health` | — (not an LT endpoint) | model state, counter; used by Docker HEALTHCHECK |
|
||||
| `GET /` | — | JSON info payload (endpoints, software) |
|
||||
|
||||
### `POST /v2/check` parameters
|
||||
|
||||
| Param | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| `text` | **used** | plain text to check |
|
||||
| `data` | **used** | JSON `{"text": "…"}` or `{"annotation": [{text|markup}, …]}` — Overleaf sends `annotation` (LaTeX markup = "ignore this text"); markup is skipped and offsets stay aligned with the editor document |
|
||||
| `language` | **used** | English codes (`en`, `en-US`, `en-GB`, …) and `auto`. Non-English: by default → `200` with empty matches (linter silent; admin can set `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE=error` for HTTP 400 instead) |
|
||||
| `level` (`default`/`picky`) | **used** | **Picky mode for the LLM** (see below). `picky` → stricter prompt (style + word choice + phrasing on top of base grammar). `default` → base prompt only (byte-identical to the desktop app). Always forwarded verbatim to the real LanguageTool box when one is configured. |
|
||||
| `disabledRules` | forwarded / ignored | Forwarded to the real LanguageTool box (understands LT rule ids); ignored by the LLM (it never produces them) |
|
||||
| `enabled`, `disabled`, `motherTongue`, `replacedText`, … | accepted, ignored | tolerance for other LT clients |
|
||||
|
||||
### Response shape
|
||||
|
||||
Full LanguageTool envelope (schema reference:
|
||||
`org.languagetool.tools.RuleMatchesAsJsonSerializer` in the `languagetool` source):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"software": { "name": "LanguageTool", "version": "6.8", "apiVersion": 1, "premium": false, "…": "…" },
|
||||
"warnings": { "incompleteResults": false }, // true + reason if the time budget cut the check short
|
||||
"language": { "name": "English (US)", "code": "en-US", "detectedLanguage": { … } },
|
||||
"matches": [
|
||||
{
|
||||
"message": "Shouldn't this be 'the'?",
|
||||
"shortMessage": "the",
|
||||
"replacements": [ { "value": "the" } ], // Overleaf shows the first 5 as apply-buttons
|
||||
"offset": 24, // absolute into the submitted document
|
||||
"length": 3,
|
||||
"context": { "text": "… I has two …", "offset": 19, "length": 3 },
|
||||
"type": { "typeName": "grammar" },
|
||||
"rule": {
|
||||
"id": "GRAMMAR_LLM",
|
||||
"description": "Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)",
|
||||
"issueType": "Grammar",
|
||||
"category": { "id": "GRAMMAR", "name": "Grammar" }, // deliberately NOT 'TYPOS' (see below)
|
||||
"tags": ["LLM", "GRMR-V3-G4B"]
|
||||
},
|
||||
"ignoreForIncompleteSentence": false,
|
||||
"contextForSureMatch": -1
|
||||
}
|
||||
],
|
||||
"sentenceRanges": [[0, 16], [17, 33]]
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the mapping:
|
||||
|
||||
- **Word-level fixes** (replacement / deletion from the core's `wordFixes`)
|
||||
become one match each. **Pure insertions** (zero-width core fixes) are
|
||||
anchored on the neighboring token — e.g. the core's `slow → slowly` +
|
||||
insert-`,` becomes two matches that re-compose exactly to `slowly, …`.
|
||||
- **Quoted-word artifact unwrapping**: GRMR models sometimes emit the
|
||||
corrected word wrapped in quotes (`goed → "good"`). The desktop app shows
|
||||
that in its review UI; a LanguageTool client would apply it verbatim and
|
||||
inject literal quote characters into the document — so the adapter strips
|
||||
one pair of matching surrounding quotes from single-word replacements.
|
||||
(Vendor core is untouched; this happens in `src/languagetool.ts`.)
|
||||
- **Sentence-level rewrites** without word fixes become one match over the
|
||||
changed span.
|
||||
- `category.id` is **`GRAMMAR`**, not `TYPOS`: Overleaf's editor filters out
|
||||
`TYPOS` matches while the Hunspell spellchecker is active — this way
|
||||
grammar-llm suggestions are never silently filtered, while both tools stay
|
||||
usable together.
|
||||
- Matches never span LaTeX `markup` segments: the core pipeline runs per
|
||||
plain-text run (markup is invisible to the model, as in LanguageTool).
|
||||
- Errors mirror LanguageTool: `400` missing `text`/`data`, `413` text too
|
||||
long, `503` model not ready / server busy, `500` unexpected.
|
||||
|
||||
## Model
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Default model | `GRMR-V3-G4B-Q4_K_M.gguf` (2.4 GB) from `https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/` |
|
||||
| Stored in | `/models` (container volume) |
|
||||
| Higher quality | `GRMR-V3-G4B-Q8_0.gguf` (set `GRAMMAR_LLM_MODEL_FILE` + matching `GRAMMAR_LLM_MODEL_URL`) |
|
||||
| Any custom GGUF | set `GRAMMAR_LLM_MODEL_FILE` and pre-place the file in `/models` (or point `GRAMMAR_LLM_MODEL_URL` at it) |
|
||||
|
||||
The model download is done by the container's own `dist/download-model.mjs`
|
||||
(node-llama-cpp `createModelDownloader`, the same API grammar-llm's desktop
|
||||
modelManager uses). It's skipped automatically if the file already exists.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `PORT` | `8010` | HTTP port (matches docker-languagetool & Overleaf's default) |
|
||||
| `HOST` | `0.0.0.0` | bind address |
|
||||
| `GRAMMAR_LLM_MODEL_DIR` | `/models` | model storage dir |
|
||||
| `GRAMMAR_LLM_MODEL_FILE` | `GRMR-V3-G4B-Q4_K_M.gguf` | model file name inside the dir |
|
||||
| `GRAMMAR_LLM_MODEL_URL` | HF Q4_K_M URL | download source (only used when file is absent) |
|
||||
| `GRAMMAR_LLM_DOWNLOAD_AT_START` | `true` | auto-download at boot when file is absent |
|
||||
| `GRAMMAR_LLM_PRELOAD` | `true` | start model load at boot (vs. first check) |
|
||||
| `GRAMMAR_LLM_CONTEXT_SIZE` | `8192` | LLM context (desktop-compatible clamp 256…131072) |
|
||||
| `GRAMMAR_LLM_CONCURRENCY` | `1` | (reserved; single shared session today) |
|
||||
| `GRAMMAR_LLM_MAX_TEXT_LENGTH` | `100000` | max characters per request (→ 413; matches Overleaf's 100 KB cap) |
|
||||
| `GRAMMAR_LLM_MAX_RUNS` | `0` (unlimited) | cap on checkable text runs per request (→ `incompleteResults`) |
|
||||
| `GRAMMAR_LLM_REQUEST_TIMEOUT_MS` | `55000` | per-request time budget; remaining sentences pass through unchanged + `warnings.incompleteResults` (keep under Overleaf's 60 s proxy timeout) |
|
||||
| `GRAMMAR_LLM_SENTENCE_TIMEOUT_MS` | `20000` | per-sentence guard (informational) |
|
||||
| `GRAMMAR_LLM_MAX_IN_FLIGHT` | `4` | concurrent checks before 503 |
|
||||
| `GRAMMAR_LLM_MAX_BODY_BYTES` | `2097152` | HTTP body cap |
|
||||
| `GRAMMAR_LLM_LANGUAGES` | `auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-in,en-za` | accepted `language` codes (lower-cased) |
|
||||
| `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE` | `empty` | `empty` = 200 + no matches, `error` = 400 |
|
||||
| `GRAMMAR_LLM_BACKEND` | `local` | `local` (node-llama-cpp) or `openai` (OpenAI-compatible remote endpoint) |
|
||||
| `GRAMMAR_LLM_OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL (OpenAI, OpenRouter, vLLM, llama.cpp `llama-server`, Ollama `/v1`, a self-hosted GRMR endpoint, …) |
|
||||
| `GRAMMAR_LLM_OPENAI_API_KEY` | _empty_ | Bearer token (only if the endpoint requires one) |
|
||||
| `GRAMMAR_LLM_OPENAI_MODEL` | _required for openai_ | model name/id on the server (e.g. `gpt-4o-mini`, or your GRMR model) |
|
||||
| `GRAMMAR_LLM_OPENAI_STYLE` | `openai` | `openai` = standard `/chat/completions`; `ollama` = native `/api/chat` with `think:false` (needed for Ollama Qwen3-family models, see below) |
|
||||
| `GRAMMAR_LLM_OPENAI_TIMEOUT_MS` | `60000` | timeout for one remote call |
|
||||
| `GRAMMAR_LLM_LANGUAGETOOL_URL` | _empty_ | optional **real LanguageTool container to query first** (e.g. `http://languagetool:8010`); empty = LLM only |
|
||||
| `GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS` | `45000` | real-LT call timeout — its window to win the race |
|
||||
| `GRAMMAR_LLM_CASCADE_BUDGET_MS` | `55000` | **shared wall-clock budget for the whole cascaded check** (LT attempt + LLM fallback). Must stay under the LT client's cap — Overleaf's controller times out at **60 s and then silently shows nothing** |
|
||||
| `GRAMMAR_LLM_LLM_GRACE_MS` | `400` | how long a fast real LT can win before the LLM starts in parallel (keeps the compute-saving benefit; 0 = pure parallel) |
|
||||
| `GRAMMAR_LLM_DEFAULT_LEVEL` | `default` | level used when the client sends none (`default` \| `picky`) |
|
||||
| `GRAMMAR_LLM_ALLOW_ORIGIN` | `*` | CORS origin (docker-languagetool runs with `--allow-origin '*'`) |
|
||||
| `GRAMMAR_LLM_TEMPERATURE` / `GRAMMAR_LLM_TOP_K` / `GRAMMAR_LLM_TOP_P` / `GRAMMAR_LLM_MIN_P` | `0.7 / 40 / 0.95 / 0.01` | sampling — identical defaults to the grammar-llm desktop app |
|
||||
| `GRAMMAR_LLM_REPORTED_LT_VERSION` | `6.8` | version string reported as "LanguageTool" |
|
||||
| `GRAMMAR_LLM_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` |
|
||||
| `LLAMA_USE_MLOCK`, `GGML_N_THREADS`, … | — | node-llama-cpp / llama.cpp passthrough env vars work as-is |
|
||||
|
||||
## Check routing & modes
|
||||
|
||||
Three independent, composable knobs control where and how a check is computed.
|
||||
|
||||
### 1) Optional real LanguageTool first (compute-saving cascade)
|
||||
|
||||
Point this container at a real LanguageTool box and it runs **first**; the LLM
|
||||
only runs when LanguageTool found nothing — saving local inference on the
|
||||
cases the rule engine already solved (typo, casing, punctuation):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL=http://languagetool:8010 # same compose network
|
||||
```
|
||||
|
||||
**How the race works** (all on ONE shared budget, `GRAMMAR_LLM_CASCADE_BUDGET_MS,
|
||||
default 55 s` — chosen to sit under Overleaf's 60 s controller timeout):
|
||||
|
||||
1. The request is forwarded **verbatim** (`text` **or** the original
|
||||
`data.annotation`, `language`, `disabledRules`, and **`level` —
|
||||
`picky`→`picky`**) to `$GRAMMAR_LLM_LANGUAGETOOL_URL/v2/check`.
|
||||
2. After a short grace (`GRAMMAR_LLM_LLM_GRACE_MS`, default 400 ms — long
|
||||
enough that a *warm* real LT, which answers in a few hundred ms, wins for
|
||||
free and the LLM start is **cancelled**), the LLM runs **in parallel**.
|
||||
3. Resolution:
|
||||
- real-LT outcome with **≥1 match** → returned as-is (same envelope
|
||||
Overleaf already consumes);
|
||||
- LLM ready → returned; if the LT is still running it gets a final capped
|
||||
grace (≤ 1.2 s) to land a match ("real LT first", with a lid on it);
|
||||
- an LLM 400/503 outcome only surfaces when the LT had nothing either.
|
||||
|
||||
Why parallel: a *serial* "wait for LT, then run LLM" pushed the total past
|
||||
clients' hard caps (measured 15 s LT timeout + 56 s LLM = 71 s > Overleaf's
|
||||
60 s → the UI silently showed nothing). The shared budget guarantees the
|
||||
response always lands in time — worst case is a *partial* result
|
||||
(`warnings.incompleteResults`), never a client-side timeout. The fallback is
|
||||
silent and automatic, so a dead LanguageTool box never breaks checking — it
|
||||
just costs one attempt. Set the URL back to empty to disable.
|
||||
|
||||
### 2) Optional remote LLM (OpenAI-compatible API)
|
||||
|
||||
When the Overleaf host can't afford local inference, redirect the LLM step to
|
||||
any OpenAI-compatible `chat/completions` endpoint (OpenAI, OpenRouter, vLLM,
|
||||
llama.cpp `llama-server`, Ollama `/v1`, a hosted GRMR/V3 LoRA, …):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_BACKEND=openai
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL=https://api.openai.com/v1 # or your endpoint
|
||||
GRAMMAR_LLM_OPENAI_MODEL=gpt-4o-mini # required
|
||||
GRAMMAR_LLM_OPENAI_API_KEY=sk-… # only if needed
|
||||
```
|
||||
|
||||
**Ollama (Qwen3 and similar reasoning models):** use the native style instead. On
|
||||
Ollama's OpenAI-compat layer the `think:false` flag is ignored for Qwen3-family
|
||||
models and the response can spend *all* completion tokens on hidden reasoning
|
||||
(measured: 111–400 tokens burned for ~15-token answers, occasionally hitting
|
||||
max_tokens and returning an empty answer with HTTP 200). The native
|
||||
`/api/chat` honors top-level `think:false` and suppresses reasoning entirely
|
||||
(measured: ~15 completion tokens, same answer):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_BACKEND=openai
|
||||
GRAMMAR_LLM_OPENAI_STYLE=ollama
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL=http://127.0.0.1:11434 # /v1 suffix is stripped automatically
|
||||
GRAMMAR_LLM_OPENAI_MODEL=qwen3.8:latest
|
||||
GRAMMAR_LLM_OPENAI_API_KEY=ollama # Ollama ignores it
|
||||
```
|
||||
|
||||
If the Ollama server only listens on the host's loopback, add a tiny TCP relay
|
||||
container bound to the docker bridge gateway IP (see
|
||||
`/data_1/docker/compose/ollama-gw/` in this deployment) so containers on the
|
||||
bridge network can reach it without exposing Ollama to the LAN.
|
||||
|
||||
With `backend=openai` **no local model is downloaded or loaded** (`start.sh`
|
||||
skips the model step; the image is unchanged). The same grammar-llm core
|
||||
pipeline (sentence diff, word-fix extraction, quote cleanup, LanguageTool
|
||||
mapping) runs locally — only the per-sentence generation call is remote.
|
||||
Prompt/stop/sampling semantics match the local backend exactly.
|
||||
|
||||
### 3) Picky mode for the LLM (`level`)
|
||||
|
||||
LanguageTool's `default` vs `picky` maps to two LLM prompt modes, mirroring
|
||||
LT's idea that *picky adds rule families on top of the default set*:
|
||||
|
||||
| level | LLM behavior |
|
||||
|------------|--------------|
|
||||
| `default` | base correction prompt only — **byte-identical to the grammar-llm desktop app** (grammar, spelling, punctuation). |
|
||||
| `picky` | base prompt **plus** a "highly picky editor" addendum: word choice, awkward/redundant phrasing, weak punctuation, style. Produces the fuller suggestion set. |
|
||||
|
||||
`/v2/check` sends whatever `level` the client gave (Overleaf default is
|
||||
`picky`). When a client sends no level, `GRAMMAR_LLM_DEFAULT_LEVEL` decides
|
||||
(default `default`). The requested level is always forwarded verbatim to the
|
||||
real LanguageTool box (step 1), so a `picky` Overleaf tab still gets
|
||||
`level=picky` there.
|
||||
|
||||
> Tuning: the picky addendum text is fixed in code (
|
||||
> `DEFAULT_PICKY_EXTRA` in `src/llama.ts`). If you want it stronger or
|
||||
> weaker, send a PR or vendor the constant — the level *mechanism* (two
|
||||
> prompts + LT forwarding) stays the same.
|
||||
|
||||
## Behavior & caveats (vs. LanguageTool)
|
||||
|
||||
| Topic | LanguageTool | grammar-llm container |
|
||||
|-------|--------------|-----------------------|
|
||||
| Languages | 30+ | English only (GRMR-V3-G4B). Non-English → silent (or 400, configurable) |
|
||||
| Speed | ms | seconds per sentence (CPU). Short docs fine; very long docs hit the time budget → `incompleteResults` + partial matches (a LanguageTool-native concept) |
|
||||
| Rules/levels | fine-grained rules, levels `default…picky` | **level supported**: `default` (base prompt, desktop parity) / `picky` (base + style addendum); `disabledRules` forward to a real LT box when configured, ignored by the LLM |
|
||||
| Where it runs | same box | **local** node-llama-cpp, or **remote** OpenAI-compatible endpoint (`GRAMMAR_LLM_BACKEND=openai`), or a real-LT-first cascade (`GRAMMAR_LLM_LANGUAGETOOL_URL`) — pick your compute strategy per deployment |
|
||||
| Dictionary | rule-based + user dicts | statistical (model) |
|
||||
| Memory | ~1–2 GB | model ~2.6 GB (Q4) + context ≈ **give the container ≥ 4 GB RAM** |
|
||||
| GPU | n/a | auto-detected by node-llama-cpp (build/run with a CUDA build + `--gpus all` for best performance) |
|
||||
| Telemetry/premium endpoints | has some | not needed by Overleaf; others → 404 |
|
||||
|
||||
The engine output is byte-identical to the grammar-llm desktop app for a given
|
||||
model: same system prompt, stop sequences, sampling, sentence pipeline
|
||||
(vendored `core/` + mirrored `llamaService` semantics).
|
||||
|
||||
## Development
|
||||
|
||||
**Build the Docker image** — no local Node installation required; the
|
||||
compile (`npm ci` + esbuild) happens inside the `node:24-bookworm-slim`
|
||||
base image's own Node:
|
||||
|
||||
```sh
|
||||
make all # docker build -t grammar-llm:latest .
|
||||
```
|
||||
|
||||
**Local development** (optional; needs Node ≥ 20 + npm to run the bundle
|
||||
and unit tests directly on the host):
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build # esbuild → dist/*.mjs (self-contained; node-llama-cpp external)
|
||||
npm test # build + node --test (LLM-free: fake engine + unit tests)
|
||||
npm run smoke # end-to-end curl against a running local server (needs model)
|
||||
```
|
||||
|
||||
`Makefile` exposes the same targets plus Docker ops — `make help`:
|
||||
|
||||
```make
|
||||
make all # build the Docker image (default target; no local Node needed)
|
||||
make docker-test # run the unit tests inside a node base-image container (no local Node needed)
|
||||
make docker-build # docker build -t grammar-llm:latest .
|
||||
make docker-smoke # build + container on :8010 + e2e smoke
|
||||
make docker-run # start the container (model volume kept)
|
||||
make docker-stop # stop + rm (volume kept) / make docker-clobber (also deletes volume)
|
||||
make logs # tail container logs
|
||||
```
|
||||
|
||||
### Updating the vendored core
|
||||
|
||||
The functional core is vendored under `core/` (snapshot of
|
||||
`grammar-llm/src/electron/core/*.ts` — provenance in `core/.sync-info`):
|
||||
|
||||
```sh
|
||||
scripts/sync-core.sh # from /data_1/compare/grammar-llm (default)
|
||||
scripts/sync-core.sh /path/to/grammar-llm
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
`src/llama.ts` mirrors `grammar-llm/src/electron/llamaService.ts` — when the
|
||||
desktop changes prompts/sampling, keep both in sync.
|
||||
|
||||
## Docker build layout
|
||||
|
||||
- **builder**: `node:24-bookworm-slim`, `npm ci` (installs
|
||||
`node-llama-cpp` + platform prebuilds + `esbuild`), bundles `dist/`.
|
||||
- **runtime**: `node:24-bookworm-slim`, `npm ci --omit=dev` (native libs for
|
||||
the target platform), non-root user, `VOLUME /models`, `HEALTHCHECK` against
|
||||
`/v2/health` (1800 s start-period to cover first-run model download).
|
||||
|
||||
## License & credits
|
||||
|
||||
See [CREDITS.md](CREDITS.md). The functional core is vendored from
|
||||
[whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm)
|
||||
(MIT, © 2025 whiteh4cker-tr) — provenance and per-file hashes in
|
||||
`core/.sync-info`. The external protocol is a compatibility implementation of
|
||||
the [LanguageTool](https://languagetool.org) v2 REST API (LGPL; API-level
|
||||
only, no LT code included).
|
||||
@@ -0,0 +1,25 @@
|
||||
// Build: bundle the TypeScript server + vendored core into self-contained
|
||||
// ES modules under dist/. node-llama-cpp stays external (native, platform
|
||||
// packages installed by npm at install time).
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const quiet = process.argv.includes('--quiet');
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/server.ts', 'src/download-model.ts', 'src/languagetool.ts', 'src/config.ts'],
|
||||
outdir: 'dist',
|
||||
outExtension: { '.js': '.mjs' },
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
// Native module: keep as a runtime import; npm installs the right
|
||||
// @node-llama-cpp/* prebuilt for the target platform.
|
||||
external: ['node-llama-cpp'],
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
logLevel: quiet ? 'warning' : 'info',
|
||||
});
|
||||
|
||||
if (!quiet) console.log('✓ build complete → dist/ (server.mjs, download-model.mjs, languagetool.mjs, config.mjs)');
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
grammar-llm:
|
||||
image: grammar-llm:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional environment (uncomment and set as a mapping — compose rejects
|
||||
# an environment key with no entries):
|
||||
# environment:
|
||||
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
|
||||
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
|
||||
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
|
||||
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
|
||||
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
|
||||
volumes:
|
||||
- ./models:/models # the model downloaded here (make download-model → ./models/GRMR-V3-G4B-Q4_K_M.gguf); survives container recreation
|
||||
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
|
||||
# uncomment; node-llama-cpp auto-detects CUDA when available):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
2026-10-28T01:38:51Z
|
||||
source: /data_1/compare/grammar-llm/src/electron/core
|
||||
ceff65a
|
||||
57303d960bf50d2084f71c80149f94e593f1927f4cc6eb1019ce2bd8b2961f73 /data_1/compare/grammar-llm/src/electron/core/apply.ts
|
||||
7c851d7a0c9fd3118a37119d9850ef149792381f82687cee70b58800dfda402d /data_1/compare/grammar-llm/src/electron/core/clean.ts
|
||||
57d585afe1ac6e232d05f03e46c33a2b3ae2c65a18478c798ad3d6f9bd68892f /data_1/compare/grammar-llm/src/electron/core/correction.ts
|
||||
d97ad1c71100da76c3ce0b356727aa51a5f66c43afaf4f73a75d0b125072c594 /data_1/compare/grammar-llm/src/electron/core/diff.ts
|
||||
f750127beb34b35fb1de3dab02ab402d2a29c2e52d852079f49af22a4e923e8a /data_1/compare/grammar-llm/src/electron/core/reconstruct.ts
|
||||
5588044015fde745805ad194d582a8604b3db2636b59c1c36aeda8ecec376593 /data_1/compare/grammar-llm/src/electron/core/sentences.ts
|
||||
133f88bc76531a8727f7c0346f1ae2a4be7ec519532d16409b8c84648fa48ebc /data_1/compare/grammar-llm/src/electron/core/types.ts
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Suggestion } from './types.js';
|
||||
|
||||
export function findAllOccurrences(haystack: string, needle: string): Array<[number, number]> {
|
||||
if (!needle || !haystack) return [];
|
||||
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'g');
|
||||
const matches: Array<[number, number]> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(haystack)) !== null) {
|
||||
matches.push([match.index, match.index + needle.length]);
|
||||
if (match.index === re.lastIndex) re.lastIndex++;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function nearestOccurrence(occurrences: Array<[number, number]>, approxStart: number): [number, number] {
|
||||
return occurrences.reduce((best, occ) =>
|
||||
Math.abs(occ[0] - approxStart) < Math.abs(best[0] - approxStart) ? occ : best,
|
||||
);
|
||||
}
|
||||
|
||||
export function applySuggestion(text: string, suggestionIndex: number, suggestions: Suggestion[]): string {
|
||||
if (suggestionIndex < 0 || suggestionIndex >= suggestions.length) {
|
||||
throw new RangeError('Invalid suggestion index');
|
||||
}
|
||||
const suggestion = suggestions[suggestionIndex];
|
||||
const { start_index: start, end_index: end } = suggestion;
|
||||
|
||||
if (start >= 0 && start <= end && end <= text.length && text.slice(start, end) === suggestion.original) {
|
||||
return text.slice(0, start) + suggestion.corrected + text.slice(end);
|
||||
}
|
||||
|
||||
const occurrences = findAllOccurrences(text, suggestion.original);
|
||||
if (occurrences.length > 0) {
|
||||
const [tStart, tEnd] = nearestOccurrence(occurrences, start);
|
||||
return text.slice(0, tStart) + suggestion.corrected + text.slice(tEnd);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function applySuggestionsBulk(text: string, suggestions: Suggestion[]): string {
|
||||
if (suggestions.length === 0) return text;
|
||||
|
||||
const sorted = suggestions
|
||||
.filter((s) => s.start_index >= 0 && s.end_index >= 0 && s.start_index <= s.end_index)
|
||||
.sort((a, b) => b.start_index - a.start_index);
|
||||
|
||||
const appliedIntervals: Array<[number, number]> = [];
|
||||
let result = text;
|
||||
|
||||
for (const s of sorted) {
|
||||
const { start_index: start, end_index: end } = s;
|
||||
let candidate: [number, number] | null = null;
|
||||
|
||||
if (end <= result.length && result.slice(start, end) === s.original) {
|
||||
candidate = [start, end];
|
||||
} else {
|
||||
const occurrences = findAllOccurrences(result, s.original);
|
||||
if (occurrences.length > 0) {
|
||||
candidate = nearestOccurrence(occurrences, start);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate) continue;
|
||||
|
||||
const [cStart, cEnd] = candidate;
|
||||
const overlaps = appliedIntervals.some(([aStart, aEnd]) => !(cEnd <= aStart || cStart >= aEnd));
|
||||
if (overlaps) continue;
|
||||
|
||||
result = result.slice(0, cStart) + s.corrected + result.slice(cEnd);
|
||||
appliedIntervals.push([cStart, cEnd]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const TEMPLATE_TAG_RE = /<\|.*?\|>/g;
|
||||
const QUOTE_PERIOD_RE = /([.!?])(["'])\s*\./g;
|
||||
const QUOTE_DUP_RE = /([.!?])(["'])\s*\1/g;
|
||||
|
||||
// Mojibake: the UTF-8 bytes of smart quotes (e.g. U+201C = E2 80 9C) can arrive
|
||||
// as Latin-1/CP1252 characters (\u00e2\u20ac\u0153 = "“", or \u00e2\u0080\u009c
|
||||
// when the C1 control bytes survive). Models fine-tuned on scraped data often
|
||||
// reproduce these instead of proper quotes. Replace longer patterns first.
|
||||
const MOJIBAKE_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\u00e2\u20ac\u0153/g, '\u201C'], // “
|
||||
[/\u00e2\u0080\u009c/g, '\u201C'], // “ (raw control bytes)
|
||||
[/\u00e2\u20ac\u009d/g, '\u201D'], // ”
|
||||
[/\u00e2\u0080\u009d/g, '\u201D'], // ” (raw control bytes)
|
||||
[/\u00e2\u20ac\u02dc/g, '\u2018'], // ‘
|
||||
[/\u00e2\u0080\u0098/g, '\u2018'], // ‘ (raw control bytes)
|
||||
[/\u00e2\u20ac\u2122/g, '\u2019'], // ’
|
||||
[/\u00e2\u0080\u0099/g, '\u2019'], // ’ (raw control bytes)
|
||||
[/\u00e2\u20ac\u00a6/g, '\u2026'], // …
|
||||
[/\u00e2\u0080\u00a6/g, '\u2026'], // … (raw control bytes)
|
||||
];
|
||||
|
||||
/** Normalize mojibake smart quotes back to proper Unicode characters. */
|
||||
export function fixMojibakeQuotes(s: string): string {
|
||||
let result = s;
|
||||
for (const [pattern, replacement] of MOJIBAKE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const INSTRUCTION_PREFIXES = [
|
||||
'correct the grammar and spelling of this sentence:',
|
||||
'here is the corrected sentence:',
|
||||
'corrected sentence:',
|
||||
'the corrected version is:',
|
||||
'grammar correction:',
|
||||
'corrected:',
|
||||
];
|
||||
|
||||
export function cleanCorrectedText(corrected: string, original: string): string {
|
||||
if (!corrected) return original;
|
||||
|
||||
let result = fixMojibakeQuotes(corrected).replace(TEMPLATE_TAG_RE, '').trim();
|
||||
|
||||
for (const prefix of INSTRUCTION_PREFIXES) {
|
||||
if (result.toLowerCase().startsWith(prefix)) {
|
||||
result = result.slice(prefix.length).trim();
|
||||
result = result.replace(/^[:]\s*/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const words = result.split(/\s+/);
|
||||
if (words.length > 10) {
|
||||
for (let i = 0; i < words.length - 5; i++) {
|
||||
const segment = words.slice(i, i + 5).join(' ');
|
||||
if (words.slice(i + 5).join(' ').includes(segment)) {
|
||||
result = words.slice(0, i + 5).join(' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (original && /^[A-Z]/.test(original) && result && /^[a-z]/.test(result)) {
|
||||
result = result[0].toUpperCase() + result.slice(1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = result;
|
||||
result = result.replace(QUOTE_PERIOD_RE, '$1$2');
|
||||
result = result.replace(QUOTE_DUP_RE, '$1$2');
|
||||
if (result === before) break;
|
||||
}
|
||||
|
||||
if (original && /[.!?]$/.test(original) && result && !/[.!?]$/.test(result)) {
|
||||
result += original[original.length - 1];
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
export function isOnlyQuoteChange(original: string, corrected: string): boolean {
|
||||
if (original === corrected) return false;
|
||||
const normalize = (s: string): string => s.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"');
|
||||
if (normalize(original) === normalize(corrected)) return true;
|
||||
if (normalize(original.trim()) === normalize(corrected.trim())) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { splitIntoSentences } from './sentences.js';
|
||||
import { cleanCorrectedText, isOnlyQuoteChange } from './clean.js';
|
||||
import { highlightWordDifferences, extractWordFixes } from './diff.js';
|
||||
import { reconstructTextFromSentences } from './reconstruct.js';
|
||||
import type { CorrectionResponse, SentenceCorrector } from './types.js';
|
||||
|
||||
export function cleanQuotePunctuation(s: string): string {
|
||||
let result = s;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = result;
|
||||
result = result.replace(/([.!?])(["'])\s*\./g, '$1$2');
|
||||
result = result.replace(/([.!?])(["'])\s*\1/g, '$1$2');
|
||||
if (result === before) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function correctText(text: string, corrector: SentenceCorrector): Promise<CorrectionResponse> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { suggestions: [], correctedText: '' };
|
||||
|
||||
const sentenceData = splitIntoSentences(text);
|
||||
const suggestions: CorrectionResponse['suggestions'] = [];
|
||||
const correctedSentences: string[] = [];
|
||||
|
||||
for (let i = 0; i < sentenceData.length; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const sentence = sent.text;
|
||||
|
||||
if (sentence.length < 2) {
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
|
||||
let corrected = await corrector.correct(sentence);
|
||||
corrected = cleanCorrectedText(corrected, sentence);
|
||||
|
||||
if (isOnlyQuoteChange(sentence, corrected)) {
|
||||
corrected = sentence;
|
||||
}
|
||||
if (corrected.length > sentence.length * 2) {
|
||||
corrected = sentence;
|
||||
}
|
||||
|
||||
corrected = cleanQuotePunctuation(corrected);
|
||||
|
||||
// Diff once: restore the original quote style (curly vs straight) so
|
||||
// quote changes are never applied to the user's text.
|
||||
const diff = highlightWordDifferences(sentence, corrected);
|
||||
corrected = diff.preservedCorrected;
|
||||
correctedSentences.push(corrected);
|
||||
|
||||
if (
|
||||
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
|
||||
corrected.trim() !== sentence.trim() &&
|
||||
corrected.length <= sentence.length * 1.5 &&
|
||||
!isOnlyQuoteChange(sentence, corrected)
|
||||
) {
|
||||
suggestions.push({
|
||||
original: sentence,
|
||||
corrected,
|
||||
sentence: `Sentence ${i + 1}`,
|
||||
start_index: sent.start,
|
||||
end_index: sent.end,
|
||||
original_highlighted: diff.originalHighlighted,
|
||||
corrected_highlighted: diff.correctedHighlighted,
|
||||
wordFixes: extractWordFixes(sentence, corrected, sent.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const correctedText = cleanQuotePunctuation(
|
||||
reconstructTextFromSentences(text, sentenceData, correctedSentences),
|
||||
);
|
||||
return { suggestions, correctedText };
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { diffArrays } from 'diff';
|
||||
import type { WordFix } from './types.js';
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
for (const char of text) {
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
} else if ('.,!?;:"()[]{}\u201C\u201D'.includes(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Curly quotes compare equal to their straight counterparts so that
|
||||
// quote-style changes alone never trigger word highlighting.
|
||||
function normalizeToken(token: string): string {
|
||||
return token
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
export function highlightWordDifferences(
|
||||
original: string,
|
||||
corrected: string,
|
||||
): {
|
||||
originalHighlighted: string;
|
||||
correctedHighlighted: string;
|
||||
/** Corrected text with the original's quote characters restored. */
|
||||
preservedCorrected: string;
|
||||
} {
|
||||
const originalTokens = tokenize(original);
|
||||
const correctedTokens = tokenize(corrected);
|
||||
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
|
||||
|
||||
let originalHighlighted = '';
|
||||
let correctedHighlighted = '';
|
||||
let preservedCorrected = '';
|
||||
let originalIndex = 0;
|
||||
let correctedIndex = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
const value = Array.isArray(part.value) ? part.value : [part.value];
|
||||
const length = value.length;
|
||||
|
||||
if (part.added) {
|
||||
for (let k = 0; k < length; k++) {
|
||||
const token = correctedTokens[correctedIndex + k];
|
||||
correctedHighlighted += token.trim()
|
||||
? `<span class="corrected-word">${token}</span>`
|
||||
: token;
|
||||
preservedCorrected += token;
|
||||
}
|
||||
correctedIndex += length;
|
||||
} else if (part.removed) {
|
||||
for (let k = 0; k < length; k++) {
|
||||
const token = originalTokens[originalIndex + k];
|
||||
originalHighlighted += token.trim()
|
||||
? `<span class="error-word">${token}</span>`
|
||||
: token;
|
||||
}
|
||||
originalIndex += length;
|
||||
} else {
|
||||
// Equal after quote normalization: emit the ORIGINAL characters on both
|
||||
// sides so quote style stays untouched and words aren't highlighted.
|
||||
const originalSlice = originalTokens.slice(originalIndex, originalIndex + length).join('');
|
||||
originalHighlighted += originalSlice;
|
||||
correctedHighlighted += originalSlice;
|
||||
preservedCorrected += originalSlice;
|
||||
originalIndex += length;
|
||||
correctedIndex += length;
|
||||
}
|
||||
}
|
||||
|
||||
return { originalHighlighted, correctedHighlighted, preservedCorrected };
|
||||
}
|
||||
|
||||
export function extractWordFixes(
|
||||
original: string,
|
||||
corrected: string,
|
||||
baseOffset: number,
|
||||
): WordFix[] {
|
||||
const originalTokens = tokenize(original);
|
||||
const correctedTokens = tokenize(corrected);
|
||||
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
|
||||
|
||||
// Sentence-relative start offset of each original token. Tokens are
|
||||
// contiguous slices of the original, so cumulative lengths give offsets.
|
||||
const originalStarts: number[] = [];
|
||||
let pos = 0;
|
||||
for (const token of originalTokens) {
|
||||
originalStarts.push(pos);
|
||||
pos += token.length;
|
||||
}
|
||||
|
||||
const fixes: WordFix[] = [];
|
||||
let originalIndex = 0;
|
||||
let correctedIndex = 0;
|
||||
let pendingRemoved: Array<{ token: string; start: number }> = [];
|
||||
|
||||
const emitDeletion = (removed: Array<{ token: string; start: number }>) => {
|
||||
const originalText = removed.map((r) => r.token).join('');
|
||||
if (originalText.trim() === '') return; // whitespace-only — nothing visible to click
|
||||
const last = removed[removed.length - 1];
|
||||
fixes.push({
|
||||
original: originalText,
|
||||
corrected: '',
|
||||
start: baseOffset + removed[0].start,
|
||||
end: baseOffset + last.start + last.token.length,
|
||||
});
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
const value = Array.isArray(part.value) ? part.value : [part.value];
|
||||
if (part.removed) {
|
||||
for (let k = 0; k < value.length; k++) {
|
||||
const idx = originalIndex + k;
|
||||
pendingRemoved.push({ token: originalTokens[idx], start: originalStarts[idx] });
|
||||
}
|
||||
originalIndex += value.length;
|
||||
} else if (part.added) {
|
||||
const addedTokens = correctedTokens.slice(correctedIndex, correctedIndex + value.length);
|
||||
if (pendingRemoved.length > 0 && addedTokens.length > 0) {
|
||||
if (pendingRemoved.length === addedTokens.length) {
|
||||
// 1:1 pairing.
|
||||
for (let k = 0; k < pendingRemoved.length; k++) {
|
||||
const removed = pendingRemoved[k];
|
||||
const added = addedTokens[k];
|
||||
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
|
||||
fixes.push({
|
||||
original: removed.token,
|
||||
corrected: added,
|
||||
start: baseOffset + removed.start,
|
||||
end: baseOffset + removed.start + removed.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unequal run. Pair as many leading tokens as possible 1:1; if the
|
||||
// leftovers are punctuation/whitespace only (e.g. 'slow' ->
|
||||
// 'slowly,'), emit them as a separate insertion so the comma shows
|
||||
// as its own marker in the editor. Otherwise keep the whole-run
|
||||
// joined fix (e.g. 'is are' -> 'was' must stay a single unit).
|
||||
const pairCount = Math.min(pendingRemoved.length, addedTokens.length);
|
||||
const leftoverRemoved = pendingRemoved.slice(pairCount);
|
||||
const leftoverAdded = addedTokens.slice(pairCount);
|
||||
// eslint-disable-next-line no-useless-escape -- the [ ] escapes are required here; unescaped [] matches nothing
|
||||
const punctOnly = (tokens: string[]) => tokens.every((t) => /^[\s.,!?;:'"()\[\]{}\u201C\u201D\u2018\u2019]*$/.test(t));
|
||||
if (punctOnly(leftoverRemoved.map((r) => r.token)) && punctOnly(leftoverAdded)) {
|
||||
for (let k = 0; k < pairCount; k++) {
|
||||
const removed = pendingRemoved[k];
|
||||
const added = addedTokens[k];
|
||||
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
|
||||
fixes.push({
|
||||
original: removed.token,
|
||||
corrected: added,
|
||||
start: baseOffset + removed.start,
|
||||
end: baseOffset + removed.start + removed.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
const pairEnd = pendingRemoved[pairCount - 1].start + pendingRemoved[pairCount - 1].token.length;
|
||||
const addedText = leftoverAdded.join('');
|
||||
if (addedText.trim() !== '') {
|
||||
fixes.push({ original: '', corrected: addedText, start: baseOffset + pairEnd, end: baseOffset + pairEnd });
|
||||
}
|
||||
// leftoverRemoved is whitespace/punctuation only — nothing
|
||||
// visible to click, so no deletion fix is emitted.
|
||||
} else {
|
||||
const originalText = pendingRemoved.map((r) => r.token).join('');
|
||||
const addedText = addedTokens.join('');
|
||||
if (originalText.trim() !== '' && addedText.trim() !== '') {
|
||||
const last = pendingRemoved[pendingRemoved.length - 1];
|
||||
fixes.push({
|
||||
original: originalText,
|
||||
corrected: addedText,
|
||||
start: baseOffset + pendingRemoved[0].start,
|
||||
end: baseOffset + last.start + last.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pendingRemoved.length === 0 && addedTokens.length > 0) {
|
||||
// Pure insertion: mark the point before the next original token.
|
||||
const addedText = addedTokens.join('');
|
||||
if (addedText.trim() !== '') {
|
||||
const insertAt = originalIndex < originalTokens.length
|
||||
? originalStarts[originalIndex]
|
||||
: original.length;
|
||||
fixes.push({ original: '', corrected: addedText, start: baseOffset + insertAt, end: baseOffset + insertAt });
|
||||
}
|
||||
}
|
||||
pendingRemoved = [];
|
||||
correctedIndex += value.length;
|
||||
} else {
|
||||
// Equal part closes any pending change group; a removed run with no
|
||||
// added counterpart is a pure deletion.
|
||||
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
|
||||
pendingRemoved = [];
|
||||
originalIndex += value.length;
|
||||
correctedIndex += value.length;
|
||||
}
|
||||
}
|
||||
|
||||
// A removed run at the very end of the diff is also a pure deletion.
|
||||
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
|
||||
|
||||
return fixes;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SentenceData } from './types.js';
|
||||
|
||||
export function reconstructTextFromSentences(
|
||||
originalText: string,
|
||||
sentenceData: SentenceData[],
|
||||
correctedSentences: string[],
|
||||
): string {
|
||||
if (sentenceData.length !== correctedSentences.length) return originalText;
|
||||
|
||||
const resultParts: string[] = [];
|
||||
let lastSpanEnd = 0;
|
||||
|
||||
for (let i = 0; i < sentenceData.length; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const corrected = correctedSentences[i];
|
||||
const { start, end } = sent;
|
||||
const spanEnd = sent.spanEnd ?? end;
|
||||
const gapBeforeStart = sent.gapBeforeStart ?? lastSpanEnd;
|
||||
|
||||
if (gapBeforeStart > lastSpanEnd) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd, gapBeforeStart));
|
||||
} else if (start > lastSpanEnd) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd, start));
|
||||
}
|
||||
|
||||
resultParts.push(corrected);
|
||||
|
||||
if (spanEnd > end) {
|
||||
resultParts.push(originalText.slice(end, spanEnd));
|
||||
} else if (i < sentenceData.length - 1) {
|
||||
if (end < originalText.length) {
|
||||
let whitespaceEnd = end;
|
||||
while (whitespaceEnd < originalText.length && /\s/.test(originalText[whitespaceEnd])) {
|
||||
whitespaceEnd++;
|
||||
}
|
||||
if (whitespaceEnd > end) {
|
||||
resultParts.push(originalText.slice(end, whitespaceEnd));
|
||||
} else if (corrected && /[.!?]$/.test(corrected) && whitespaceEnd < originalText.length) {
|
||||
const nextChar = originalText[whitespaceEnd];
|
||||
// Python: next_char.isalpha() and next_char.isupper()
|
||||
if (nextChar && /[A-Z]/.test(nextChar)) {
|
||||
resultParts.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSpanEnd = spanEnd;
|
||||
}
|
||||
|
||||
if (lastSpanEnd < originalText.length) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd));
|
||||
}
|
||||
|
||||
return resultParts.join('');
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { SentenceData } from './types.js';
|
||||
|
||||
const ABBREVIATIONS = new Set([
|
||||
'etc', 'eg', 'e.g', 'ie', 'i.e', 'vs', 'viz', 'cf', 'ca', 'approx',
|
||||
'no', 'vol', 'fig', 'p', 'pp', 'ch', 'sec', 'ex', 'al', 'et', 'seq',
|
||||
'etc.', 'e.g.', 'i.e.', 'vs.', 'viz.', 'cf.', 'ca.', 'approx.',
|
||||
'no.', 'vol.', 'fig.', 'p.', 'pp.', 'ch.', 'sec.', 'ex.', 'et al.', 'seq.',
|
||||
'mr', 'mrs', 'ms', 'dr', 'prof', 'rev', 'sr', 'jr', 'st',
|
||||
]);
|
||||
|
||||
// Python: (?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$
|
||||
// (re.VERBOSE + re.IGNORECASE; \w is ASCII in JS — acceptable for English text)
|
||||
const SENTENCE_BOUNDARY_RE = /(?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$/gi;
|
||||
|
||||
// A missing space after terminal punctuation — "…on its functionality.The team
|
||||
// were…". The original pattern needs \s+ after the punctuation, so such a run
|
||||
// was swallowed into one sentence. This boundary is zero-width, so it is found
|
||||
// separately, and only when:
|
||||
// * the punctuated word has at least two letters — keeps single-letter
|
||||
// initials glued ("U.S.Today") and decimals out ("3.5.Next"); abbreviations
|
||||
// such as "etc.They" are rejected later by ABBREVIATIONS; and
|
||||
// * the next word starts upper-case followed by a lower-case letter — keeps
|
||||
// "README.TXT" glued and skips "happy.enough".
|
||||
const MISSING_SPACE_BOUNDARY_RE = /(?<=[A-Za-z]{2}[.!?])(?=[A-Z][a-z])/g;
|
||||
|
||||
interface BoundaryMatch {
|
||||
index: number; // position immediately after the terminal punctuation
|
||||
length: number; // whitespace separating the two sentences (0 when missing)
|
||||
}
|
||||
|
||||
function findBoundaryMatches(text: string): BoundaryMatch[] {
|
||||
const matches: BoundaryMatch[] = [];
|
||||
for (const pattern of [SENTENCE_BOUNDARY_RE, MISSING_SPACE_BOUNDARY_RE]) {
|
||||
const re = new RegExp(pattern.source, pattern.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
matches.push({ index: match.index, length: match[0].length });
|
||||
// JS exec() loops forever on zero-width matches; Python's finditer()
|
||||
// auto-advances. Guard like findAllOccurrences does.
|
||||
if (match.index === re.lastIndex) re.lastIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.index - b.index || b.length - a.length);
|
||||
// The two patterns are mutually exclusive (one needs a non-word character
|
||||
// right after the punctuation, the other an upper-case letter), but never
|
||||
// emit the same split twice.
|
||||
return matches.filter((m, i) => i === 0 || m.index !== matches[i - 1].index);
|
||||
}
|
||||
|
||||
export function splitIntoSentences(text: string): SentenceData[] {
|
||||
const sentences: SentenceData[] = [];
|
||||
if (!text.trim()) return sentences;
|
||||
|
||||
let lastEnd = 0;
|
||||
const potentialSplits = findBoundaryMatches(text);
|
||||
|
||||
for (const { index: splitPos, length: gapLength } of potentialSplits) {
|
||||
const sentenceText = text.slice(lastEnd, splitPos).trim();
|
||||
if (!sentenceText) {
|
||||
lastEnd = splitPos + gapLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
let isTrueBoundary = true;
|
||||
|
||||
const prevWords = sentenceText.toLowerCase().split(/\s+/);
|
||||
if (prevWords.length > 0) {
|
||||
const lastWord = prevWords[prevWords.length - 1].replace(/^[.,!?;:"']+|[.,!?;:"']+$/g, '');
|
||||
if (ABBREVIATIONS.has(lastWord)) {
|
||||
isTrueBoundary = false;
|
||||
} else if (/\d\.\d/.test(sentenceText.slice(-10))) {
|
||||
// Python quirk replicated: the single-letter-initial check was dead
|
||||
// code in main.py (misspelled variable), so initials ARE split here.
|
||||
isTrueBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only meaningful for whitespace boundaries: it inspects the character
|
||||
// after the gap's first character to reject a lower-case continuation.
|
||||
// A space-less boundary is already constrained to an upper-case start.
|
||||
if (gapLength > 0 && splitPos + 2 < text.length) {
|
||||
const nextChars = text.slice(splitPos + 1, splitPos + 3);
|
||||
// Python: (next_chars and next_chars[0].islower()) or next_chars[0].isdigit()
|
||||
if (/[a-z\d]/.test(nextChars[0])) {
|
||||
isTrueBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTrueBoundary) continue;
|
||||
|
||||
let startNoWs = lastEnd;
|
||||
while (startNoWs < splitPos && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
|
||||
let spanEnd = splitPos;
|
||||
while (spanEnd < text.length && /\s/.test(text[spanEnd])) spanEnd++;
|
||||
// Nothing was consumed: only mark a one-character span when the boundary
|
||||
// sits at the very end of the text. Mid-text space-less boundaries must keep
|
||||
// spanEnd === end so reconstructTextFromSentences re-inserts the space.
|
||||
if (spanEnd === splitPos && splitPos >= text.length) spanEnd = splitPos + 1;
|
||||
|
||||
sentences.push({ text: sentenceText, start: startNoWs, end: splitPos, spanEnd, gapBeforeStart: lastEnd });
|
||||
lastEnd = spanEnd;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
const remaining = text.slice(lastEnd).trim();
|
||||
if (remaining) {
|
||||
let startNoWs = lastEnd;
|
||||
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
sentences.push({ text: remaining, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: lastEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (sentences.length === 0) {
|
||||
const content = text.trim();
|
||||
if (content) {
|
||||
let startNoWs = 0;
|
||||
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
sentences.push({ text: content, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return sentences;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface SentenceData {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
spanEnd: number;
|
||||
gapBeforeStart: number;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
original: string;
|
||||
corrected: string;
|
||||
sentence: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
original_highlighted: string;
|
||||
corrected_highlighted: string;
|
||||
wordFixes: WordFix[];
|
||||
}
|
||||
|
||||
export interface WordFix {
|
||||
original: string; // misspelled word as it appears in the text
|
||||
corrected: string; // replacement word
|
||||
start: number; // absolute offset in the full input text
|
||||
end: number; // absolute offset (exclusive)
|
||||
}
|
||||
|
||||
export interface CorrectionResponse {
|
||||
suggestions: Suggestion[];
|
||||
correctedText: string;
|
||||
}
|
||||
|
||||
export interface SentenceCorrector {
|
||||
correct(sentence: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# grammar-llm container — docker-compose (modeled on docker-languagetool's
|
||||
# compose so admins can swap containers with a one-line service change).
|
||||
#
|
||||
# Mirrors erikvl87/languagetool: same default port 8010, /v2/* API.
|
||||
# The model is kept in a named volume (auto-downloaded on first start from
|
||||
# Hugging Face unless you pre-place it), so it survives container recreation.
|
||||
|
||||
|
||||
services:
|
||||
grammar-llm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: grammar-llm:latest
|
||||
container_name: grammar-llm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8010:8010 # same default port as docker-languagetool
|
||||
# Optional environment (uncomment and set as a mapping — compose rejects
|
||||
# an environment key with no entries):
|
||||
# environment:
|
||||
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
|
||||
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
|
||||
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
|
||||
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
|
||||
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
|
||||
# # — (a) real LanguageTool first, LLM second (compute-saving cascade) —
|
||||
# GRAMMAR_LLM_LANGUAGETOOL_URL: "http://languagetool:8010" # optional; empty = LLM only
|
||||
# GRAMMAR_LLM_CASCADE_BUDGET_MS: "55000" # shared LT+LLM time budget (keep < LT-client cap; Overleaf: 60 s)
|
||||
# GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: "45000" # LT's window to win the race
|
||||
# GRAMMAR_LLM_LLM_GRACE_MS: "400" # ms before the LLM starts in parallel (0 = pure parallel)
|
||||
# # — (b) route the LLM step to a remote OpenAI-compatible API —
|
||||
# GRAMMAR_LLM_BACKEND: "openai" # local (default) | openai
|
||||
# GRAMMAR_LLM_OPENAI_BASE_URL: "https://api.openai.com/v1"
|
||||
# GRAMMAR_LLM_OPENAI_MODEL: "gpt-4o-mini" # required when backend=openai
|
||||
# GRAMMAR_LLM_OPENAI_API_KEY: "sk-..." # only if the endpoint needs one
|
||||
# # — (c) level used when the client sends none —
|
||||
# GRAMMAR_LLM_DEFAULT_LEVEL: "default" # default | picky
|
||||
volumes:
|
||||
- grammar-llm-models:/models # persistent model storage (~2.5 GB)
|
||||
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
|
||||
# uncomment; node-llama-cpp auto-detects CUDA when available):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
grammar-llm-models:
|
||||
+2333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "grammar-llm-container",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "LanguageTool-compatible (/v2/* API) HTTP grammar checker — drop-in replacement for docker-languagetool — powered by the grammar-llm engine (GRMR-V3-G4B via node-llama-cpp).",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"start": "node dist/server.mjs",
|
||||
"download-model": "node dist/download-model.mjs",
|
||||
"test": "node build.mjs --quiet && node --test test/*.test.mjs",
|
||||
"smoke": "bash test/smoke.sh",
|
||||
"sync-core": "bash scripts/sync-core.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-llama-cpp": "3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"diff": "^9.0.0",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync-core.sh — refresh the vendored grammar-llm core in ./core/ from the
|
||||
# grammar-llm source tree.
|
||||
#
|
||||
# The container's functional core lives in the grammar-llm project
|
||||
# (/data_1/compare/grammar-llm on this machine). This project vendors a
|
||||
# snapshot of src/electron/core/*.ts (the pure, framework-free engine) so the
|
||||
# Docker build stays self-contained. Run this script after updating
|
||||
# grammar-llm to pick up engine changes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sync-core.sh [path-to-grammar-llm-repo]
|
||||
#
|
||||
# The default location is the sibling repo at <compare-root>/grammar-llm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Default: /data_1/compare/grammar-llm (project is at
|
||||
# /data_1/compare/overleaf-cep/tools/grammar-llm-container)
|
||||
DEFAULT_SRC="$(cd "$PROJECT_ROOT/../../.." && pwd)/grammar-llm"
|
||||
SRC="${1:-$DEFAULT_SRC}"
|
||||
|
||||
if [ ! -d "$SRC/src/electron/core" ]; then
|
||||
echo "ERROR: $SRC/src/electron/core not found." >&2
|
||||
echo "Usage: $0 [path-to-grammar-llm-repo]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST="$PROJECT_ROOT/core"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Copy the non-test core modules (apply.ts is UI-side app logic, kept for
|
||||
# completeness; the server uses sentences/clean/correction/diff/reconstruct/types).
|
||||
shopt -s nullglob
|
||||
for f in "$SRC"/src/electron/core/*.ts; do
|
||||
b="$(basename "$f")"
|
||||
case "$b" in
|
||||
*.test.ts) ;;
|
||||
*) cp -f "$f" "$DEST/$b" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Record provenance
|
||||
{
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
echo "source: $SRC"
|
||||
if git -C "$SRC" rev-parse --short HEAD >/dev/null 2>&1; then
|
||||
echo "commit: $(git -C "$SRC" rev-parse --short HEAD)"
|
||||
echo "status: $(git -C "$SRC" status --porcelain -- src/electron/core | head -1)"
|
||||
fi
|
||||
sha256sum "$SRC"/src/electron/core/*.ts | grep -v test || true
|
||||
} > "$DEST/.sync-info"
|
||||
|
||||
echo "Synced $(ls "$DEST"/*.ts | wc -l) core module(s) from $SRC"
|
||||
echo "Provenance written to $DEST/.sync-info"
|
||||
@@ -0,0 +1,211 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_MODEL_URL =
|
||||
'https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf';
|
||||
|
||||
export interface Sampling {
|
||||
temperature: number;
|
||||
topK: number;
|
||||
topP: number;
|
||||
minP: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
allowOrigin: string;
|
||||
|
||||
/** Reported as the LanguageTool software version in response envelopes. */
|
||||
reportedVersion: string;
|
||||
|
||||
modelDir: string;
|
||||
modelFile: string;
|
||||
modelUrl: string;
|
||||
modelPath: string;
|
||||
contextSize: number;
|
||||
concurrency: number;
|
||||
preload: boolean;
|
||||
|
||||
maxTextLength: number;
|
||||
maxRuns: number; // 0 = unlimited checkable text runs per request
|
||||
requestTimeoutMs: number;
|
||||
sentenceTimeoutMs: number;
|
||||
maxBodyBytes: number;
|
||||
maxInFlight: number;
|
||||
|
||||
/** Language codes accepted by /v2/check (lower-cased, includes 'auto'). */
|
||||
supportedLanguages: Set<string>;
|
||||
/** What to do for a non-English language code: 'empty' (silent) or 'error' (HTTP 400). */
|
||||
unsupportedLanguageMode: 'empty' | 'error';
|
||||
|
||||
/** LLM backend: 'local' (node-llama-cpp) or 'openai' (OpenAI-compatible API). */
|
||||
backend: 'local' | 'openai';
|
||||
/**
|
||||
* Wire style for the 'openai' backend: 'openai' = standard /chat/completions
|
||||
* (OpenAI, vLLM, llama-server, …); 'ollama' = Ollama's native /api/chat
|
||||
* (needed for models like Qwen3 where the OpenAI-compat layer ignores
|
||||
* think:false and burns the token budget on hidden reasoning).
|
||||
*/
|
||||
openaiStyle: 'openai' | 'ollama';
|
||||
/**
|
||||
* Optional custom base system prompt for the 'openai' backend (e.g. a
|
||||
* fine-tuned model's own trained prompt, such as
|
||||
* ifioravanti/mistral-grammar-checker's). Empty = the built-in grammar-llm
|
||||
* prompt. The 'picky' level still appends its addendum on top.
|
||||
*/
|
||||
openaiSystemPrompt: string;
|
||||
/** OpenAI-compatible base URL (e.g. https://api.openai.com/v1, a llama-server, vLLM, Ollama /v1, …). */
|
||||
openaiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiModel: string;
|
||||
/** Timeout for a single remote LLM call (ms). */
|
||||
openaiTimeoutMs: number;
|
||||
/**
|
||||
* Completion-token headroom added to the sentence length (per-sentence
|
||||
* max_tokens / num_predict). Some models (hidden reasoning, retokenization)
|
||||
* need more than the visible output — raise when a model returns empty
|
||||
* answers (e.g. muse-glimmer needs ~75 completion tokens per sentence).
|
||||
*/
|
||||
openaiMaxTokenPad: number;
|
||||
|
||||
/**
|
||||
* Optional real LanguageTool container to query FIRST (cascade).
|
||||
* LT runs with the requested level (picky→picky), and its matches win;
|
||||
* when LT finds nothing, the LLM runs second. null = LLM only.
|
||||
*/
|
||||
gtUrl: string | null;
|
||||
/** Timeout for the real-LanguageTool call (ms) — the LT's window to win the race. */
|
||||
gtTimeoutMs: number;
|
||||
/**
|
||||
* Total wall-clock budget (ms) for a cascaded check (LT attempt + LLM
|
||||
* fallback). Kept under LT-client caps (Overleaf's controller times out at
|
||||
* 60 s and then shows nothing) so the response always arrives in time.
|
||||
*/
|
||||
cascadeBudgetMs: number;
|
||||
/**
|
||||
* How long (ms) to let a fast real-LT win before starting the LLM in the
|
||||
* cascade. Keeps the "compute-saving" benefit when LT answers quickly, but
|
||||
* the LLM is otherwise running in parallel so the fallback is not blocked.
|
||||
* 0 = start the LLM immediately (pure parallel).
|
||||
*/
|
||||
llmGraceMs: number;
|
||||
|
||||
/** Level used when the client does not send one ('default' or 'picky'). */
|
||||
defaultLevel: 'default' | 'picky';
|
||||
|
||||
sampling: Sampling;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
function envStr(env: NodeJS.ProcessEnv, key: string, def: string): string {
|
||||
const v = env[key];
|
||||
return v !== undefined && v !== '' ? v : def;
|
||||
}
|
||||
function envInt(env: NodeJS.ProcessEnv, key: string, def: number): number {
|
||||
const v = env[key];
|
||||
if (v === undefined || v === '') return def;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) throw new Error(`env ${key} must be an integer, got ${v}`);
|
||||
return Math.trunc(n);
|
||||
}
|
||||
function envBool(env: NodeJS.ProcessEnv, key: string, def: boolean): boolean {
|
||||
const v = env[key];
|
||||
if (v === undefined || v === '') return def;
|
||||
const s = String(v).toLowerCase();
|
||||
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const contextSize = Math.min(131_072, Math.max(256, envInt(env, 'GRAMMAR_LLM_CONTEXT_SIZE', 8192)));
|
||||
const modelDir = envStr(env, 'GRAMMAR_LLM_MODEL_DIR', '/models');
|
||||
const modelFile = envStr(env, 'GRAMMAR_LLM_MODEL_FILE', 'GRMR-V3-G4B-Q4_K_M.gguf');
|
||||
const logLevel = (envStr(env, 'GRAMMAR_LLM_LOG_LEVEL', 'info').toLowerCase()) as Config['logLevel'];
|
||||
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
|
||||
throw new Error(`GRAMMAR_LLM_LOG_LEVEL must be one of debug|info|warn|error, got '${env.GRAMMAR_LLM_LOG_LEVEL}'`);
|
||||
}
|
||||
const unsupportedLanguageMode = envStr(env, 'GRAMMAR_LLM_UNSUPPORTED_LANGUAGE', 'empty').toLowerCase();
|
||||
if (unsupportedLanguageMode !== 'empty' && unsupportedLanguageMode !== 'error') {
|
||||
throw new Error(`GRAMMAR_LLM_UNSUPPORTED_LANGUAGE must be 'empty' or 'error', got '${env.GRAMMAR_LLM_UNSUPPORTED_LANGUAGE}'`);
|
||||
}
|
||||
|
||||
const backend = envStr(env, 'GRAMMAR_LLM_BACKEND', 'local').toLowerCase();
|
||||
if (backend !== 'local' && backend !== 'openai') {
|
||||
throw new Error(`GRAMMAR_LLM_BACKEND must be 'local' or 'openai', got '${env.GRAMMAR_LLM_BACKEND}'`);
|
||||
}
|
||||
const openaiStyleRaw = envStr(env, 'GRAMMAR_LLM_OPENAI_STYLE', 'openai').toLowerCase();
|
||||
if (openaiStyleRaw !== 'openai' && openaiStyleRaw !== 'ollama') {
|
||||
throw new Error(`GRAMMAR_LLM_OPENAI_STYLE must be 'openai' or 'ollama', got '${env.GRAMMAR_LLM_OPENAI_STYLE}'`);
|
||||
}
|
||||
const openaiModel = envStr(env, 'GRAMMAR_LLM_OPENAI_MODEL', '');
|
||||
if (backend === 'openai' && !openaiModel) {
|
||||
throw new Error('GRAMMAR_LLM_BACKEND=openai requires GRAMMAR_LLM_OPENAI_MODEL (e.g. gpt-4o-mini, or your GRMR model id on the server)');
|
||||
}
|
||||
const defaultLevel = envStr(env, 'GRAMMAR_LLM_DEFAULT_LEVEL', 'default').toLowerCase();
|
||||
if (defaultLevel !== 'default' && defaultLevel !== 'picky') {
|
||||
throw new Error(`GRAMMAR_LLM_DEFAULT_LEVEL must be 'default' or 'picky', got '${env.GRAMMAR_LLM_DEFAULT_LEVEL}'`);
|
||||
}
|
||||
const gtUrlRaw = envStr(env, 'GRAMMAR_LLM_LANGUAGETOOL_URL', '');
|
||||
if (gtUrlRaw && !/^https?:\/\//.test(gtUrlRaw)) {
|
||||
throw new Error(`GRAMMAR_LLM_LANGUAGETOOL_URL must be an http(s) URL, got '${gtUrlRaw}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
host: envStr(env, 'HOST', '0.0.0.0'),
|
||||
port: Math.max(1, Math.min(65535, envInt(env, 'PORT', 8010))),
|
||||
allowOrigin: envStr(env, 'GRAMMAR_LLM_ALLOW_ORIGIN', '*'),
|
||||
reportedVersion: envStr(env, 'GRAMMAR_LLM_REPORTED_LT_VERSION', '6.8'),
|
||||
|
||||
modelDir,
|
||||
modelFile,
|
||||
modelUrl: envStr(env, 'GRAMMAR_LLM_MODEL_URL', DEFAULT_MODEL_URL),
|
||||
modelPath: path.join(modelDir, modelFile),
|
||||
contextSize,
|
||||
concurrency: Math.max(1, envInt(env, 'GRAMMAR_LLM_CONCURRENCY', 1)),
|
||||
preload: envBool(env, 'GRAMMAR_LLM_PRELOAD', true),
|
||||
|
||||
maxTextLength: Math.max(1000, envInt(env, 'GRAMMAR_LLM_MAX_TEXT_LENGTH', 100_000)),
|
||||
maxRuns: Math.max(0, envInt(env, 'GRAMMAR_LLM_MAX_RUNS', 0)),
|
||||
requestTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_REQUEST_TIMEOUT_MS', 55_000)),
|
||||
sentenceTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_SENTENCE_TIMEOUT_MS', 20_000)),
|
||||
maxBodyBytes: Math.max(10 * 1024, envInt(env, 'GRAMMAR_LLM_MAX_BODY_BYTES', 2 * 1024 * 1024)),
|
||||
maxInFlight: Math.max(1, envInt(env, 'GRAMMAR_LLM_MAX_IN_FLIGHT', 4)),
|
||||
cascadeBudgetMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_CASCADE_BUDGET_MS', 55_000)),
|
||||
llmGraceMs: Math.max(0, envInt(env, 'GRAMMAR_LLM_LLM_GRACE_MS', 400)),
|
||||
|
||||
supportedLanguages: new Set(
|
||||
envStr(env, 'GRAMMAR_LLM_LANGUAGES', 'auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-za,en-in')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
unsupportedLanguageMode,
|
||||
|
||||
backend,
|
||||
openaiStyle: openaiStyleRaw as 'openai' | 'ollama',
|
||||
openaiSystemPrompt: envStr(env, 'GRAMMAR_LLM_OPENAI_SYSTEM_PROMPT', ''),
|
||||
openaiBaseUrl: envStr(env, 'GRAMMAR_LLM_OPENAI_BASE_URL', 'https://api.openai.com/v1').replace(/\/$/, ''),
|
||||
openaiApiKey: envStr(env, 'GRAMMAR_LLM_OPENAI_API_KEY', ''),
|
||||
openaiModel,
|
||||
openaiTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_OPENAI_TIMEOUT_MS', 60_000)),
|
||||
openaiMaxTokenPad: Math.max(0, envInt(env, 'GRAMMAR_LLM_OPENAI_MAX_TOKEN_PAD', 40)),
|
||||
|
||||
gtUrl: gtUrlRaw ? gtUrlRaw.replace(/\/$/, '') : null,
|
||||
gtTimeoutMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS', 45_000)),
|
||||
|
||||
defaultLevel: defaultLevel === 'picky' ? 'picky' : 'default',
|
||||
|
||||
sampling: {
|
||||
temperature: Number(envStr(env, 'GRAMMAR_LLM_TEMPERATURE', '0.7')),
|
||||
topK: Math.max(1, envInt(env, 'GRAMMAR_LLM_TOP_K', 40)),
|
||||
topP: Number(envStr(env, 'GRAMMAR_LLM_TOP_P', '0.95')),
|
||||
minP: Number(envStr(env, 'GRAMMAR_LLM_MIN_P', '0.01')),
|
||||
},
|
||||
logLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLanguageSupported(cfg: Config, code: string | null | undefined): boolean {
|
||||
if (code === undefined || code === null || String(code).trim() === '') return true; // LT default
|
||||
const c = String(code).trim().toLowerCase();
|
||||
return cfg.supportedLanguages.has(c);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// One-shot model downloader (start.sh calls this before the server boots).
|
||||
// Uses node-llama-cpp's createModelDownloader (same API the desktop app's
|
||||
// modelManager.ts adapter uses against v3.19:
|
||||
// await createModelDownloader({ modelUri, dirPath, fileName, onProgress })
|
||||
// → { download(): Promise, cancel(): Promise }
|
||||
// onProgress(status: { downloadedSize, totalSize, ... })
|
||||
//
|
||||
// Usage:
|
||||
// node dist/download-model.mjs --url <url> --out /models/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
// node dist/download-model.mjs --url <url> --dir /models --file name.gguf
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return undefined;
|
||||
return process.argv[i + 1];
|
||||
}
|
||||
|
||||
function die(msg: string): never {
|
||||
console.error(`ERROR: ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const url = arg('url');
|
||||
const out = arg('out');
|
||||
const dir = arg('dir');
|
||||
const file = arg('file');
|
||||
|
||||
if (!url) die('--url is required');
|
||||
if (!out && !(dir && file)) die('either --out or both --dir and --file are required');
|
||||
|
||||
const outPath = out ?? path.join(dir as string, file as string);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
|
||||
const existing = fs.existsSync(outPath) ? fs.statSync(outPath).size : 0;
|
||||
if (existing > 0) {
|
||||
console.log(`Model already present (${(existing / 1024 / 1024).toFixed(0)} MB): ${outPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Downloading model…`);
|
||||
console.log(` url: ${url}`);
|
||||
console.log(` target: ${outPath}`);
|
||||
|
||||
const { createModelDownloader } = await import('node-llama-cpp');
|
||||
|
||||
let lastLog = 0;
|
||||
let lastPct = -1;
|
||||
const downloader = await createModelDownloader({
|
||||
modelUri: url,
|
||||
dirPath: path.dirname(outPath),
|
||||
fileName: path.basename(outPath),
|
||||
onProgress: (status: { downloadedSize: number; totalSize: number }) => {
|
||||
const now = Date.now();
|
||||
const pct = status.totalSize > 0 ? Math.floor((status.downloadedSize / status.totalSize) * 100) : 0;
|
||||
if (now - lastLog > 1000 || pct !== lastPct) {
|
||||
lastLog = now;
|
||||
lastPct = pct;
|
||||
const mb = (status.downloadedSize / 1024 / 1024).toFixed(1);
|
||||
const tot = status.totalSize > 0 ? ` / ${(status.totalSize / 1024 / 1024).toFixed(0)} MB` : '';
|
||||
process.stdout.write(`\r ${pct}% ${mb} MB${tot} `);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await downloader.download();
|
||||
console.log('');
|
||||
const size = fs.statSync(outPath).size;
|
||||
console.log(`Model downloaded: ${outPath} (${(size / 1024 / 1024).toFixed(0)} MB)`);
|
||||
} catch (err) {
|
||||
console.error('');
|
||||
console.error(`Model download failed: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Check engine: runs the grammar-llm core pipeline (sentence splitting, LLM
|
||||
// correction, cleaning, diff/word-fix extraction, reconstruction) over the
|
||||
// plain-text runs of a document, with a per-request time budget.
|
||||
//
|
||||
// The per-sentence guard pipeline mirrors grammar-llm's core/correction.ts
|
||||
// (correctText) exactly — same order of guards, same suggestion shape — so
|
||||
// container results stay identical to the desktop app for the same model.
|
||||
|
||||
import { cleanCorrectedText, isOnlyQuoteChange } from '../core/clean.js';
|
||||
import { cleanQuotePunctuation } from '../core/correction.js';
|
||||
import { extractWordFixes, highlightWordDifferences } from '../core/diff.js';
|
||||
import { reconstructTextFromSentences } from '../core/reconstruct.js';
|
||||
import { splitIntoSentences } from '../core/sentences.js';
|
||||
import type { CorrectionResponse, Suggestion } from '../core/types.js';
|
||||
import { LlamaService, type CheckLevel } from './llama.js';
|
||||
import { OpenAIService } from './openai.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { Logger } from './log.js';
|
||||
import type { TextRun } from './languagetool.js';
|
||||
|
||||
/** Common contract of the two backends (local node-llama-cpp / OpenAI-compatible). */
|
||||
interface SentenceBackend {
|
||||
isReady: boolean;
|
||||
ensureLoaded(): Promise<void>;
|
||||
correct(sentence: string, level: CheckLevel): Promise<string>;
|
||||
loadErrorText: string | null;
|
||||
describe(): string;
|
||||
}
|
||||
|
||||
function describeLocal(svc: LlamaService): string {
|
||||
return `local model=${svc.modelPath}`;
|
||||
}
|
||||
|
||||
export type EngineState = 'stopped' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface EngineStatus {
|
||||
state: EngineState;
|
||||
errorMessage: string | null;
|
||||
modelPath: string;
|
||||
checksCompleted: number;
|
||||
checksFailed: number;
|
||||
}
|
||||
|
||||
export interface RunCheckResult extends CorrectionResponse {
|
||||
/** true when the request time budget cut the check short. */
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
export class CheckEngine {
|
||||
private backend: SentenceBackend;
|
||||
private localLlama: LlamaService | null = null;
|
||||
private state: EngineState = 'stopped';
|
||||
private loadError: string | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
/**
|
||||
* Serialization chain: the LLM backend keeps ONE shared chat context
|
||||
* (resetChatHistory() per sentence), so concurrent checks must not interleave.
|
||||
* Checks queue here; each keeps its own internal deadline semantics.
|
||||
*/
|
||||
private checkChain: Promise<unknown> = Promise.resolve();
|
||||
checksCompleted = 0;
|
||||
checksFailed = 0;
|
||||
|
||||
constructor(private cfg: Config, private log: Logger) {
|
||||
if (cfg.backend === 'openai') {
|
||||
this.backend = new OpenAIService({
|
||||
baseUrl: cfg.openaiBaseUrl,
|
||||
apiKey: cfg.openaiApiKey,
|
||||
model: cfg.openaiModel,
|
||||
sampling: cfg.sampling,
|
||||
timeoutMs: cfg.openaiTimeoutMs,
|
||||
tokenPad: cfg.openaiMaxTokenPad,
|
||||
logger: log,
|
||||
style: cfg.openaiStyle,
|
||||
systemPrompt: cfg.openaiSystemPrompt || undefined,
|
||||
});
|
||||
} else {
|
||||
const svc = new LlamaService(
|
||||
{
|
||||
modelPath: cfg.modelPath,
|
||||
contextSize: cfg.contextSize,
|
||||
sampling: cfg.sampling,
|
||||
logger: log,
|
||||
},
|
||||
log,
|
||||
);
|
||||
this.localLlama = svc;
|
||||
this.backend = {
|
||||
isReady: svc.isReady,
|
||||
ensureLoaded: () => svc.ensureLoaded(),
|
||||
correct: (s, l) => svc.correct(s, l),
|
||||
get loadErrorText() {
|
||||
return svc.loadErrorText;
|
||||
},
|
||||
describe: () => describeLocal(svc),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
status(): EngineStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
errorMessage: this.loadError ?? this.backend.loadErrorText,
|
||||
modelPath: this.cfg.backend === 'openai' ? `(remote: ${this.cfg.openaiModel})` : this.cfg.modelPath,
|
||||
checksCompleted: this.checksCompleted,
|
||||
checksFailed: this.checksFailed,
|
||||
};
|
||||
}
|
||||
|
||||
async ensureReady(): Promise<void> {
|
||||
if (this.state === 'ready') return;
|
||||
if (this.loading) await this.loading;
|
||||
if (this.state === 'ready') return;
|
||||
this.state = 'loading';
|
||||
this.loading = this.backend
|
||||
.ensureLoaded()
|
||||
.then(() => {
|
||||
this.state = 'ready';
|
||||
this.loadError = null;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.state = 'error';
|
||||
this.loadError = String(err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = null;
|
||||
});
|
||||
await this.loading;
|
||||
}
|
||||
|
||||
/** Check a single plain-text run. Never throws for per-sentence failures. */
|
||||
async correctRun(run: TextRun, deadline: number, level: CheckLevel = 'default'): Promise<RunCheckResult> {
|
||||
const text = run.text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { suggestions: [], correctedText: '', incomplete: false };
|
||||
|
||||
const sentenceData = splitIntoSentences(text);
|
||||
const n = sentenceData.length;
|
||||
const deadlineExceeded = () => Date.now() > deadline;
|
||||
|
||||
// Process sentences one at a time (single shared LLM session), stopping
|
||||
// at the request deadline; unprocessed sentences pass through unchanged.
|
||||
const suggestions: Suggestion[] = [];
|
||||
const correctedSentences: string[] = [];
|
||||
let incomplete = false;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const sentence = sent.text;
|
||||
if (sentence.length < 2) {
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
if (deadlineExceeded()) {
|
||||
incomplete = true;
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
let corrected = await this.backend.correct(sentence, level);
|
||||
corrected = cleanCorrectedText(corrected, sentence);
|
||||
if (isOnlyQuoteChange(sentence, corrected)) corrected = sentence;
|
||||
if (corrected.length > sentence.length * 2) corrected = sentence;
|
||||
corrected = cleanQuotePunctuation(corrected);
|
||||
|
||||
// Diff once: restore the original quote style (curly vs straight) so
|
||||
// quote changes are never applied to the user's text.
|
||||
const diff = highlightWordDifferences(sentence, corrected);
|
||||
corrected = diff.preservedCorrected;
|
||||
correctedSentences.push(corrected);
|
||||
|
||||
if (
|
||||
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
|
||||
corrected.trim() !== sentence.trim() &&
|
||||
corrected.length <= sentence.length * 1.5 &&
|
||||
!isOnlyQuoteChange(sentence, corrected)
|
||||
) {
|
||||
suggestions.push({
|
||||
original: sentence,
|
||||
corrected,
|
||||
sentence: `Sentence ${i + 1}`,
|
||||
start_index: sent.start,
|
||||
end_index: sent.end,
|
||||
original_highlighted: diff.originalHighlighted,
|
||||
corrected_highlighted: diff.correctedHighlighted,
|
||||
wordFixes: extractWordFixes(sentence, corrected, sent.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const correctedText = cleanQuotePunctuation(reconstructTextFromSentences(text, sentenceData, correctedSentences));
|
||||
return { suggestions, correctedText, incomplete };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all runs of a resolved input (sequentially; each run is bounded by
|
||||
* the shared deadline). Returns per-run results in order.
|
||||
* Concurrent callers are serialized on the shared LLM context.
|
||||
*/
|
||||
check(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
|
||||
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
|
||||
const task = this.checkChain.then(() => this.runCheck(runs, opts));
|
||||
// Keep the chain alive without swallowing task errors for the caller.
|
||||
this.checkChain = task.catch(() => { /* errors reach the caller via `task` */ });
|
||||
return task;
|
||||
}
|
||||
|
||||
private async runCheck(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
|
||||
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
|
||||
await this.ensureReady();
|
||||
const start = Date.now();
|
||||
const budget = opts.requestTimeoutMs ?? this.cfg.requestTimeoutMs;
|
||||
const deadline = start + budget;
|
||||
const maxRuns = opts.maxRuns ?? this.cfg.maxRuns;
|
||||
const level = opts.level ?? this.cfg.defaultLevel;
|
||||
|
||||
const results: RunCheckResult[] = [];
|
||||
let incomplete = false;
|
||||
const considered = maxRuns > 0 ? runs.slice(0, maxRuns) : runs;
|
||||
if (maxRuns > 0 && runs.length > maxRuns) incomplete = true;
|
||||
|
||||
for (let i = 0; i < considered.length; i++) {
|
||||
const run = considered[i];
|
||||
if (Date.now() > deadline) {
|
||||
incomplete = true;
|
||||
this.log.warn('check time budget exhausted; remaining sentences passed through unchanged', {
|
||||
budgetMs: budget,
|
||||
ranMs: Date.now() - start,
|
||||
run: i,
|
||||
});
|
||||
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await this.correctRun(run, deadline, level);
|
||||
results.push(r);
|
||||
if (r.incomplete) incomplete = true;
|
||||
} catch (err) {
|
||||
this.checksFailed++;
|
||||
this.log.error('check run failed', { err: String(err) });
|
||||
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
this.checksCompleted++;
|
||||
this.log.info('check completed', {
|
||||
backend: this.backend.describe(),
|
||||
level,
|
||||
runs: results.length,
|
||||
suggestions: results.reduce((a, r) => a + r.suggestions.length, 0),
|
||||
ranMs: Date.now() - start,
|
||||
incomplete,
|
||||
});
|
||||
return { results, incomplete };
|
||||
}
|
||||
}
|
||||
|
||||
export interface SentenceCorrectorShim {
|
||||
correct(sentence: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// LanguageTool response builder + request resolution.
|
||||
//
|
||||
// Converts grammar-llm core output (suggestions/wordFixes) into the
|
||||
// LanguageTool HTTP v2 "check" JSON shape so Overleaf — and any other client
|
||||
// speaking the LanguageTool protocol — works unchanged.
|
||||
//
|
||||
// Key invariant (from Overleaf's LanguageTool module):
|
||||
// annotations.map(a => a.text ?? a.markup).join('') === editor source
|
||||
// so match offsets must be offsets into the EDITOR SOURCE document.
|
||||
//
|
||||
// Strategy:
|
||||
// - Reconstruct the full document text (text + markup segments in order).
|
||||
// - Identify maximal plain-text runs (language: markup = "ignore this text").
|
||||
// - Run the grammar-llm core pipeline on each run (the LLM never sees LaTeX
|
||||
// markup, and no match can ever span it).
|
||||
// - Rebase suggestion offsets by the run's document offset — matches end up
|
||||
// in editor-source coordinates, as Overleaf expects.
|
||||
//
|
||||
// Response schema reference
|
||||
// (languagetool: org.languagetool.tools.RuleMatchesAsJsonSerializer):
|
||||
// top-level : { software, warnings, language, matches[], sentenceRanges }
|
||||
// match : { message, shortMessage?, replacements[], offset, length,
|
||||
// context, type, rule, ignoreForIncompleteSentence, contextForSureMatch }
|
||||
// replacement: { value, suffix?, type? }
|
||||
// rule : { id, description, issueType, category, tags? }
|
||||
// category : { id, name }
|
||||
//
|
||||
// Overleaf consumes: matches[].offset/.length, matches[].message,
|
||||
// matches[].replacements[] (first 5 values), matches[].rule.category.id
|
||||
// (filters id === 'TYPOS' while Hunspell is active). We use
|
||||
// category.id = 'GRAMMAR' so our results survive that filter.
|
||||
|
||||
import type { Suggestion, WordFix } from '../core/types.js';
|
||||
|
||||
export interface LtReplacement {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LtRule {
|
||||
id: string;
|
||||
description: string;
|
||||
issueType: string;
|
||||
category: { id: string; name: string };
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface LtMatch {
|
||||
message: string;
|
||||
shortMessage?: string;
|
||||
replacements: LtReplacement[];
|
||||
offset: number;
|
||||
length: number;
|
||||
context: { text: string; offset: number; length: number };
|
||||
type: { typeName: string };
|
||||
rule: LtRule;
|
||||
ignoreForIncompleteSentence: boolean;
|
||||
contextForSureMatch: number;
|
||||
}
|
||||
|
||||
export interface LtLanguage {
|
||||
name: string;
|
||||
code: string;
|
||||
longCode: string;
|
||||
}
|
||||
|
||||
export interface LtCheckResponse {
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
buildDate: string;
|
||||
apiVersion: number;
|
||||
premium: boolean;
|
||||
status: string;
|
||||
};
|
||||
warnings: { incompleteResults: boolean; incompleteResultsReason?: string };
|
||||
language: {
|
||||
name: string;
|
||||
code: string;
|
||||
detectedLanguage: { name: string; code: string; confidence: number; source: string };
|
||||
};
|
||||
matches: LtMatch[];
|
||||
sentenceRanges: Array<[number, number]>;
|
||||
}
|
||||
|
||||
/** Static rule object reported on every match. */
|
||||
const RULE: LtRule = {
|
||||
id: 'GRAMMAR_LLM',
|
||||
description: 'Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)',
|
||||
issueType: 'Grammar',
|
||||
category: { id: 'GRAMMAR', name: 'Grammar' },
|
||||
tags: ['LLM', 'GRMR-V3-G4B'],
|
||||
};
|
||||
|
||||
/** Languages we advertise (GRMR-V3-G4B is English-only). */
|
||||
const LANGUAGES: LtLanguage[] = [
|
||||
{ name: 'English (US)', code: 'en-US', longCode: 'en-US' },
|
||||
{ name: 'English (UK)', code: 'en-GB', longCode: 'en-GB' },
|
||||
{ name: 'English (Australia)', code: 'en-AU', longCode: 'en-AU' },
|
||||
{ name: 'English (Canada)', code: 'en-CA', longCode: 'en-CA' },
|
||||
{ name: 'English (Ireland)', code: 'en-IE', longCode: 'en-IE' },
|
||||
{ name: 'English (New Zealand)', code: 'en-NZ', longCode: 'en-NZ' },
|
||||
{ name: 'English (India)', code: 'en-IN', longCode: 'en-IN' },
|
||||
{ name: 'English (South Africa)', code: 'en-ZA', longCode: 'en-ZA' },
|
||||
{ name: 'English', code: 'en', longCode: 'en' },
|
||||
];
|
||||
|
||||
export function getLanguages(): LtLanguage[] {
|
||||
return LANGUAGES;
|
||||
}
|
||||
|
||||
export function normalizeLanguage(code: string | null | undefined): LtLanguage | null {
|
||||
if (code === null || code === undefined || String(code).trim() === '' || String(code).trim().toLowerCase() === 'auto') {
|
||||
return LANGUAGES[0]; // default: English (US)
|
||||
}
|
||||
const c = String(code).trim().toLowerCase();
|
||||
return LANGUAGES.find((l) => l.code.toLowerCase() === c) ?? null;
|
||||
}
|
||||
|
||||
/** A plain-text segment with its absolute offset into the full document. */
|
||||
export interface TextRun {
|
||||
text: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCheckInput {
|
||||
/** Full document text (markup included) — offsets/limits are against this. */
|
||||
text: string;
|
||||
/** Maximal checkable runs (plain text), offsets absolute into `text`. */
|
||||
runs: TextRun[];
|
||||
}
|
||||
|
||||
export type CheckInput = ResolvedCheckInput | { error: string };
|
||||
|
||||
/**
|
||||
* Resolve the text to check from a request body (LanguageTool `text`/`data`
|
||||
* parameters; `data` may be a JSON object {text, annotation:[{text|markup}]}
|
||||
* — Overleaf sends annotation when it has a Lezer syntax tree).
|
||||
*/
|
||||
export function resolveCheckInput(body: Record<string, unknown>): CheckInput {
|
||||
const rawText = body['text'];
|
||||
const rawData = body['data'];
|
||||
|
||||
if (rawText === undefined && rawData === undefined) {
|
||||
return { error: "Missing 'text' or 'data' parameter" };
|
||||
}
|
||||
|
||||
// 1) plain `text` parameter
|
||||
if (typeof rawText === 'string') {
|
||||
return { text: rawText, runs: [{ text: rawText, offset: 0 }] };
|
||||
}
|
||||
|
||||
// 2) `data` parameter (string JSON or object)
|
||||
let data: unknown = rawData;
|
||||
if (typeof rawData === 'string') {
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
} catch {
|
||||
// Not JSON — treat the raw string as plain text.
|
||||
return { text: rawData, runs: [{ text: rawData, offset: 0 }] };
|
||||
}
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (typeof d['text'] === 'string' && d['annotation'] === undefined) {
|
||||
return { text: d['text'], runs: [{ text: d['text'], offset: 0 }] };
|
||||
}
|
||||
if (Array.isArray(d['annotation'])) {
|
||||
// Reconstruct the full document and collect maximal plain-text runs
|
||||
// with offsets absolute into it (markup segments keep alignment).
|
||||
let text = '';
|
||||
let pendingStart = -1;
|
||||
const runs: TextRun[] = [];
|
||||
const flush = () => {
|
||||
if (pendingStart >= 0) {
|
||||
runs.push({ text: text.slice(pendingStart), offset: pendingStart });
|
||||
pendingStart = -1;
|
||||
}
|
||||
};
|
||||
for (const seg of d['annotation']) {
|
||||
if (!seg || typeof seg !== 'object') continue;
|
||||
const s = seg as Record<string, unknown>;
|
||||
if (typeof s['text'] === 'string') {
|
||||
if (pendingStart < 0) pendingStart = text.length;
|
||||
text += s['text'];
|
||||
} else if (typeof s['markup'] === 'string') {
|
||||
flush();
|
||||
text += s['markup'];
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return { text, runs: runs.filter((r) => r.text.trim().length > 0) };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Missing 'text' or 'data' parameter" };
|
||||
}
|
||||
|
||||
function buildContext(fullText: string, offset: number, length: number, radius = 25): { text: string; offset: number; length: number } {
|
||||
const from = Math.max(0, offset - radius);
|
||||
const to = Math.min(fullText.length, offset + length + radius);
|
||||
return {
|
||||
text: fullText.slice(from, to),
|
||||
offset: offset - from,
|
||||
length: Math.max(0, length),
|
||||
};
|
||||
}
|
||||
|
||||
interface BaseMatchOpts {
|
||||
message: string;
|
||||
shortMessage?: string;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function makeMatch(fullText: string, offset: number, length: number, opts: BaseMatchOpts): LtMatch {
|
||||
return {
|
||||
message: opts.message,
|
||||
...(opts.shortMessage !== undefined ? { shortMessage: opts.shortMessage } : {}),
|
||||
replacements: [{ value: opts.replacement }],
|
||||
offset,
|
||||
length,
|
||||
context: buildContext(fullText, offset, length),
|
||||
type: { typeName: 'grammar' },
|
||||
rule: RULE,
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a zero-width (pure insertion) fix to the nearest non-whitespace
|
||||
* token, expressed as a LanguageTool replacement match:
|
||||
* - insertion before token T (at or before T): match T, replace with added+T
|
||||
* - insertion at run end: match the last token, replace with T+added
|
||||
*/
|
||||
function anchorInsertion(
|
||||
fullText: string,
|
||||
run: TextRun,
|
||||
pos: number,
|
||||
addedText: string,
|
||||
): LtMatch | null {
|
||||
const added = addedText.trim();
|
||||
if (!added) return null;
|
||||
const base = run.offset;
|
||||
const isPunct = /^\s*[.,!?;:'"()\[\]{}]+\s*$/.test(added);
|
||||
|
||||
// Next non-whitespace token at/after pos (within the run).
|
||||
const rest = run.text.slice(pos);
|
||||
const mNext = rest.match(/^\s*(\S+)/);
|
||||
if (mNext) {
|
||||
const wsLen = mNext[0].length - mNext[1].length;
|
||||
const tok = mNext[1];
|
||||
const tokStart = pos + wsLen;
|
||||
const tokEnd = tokStart + tok.length;
|
||||
if (tokEnd <= run.text.length) {
|
||||
const gap = run.text.slice(pos, tokStart);
|
||||
// Punctuation attaches to the preceding word (no leading gap); a word
|
||||
// keeps the preceding gap and gets exactly one space before the token.
|
||||
const replacement = (isPunct ? added + (gap ? ' ' : '') + tok : gap + added + ' ' + tok) as string;
|
||||
const collapsed = replacement.replace(/\s{2,}/g, ' ');
|
||||
return makeMatch(fullText, base + pos, tokEnd - pos, {
|
||||
message: isPunct ? `Add '${added}' after the previous word` : `Add '${added}' before '${tok}'`,
|
||||
shortMessage: `add "${added}"`,
|
||||
replacement: collapsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No token after the insertion point → anchor on the last non-whitespace
|
||||
// token of the run (insertion at the run's end).
|
||||
const before = run.text.slice(0, pos);
|
||||
const mLast = before.match(/(\S+)\s*$/);
|
||||
if (mLast && mLast.index !== undefined) {
|
||||
const tok = mLast[1];
|
||||
const tokStart = mLast.index;
|
||||
const replacement = (tok + (isPunct ? added : ` ${added}`)).replace(/\s{2,}/g, ' ');
|
||||
return makeMatch(fullText, base + tokStart, tok.length, {
|
||||
message: `Add '${added}' at the end of the sentence`,
|
||||
shortMessage: `add "${added}"`,
|
||||
replacement,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one core suggestion (offsets are absolute into the original full input
|
||||
* text passed to correctText) to LanguageTool matches, rebased onto the
|
||||
* editor document via `run`.
|
||||
*/
|
||||
export function suggestionToMatches(fullText: string, run: TextRun, suggestion: Suggestion): LtMatch[] {
|
||||
const out: LtMatch[] = [];
|
||||
const fixes = suggestion.wordFixes ?? [];
|
||||
let usedWordFixes = false;
|
||||
|
||||
for (const fix of fixes) {
|
||||
const m = wordFixToMatch(fullText, run, fix);
|
||||
if (m) {
|
||||
out.push(m);
|
||||
usedWordFixes = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Full rewrite with no word-level fixes: one sentence-level match over the
|
||||
// changed middle span, replacing it with the corrected middle.
|
||||
if (!usedWordFixes) {
|
||||
const span = sentenceChangeSpan(run, suggestion);
|
||||
if (span) {
|
||||
out.push(
|
||||
makeMatch(fullText, span.offset, span.length, {
|
||||
message: 'Suggested grammar/style correction (rewrite by grammar-llm)',
|
||||
shortMessage: 'rewrite',
|
||||
replacement: span.replacement,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GRMR models sometimes emit the corrected word wrapped in quotes:
|
||||
* 'goed' -> '"good"', 'cat' -> '"Cat"'.
|
||||
* The desktop app shows that verbatim in the review UI, but a LanguageTool-style
|
||||
* client applies replacements[] verbatim, which would inject literal quote
|
||||
* characters into the document (bad in LaTeX). The adapter layer therefore
|
||||
* unwraps one pair of matching surrounding quotes from a word fix.
|
||||
*/
|
||||
function unwrapQuotedCorrection(corrected: string): string {
|
||||
if (!corrected || corrected.length < 3) return corrected;
|
||||
const pairs: Array<[string, string]> = [
|
||||
['\u201C', '\u201D'], // “ ”
|
||||
['"', '"'], // “ ”
|
||||
['\u2018', '\u2019'], // ‘ ’
|
||||
["'", "'"], // ' '
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (corrected.startsWith(open) && corrected.endsWith(close) && corrected.length >= open.length + close.length + 1) {
|
||||
const inner = corrected.slice(open.length, corrected.length - close.length);
|
||||
if (inner.trim().length > 0 && !inner.includes('\n') && (inner.match(/\s/g) ?? []).length <= 1) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function wordFixToMatch(fullText: string, run: TextRun, fix: WordFix): LtMatch | null {
|
||||
// WordFix offsets are absolute in the text passed to correctText (the run's
|
||||
// text, 0-based) — rebase via run.offset.
|
||||
const relStart = fix.start - 0; // fix.start is already run-relative
|
||||
const relEnd = fix.end;
|
||||
if (relStart < 0 || relStart > run.text.length) return null;
|
||||
const base = run.offset;
|
||||
|
||||
if (relEnd > relStart) {
|
||||
const length = Math.min(relEnd - relStart, run.text.length - relStart);
|
||||
if (length <= 0) return null;
|
||||
const replacement = unwrapQuotedCorrection(fix.corrected ?? '');
|
||||
const message = replacement === '' ? `Remove '${fix.original}'` : `Shouldn't this be '${replacement}'?`;
|
||||
return makeMatch(fullText, base + relStart, length, {
|
||||
message,
|
||||
shortMessage: replacement === '' ? undefined : replacement,
|
||||
replacement,
|
||||
});
|
||||
}
|
||||
|
||||
// Zero width → pure insertion; anchor on a neighbouring token.
|
||||
return anchorInsertion(fullText, run, relEnd, fix.corrected ?? '');
|
||||
}
|
||||
|
||||
interface SpanResult {
|
||||
offset: number;
|
||||
length: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
/** Changed middle span of a sentence-level rewrite (run-relative). */
|
||||
function sentenceChangeSpan(run: TextRun, s: Suggestion): SpanResult | null {
|
||||
const original = s.original;
|
||||
const corrected = s.corrected ?? '';
|
||||
if (!original || !corrected || original === corrected) return null;
|
||||
if (s.start_index < 0 || s.end_index <= s.start_index) return null;
|
||||
|
||||
const a = original;
|
||||
const b = corrected;
|
||||
let p = 0;
|
||||
const max = Math.min(a.length, b.length);
|
||||
while (p < max && a[p] === b[p]) p++;
|
||||
let suffix = 0;
|
||||
while (suffix < a.length - p && suffix < b.length - p && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
|
||||
|
||||
const relOffset = s.start_index + p;
|
||||
const relLength = a.length - p - suffix;
|
||||
if (relOffset < 0 || relLength <= 0 || relOffset >= run.text.length) return null;
|
||||
const length = Math.min(relLength, run.text.length - relOffset);
|
||||
if (length <= 0) return null;
|
||||
return {
|
||||
offset: run.offset + relOffset,
|
||||
length,
|
||||
replacement: b.slice(p, b.length - suffix),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full LanguageTool v2 "check" JSON body.
|
||||
*/
|
||||
export function buildCheckResponse(opts: {
|
||||
fullText: string;
|
||||
matches: LtMatch[];
|
||||
sentenceRanges: Array<[number, number]>;
|
||||
languageCode?: string;
|
||||
incomplete?: string;
|
||||
reportedVersion?: string;
|
||||
}): LtCheckResponse {
|
||||
const lang = normalizeLanguage(opts.languageCode) ?? LANGUAGES[0];
|
||||
const matches = [...opts.matches].sort((x, y) => x.offset - y.offset || x.length - y.length);
|
||||
|
||||
return {
|
||||
software: {
|
||||
name: 'LanguageTool',
|
||||
version: opts.reportedVersion ?? '6.8',
|
||||
buildDate: new Date().toISOString().slice(0, 10),
|
||||
apiVersion: 1,
|
||||
premium: false,
|
||||
status: '',
|
||||
},
|
||||
warnings: {
|
||||
incompleteResults: Boolean(opts.incomplete),
|
||||
...(opts.incomplete ? { incompleteResultsReason: opts.incomplete } : {}),
|
||||
},
|
||||
language: {
|
||||
name: lang.name,
|
||||
code: lang.code,
|
||||
detectedLanguage: { name: lang.name, code: lang.code, confidence: 1, source: 'given' },
|
||||
},
|
||||
matches,
|
||||
sentenceRanges: opts.sentenceRanges ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// LLM inference wrapper. Mirrors grammar-llm's src/electron/llamaService.ts
|
||||
// (SYSTEM_PROMPT, STOP_SEQUENCES, sampling params, per-sentence semantics) so
|
||||
// the container produces the same corrections as the desktop app.
|
||||
//
|
||||
// node-llama-cpp v3.19 API (verified against the desktop's usage, which is
|
||||
// itself "API verified against installed node-llama-cpp v3.19 types"):
|
||||
// - getLlama() auto-detects CUDA / Metal / Vulkan / CPU
|
||||
// - llama.loadModel({ modelPath }) -> model
|
||||
// - contextSize belongs on model.createContext(), not loadModel()
|
||||
// - sequences are created via context.getSequence()
|
||||
// - Sampling params (temperature/topK/topP/minP/maxTokens/repeatPenalty/
|
||||
// customStopTriggers) are per-prompt options for session.prompt(), NOT
|
||||
// session options.
|
||||
// - session.resetChatHistory() is called before each sentence.
|
||||
// - node-llama-cpp is ESM with top-level await, so it is loaded via dynamic
|
||||
// import() (see desktop modelManager.ts notes).
|
||||
|
||||
import type { Logger } from './log.js';
|
||||
|
||||
export interface Sampling {
|
||||
temperature: number;
|
||||
topK: number;
|
||||
topP: number;
|
||||
minP: number;
|
||||
}
|
||||
|
||||
export interface LlamaServiceOptions {
|
||||
modelPath: string;
|
||||
contextSize: number;
|
||||
sampling: Sampling;
|
||||
pickyExtraPrompt?: string;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check level (LanguageTool's `level` parameter).
|
||||
* - 'default': base correction prompt — byte-identical to the desktop app.
|
||||
* - 'picky': base prompt + strict picky addendum (style, word choice,
|
||||
* phrasing, punctuation — like LanguageTool's picky level
|
||||
* adding rule families on top of the default set).
|
||||
*/
|
||||
export type CheckLevel = 'default' | 'picky';
|
||||
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are a grammar correction assistant. Correct the grammar, spelling, and punctuation of the given text. Return only the corrected text without any additional explanations or prefixes.';
|
||||
|
||||
export const DEFAULT_PICKY_EXTRA =
|
||||
'Additionally, be highly picky, like a careful editor: also fix word choice, awkward or redundant phrasing, weak punctuation, and style issues. Prefer the most precise and idiomatic English. ';
|
||||
|
||||
export const STOP_SEQUENCES = ['<|endoftext|>', '<|corrected_end|>', '\n\n', 'Corrected:', 'Here is'];
|
||||
|
||||
interface SessionLike {
|
||||
resetChatHistory(): void;
|
||||
prompt(sentence: string, options: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class LlamaService {
|
||||
private baseSession: SessionLike | null = null;
|
||||
private pickySession: SessionLike | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
private loadError: unknown = null;
|
||||
|
||||
readonly modelPath: string;
|
||||
readonly contextSize: number;
|
||||
readonly sampling: Sampling;
|
||||
readonly pickyExtraPrompt: string;
|
||||
private log: Logger;
|
||||
|
||||
constructor(opts: LlamaServiceOptions, log?: Logger) {
|
||||
this.modelPath = opts.modelPath;
|
||||
this.contextSize = opts.contextSize;
|
||||
this.sampling = opts.sampling;
|
||||
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
|
||||
this.log = log ?? defaultLogger;
|
||||
}
|
||||
|
||||
get loadErrorText(): string | null {
|
||||
return this.loadError ? String(this.loadError) : null;
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.baseSession !== null;
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.baseSession) return;
|
||||
if (this.loading) return this.loading;
|
||||
|
||||
this.loading = (async () => {
|
||||
if (!this.modelPath) throw new Error('No model path configured');
|
||||
// Dynamic import: node-llama-cpp is ESM with top-level await.
|
||||
const { getLlama, LlamaChatSession } = await import('node-llama-cpp');
|
||||
const llama = await getLlama(); // auto-detects CUDA / Metal / Vulkan / CPU
|
||||
const model = await llama.loadModel({ modelPath: this.modelPath });
|
||||
// sequences: 2 → one chat session per level (shared KV cache context).
|
||||
const context = await model.createContext({ contextSize: this.contextSize, sequences: 2 });
|
||||
// Two chat sessions on the shared KV context (separate sequences):
|
||||
// - base: the desktop system prompt (default level = desktop parity)
|
||||
// - picky: base + picky addendum in the SYSTEM slot. (A GRMR fine-tune
|
||||
// echoes instructions placed in the user message, so the
|
||||
// addendum must not go there.)
|
||||
this.baseSession = new LlamaChatSession({
|
||||
contextSequence: context.getSequence(),
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
}) as unknown as SessionLike;
|
||||
this.pickySession = new LlamaChatSession({
|
||||
contextSequence: context.getSequence(),
|
||||
systemPrompt: SYSTEM_PROMPT + ' ' + this.pickyExtraPrompt,
|
||||
}) as unknown as SessionLike;
|
||||
this.log.info('model loaded and ready', {
|
||||
modelPath: this.modelPath,
|
||||
contextSize: this.contextSize,
|
||||
levels: ['default(base)', 'picky(base+editor addendum)'],
|
||||
});
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.loading;
|
||||
} catch (err) {
|
||||
this.loadError = err;
|
||||
this.log.error('failed to load model', { err: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct a single sentence — raw sentence in the user slot (desktop
|
||||
* parity; GRMR fine-tunes echo user-slot instructions, so never put
|
||||
* instructions there). Per-level selection happens via the session's
|
||||
* system prompt (base vs base+picky addendum).
|
||||
* Mirrors the desktop corrector: reset chat history, per-prompt sampling,
|
||||
* trim, fall back to the original sentence on error so the pipeline keeps
|
||||
* going.
|
||||
*/
|
||||
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
|
||||
await this.ensureLoaded();
|
||||
const session = level === 'picky' ? this.pickySession : this.baseSession;
|
||||
if (!session) throw new Error('session not ready');
|
||||
try {
|
||||
session.resetChatHistory();
|
||||
const response = (await session.prompt(sentence, {
|
||||
maxTokens: sentence.length + 20,
|
||||
temperature: this.sampling.temperature,
|
||||
topK: this.sampling.topK,
|
||||
topP: this.sampling.topP,
|
||||
minP: this.sampling.minP,
|
||||
repeatPenalty: { penalty: 1 }, // penalty 1 = disabled (desktop parity)
|
||||
customStopTriggers: STOP_SEQUENCES,
|
||||
})) as unknown as string;
|
||||
return response.trim();
|
||||
} catch (error) {
|
||||
this.log.error(`Error correcting sentence '${sentence}':`, { err: String(error) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLogger: Logger = {
|
||||
debug: () => undefined,
|
||||
info: (m) => console.log(m),
|
||||
warn: (m) => console.warn(m),
|
||||
error: (m, x) => console.error(m, x ? String(x) : ''),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Minimal leveled logger (no dependencies). Writes ISO-timestamped lines. */
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const RANK: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
export interface Logger {
|
||||
debug: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
info: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
warn: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
error: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function createLogger(minLevel: Level = 'info'): Logger {
|
||||
const threshold = RANK[minLevel];
|
||||
const emit = (level: Level, msg: string, extra?: Record<string, unknown>) => {
|
||||
if (RANK[level] < threshold) return;
|
||||
const line = extra && Object.keys(extra).length > 0 ? `${msg} ${JSON.stringify(extra)}` : msg;
|
||||
const ts = new Date().toISOString();
|
||||
if (level === 'error') console.error(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
else if (level === 'warn') console.warn(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
else console.log(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
};
|
||||
return {
|
||||
debug: (m, x) => emit('debug', m, x),
|
||||
info: (m, x) => emit('info', m, x),
|
||||
warn: (m, x) => emit('warn', m, x),
|
||||
error: (m, x) => emit('error', m, x),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// OpenAI-compatible (chat/completions) backend — same contract as LlamaService
|
||||
// so an Overleaf server without local inference capacity can redirect the LLM
|
||||
// step to a hosted model (OpenAI, OpenRouter, vLLM, llama.cpp llama-server,
|
||||
// Ollama /v1, a self-hosted GRMR-V3 LoRA endpoint, …).
|
||||
//
|
||||
// Prompt/stop/sampling semantics match src/llama.ts (and therefore the
|
||||
// grammar-llm desktop app): same system prompt, same level addendum, same
|
||||
// stop sequences, same sampling, same "fall back to the original sentence on
|
||||
// error" behavior so the pipeline keeps going.
|
||||
|
||||
import type { Logger } from './log.js';
|
||||
import type { Sampling, CheckLevel } from './llama.js';
|
||||
import { SYSTEM_PROMPT, DEFAULT_PICKY_EXTRA, STOP_SEQUENCES } from './llama.js';
|
||||
|
||||
export interface OpenAIServiceOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
sampling: Sampling;
|
||||
timeoutMs: number;
|
||||
/** completion-token headroom over sentence length (num_predict / max_tokens margin). */
|
||||
tokenPad?: number;
|
||||
pickyExtraPrompt?: string;
|
||||
/** Custom base system prompt (e.g. a fine-tune's trained prompt). Undefined = built-in. */
|
||||
systemPrompt?: string;
|
||||
logger?: Logger;
|
||||
/** 'openai' = /chat/completions (default); 'ollama' = native /api/chat with think:false. */
|
||||
style?: 'openai' | 'ollama';
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
readonly model: string;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private sampling: Sampling;
|
||||
private timeoutMs: number;
|
||||
private tokenPad: number;
|
||||
private pickyExtraPrompt: string;
|
||||
private sysPrompt: string;
|
||||
private log: Logger;
|
||||
private style: 'openai' | 'ollama';
|
||||
private pingDone = false;
|
||||
|
||||
constructor(opts: OpenAIServiceOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
if (opts.style === 'ollama') this.baseUrl = this.baseUrl.replace(/\/v1$/, '');
|
||||
this.apiKey = opts.apiKey;
|
||||
this.model = opts.model;
|
||||
this.sampling = opts.sampling;
|
||||
this.timeoutMs = opts.timeoutMs;
|
||||
this.tokenPad = Math.max(0, opts.tokenPad ?? 40);
|
||||
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
|
||||
this.sysPrompt = (opts.systemPrompt ?? '').trim();
|
||||
this.log = opts.logger ?? defaultLogger;
|
||||
this.style = opts.style ?? 'openai';
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return true; // remote backend — readiness is a connectivity question
|
||||
}
|
||||
|
||||
get loadErrorText(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** No local model to load. Best-effort availability ping (never fatal). */
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.pingDone) return;
|
||||
this.pingDone = true;
|
||||
this.log.info('openai-compatible backend', { baseUrl: this.baseUrl, model: this.model, style: this.style });
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), 5000);
|
||||
const r = await fetch(`${this.baseUrl}/${this.style === 'ollama' ? 'api/tags' : 'models'}`, {
|
||||
headers: this.authHeaders(),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) this.log.warn('model list endpoint not reachable/ok (will still attempt completions)', { status: r.status });
|
||||
} catch (err) {
|
||||
this.log.warn('model list endpoint unreachable (will still attempt completions)', { err: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return `openai:${this.baseUrl} model=${this.model}`;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* One chat call per sentence. `level === 'picky'` uses the system prompt +
|
||||
* picky addendum; 'default' is the base prompt (identical to the desktop
|
||||
* app). Returns the original sentence on any error.
|
||||
*/
|
||||
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
|
||||
const base = this.sysPrompt || SYSTEM_PROMPT;
|
||||
const system = level === 'picky' ? `${base} ${this.pickyExtraPrompt}` : base;
|
||||
if (this.style === 'ollama') return this.correctOllama(sentence, system);
|
||||
const body = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: sentence },
|
||||
],
|
||||
temperature: this.sampling.temperature,
|
||||
top_p: this.sampling.topP,
|
||||
top_k: this.sampling.topK,
|
||||
max_tokens: sentence.length + this.tokenPad,
|
||||
stop: STOP_SEQUENCES,
|
||||
};
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
|
||||
const r = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
this.log.error('openai backend error', { status: r.status, body: text.slice(0, 300) });
|
||||
return sentence;
|
||||
}
|
||||
const json: { choices?: Array<{ message?: { content?: string } }> } = await r.json();
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
this.log.error('openai backend returned no usable content', {});
|
||||
return sentence;
|
||||
}
|
||||
return content.trim();
|
||||
} catch (err) {
|
||||
this.log.error(`Error correcting sentence '${sentence}' via openai backend:`, { err: String(err) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama native /api/chat path. Used for Qwen3-family and similar thinking
|
||||
* models: the OpenAI-compat layer of this Ollama build ignores the
|
||||
* top-level think flag and ~100× burns the completion budget on hidden
|
||||
* reasoning (measured: 111–400 completion tokens for ~15-token answers,
|
||||
* occasionally hitting max_tokens with an EMPTY answer). Native think:false
|
||||
* suppresses reasoning entirely (measured: ~15 completion tokens, same
|
||||
* answer) — same contract, deterministic budget.
|
||||
*/
|
||||
private async correctOllama(sentence: string, system: string): Promise<string> {
|
||||
const body = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: sentence },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: this.sampling.temperature,
|
||||
top_k: this.sampling.topK,
|
||||
top_p: this.sampling.topP,
|
||||
stop: STOP_SEQUENCES,
|
||||
num_predict: Math.max(sentence.length + this.tokenPad, 128),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
|
||||
const r = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
this.log.error('ollama backend error', { status: r.status, body: text.slice(0, 300) });
|
||||
return sentence;
|
||||
}
|
||||
const json: { message?: { content?: string } } = await r.json();
|
||||
const content = json.message?.content;
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
this.log.error('ollama backend returned no usable content', {});
|
||||
return sentence;
|
||||
}
|
||||
return content.trim();
|
||||
} catch (err) {
|
||||
this.log.error(`Error correcting sentence '${sentence}' via ollama backend:`, { err: String(err) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLogger: Logger = {
|
||||
debug: () => undefined,
|
||||
info: (m) => console.log(m),
|
||||
warn: (m) => console.warn(m),
|
||||
error: (m, x) => console.error(m, x ? String(x) : ''),
|
||||
};
|
||||
@@ -0,0 +1,647 @@
|
||||
// grammar-llm server — LanguageTool-compatible HTTP API (v2).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /v2/languages LanguageTool language list
|
||||
// POST /v2/check grammar check (form-urlencoded or JSON body)
|
||||
// GET /v2/version version info
|
||||
// GET /v2/health engine/model status (container HEALTHCHECK)
|
||||
// GET / short info payload
|
||||
//
|
||||
// Drop-in for erikvl87/languagetool (docker-languagetool): same default port
|
||||
// (8010), same wire API, `GET /v2/languages` + `POST /v2/check` semantics.
|
||||
|
||||
import http from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { isLanguageSupported, loadConfig, type Config } from './config.js';
|
||||
import { CheckEngine, type CheckEngine } from './engine.js';
|
||||
import { createLogger, type Logger } from './log.js';
|
||||
import {
|
||||
buildCheckResponse,
|
||||
getLanguages,
|
||||
resolveCheckInput,
|
||||
suggestionToMatches,
|
||||
type LtMatch,
|
||||
type TextRun,
|
||||
} from './languagetool.js';
|
||||
import { splitIntoSentences } from '../core/sentences.js';
|
||||
import type { Suggestion } from '../core/types.js';
|
||||
|
||||
// Re-export for consumers/tests (e.g. building the real engine with the
|
||||
// openai backend directly).
|
||||
export { CheckEngine };
|
||||
|
||||
/** LLM half of a check (cascade race participant). */
|
||||
interface LlmOutcome {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** Real-LanguageTool half of the cascade race: null = nothing found / down. */
|
||||
interface LtOutcome {
|
||||
kind: 'lt';
|
||||
value: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EngineLike {
|
||||
status(): {
|
||||
state: string;
|
||||
errorMessage: string | null;
|
||||
modelPath: string;
|
||||
checksCompleted: number;
|
||||
checksFailed: number;
|
||||
};
|
||||
ensureReady(): Promise<void>;
|
||||
check(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: 'default' | 'picky' },
|
||||
): Promise<{ results: Array<{ suggestions: Suggestion[]; correctedText: string; incomplete: boolean }>; incomplete: boolean }>;
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
cfg: Config;
|
||||
engine: EngineLike;
|
||||
log?: Logger;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
params: Record<string, string | undefined>;
|
||||
json: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, maxBytes: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let done = false;
|
||||
req.on('data', (c: Buffer) => {
|
||||
if (done) return;
|
||||
size += c.length;
|
||||
if (size > maxBytes) {
|
||||
done = true;
|
||||
req.removeAllListeners('data');
|
||||
req.resume();
|
||||
reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function parseRequestBody(body: string, req: http.IncomingMessage): RequestBody {
|
||||
const ctype = String(req.headers['content-type'] ?? '').toLowerCase();
|
||||
const params: Record<string, string | undefined> = {};
|
||||
let json: Record<string, unknown> | null = null;
|
||||
|
||||
if (ctype.includes('application/json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
json = parsed;
|
||||
// Also expose scalar params (some clients mix both encodings).
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'string') params[k] = v;
|
||||
}
|
||||
} catch {
|
||||
// fall through to urlencoded
|
||||
}
|
||||
}
|
||||
if (!json && (ctype.includes('application/x-www-form-urlencoded') || ctype === '' || ctype.includes('text/plain'))) {
|
||||
const sp = new URLSearchParams(body);
|
||||
for (const [k, v] of sp.entries()) {
|
||||
params[k] = v;
|
||||
}
|
||||
// If it was actually JSON (no content-type), be helpful.
|
||||
if (params['text'] === undefined && params['data'] === undefined && body.trim().startsWith('{')) {
|
||||
try {
|
||||
json = JSON.parse(body) as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(json)) {
|
||||
if (typeof v === 'string') params[k] = v;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON after all */
|
||||
}
|
||||
}
|
||||
}
|
||||
return { params, json };
|
||||
}
|
||||
|
||||
function jsonOut(res: http.ServerResponse, status: number, obj: unknown, allowOrigin: string): void {
|
||||
const payload = JSON.stringify(obj);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function errorOut(res: http.ServerResponse, status: number, message: string, allowOrigin: string): void {
|
||||
jsonOut(res, status, { message }, allowOrigin);
|
||||
}
|
||||
|
||||
export class LtServer {
|
||||
private inFlight = 0;
|
||||
private readonly log: Logger;
|
||||
|
||||
constructor(private deps: ServerDeps) {
|
||||
this.log = deps.log ?? createLogger(deps.cfg.logLevel);
|
||||
this.httpServer = http.createServer((req, res) => {
|
||||
this.log.debug('request', { method: req.method, url: req.url ?? '' });
|
||||
void this.handle(req, res).catch((err) => {
|
||||
this.log.error('unhandled error', { err: String(err) });
|
||||
if (!res.headersSent) errorOut(res, 500, 'internal error', deps.cfg.allowOrigin);
|
||||
else res.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly httpServer: http.Server;
|
||||
|
||||
listen(port: number, host: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer.once('error', reject);
|
||||
this.httpServer.listen(port, host, () => {
|
||||
this.log.info(`listening on http://${host}:${port} (LanguageTool-compatible API)`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve) => this.httpServer.close(() => resolve()));
|
||||
}
|
||||
|
||||
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { cfg } = this.deps;
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
const path = url.pathname;
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': cfg.allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/v2/languages' || path === '/languages')) {
|
||||
jsonOut(res, 200, getLanguages(), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/v2/version') {
|
||||
jsonOut(res, 200, this.versionObject(), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/v2/health' || path === '/healthz' || path === '/health')) {
|
||||
this.handleHealth(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/' || path === '/index.html')) {
|
||||
jsonOut(res, 200, { ...this.versionObject(), endpoints: ['/v2/languages', 'POST /v2/check', '/v2/version', '/v2/health'] }, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST' && (path === '/v2/check' || path === '/check')) {
|
||||
await this.handleCheck(req, res, url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith('/v2/')) {
|
||||
errorOut(res, 404, `unknown v2 endpoint: ${path}`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
errorOut(res, 404, `not found: ${path}`, cfg.allowOrigin);
|
||||
}
|
||||
|
||||
private versionObject(): Record<string, unknown> {
|
||||
const { cfg } = this.deps;
|
||||
const st = this.deps.engine.status();
|
||||
return {
|
||||
name: 'LanguageTool',
|
||||
version: cfg.reportedVersion,
|
||||
apiVersion: 1,
|
||||
engine: {
|
||||
name: 'grammar-llm',
|
||||
backend: cfg.backend,
|
||||
...(cfg.backend === 'openai' ? { model: cfg.openaiModel } : { model: cfg.modelFile }),
|
||||
state: st.state,
|
||||
},
|
||||
languageToolProxy: cfg.gtUrl ?? null,
|
||||
defaultLevel: cfg.defaultLevel,
|
||||
};
|
||||
}
|
||||
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
const { cfg } = this.deps;
|
||||
const st = this.deps.engine.status();
|
||||
const ok = st.state === 'ready';
|
||||
jsonOut(res, ok ? 200 : 503, {
|
||||
status: ok ? 'ok' : 'degraded',
|
||||
state: st.state,
|
||||
...(st.errorMessage ? { error: st.errorMessage } : {}),
|
||||
model: {
|
||||
file: cfg.modelFile,
|
||||
path: cfg.modelPath,
|
||||
},
|
||||
checks: { completed: st.checksCompleted, failed: st.checksFailed },
|
||||
backend: cfg.backend,
|
||||
languageToolProxy: cfg.gtUrl ?? null,
|
||||
defaultLevel: cfg.defaultLevel,
|
||||
inFlight: this.inFlight,
|
||||
version: cfg.reportedVersion,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
}, cfg.allowOrigin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the request to a real LanguageTool container (v2 API), exactly
|
||||
* like LanguageTool itself would see it:
|
||||
* - original `text` or `data` (annotation) payload — LT handles Overleaf's
|
||||
* LaTeX annotations natively and returns offsets in editor-document space
|
||||
* - `level` always present ('picky'→'picky', otherwise 'default')
|
||||
* - `disabledRules` / `language` passed through
|
||||
* Returns the LT response when it produced ≥1 match, otherwise null (the
|
||||
* caller then runs the LLM). Never throws — LT failure means "try the LLM".
|
||||
*/
|
||||
private async forwardToLanguagetool(args: {
|
||||
cfg: Config;
|
||||
body: Buffer;
|
||||
params: Record<string, string | undefined>;
|
||||
bodyObj: Record<string, unknown>;
|
||||
language: string;
|
||||
level: 'default' | 'picky';
|
||||
deadline: number;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
const { cfg, body, params, bodyObj, language, level, deadline } = args;
|
||||
const gtUrl = cfg.gtUrl;
|
||||
if (!gtUrl) return null;
|
||||
|
||||
// Rebuild a clean form body from the merged params (LT v2 is form-based).
|
||||
const text = typeof bodyObj['text'] === 'string' ? bodyObj['text'] : params['text'];
|
||||
const data = typeof bodyObj['data'] === 'string' ? bodyObj['data'] : bodyObj['data'] ? JSON.stringify(bodyObj['data']) : params['data'];
|
||||
if (!text && !data) return null; // nothing to forward (caller 400s)
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('language', language);
|
||||
form.set('level', level); // ← the picky flag, always explicit
|
||||
if (typeof text === 'string') form.set('text', text);
|
||||
if (typeof data === 'string') form.set('data', data);
|
||||
const disabledRules = params['disabledRules'];
|
||||
if (disabledRules) form.set('disabledRules', disabledRules);
|
||||
|
||||
const remaining = Math.max(500, Math.min(cfg.gtTimeoutMs, deadline - Date.now()));
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), remaining);
|
||||
try {
|
||||
const res = await fetch(`${gtUrl}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.log.warn('real LanguageTool returned an error — falling back to grammar-llm', { status: res.status });
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
const matches = Array.isArray(json['matches']) ? (json['matches'] as unknown[]) : [];
|
||||
if (matches.length === 0) return null; // nothing found → LLM runs second
|
||||
// Pass the genuine LT response through (add missing envelope bits if
|
||||
// the upstream LT is an unusual build).
|
||||
if (json['software'] === undefined) json['software'] = { name: 'LanguageTool', version: cfg.reportedVersion, apiVersion: 1 };
|
||||
if (json['warnings'] === undefined) json['warnings'] = { incompleteResults: false };
|
||||
return json;
|
||||
} catch (err) {
|
||||
this.log.warn('real LanguageTool unreachable — falling back to grammar-llm', { err: String(err), gtUrl });
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheck(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
|
||||
const { cfg, engine } = this.deps;
|
||||
|
||||
// In-flight guard (protect the single LLM session from pileups).
|
||||
if (this.inFlight >= cfg.maxInFlight) {
|
||||
errorOut(res, 503, 'server busy — too many checks in flight, retry shortly', cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
this.inFlight++;
|
||||
try {
|
||||
const body = await readBody(req, cfg.maxBodyBytes).catch((err: unknown) => {
|
||||
if ((err as { statusCode?: number })?.statusCode === 413) {
|
||||
errorOut(res, 413, 'request body too large', cfg.allowOrigin);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (body === null) return;
|
||||
|
||||
// Query-string params may also carry the request (LT allows both); merged into bodyObj below.
|
||||
|
||||
const parsed = parseRequestBody(body.toString('utf8'), req);
|
||||
const bodyObj: Record<string, unknown> = parsed.json ?? {};
|
||||
// LanguageTool accepts text/data/level/language as query or body params;
|
||||
// merge so both styles work.
|
||||
if (bodyObj['text'] === undefined && parsed.params['text'] !== undefined) bodyObj['text'] = parsed.params['text'];
|
||||
if (bodyObj['data'] === undefined && parsed.params['data'] !== undefined) bodyObj['data'] = parsed.params['data'];
|
||||
const params: Record<string, string | undefined> = {
|
||||
...Object.fromEntries(url.searchParams.entries()),
|
||||
...Object.fromEntries(Object.entries(parsed.params)),
|
||||
};
|
||||
|
||||
const language = params['language'] ?? 'auto';
|
||||
// LanguageTool's `level`: 'picky' or 'default'. The level is ALWAYS forwarded
|
||||
// to the real LanguageTool container (picky→picky) and drives the LLM mode.
|
||||
const levelRaw = String(params['level'] ?? bodyObj['level'] ?? '').trim().toLowerCase();
|
||||
const level: 'default' | 'picky' = levelRaw === 'picky' ? 'picky' : levelRaw === 'default' ? 'default' : cfg.defaultLevel;
|
||||
if (levelRaw) this.log.debug('level requested', { levelRaw, resolved: level });
|
||||
const disabledRules = params['disabledRules'] ?? (parsed.json?.['disabledRules'] as string | undefined);
|
||||
if (disabledRules) this.log.debug('disabledRules (forwarded to real LT when configured; ignored by grammar-llm)', { disabledRules });
|
||||
|
||||
// 2) resolve text/runs (also validates text length)
|
||||
const input = resolveCheckInput(bodyObj);
|
||||
if ('error' in input) {
|
||||
errorOut(res, 400, input.error, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
const fullText = input.text;
|
||||
if (fullText.trim().length === 0) {
|
||||
jsonOut(res, 200, buildCheckResponse({ fullText, matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
if (fullText.length > cfg.maxTextLength) {
|
||||
errorOut(res, 413, `text too long (${fullText.length} > ${cfg.maxTextLength})`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) LANGUAGE GATE for the LLM (English model only). The real LT (when
|
||||
// configured as a cascade front) can still serve other languages below.
|
||||
// (Applied early so an unsupported language never spends LLM compute.)
|
||||
if (!isLanguageSupported(cfg, language)) {
|
||||
if (cfg.unsupportedLanguageMode === 'error') {
|
||||
errorOut(res, 400, `language not supported (this deployment runs the English grammar-llm model; got '${language}')`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
this.log.info('unsupported language requested — returning empty matches', { language });
|
||||
jsonOut(res, 200, buildCheckResponse({ fullText: '', matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) CASCADE (when a real LanguageTool is configured): run the real LT
|
||||
// and the LLM on ONE shared wall-clock budget (cascadeBudgetMs, kept
|
||||
// under Overleaf's 60 s controller timeout — over that it silently shows
|
||||
// nothing). Rules:
|
||||
// - real LT with ≥1 match wins the moment it resolves (fast path,
|
||||
// compute-saving: the LLM start is cancelled if it has not begun);
|
||||
// - the LLM starts after llmGraceMs so a warm LT can win for free, but
|
||||
// it then runs in PARALLEL — a slow/empty LT no longer blocks it;
|
||||
// - whichever resolves first with a 200 result is served; 400/503 LLM
|
||||
// outcomes only surface when the LT had nothing either.
|
||||
if (cfg.gtUrl) {
|
||||
const cascadeDeadline = Date.now() + cfg.cascadeBudgetMs;
|
||||
const t0 = Date.now();
|
||||
const ltPromise = this.forwardToLanguagetool({
|
||||
cfg, body, params, bodyObj, language, level,
|
||||
deadline: cascadeDeadline,
|
||||
}).then((value) => ({ kind: 'lt' as const, value }));
|
||||
|
||||
const gate = { cancelled: false };
|
||||
let cancelGate = (): void => { /* default: grace is 0 */ };
|
||||
const gatePromise = new Promise<void>((resolve) => {
|
||||
if (cfg.llmGraceMs === 0) { resolve(); return; }
|
||||
const t = setTimeout(resolve, cfg.llmGraceMs);
|
||||
cancelGate = () => { clearTimeout(t); gate.cancelled = true; resolve(); };
|
||||
});
|
||||
const llmPromise = (async () => {
|
||||
await gatePromise;
|
||||
if (gate.cancelled) {
|
||||
// LT already won with matches: never start the LLM (zero waste).
|
||||
return new Promise<LlmOutcome>(() => { /* suppressed on purpose */ });
|
||||
}
|
||||
return this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline });
|
||||
})();
|
||||
|
||||
const outcome = await this.resolveCascade(ltPromise, llmPromise, () => cancelGate());
|
||||
if (outcome.ltWon) {
|
||||
this.log.info('real LanguageTool produced matches — LLM skipped', { gtUrl: cfg.gtUrl, level, ms: Date.now() - t0, matches: (outcome.body as { matches?: unknown[] })?.matches?.length });
|
||||
} else {
|
||||
this.log.info('real LT found no matches (or was unreachable) — serving LLM result', { gtUrl: cfg.gtUrl, level });
|
||||
}
|
||||
jsonOut(res, outcome.status, outcome.body, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) LLM-ONLY path (no real LT configured).
|
||||
const outcome = await this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline: null });
|
||||
if (outcome.status !== 200) {
|
||||
errorOut(res, outcome.status as 400 | 500 | 503, String((outcome.body as { message?: string })?.message ?? 'check failed'), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
jsonOut(res, 200, outcome.body, cfg.allowOrigin);
|
||||
} finally {
|
||||
this.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The LLM half of a check (language gate → engine → LT JSON). Resolves to a
|
||||
* full HTTP outcome, never throws (internal errors become 500 outcomes).
|
||||
*/
|
||||
private async runLlmCheck(args: {
|
||||
cfg: Config;
|
||||
language: string;
|
||||
level: 'default' | 'picky';
|
||||
fullText: string;
|
||||
runs: TextRun[];
|
||||
/** shared cascade deadline (Date.now()+budget), or null for LLM-only. */
|
||||
cascadeDeadline: number | null;
|
||||
}): Promise<LlmOutcome> {
|
||||
const { cfg, language, level, fullText, runs, cascadeDeadline } = args;
|
||||
const { engine } = this.deps;
|
||||
|
||||
try {
|
||||
await engine.ensureReady();
|
||||
} catch { /* surfaced via status() */ }
|
||||
const st = engine.status();
|
||||
if (st.state !== 'ready') {
|
||||
const hint = st.state === 'error'
|
||||
? ` engine error: ${st.errorMessage ?? 'unknown'}`
|
||||
: ' (model still loading or downloading)';
|
||||
return { status: 503, body: { message: `grammar-llm engine not ready${hint}` } };
|
||||
}
|
||||
|
||||
try {
|
||||
// The LLM gets the budget that is LEFT of the shared cascade window
|
||||
// (or its own default in LLM-only mode), so the total response time
|
||||
// stays under LT-client caps like Overleaf's 60 s.
|
||||
const budgetMs = cascadeDeadline
|
||||
? Math.min(cfg.requestTimeoutMs, Math.max(1500, cascadeDeadline - Date.now()))
|
||||
: cfg.requestTimeoutMs;
|
||||
const { results, incomplete } = await engine.check(runs, {
|
||||
maxRuns: cfg.maxRuns,
|
||||
requestTimeoutMs: budgetMs,
|
||||
level,
|
||||
});
|
||||
|
||||
const matches: LtMatch[] = [];
|
||||
const sentenceRanges: Array<[number, number]> = [];
|
||||
results.forEach((r, i) => {
|
||||
for (const s of r.suggestions) {
|
||||
matches.push(...suggestionToMatches(fullText, runs[i], s));
|
||||
}
|
||||
for (const s of splitIntoSentences(runs[i].text)) {
|
||||
sentenceRanges.push([runs[i].offset + s.start, runs[i].offset + s.end]);
|
||||
}
|
||||
});
|
||||
sentenceRanges.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
this.log.info('LLM check completed', {
|
||||
language, level, runs: results.length,
|
||||
matches: matches.length, incomplete,
|
||||
budgetMs,
|
||||
});
|
||||
|
||||
const incompleteReason = incomplete
|
||||
? 'Check stopped early: per-request time budget exhausted. Only a subset of the document was checked.'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: buildCheckResponse({
|
||||
fullText,
|
||||
matches,
|
||||
sentenceRanges,
|
||||
languageCode: language,
|
||||
incomplete: incompleteReason,
|
||||
reportedVersion: cfg.reportedVersion,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
this.log.error('LLM check failed', { err: String(err) });
|
||||
return { status: 500, body: { message: 'grammar-llm engine failed during check' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race between the real-LT outcome and the LLM outcome with LT preference:
|
||||
* - LT resolves with matches → served immediately (LLM cancelled/ignored);
|
||||
* - LLM resolves with 200 → served immediately (LT is a lost cause then);
|
||||
* - a 400/503 LLM outcome is held back while the LT is still running;
|
||||
* it is served only if the LT also had nothing.
|
||||
*/
|
||||
private async resolveCascade(
|
||||
ltPromise: Promise<LtOutcome>,
|
||||
llmPromise: Promise<LlmOutcome>,
|
||||
onLltWin: () => void,
|
||||
/**
|
||||
* When the LLM is ready but the LT is still running, give the LT this many
|
||||
* extra ms (capped) to land a match before serving the LLM. Keeps "real LT
|
||||
* first" without waiting out the LT's full budget.
|
||||
*/
|
||||
ltExtraWaitMs = 1200,
|
||||
): Promise<{ status: number; body: unknown; ltWon: boolean }> {
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
// Tag each side so we can identify which promise settled (identity is not
|
||||
// preserved through Promise.race). The ids are unique, one per side.
|
||||
const ltTagged = ltPromise.then((outcome) => ({ id: 'lt' as const, outcome }));
|
||||
const llmTagged = llmPromise.then((outcome) => ({ id: 'llm' as const, outcome }));
|
||||
const pending: Array<Promise<{ id: 'lt' | 'llm'; outcome: LtOutcome | LlmOutcome }>> = [ltTagged, llmTagged];
|
||||
let staleLlm: { status: number; body: unknown } | undefined;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { id, outcome } = await Promise.race(pending);
|
||||
// Drop whichever entry settled, by its unique tag (not by object identity).
|
||||
const settledWrapper = id === 'lt' ? ltTagged : llmTagged;
|
||||
const at = pending.indexOf(settledWrapper);
|
||||
if (at >= 0) pending.splice(at, 1);
|
||||
|
||||
if (id === 'lt') {
|
||||
if (outcome.value) {
|
||||
onLltWin(); // cancel the LLM start if it has not begun yet
|
||||
return { status: 200, body: outcome.value, ltWon: true };
|
||||
}
|
||||
if (pending.length === 0) {
|
||||
return staleLlm
|
||||
? { status: staleLlm.status, body: staleLlm.body, ltWon: false }
|
||||
: { status: 200, body: { software: { name: 'LanguageTool', version: '6.8', apiVersion: 1 }, warnings: {}, matches: [] }, ltWon: false };
|
||||
}
|
||||
continue; // wait for the LLM
|
||||
}
|
||||
|
||||
// LLM outcome:
|
||||
if (outcome.status === 200) {
|
||||
if (pending.length > 0) {
|
||||
// The LT is still running: give it a short capped chance to win.
|
||||
const maybeLt = await Promise.race([
|
||||
pending[0].then((o) => o),
|
||||
sleep(Math.max(0, Math.min(ltExtraWaitMs, 1200))).then(() => null),
|
||||
]);
|
||||
if (maybeLt && maybeLt.id === 'lt' && maybeLt.outcome.value) {
|
||||
onLltWin();
|
||||
return { status: 200, body: maybeLt.outcome.value, ltWon: true };
|
||||
}
|
||||
}
|
||||
return { status: 200, body: outcome.body, ltWon: false };
|
||||
}
|
||||
staleLlm = { status: outcome.status, body: outcome.body };
|
||||
if (pending.length === 0) return { status: outcome.status, body: outcome.body, ltWon: false };
|
||||
}
|
||||
throw new Error('cascade resolved without an outcome');
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const log = createLogger(cfg.logLevel);
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
const lt = new LtServer({ cfg, engine, log });
|
||||
|
||||
if (cfg.preload) {
|
||||
if (cfg.backend === 'openai') {
|
||||
log.info('openai-compatible backend — no local model to preload', { baseUrl: cfg.openaiBaseUrl, model: cfg.openaiModel });
|
||||
} else {
|
||||
log.info('preloading model (first check waits for this to finish)…', { modelPath: cfg.modelPath });
|
||||
}
|
||||
engine.ensureReady().catch(() => {
|
||||
// surfaced via /v2/health and as 503s on /v2/check
|
||||
});
|
||||
}
|
||||
|
||||
await lt.listen(cfg.port, cfg.host);
|
||||
const shutdown = (sig: string) => {
|
||||
log.info(`${sig} received, shutting down`);
|
||||
void lt.close().then(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
// Auto-start when executed directly (node dist/server.mjs); no-op when imported by tests.
|
||||
const isMain = (() => {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# grammar-llm container entrypoint.
|
||||
#
|
||||
# 1. Warn about LanguageTool-container env vars that don't apply here
|
||||
# (drop-in sanity: admins often copy the old container's env verbatim).
|
||||
# 2. Ensure the model file is present (download on first start by default).
|
||||
# 3. Exec the HTTP server.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- drop-in compatibility warnings ---------------------------------------
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
echo "WARN: env '$name' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
|
||||
done < <(env | grep -Eo '^langtool_[A-Za-z0-9_]*' | sort -u)
|
||||
for var in Java_Xms Java_Xmx; do
|
||||
case "$var" in Java_Xms) val="${Java_Xms:-}" ;; Java_Xmx) val="${Java_Xmx:-}" ;; *) val="" ;; esac
|
||||
if [ -n "$val" ]; then
|
||||
echo "WARN: env '$var' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# --- configuration (defaults match docker-languagetool expectations) -------
|
||||
BACKEND="${GRAMMAR_LLM_BACKEND:-local}"
|
||||
MODEL_DIR="${GRAMMAR_LLM_MODEL_DIR:-/models}"
|
||||
MODEL_FILE="${GRAMMAR_LLM_MODEL_FILE:-GRMR-V3-G4B-Q4_K_M.gguf}"
|
||||
MODEL_URL="${GRAMMAR_LLM_MODEL_URL:-https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf}"
|
||||
MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
|
||||
DOWNLOAD_AT_START="${GRAMMAR_LLM_DOWNLOAD_AT_START:-true}"
|
||||
|
||||
export GRAMMAR_LLM_MODEL_DIR="$MODEL_DIR"
|
||||
export GRAMMAR_LLM_MODEL_FILE="$MODEL_FILE"
|
||||
export GRAMMAR_LLM_MODEL_URL="$MODEL_URL"
|
||||
|
||||
mkdir -p "$MODEL_DIR"
|
||||
|
||||
if [ "${BACKEND}" != "openai" ]; then
|
||||
if [ ! -s "$MODEL_PATH" ]; then
|
||||
if [ "$DOWNLOAD_AT_START" = "true" ]; then
|
||||
echo "INFO: model not found at $MODEL_PATH — downloading $(basename "$MODEL_PATH") ..."
|
||||
node "$APP_DIR/dist/download-model.mjs" --url "$MODEL_URL" --out "$MODEL_PATH"
|
||||
else
|
||||
echo "ERROR: model not found at $MODEL_PATH and GRAMMAR_LLM_DOWNLOAD_AT_START=false" >&2
|
||||
echo " Mount the model file into $MODEL_DIR or set GRAMMAR_LLM_DOWNLOAD_AT_START=true." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ ! -s "$MODEL_PATH" ]; then
|
||||
echo "ERROR: model still missing at $MODEL_PATH after download step" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "INFO: using model $MODEL_PATH (local backend)"
|
||||
else
|
||||
echo "INFO: backend=openai (model=${GRAMMAR_LLM_OPENAI_MODEL:-<unset>} base=${GRAMMAR_LLM_OPENAI_BASE_URL:-https://api.openai.com/v1}) — no local model needed"
|
||||
fi
|
||||
echo "INFO: starting grammar-llm server (LanguageTool-compatible API) on port ${PORT:-8010}"
|
||||
exec node "$APP_DIR/dist/server.mjs"
|
||||
@@ -0,0 +1,303 @@
|
||||
// Unit tests for the LanguageTool mapper & request resolution.
|
||||
// Runs against the esbuild bundle (dist/languagetool.mjs) with the vendored
|
||||
// core — no LLM, no network.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildCheckResponse,
|
||||
getLanguages,
|
||||
normalizeLanguage,
|
||||
resolveCheckInput,
|
||||
suggestionToMatches,
|
||||
} from '../dist/languagetool.mjs';
|
||||
|
||||
const RUN = { text: 'He said teh thing.', offset: 0 };
|
||||
|
||||
function suggestionWith(fixes, original = 'He said teh thing.', corrected = 'He said the thing.') {
|
||||
return {
|
||||
original,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: original.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: fixes,
|
||||
};
|
||||
}
|
||||
|
||||
test('replacement word fix → LT match with absolute offset', () => {
|
||||
const matches = suggestionToMatches(RUN.text, RUN, suggestionWith([{ original: 'teh', corrected: 'the', start: 8, end: 11 }]));
|
||||
assert.equal(matches.length, 1);
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 8);
|
||||
assert.equal(m.length, 3);
|
||||
assert.deepEqual(m.replacements, [{ value: 'the' }]);
|
||||
assert.match(m.message, /'the'/);
|
||||
assert.equal(m.rule.category.id, 'GRAMMAR'); // must not be 'TYPOS' (Overleaf filter)
|
||||
assert.equal(m.context.text.slice(m.context.offset, m.context.offset + m.length), 'teh');
|
||||
});
|
||||
|
||||
test('deletion word fix → empty replacement', () => {
|
||||
const text = 'He said very very thing.';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He said very thing.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'very', corrected: '', start: 13, end: 17 }],
|
||||
});
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].offset, 13);
|
||||
assert.equal(matches[0].length, 4);
|
||||
assert.deepEqual(matches[0].replacements, [{ value: '' }]);
|
||||
assert.match(matches[0].message, /Remove/);
|
||||
});
|
||||
|
||||
test('punctuation insertion after a word attaches to the previous word (core ground truth)', () => {
|
||||
// core emit for 'slow and happy' -> 'slowly, and happy':
|
||||
// slow->slowly [0,4) + pure insertion ',' at pos 4
|
||||
const text = 'slow and happy';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'slowly, and happy',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'slow', corrected: 'slowly', start: 0, end: 4 }, { original: '', corrected: ',', start: 4, end: 4 }],
|
||||
});
|
||||
const m = matches[1];
|
||||
assert.equal(m.offset, 4);
|
||||
assert.equal(m.length, 4); // covers ' and' (gap + token)
|
||||
assert.equal(m.replacements[0].value, ', and');
|
||||
// Apply both matches (reverse offset order) to the original doc.
|
||||
let doc = text;
|
||||
for (const m2 of [...matches].sort((a, b) => b.offset - a.offset)) {
|
||||
doc = doc.slice(0, m2.offset) + m2.replacements[0].value + doc.slice(m2.offset + m2.length);
|
||||
}
|
||||
assert.equal(doc, 'slowly, and happy');
|
||||
});
|
||||
|
||||
test('word insertion before a token keeps spacing (core ground truth)', () => {
|
||||
// core emit for 'quick and happy' -> 'quick and very happy':
|
||||
// pure insertion 'very ' at pos 10 (before 'happy')
|
||||
const text = 'quick and happy';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'quick and very happy',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: 'very ', start: 10, end: 10 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 10);
|
||||
assert.equal(m.length, 5); // token 'happy'
|
||||
assert.equal(m.replacements[0].value, 'very happy');
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'quick and very happy');
|
||||
});
|
||||
|
||||
test('punctuation insertion right before a token (no gap)', () => {
|
||||
const text = 'He ate andbutter fast';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He ate and,butter fast',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: ',', start: 10, end: 10 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 10);
|
||||
assert.equal(m.length, 6); // token 'butter'
|
||||
assert.equal(m.replacements[0].value, ',butter');
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'He ate and,butter fast');
|
||||
});
|
||||
|
||||
test('insertion at run end anchors on last token', () => {
|
||||
const text = 'He ran quick';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He ran quick.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: '.', start: 12, end: 12 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'He ran quick.');
|
||||
});
|
||||
|
||||
test('model quote artifacts are unwrapped from replacements', () => {
|
||||
// GRMR sometimes wraps the corrected word in quotes: 'goed' -> '"good"'
|
||||
const text = 'The cat is goed.';
|
||||
const run = { text, offset: 0 };
|
||||
for (const corrected of ['"good"', '\u201Cgood\u201D', "'good'"]) {
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'The cat is good.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'goed', corrected, start: 11, end: 15 }],
|
||||
});
|
||||
assert.equal(matches.length, 1, 'one match for ' + corrected);
|
||||
assert.equal(matches[0].replacements[0].value, 'good');
|
||||
}
|
||||
// …same for a different word — a grammar model quoting its replacement is a
|
||||
// display artifact, not a request to add quotes to the document
|
||||
const m2 = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'The cat is wonderful.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'goed', corrected: '"wonderful"', start: 11, end: 15 }],
|
||||
});
|
||||
assert.equal(m2[0].replacements[0].value, 'wonderful');
|
||||
});
|
||||
|
||||
test('sentence-level rewrite (no word fixes) → partial-span replacement, offset-rebased', () => {
|
||||
const text = 'The committee met every day';
|
||||
const run = { text, offset: 100 };
|
||||
const s = suggestionWith([], 'The committee met every day', 'The committee has met daily');
|
||||
const matches = suggestionToMatches(text, run, s);
|
||||
assert.equal(matches.length, 1);
|
||||
const m = matches[0];
|
||||
const result = text.slice(0, m.offset - 100) + m.replacements[0].value + text.slice(m.offset - 100 + m.length);
|
||||
assert.equal(result, 'The committee has met daily');
|
||||
assert.ok(m.offset >= 100); // rebased onto the document
|
||||
});
|
||||
|
||||
test('matches outside the run text are dropped', () => {
|
||||
const run = { text: 'short', offset: 0 };
|
||||
const matches = suggestionToMatches('short', run, suggestionWith([{ original: 'x', corrected: 'y', start: 99, end: 100 }]));
|
||||
assert.equal(matches.length, 0);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: plain text param', () => {
|
||||
const r = resolveCheckInput({ text: 'Hello world' });
|
||||
assert.ok(!('error' in r));
|
||||
assert.equal(r.text, 'Hello world');
|
||||
assert.deepEqual(r.runs, [{ text: 'Hello world', offset: 0 }]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: annotation (Overleaf LaTeX payload) keeps offset alignment', () => {
|
||||
const annotation = [
|
||||
{ text: 'The dog ' },
|
||||
{ markup: '{\\textbf{runs}} ' },
|
||||
{ text: 'fast.' },
|
||||
];
|
||||
const r = resolveCheckInput({ data: JSON.stringify({ annotation }) });
|
||||
assert.ok(!('error' in r));
|
||||
assert.equal(r.text, 'The dog {\\textbf{runs}} fast.');
|
||||
assert.deepEqual(r.runs, [
|
||||
{ text: 'The dog ', offset: 0 },
|
||||
{ text: 'fast.', offset: 24 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: { text } data object', () => {
|
||||
const r = resolveCheckInput({ data: { text: 'abc' } });
|
||||
assert.equal(r.text, 'abc');
|
||||
assert.deepEqual(r.runs, [{ text: 'abc', offset: 0 }]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: missing text and data → error', () => {
|
||||
const r = resolveCheckInput({});
|
||||
assert.ok('error' in r);
|
||||
assert.match(r.error, /Missing/);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: non-JSON string data is treated as text', () => {
|
||||
const r = resolveCheckInput({ data: 'plain string data' });
|
||||
assert.equal(r.text, 'plain string data');
|
||||
});
|
||||
|
||||
test('normalizeLanguage: auto + en variants accepted, de rejected', () => {
|
||||
assert.equal(normalizeLanguage(null).code, 'en-US');
|
||||
assert.equal(normalizeLanguage('auto').code, 'en-US');
|
||||
assert.equal(normalizeLanguage('en-GB').code, 'en-GB');
|
||||
assert.equal(normalizeLanguage('EN').code, 'en');
|
||||
assert.equal(normalizeLanguage('de'), null);
|
||||
});
|
||||
|
||||
test('getLanguages: LanguageTool list shape (name/code/longCode)', () => {
|
||||
const langs = getLanguages();
|
||||
assert.ok(Array.isArray(langs));
|
||||
for (const l of langs) {
|
||||
assert.ok(typeof l.name === 'string');
|
||||
assert.ok(typeof l.code === 'string');
|
||||
assert.ok(typeof l.longCode === 'string');
|
||||
}
|
||||
assert.ok(langs.some((l) => l.code === 'en-US'));
|
||||
});
|
||||
|
||||
test('buildCheckResponse: LT envelope shape + sorted matches + warnings', () => {
|
||||
const body = buildCheckResponse({
|
||||
fullText: 'He said teh thing.',
|
||||
matches: [
|
||||
{
|
||||
message: 'm2',
|
||||
replacements: [{ value: 'b' }],
|
||||
offset: 10,
|
||||
length: 1,
|
||||
context: { text: 'x', offset: 0, length: 1 },
|
||||
type: { typeName: 'grammar' },
|
||||
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
},
|
||||
{
|
||||
message: 'm1',
|
||||
replacements: [{ value: 'a' }],
|
||||
offset: 3,
|
||||
length: 1,
|
||||
context: { text: 'x', offset: 0, length: 1 },
|
||||
type: { typeName: 'grammar' },
|
||||
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
},
|
||||
],
|
||||
sentenceRanges: [
|
||||
[0, 18],
|
||||
],
|
||||
languageCode: 'en-GB',
|
||||
incomplete: 'stopped early',
|
||||
});
|
||||
assert.equal(body.software.name, 'LanguageTool');
|
||||
assert.equal(body.software.apiVersion, 1);
|
||||
assert.equal(body.software.premium, false);
|
||||
assert.equal(body.language.code, 'en-GB');
|
||||
assert.equal(body.language.detectedLanguage.source, 'given');
|
||||
assert.deepEqual(body.matches.map((m) => m.offset), [3, 10]);
|
||||
assert.equal(body.warnings.incompleteResults, true);
|
||||
assert.equal(body.warnings.incompleteResultsReason, 'stopped early');
|
||||
assert.deepEqual(body.sentenceRanges, [[0, 18]]);
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
// HTTP API tests with a deterministic fake engine (no LLM, no network).
|
||||
// Mirrors how Overleaf's LanguageToolController talks to a LT-compatible box:
|
||||
// GET /v2/languages
|
||||
// POST /v2/check (x-www-form-urlencoded: language, level, data/annotation,
|
||||
// disabledRules — see LanguageToolController.mjs)
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { LtServer } from '../dist/server.mjs';
|
||||
import { CheckEngine } from '../dist/server.mjs';
|
||||
import { loadConfig } from '../dist/config.mjs';
|
||||
import http from 'node:http';
|
||||
|
||||
function makeConfig(over = {}) {
|
||||
const env = {
|
||||
PORT: '8010',
|
||||
HOST: '127.0.0.1',
|
||||
GRAMMAR_LLM_MODEL_DIR: '/tmp',
|
||||
GRAMMAR_LLM_PRELOAD: 'false',
|
||||
GRAMMAR_LLM_LOG_LEVEL: 'warn',
|
||||
...over,
|
||||
};
|
||||
return loadConfig(env);
|
||||
}
|
||||
|
||||
/** Deterministic "engine": corrects the literal word 'teh' → 'the' wherever it
|
||||
* appears, and inserts a comma after 'quick'. */
|
||||
class FakeEngine {
|
||||
state = 'ready';
|
||||
statusError = null;
|
||||
checkCalls = 0;
|
||||
lastLevel = null;
|
||||
constructor() {}
|
||||
|
||||
status() {
|
||||
return {
|
||||
state: this.state,
|
||||
errorMessage: this.statusError,
|
||||
modelPath: '/tmp/fake.gguf',
|
||||
checksCompleted: 0,
|
||||
checksFailed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async ensureReady() {
|
||||
if (this.state !== 'ready') throw new Error('model not ready');
|
||||
}
|
||||
|
||||
async check(runs, opts = {}) {
|
||||
if (this.state !== 'ready') throw new Error('not ready');
|
||||
this.checkCalls++;
|
||||
this.lastLevel = opts.level ?? null;
|
||||
const results = [];
|
||||
for (const run of runs) {
|
||||
const out = { suggestions: [], correctedText: run.text, incomplete: false };
|
||||
const t = run.text;
|
||||
const idx = t.indexOf('teh');
|
||||
if (idx >= 0) {
|
||||
const corrected = t.slice(0, idx) + 'the' + t.slice(idx + 3);
|
||||
out.correctedText = corrected;
|
||||
out.suggestions = [
|
||||
{
|
||||
original: t,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: t.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'teh', corrected: 'the', start: idx, end: idx + 3 }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const kidx = t.indexOf('quick');
|
||||
if (kidx >= 0 && out.suggestions.length === 0) {
|
||||
// insertion after 'quick' (pos = kidx+5)
|
||||
const pos = kidx + 5;
|
||||
const corrected = t.slice(0, pos) + ',' + t.slice(pos);
|
||||
out.correctedText = corrected;
|
||||
out.suggestions = [
|
||||
{
|
||||
original: t,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: t.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: ',', start: pos, end: pos }],
|
||||
},
|
||||
];
|
||||
}
|
||||
results.push(out);
|
||||
}
|
||||
return { results, incomplete: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(_over = {}, engineOverride, cfgOver = {}) {
|
||||
const cfg = makeConfig(cfgOver);
|
||||
const engine = engineOverride ?? new FakeEngine();
|
||||
const server = new LtServer({ cfg, engine });
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const port = server.httpServer.address().port;
|
||||
return { cfg, engine, server, base: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
async function close(server) {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
test('GET /v2/languages: LT shape (Overleaf reads name+code)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/languages`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.ok(Array.isArray(body));
|
||||
assert.ok(body.some((l) => l.code === 'en-US'));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check (Overleaf style: form + annotation data)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const source = 'He wrote \\textbf{report} about teh project.';
|
||||
const annotation = [
|
||||
{ text: 'He wrote ' },
|
||||
{ markup: '\\textbf{report} ' },
|
||||
{ text: 'about teh project.' },
|
||||
];
|
||||
const params = new URLSearchParams();
|
||||
params.set('language', 'auto');
|
||||
params.set('level', 'picky');
|
||||
params.set('data', JSON.stringify({ annotation }));
|
||||
params.set('disabledRules', 'WHITESPACE_RULE,CONSECUTIVE_SPACES');
|
||||
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: params.toString(),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
|
||||
assert.equal(body.software.name, 'LanguageTool');
|
||||
assert.equal(body.software.apiVersion, 1);
|
||||
assert.equal(body.warnings.incompleteResults, false);
|
||||
assert.equal(body.language.code, 'en-US');
|
||||
|
||||
// 'teh' is at absolute offset: len('He wrote ') + len('\\textbf{report} ')
|
||||
const baseOffset = 'He wrote '.length + '\\textbf{report} '.length;
|
||||
assert.equal(body.matches.length, 1);
|
||||
const m = body.matches[0];
|
||||
assert.equal(m.offset, baseOffset + 'about '.length);
|
||||
assert.equal(m.length, 3);
|
||||
assert.deepEqual(m.replacements, [{ value: 'the' }]);
|
||||
assert.equal(m.rule.category.id, 'GRAMMAR');
|
||||
assert.ok(m.context.text.includes('teh'));
|
||||
// overleaf invariant: offset within doc
|
||||
assert.ok(m.offset + m.length <= source.length);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check with JSON body', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: 'en-GB', level: 'default', text: 'I has teh book.' }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.language.code, 'en-GB');
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].offset, 6); // 'has ' before 'teh' → 'I has teh' → t@6
|
||||
assert.deepEqual(body.matches[0].replacements, [{ value: 'the' }]);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: no text and no data → 400', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en',
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /Missing/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: too-long text → 413', async (t) => {
|
||||
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_MAX_TEXT_LENGTH: '3000' });
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('a'.repeat(3500)),
|
||||
});
|
||||
assert.equal(res.status, 413);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: unsupported language → empty matches (default mode)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.deepEqual(body.matches, []);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: unsupported language → 400 (error mode)', async (t) => {
|
||||
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_UNSUPPORTED_LANGUAGE: 'error' });
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: engine not ready → 503 with message', async (t) => {
|
||||
const engine = new FakeEngine();
|
||||
engine.state = 'loading';
|
||||
const { server, base } = await boot({}, engine);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I has teh book.'),
|
||||
});
|
||||
assert.equal(res.status, 503);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /not ready/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/health reflects engine state', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/health`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.status, 'ok');
|
||||
assert.equal(body.state, 'ready');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/health: not ready → 503', async (t) => {
|
||||
const engine = new FakeEngine();
|
||||
engine.state = 'error';
|
||||
engine.statusError = 'boom';
|
||||
const { server, base } = await boot({}, engine);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/health`);
|
||||
assert.equal(res.status, 503);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.state, 'error');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/version and / info payloads', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const v = (await (await fetch(`${base}/v2/version`)).json());
|
||||
assert.equal(v.name, 'LanguageTool');
|
||||
assert.ok(v.engine);
|
||||
|
||||
const i = (await (await fetch(`${base}/`)).json());
|
||||
assert.ok(Array.isArray(i.endpoints));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown /v2 endpoint → 404 JSON', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/nope`);
|
||||
assert.equal(res.status, 404);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /unknown v2 endpoint/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('OPTIONS preflight → CORS headers', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, { method: 'OPTIONS' });
|
||||
assert.ok([200, 204].includes(res.status));
|
||||
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('responses carry CORS + no-store headers (browser client compat)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/languages`);
|
||||
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New: LT-first cascade, LLM level modes, OpenAI-compatible remote backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** In-process fake LanguageTool (v2 /check). Optional {delayMs} to simulate a slow LT. */
|
||||
function fakeLt(handler, { delayMs = 0 } = {}) {
|
||||
const seen = { bodies: [] };
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const body = new URLSearchParams(raw);
|
||||
seen.bodies.push(Object.fromEntries(body.entries()));
|
||||
const done = () => {
|
||||
const out = handler(body, seen.bodies.length - 1) ?? { matches: [] };
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(out));
|
||||
};
|
||||
if (delayMs > 0) setTimeout(done, delayMs);
|
||||
else done();
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
seen,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** In-process fake OpenAI-compatible chat/completions endpoint. */
|
||||
function fakeOpenAi(corrections = {}) {
|
||||
const seen = { requests: [] };
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url?.endsWith('/models')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"data":[{"id":"fake"}]}');
|
||||
return;
|
||||
}
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', async () => {
|
||||
const json = JSON.parse(raw || '{}');
|
||||
seen.requests.push(json);
|
||||
const user = json.messages?.[1]?.content ?? '';
|
||||
const corrected = corrections[user] ?? user;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
choices: [{ message: { content: corrected } }],
|
||||
}));
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
seen,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('cascade: real LT produces matches → LT wins, LLM skipped, level forwarded', async () => {
|
||||
const lt = await fakeLt(() => ({
|
||||
software: { name: 'LanguageTool', version: '6.4' },
|
||||
warnings: { incompleteResults: false },
|
||||
matches: [
|
||||
{
|
||||
message: 'FROM-REAL-LT',
|
||||
shortMessage: 'LT-match',
|
||||
replacements: [{ value: 'the' }],
|
||||
offset: 2,
|
||||
length: 3,
|
||||
context: { text: 'I teh has', offset: 0, length: 9 },
|
||||
type: { typeName: 'Typographical' },
|
||||
rule: { id: 'RULE_LT', description: 'x', issueType: 'Typo', category: { id: 'TYPOS', name: 'Typos' } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
try {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].message, 'FROM-REAL-LT');
|
||||
assert.equal(body.software.version, '6.4');
|
||||
assert.equal(engine.checkCalls, 0, 'LLM must be skipped when LT has matches');
|
||||
// the picky flag must reach the real LT verbatim:
|
||||
assert.equal(lt.seen.bodies[0].level, 'picky');
|
||||
assert.equal(lt.seen.bodies[0].language, 'en');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
} finally {
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade: real LT finds nothing → LLM runs second (fallback)', async () => {
|
||||
const lt = await fakeLt(() => ({ matches: [] }));
|
||||
try {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1, 'fallback LLM match expected');
|
||||
assert.match(body.matches[0].message, /the/);
|
||||
assert.equal(engine.checkCalls, 1, 'LLM must run exactly once as fallback');
|
||||
assert.equal(lt.seen.bodies[0].level, 'default');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
} finally {
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade: real LT down → LLM fallback still returns 200', async () => {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: 'http://127.0.0.1:9', // closed port
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '800',
|
||||
},
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(engine.checkCalls, 1);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade race: slow empty LT does NOT block the LLM (the 71 s bug)', async () => {
|
||||
// The real-world bug: LT took (or was given) a long time and answered "0
|
||||
// matches"; the LLM then ran AFTER it, pushing the total past Overleaf's
|
||||
// 60 s controller timeout → the UI silently showed nothing.
|
||||
// With the shared-budget concurrent cascade the LLM must be served as soon
|
||||
// as IT is ready, even while the LT is still running.
|
||||
const lt = await fakeLt(() => ({ matches: [] }), { delayMs: 2000 });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const engine = new FakeEngine();
|
||||
const origCheck = engine.check.bind(engine);
|
||||
// make the LLM take ~300 ms so it settles well before the 2 s LT
|
||||
engine.check = async (runs, opts = {}) => {
|
||||
await sleep(300);
|
||||
return origCheck(runs, opts);
|
||||
};
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
|
||||
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
|
||||
GRAMMAR_LLM_LLM_GRACE_MS: '50', // LLM starts quickly, as in production
|
||||
},
|
||||
);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
const elapsed = Date.now() - t0;
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(body.matches.length >= 1, 'LLM fallback match must be present');
|
||||
assert.ok(
|
||||
elapsed < 2000,
|
||||
`LLM result must be served before the slow empty LT settles (elapsed ${elapsed} ms; LT was 2000 ms)`,
|
||||
);
|
||||
assert.equal(engine.checkCalls, 1, 'the LLM ran');
|
||||
} finally {
|
||||
await close(server);
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade race: LT with matches still wins even when slower than the LLM grace', async () => {
|
||||
const lt = await fakeLt(() => ({
|
||||
software: { name: 'LanguageTool', version: '6.4' },
|
||||
warnings: { incompleteResults: false },
|
||||
matches: [
|
||||
{
|
||||
message: 'FROM-REAL-LT',
|
||||
replacements: [{ value: 'the' }],
|
||||
offset: 2,
|
||||
length: 3,
|
||||
context: { text: 'I teh has', offset: 0, length: 9 },
|
||||
type: { typeName: 'Typographical' },
|
||||
rule: { id: 'RULE_LT', category: { id: 'TYPOS', name: 'Typos' } },
|
||||
},
|
||||
],
|
||||
}), { delayMs: 700 });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const engine = new FakeEngine();
|
||||
const origCheck = engine.check.bind(engine);
|
||||
engine.check = async (runs, opts = {}) => {
|
||||
await sleep(200); // LLM would "finish" first — LT (700 ms) must still win
|
||||
return origCheck(runs, opts);
|
||||
};
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
|
||||
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
|
||||
GRAMMAR_LLM_LLM_GRACE_MS: '50',
|
||||
},
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].message, 'FROM-REAL-LT', 'the LT match must win');
|
||||
} finally {
|
||||
await close(server);
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('level: absent/invalid → cfg.defaultLevel; explicit respected (LLM receives it)', async () => {
|
||||
const mk = (env) => {
|
||||
const engine = new FakeEngine();
|
||||
return { engine, env: { ...env } };
|
||||
};
|
||||
// absent level
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// invalid level → falls back to defaultLevel
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=bogus&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// explicit picky
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, {});
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// explicit default
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, {});
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'default');
|
||||
await close(server);
|
||||
}
|
||||
void mk;
|
||||
});
|
||||
|
||||
test('openai backend: real engine pipeline against a fake OpenAI endpoint (default level prompt)', async () => {
|
||||
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
|
||||
try {
|
||||
const cfg = makeConfig({
|
||||
GRAMMAR_LLM_BACKEND: 'openai',
|
||||
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
|
||||
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
|
||||
GRAMMAR_LLM_DEFAULT_LEVEL: 'default',
|
||||
});
|
||||
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
const server = new LtServer({ cfg, engine, log });
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('He run fast to school.'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1, 'core diff must produce the ran-fix');
|
||||
assert.equal(body.matches[0].replacements[0].value, 'ran');
|
||||
// request shape: model + system prompt
|
||||
const req = fake.seen.requests[0];
|
||||
assert.equal(req.model, 'fake-model-123');
|
||||
assert.equal(req.messages[0].role, 'system');
|
||||
assert.match(req.messages[0].content, /grammar correction assistant/);
|
||||
assert.doesNotMatch(req.messages[0].content, /highly picky/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('openai backend: picky level gets the stricter prompt; default does not', async () => {
|
||||
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
|
||||
try {
|
||||
const mk = (defaultLevel) => {
|
||||
const cfg = makeConfig({
|
||||
GRAMMAR_LLM_BACKEND: 'openai',
|
||||
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
|
||||
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
|
||||
GRAMMAR_LLM_DEFAULT_LEVEL: defaultLevel,
|
||||
});
|
||||
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
return new LtServer({ cfg, engine, log });
|
||||
};
|
||||
// explicit picky
|
||||
{
|
||||
const server = mk('default');
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('He run fast to school.'),
|
||||
});
|
||||
await server.close();
|
||||
}
|
||||
const pickyReq = fake.seen.requests[fake.seen.requests.length - 1];
|
||||
assert.match(pickyReq.messages[0].content, /highly picky/);
|
||||
assert.match(pickyReq.messages[0].content, /grammar correction assistant/);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# smoke.sh — end-to-end check against a RUNNING grammar-llm server.
|
||||
#
|
||||
# This does NOT start the server (it would need the model). Run a server
|
||||
# first, e.g.:
|
||||
#
|
||||
# node dist/download-model.mjs --out /tmp/models/GRMR-V3-G4B-Q4_K_M.gguf \
|
||||
# --url https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
# GRAMMAR_LLM_MODEL_DIR=/tmp/models PORT=8090 node dist/server.mjs &
|
||||
#
|
||||
# then:
|
||||
#
|
||||
# BASE=http://127.0.0.1:8090 npm run smoke
|
||||
#
|
||||
# or against the docker container:
|
||||
#
|
||||
# BASE=http://127.0.0.1:8010 npm run smoke
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-http://127.0.0.1:8010}"
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
|
||||
|
||||
say "GET $BASE/v2/languages"
|
||||
body="$(curl -fsS "$BASE/v2/languages")"
|
||||
if echo "$body" | jq -e '.[0].name and .[0].code' >/dev/null 2>&1; then
|
||||
ok "languages list has name+code"
|
||||
echo "$body" | jq -c '.[0]'
|
||||
else
|
||||
bad "languages list unexpected: $body"
|
||||
fi
|
||||
|
||||
say "GET $BASE/v2/health"
|
||||
body="$(curl -fsS "$BASE/v2/health" || true)"
|
||||
state="$(echo "$body" | jq -r '.state // "missing"' 2>/dev/null || echo missing)"
|
||||
echo " state: $state"
|
||||
[ "$state" = "ready" ] && ok "model ready" || { echo " (model not ready — /v2/check will 503; continuing for shape checks)"; }
|
||||
|
||||
say "POST $BASE/v2/check (text=…, Overleaf style)"
|
||||
code="$(curl -s -o /tmp/glm-smoke-check.json -w '%{http_code}' -X POST "$BASE/v2/check" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=auto' \
|
||||
--data-urlencode 'level=picky' \
|
||||
--data-urlencode 'disabledRules=WHITESPACE_RULE,CONSECUTIVE_SPACES' \
|
||||
--data-urlencode 'text=I has two cats. They is very goed cat.')"
|
||||
resp="$(cat /tmp/glm-smoke-check.json)"
|
||||
|
||||
echo "$resp" | head -c 400; echo
|
||||
if [ "$code" = "200" ]; then
|
||||
n="$(echo "$resp" | jq '.matches | length' 2>/dev/null || echo '?')"
|
||||
echo " matches: $n"
|
||||
echo "$resp" | jq -c '.matches[]? | {offset, length, replacements: [ .replacements[].value ], message}' 2>/dev/null | head -5 || true
|
||||
if [ "$n" != "?" ] && [ "$n" -ge 1 ]; then ok "produced matches"; else bad "expected >=1 match (model may have found none)"; fi
|
||||
if echo "$resp" | jq -e '.software // .software' >/dev/null 2>&1; then ok "has software block"; else bad "no software block"; fi
|
||||
else
|
||||
# 503 (model not ready) is acceptable for a shape test, but report status
|
||||
echo " status: $code"
|
||||
[ "$code" = "503" ] && { echo " (model still loading — rerun smoke once /v2/health shows ready)"; } || bad "unexpected status $code"
|
||||
fi
|
||||
|
||||
say "POST /v2/check with annotation (LaTeX markup must be skipped)"
|
||||
resp="$(curl -s -X POST "$BASE/v2/check" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=en-US' \
|
||||
--data-urlencode 'data={"annotation":[{"text":"The "},{"markup":"{\\textbf{bold}} "},{"text":"cat is goed."}]}' )"
|
||||
echo "$resp" | head -c 400; echo
|
||||
# No match may fall inside the markup span [4,16)
|
||||
if echo "$resp" | jq -e '.matches[] | select(.offset >= 4 and .offset < 16)' >/dev/null 2>&1; then
|
||||
bad "a match overlaps the LaTeX markup span (offsets misaligned?)"
|
||||
else
|
||||
ok "no match crosses the markup span"
|
||||
fi
|
||||
|
||||
say "negative: missing text/data → 400"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v2/check" -H 'Content-Type: application/x-www-form-urlencoded' --data 'language=en')"
|
||||
[ "$code" = "400" ] && ok "400 as expected" || bad "expected 400, got $code"
|
||||
|
||||
say "CORS preflight"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$BASE/v2/check")"
|
||||
[ "$code" = "204" ] || [ "$code" = "200" ] && ok "preflight $code" || bad "preflight $code"
|
||||
|
||||
say "summary"
|
||||
echo " pass=$pass fail=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,77 @@
|
||||
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
|
||||
import { promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
import { db } from './lib/mongodb.mjs'
|
||||
|
||||
const tags = ['server-ce', 'server-pro']
|
||||
|
||||
const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10
|
||||
|
||||
// The Zotero module used to store linkedFileData with its own key names.
|
||||
// "Zotero: fix for 6.3.0" switched to the names accepted by the
|
||||
// overleaf-editor-core schema, but files imported before that still carry the
|
||||
// old keys, which document-updater rejects (e.g. when cloning the project).
|
||||
const LEGACY_KEYS = [
|
||||
'zoteroGroupId',
|
||||
'importedByUserId',
|
||||
'importedByName',
|
||||
'bibFormat',
|
||||
]
|
||||
|
||||
function hasLegacyKeys(data) {
|
||||
return (
|
||||
data?.provider === 'zotero' && LEGACY_KEYS.some(key => key in data)
|
||||
)
|
||||
}
|
||||
|
||||
function convert(data) {
|
||||
const importerId = data.importer_id ?? data.importedByUserId
|
||||
const format = data.format ?? data.bibFormat
|
||||
return {
|
||||
provider: 'zotero',
|
||||
group_id: data.group_id ?? data.zoteroGroupId ?? null,
|
||||
...(data.importedAt && { importedAt: data.importedAt }),
|
||||
...(importerId && { importer_id: importerId.toString() }),
|
||||
importer_name: data.importer_name ?? data.importedByName ?? 'Unknown',
|
||||
format: format === 'biblatex' ? 'biblatex' : 'bibtex',
|
||||
}
|
||||
}
|
||||
|
||||
function collectUpdates(folder, mongoPath, updates) {
|
||||
if (!folder) return
|
||||
;(folder.fileRefs || []).forEach((file, i) => {
|
||||
if (hasLegacyKeys(file?.linkedFileData)) {
|
||||
updates[`${mongoPath}.fileRefs.${i}.linkedFileData`] = convert(
|
||||
file.linkedFileData
|
||||
)
|
||||
}
|
||||
})
|
||||
;(folder.folders || []).forEach((subfolder, i) => {
|
||||
collectUpdates(subfolder, `${mongoPath}.folders.${i}`, updates)
|
||||
})
|
||||
}
|
||||
|
||||
const migrate = async () => {
|
||||
await batchedUpdate(
|
||||
db.projects,
|
||||
{},
|
||||
async nextBatch => {
|
||||
await promiseMapWithLimit(WRITE_CONCURRENCY, nextBatch, async project => {
|
||||
const updates = {}
|
||||
;(project.rootFolder || []).forEach((folder, i) => {
|
||||
collectUpdates(folder, `rootFolder.${i}`, updates)
|
||||
})
|
||||
if (Object.keys(updates).length === 0) return
|
||||
await db.projects.updateOne({ _id: project._id }, { $set: updates })
|
||||
})
|
||||
},
|
||||
{ _id: 1, rootFolder: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
const rollback = async () => {}
|
||||
|
||||
export default {
|
||||
tags,
|
||||
migrate,
|
||||
rollback,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
backup_seaweedfs_online
|
||||
+1
@@ -0,0 +1 @@
|
||||
docker pull chrislusf/seaweedfs:latest
|
||||
@@ -0,0 +1,2 @@
|
||||
sudo apt update
|
||||
sudo apt install -y awscli
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
|
||||
echo "Testing connection to SeaweedFS S3 gateway..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
|
||||
# Array of required Overleaf buckets
|
||||
BUCKETS=(
|
||||
"overleaf-user-files"
|
||||
"overleaf-template-files"
|
||||
"overleaf-project-blobs"
|
||||
"overleaf-chunks"
|
||||
)
|
||||
|
||||
echo "Checking and creating Overleaf buckets..."
|
||||
for bucket in "${BUCKETS[@]}"; do
|
||||
if ! aws --endpoint-url "$ENDPOINT" s3 ls "s3://$bucket" >/dev/null 2>&1; then
|
||||
echo "Creating bucket: s3://$bucket"
|
||||
aws --endpoint-url "$ENDPOINT" s3 mb "s3://$bucket"
|
||||
else
|
||||
echo "Bucket already exists: s3://$bucket"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Bucket setup complete. Current buckets:"
|
||||
aws --endpoint-url "$ENDPOINT" s3 ls
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Path to your .env file (adjust if your script runs from a different directory)
|
||||
ENV_FILE="./.env"
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found at $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure AWS variables are exported for the AWS CLI
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
|
||||
ENDPOINT="http://localhost:8333"
|
||||
BACKUP_DIR="./backup_seaweedfs_online"
|
||||
TIMESTAMP=$(date +%F_%H-%M-%S)
|
||||
|
||||
mkdir -p "$BACKUP_DIR/$TIMESTAMP"
|
||||
|
||||
echo "1. Capturing online metadata snapshot..."
|
||||
docker exec seaweedfs-filer weed shell -master="master:9333" <<< "fs.meta.save /data/filer_meta_$TIMESTAMP.json"
|
||||
|
||||
echo "2. Syncing S3 buckets live..."
|
||||
for bucket in overleaf-user-files overleaf-template-files overleaf-project-blobs overleaf-chunks; do
|
||||
echo "Syncing s3://$bucket..."
|
||||
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$bucket" "$BACKUP_DIR/$TIMESTAMP/$bucket"
|
||||
done
|
||||
|
||||
echo "Online backup completed successfully at $TIMESTAMP"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# SeaweedFS S3 Server (Docker Compose)
|
||||
|
||||
A small single-node SeaweedFS cluster with a hosted S3 API. It serves as the
|
||||
S3 backend (filestore *and* history).
|
||||
|
||||
All four services share one image (`chrislusf/seaweedfs:latest`); the root
|
||||
`compose.yaml` merges them from the per-service files with `extends:`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── compose.yaml # root compose (merges the 4 services, depends_on order)
|
||||
├── seaweedfs-master/compose.yaml # cluster metadata + data/ (persisted)
|
||||
├── seaweedfs-volume/compose.yaml # data bricks + data/ (persisted)
|
||||
├── seaweedfs-filer/compose.yaml # file namespace + data/ (persisted)
|
||||
├── seaweedfs-s3/compose.yaml # S3 API gateway (no data dir)
|
||||
├── .env / env.sample # S3 credentials (never commit .env)
|
||||
├── 0_pull_other_containers.sh # setup: pull the shared image
|
||||
├── 1_install_aws.sh # setup: awscli on the host
|
||||
├── 2_create_s3_buckets.sh # setup: create the Overleaf buckets (idempotent)
|
||||
├── 3_online_backup_s3.sh # backup: metadata snapshot + bucket sync
|
||||
├── autoupdate_seaweedfs-*.sh # update: one service at a time, only if image is newer
|
||||
├── up.sh / down.sh # whole stack up / down
|
||||
└── up|down|logs_seaweedfs-*.sh # per-service shortcuts
|
||||
```
|
||||
|
||||
## Services, ports, network aliases
|
||||
|
||||
Start-up order is enforced with `depends_on ... condition: service_healthy`:
|
||||
`master` → `volume` → `filer` → `s3`.
|
||||
|
||||
| Service | Network alias | Host ports (host → container) | Role | Healthcheck |
|
||||
|--------------------|---------------|------------------------------------------------|-------------------------------|---------------------------|
|
||||
| `seaweedfs-master` | `master` | `9333` (master HTTP API), `19333` (master gRPC), `9327` (Prometheus metrics) | cluster metadata | `:9333/cluster/healthz` |
|
||||
| `seaweedfs-volume` | `volume` | `18080` → `8080` (volume gRPC; host `8080` is taken by another service) | data bricks | `:8080/status` |
|
||||
| `seaweedfs-filer` | `filer` | `8888` (filer HTTP), `18888` (filer gRPC) | file namespace / directories | `:8888/` |
|
||||
| `seaweedfs-s3` | `s3` | `8333` (S3 API) | S3 gateway for AWS-SDK clients | `:8333/` answers HTTP |
|
||||
|
||||
**Where the aliases resolve (common gotcha):**
|
||||
|
||||
- The aliases (`s3`, `filer`, `master`, `volume`) are registered on the
|
||||
compose network (default project network of this folder, i.e.
|
||||
`s3_server_compose_default`).
|
||||
- ✅ They resolve from **any container attached to that network** — that is
|
||||
why `http://s3:8333` works in the Overleaf config below. Check with
|
||||
`docker exec seaweedfs-filer ping -c1 s3` (must reply from the stack IP).
|
||||
- ❌ They do **not** resolve from the host machine — Docker's embedded DNS
|
||||
exists only inside the network. From the host, always use the published
|
||||
ports, e.g. `http://localhost:8333` (your `2_`/`3_` scripts already do this).
|
||||
- A container on a *different* network cannot reach the aliases at all; it
|
||||
must either be attached to this network (`docker network connect
|
||||
s3_server_compose_default <container>`) or use `http://<host>:8333`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
cp env.sample .env # then fill in keys (generation one-liners are in the file)
|
||||
./0_pull_other_containers.sh
|
||||
./1_install_aws.sh # installs awscli via apt on the host
|
||||
./up.sh # = docker compose up -d (wait until all 4 are healthy)
|
||||
./2_create_s3_buckets.sh # idempotent: creates the 4 Overleaf buckets if missing
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker ps | grep seaweedfs # 4 × healthy
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8333/ # 403 = S3 API answers (expected)
|
||||
```
|
||||
|
||||
The Overleaf buckets created by `2_create_s3_buckets.sh`:
|
||||
|
||||
`overleaf-user-files`, `overleaf-template-files`, `overleaf-project-blobs`, `overleaf-chunks`
|
||||
|
||||
## Overleaf integration
|
||||
|
||||
For the Overleaf container compose.yaml file (it is assume that the overleaf compose contains AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY):
|
||||
|
||||
```
|
||||
#########################################
|
||||
## Overleaf Filestore S3 Configuration ##
|
||||
#########################################
|
||||
|
||||
OVERLEAF_FILESTORE_BACKEND: s3
|
||||
OVERLEAF_FILESTORE_USER_FILES_BUCKET_NAME: overleaf-user-files
|
||||
OVERLEAF_FILESTORE_TEMPLATE_FILES_BUCKET_NAME: overleaf-template-files
|
||||
|
||||
OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
OVERLEAF_FILESTORE_S3_ENDPOINT: http://s3:8333
|
||||
OVERLEAF_FILESTORE_S3_PATH_STYLE: "true"
|
||||
OVERLEAF_FILESTORE_S3_REGION: "us-east-1"
|
||||
|
||||
#########################################
|
||||
## Overleaf History S3 Configuration ##
|
||||
#########################################
|
||||
|
||||
OVERLEAF_HISTORY_BACKEND: s3
|
||||
OVERLEAF_HISTORY_PROJECT_BLOBS_BUCKET: overleaf-project-blobs
|
||||
OVERLEAF_HISTORY_CHUNKS_BUCKET: overleaf-chunks
|
||||
|
||||
OVERLEAF_HISTORY_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
OVERLEAF_HISTORY_S3_ENDPOINT: http://s3:8333
|
||||
OVERLEAF_HISTORY_S3_PATH_STYLE: "true"
|
||||
OVERLEAF_HISTORY_S3_REGION: "us-east-1"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `http://s3:8333` relies on the `s3` network alias — it only works while the
|
||||
Overleaf **server container is attached to this stack's network**
|
||||
(same compose network as the `seaweedfs-*` containers).
|
||||
- If the Overleaf server runs elsewhere (other host, other compose network),
|
||||
replace the endpoint with `http://<reachable-host>:8333` in both
|
||||
`OVERLEAF_FILESTORE_S3_ENDPOINT` and `OVERLEAF_HISTORY_S3_ENDPOINT`.
|
||||
- `OVERLEAF_*_S3_PATH_STYLE: "true"` is required (SeaweedFS is a virtual-host
|
||||
style-incompatible gateway), and the region value is cosmetic for a local
|
||||
gateway but must be set consistently.
|
||||
|
||||
## Scripts
|
||||
|
||||
### Setup (numbered)
|
||||
|
||||
| Script | What it does |
|
||||
|---------------------------|--------------|
|
||||
| `0_pull_other_containers.sh` | `docker pull chrislusf/seaweedfs:latest` — all four services share this one image, so a single pull covers the whole stack. |
|
||||
| `1_install_aws.sh` | Installs `awscli` on the host via `apt` (needed by the bucket/backup scripts, which use `aws s3` locally). |
|
||||
| `2_create_s3_buckets.sh` | Loads `.env`, checks reachability of `http://localhost:8333`, then creates the four Overleaf buckets if they don't exist yet. **Idempotent** — safe to re-run. |
|
||||
| `3_online_backup_s3.sh` | **Online** backup (no downtime): ① snapshots filer metadata into the running filer via `weed shell` (`fs.meta.save`, reached through the `master:9333` alias), ② `aws s3 sync`s all four buckets into `./backup_seaweedfs_online/<TIMESTAMP>/<bucket>/`. |
|
||||
|
||||
### Updates
|
||||
|
||||
| Script | What it does |
|
||||
|-------------------------------|--------------|
|
||||
| `autoupdate_seaweedfs-master.sh` / `…-volume.sh` / `…-filer.sh` / `…-s3.sh` | Pulls the latest image, compares the short image ID of the running container with the local one; if unchanged → no-op, if newer → `docker compose down` + `up -d` **of that service only**; if the container is missing → just starts it. Sources `.env` first. |
|
||||
|
||||
> The autoupdate scripts `cd "$COMPOSE_DIRECTORY"` before calling
|
||||
> `docker compose`, which now defaults to the **current directory**
|
||||
> (`${COMPOSE_DIRECTORY:-$(pwd)}`). Just run them from this folder,
|
||||
> or point `COMPOSE_DIRECTORY` somewhere else to override.
|
||||
|
||||
To update the whole stack, run the four in this order: `master` → `volume` →
|
||||
`filer` → `s3`.
|
||||
|
||||
### Everyday
|
||||
|
||||
| Script | Equivalent |
|
||||
|---------------------------------|------------|
|
||||
| `up.sh` / `down.sh` | `docker compose up -d` / `docker compose down` |
|
||||
| `up_seaweedfs-<svc>.sh` | `docker compose up -d <svc>` |
|
||||
| `down_seaweedfs-<svc>.sh` | `docker compose down <svc>` |
|
||||
| `logs_seaweedfs-<svc>.sh` | `docker compose logs -f <svc>` |
|
||||
|
||||
## Data & operations
|
||||
|
||||
- Persistent data: `seaweedfs-master/data/`, `seaweedfs-volume/data/`,
|
||||
`seaweedfs-filer/data/` (bind-mounted at `/data` in each container).
|
||||
The S3 gateway needs no data dir. Back up the **volume** data dir (the
|
||||
actual file bytes) — metadata + buckets are reconstructed/synced by the
|
||||
other pieces.
|
||||
- All services run `restart: unless-stopped`; healthchecks per the table
|
||||
above gate the start-up order and `depends_on`.
|
||||
- Metrics: Prometheus endpoint at `http://localhost:9327` (master).
|
||||
- Security: the S3 gateway binds `0.0.0.0:8333` — the keypair in `.env` is
|
||||
the only gate (plus a `403` on anonymous requests). Keep `.env` private
|
||||
(already git-ignored) and firewall `8333` if this host is reachable from
|
||||
the network.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-filer"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-master"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-s3"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables from .env file
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="seaweedfs-volume"
|
||||
IMAGE_NAME="chrislusf/seaweedfs:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
seaweedfs-master:
|
||||
extends:
|
||||
file: ./seaweedfs-master/compose.yaml
|
||||
service: seaweedfs-master
|
||||
|
||||
seaweedfs-volume:
|
||||
extends:
|
||||
file: ./seaweedfs-volume/compose.yaml
|
||||
service: seaweedfs-volume
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
|
||||
seaweedfs-filer:
|
||||
extends:
|
||||
file: ./seaweedfs-filer/compose.yaml
|
||||
service: seaweedfs-filer
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
seaweedfs-volume:
|
||||
condition: service_healthy
|
||||
|
||||
seaweedfs-s3:
|
||||
extends:
|
||||
file: ./seaweedfs-s3/compose.yaml
|
||||
service: seaweedfs-s3
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-filer
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-master
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-s3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose down seaweedfs-volume
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# You need a .env based on this env.sample
|
||||
#S3 Buckets
|
||||
# openssl rand -base64 30 | tr -dc 'a-zA-Z0-9' | head -c 20; echo
|
||||
AWS_ACCESS_KEY_ID=[MISSING]
|
||||
# openssl rand -base64 60 | tr -dc 'a-zA-Z0-9' | head -c 40; echo
|
||||
AWS_SECRET_ACCESS_KEY=[MISSING]
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-filer
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-master
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-s3
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
docker compose logs -f seaweedfs-volume
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
seaweedfs-filer:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-filer
|
||||
hostname: seaweedfs-filer
|
||||
ports:
|
||||
- "8888:8888" # filer HTTP
|
||||
- "18888:18888" # filer gRPC
|
||||
command: 'filer -master="master:9333" -ip=filer -ip.bind=0.0.0.0'
|
||||
volumes:
|
||||
- ./data:/data
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- filer
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8888/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
seaweedfs-master:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-master
|
||||
hostname: seaweedfs-master
|
||||
ports:
|
||||
- "9333:9333" # master HTTP
|
||||
- "19333:19333" # master gRPC
|
||||
command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9327"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
networks:
|
||||
default:
|
||||
# classic seaweedfs names (master:9333 etc.) must resolve on the shared
|
||||
# network while the container keeps its seaweedfs-* name.
|
||||
aliases:
|
||||
- master
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9333/cluster/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
seaweedfs-s3:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-s3
|
||||
hostname: seaweedfs-s3
|
||||
ports:
|
||||
- "8333:8333" # S3 API
|
||||
command: 's3 -filer="filer:8888" -ip.bind=0.0.0.0'
|
||||
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- s3
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -S -o- http://localhost:8333/ 2>&1 | grep -q 'HTTP/'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
seaweedfs-volume:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-volume
|
||||
# no host port mapping: volume is only reached inside the network as
|
||||
# volume:8080 (host 8080 is taken by another service) — gRPC stays mapped.
|
||||
ports:
|
||||
- "18080:18080" # volume gRPC
|
||||
command: 'volume -mserver="master:9333" -ip=volume -ip.bind=0.0.0.0 -port=8080 -dir=/data -max=0'
|
||||
volumes:
|
||||
- ./data:/data
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- volume
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d seaweedfs-filer
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d seaweedfs-master
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d seaweedfs-s3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
docker compose up -d seaweedfs-volume
|
||||
|
||||
Reference in New Issue
Block a user