Masked translation round-trip; named translator keys
- pagerite/masking.py: technical spans (code, URLs, {placeholders}, attrs,
footnote/link labels, container names, HTML tags) become numbered sentinels
for the LLM round trip; results are restored by number and rejected when a
sentinel is mangled (skipped for the rest of the run, stays pending).
Chunks with no prose left after masking are never dispatched.
- Data.translate_key -> translate_keys dict (key -> name); the first key is
generated at bootstrap, result transactions record the key as user=, and
startup logs the service URL(s) via translate.log_service_urls.
- Fix /_translate proxying through the Vite dev server (missing slash).
This commit is contained in:
@@ -57,5 +57,5 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
|
|||||||
|
|
||||||
- Keep dependencies minimal; add via `uv add` and mention it.
|
- Keep dependencies minimal; add via `uv add` and mention it.
|
||||||
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
|
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
|
||||||
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_key`, see docs/localization.md).
|
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
|
||||||
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
|
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
|
||||||
|
|||||||
+34
-4
@@ -258,9 +258,16 @@ local to that language.
|
|||||||
An external machine-translation service connects over WebSocket at
|
An external machine-translation service connects over WebSocket at
|
||||||
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO
|
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO
|
||||||
forward-auth does not cover that route, and the key in the path is the
|
forward-auth does not cover that route, and the key in the path is the
|
||||||
access control. The key is `Data.translate_key`, generated once at startup
|
access control. Keys live in `Data.translate_keys` (key -> display name) —
|
||||||
and surfaced to the admin in `GET /_api/settings` as `translate_key`. A
|
12 lowercase alphanumeric characters each, the first one generated at
|
||||||
wrong or empty key rejects the handshake (close-before-accept → HTTP 403).
|
database bootstrap and multiple keys reserved for future management (e.g.
|
||||||
|
a web UI). The full WS URL(s) are printed in the startup log
|
||||||
|
(`ws://localhost:{port}/_translate/{key}` locally,
|
||||||
|
`wss://{hostname}/_translate/{key}` on a public hostname) and the keys are
|
||||||
|
surfaced to the admin in `GET /_api/settings` as `translate_keys`. An
|
||||||
|
unknown or empty key rejects the handshake (close-before-accept → HTTP
|
||||||
|
403). Transactions storing results record the connecting key as the kanta
|
||||||
|
transaction `user`.
|
||||||
|
|
||||||
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
|
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
|
||||||
`bytes` fields ride as base64):
|
`bytes` fields ride as base64):
|
||||||
@@ -269,7 +276,8 @@ Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
|
|||||||
**capabilities**: the language codes its model can produce (normalized
|
**capabilities**: the language codes its model can produce (normalized
|
||||||
to base subtags; `en`/empty dropped).
|
to base subtags; `en`/empty dropped).
|
||||||
- `{"type": "job", "lang", "key", "text", "path", "kind"}` — server push:
|
- `{"type": "job", "lang", "key", "text", "path", "kind"}` — server push:
|
||||||
ONE fragment to translate (an article title or a chunk).
|
ONE fragment to translate (an article title or a chunk), its text
|
||||||
|
**masked** (see Masking below).
|
||||||
- `{"type": "result", "lang", "key", "text"}` — client reply: the
|
- `{"type": "result", "lang", "key", "text"}` — client reply: the
|
||||||
translation of the connection's current job, matching it by (lang, key).
|
translation of the connection's current job, matching it by (lang, key).
|
||||||
|
|
||||||
@@ -299,6 +307,28 @@ Results are stored into `trans` in one transaction and set
|
|||||||
pages gain a language from one fragment). Unknown keys are stored anyway
|
pages gain a language from one fragment). Unknown keys are stored anyway
|
||||||
and re-storing overwrites — results are idempotent.
|
and re-storing overwrites — results are idempotent.
|
||||||
|
|
||||||
|
#### Masking
|
||||||
|
|
||||||
|
Fragments cross the wire **masked** (`pagerite/masking.py`): spans the model
|
||||||
|
must copy byte-identically are replaced with numbered `⟦N⟧` sentinels before
|
||||||
|
dispatch and restored by number from the result. Masked: code spans,
|
||||||
|
container-fence names, link and image *destinations* (link text, alt text
|
||||||
|
and captions stay visible for translation), reference and footnote labels,
|
||||||
|
`{...}` spans (placeholders like `{dates}` as well as attrs), inline HTML
|
||||||
|
tags and bare URLs. Markdown punctuation (`*`, `|`, `[]()`, `:::`) is not
|
||||||
|
masked — it carries no lexical content and models preserve it. Chunks with
|
||||||
|
no prose left after masking (a lone `{dates}`, container fences, pure
|
||||||
|
code/HTML) are never dispatched at all (`needs_translation`); every language
|
||||||
|
renders them from the original chunk.
|
||||||
|
|
||||||
|
A result is accepted only if every sentinel survived exactly once, in any
|
||||||
|
order (translations legitimately reorder spans). A mangled result is dropped
|
||||||
|
and logged, and the (lang, key) pair is skipped for the rest of the server
|
||||||
|
run — generation is near-deterministic, so an immediate retry would re-fail
|
||||||
|
the same way; the fragment stays pending and gets another chance on restart
|
||||||
|
or a model/masking change. `Data.trans` therefore only ever holds clean,
|
||||||
|
unmasked text.
|
||||||
|
|
||||||
### Explicitly out of scope for phase 2
|
### Explicitly out of scope for phase 2
|
||||||
|
|
||||||
- The machine translation itself: the API above moves fragments in and out;
|
- The machine translation itself: the API above moves fragments in and out;
|
||||||
|
|||||||
+7
-5
@@ -52,9 +52,9 @@ class Node(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
class Data(msgspec.Struct):
|
class Data(msgspec.Struct):
|
||||||
...
|
...
|
||||||
#: API key gating the translator service WebSocket (/_translate/{key});
|
#: API keys gating the translator service WebSocket (/_translate/{key}):
|
||||||
#: generated lazily at startup (see the lifespan in app.py).
|
#: key -> display name; the first is generated at bootstrap (app.py).
|
||||||
translate_key: str = ""
|
translate_keys: dict[str, str] = {}
|
||||||
#: Wanted target languages for the translator service (presence-keys);
|
#: Wanted target languages for the translator service (presence-keys);
|
||||||
#: jobs are offered only in these ∩ a connection's capabilities.
|
#: jobs are offered only in these ∩ a connection's capabilities.
|
||||||
translate_langs: dict[str, True] = {}
|
translate_langs: dict[str, True] = {}
|
||||||
@@ -81,8 +81,10 @@ Notes:
|
|||||||
`trans.get(hash(node.title), {}).get(lang)`. No separate title storage;
|
`trans.get(hash(node.title), {}).get(lang)`. No separate title storage;
|
||||||
editing a title invalidates its translations automatically.
|
editing a title invalidates its translations automatically.
|
||||||
- **Per-hunk options** live in two places: *inherent* options are derived at
|
- **Per-hunk options** live in two places: *inherent* options are derived at
|
||||||
chunking time (code fences and HTML blocks are marked no-translate without
|
chunking time (code fences, HTML blocks and prose-free chunks are
|
||||||
storing anything); *editor-set* flags are `node.no_trans` (keyed by chunk
|
no-translate without storing anything — `needs_translation`, see
|
||||||
|
docs/localization.md "Masking"); *editor-set* flags are `node.no_trans`
|
||||||
|
(keyed by chunk
|
||||||
hash, so a heavy edit silently drops the flag — acceptable and
|
hash, so a heavy edit silently drops the flag — acceptable and
|
||||||
self-healing).
|
self-healing).
|
||||||
- **Patch payloads stay inline** in `Patch.hunks` — patches are small by
|
- **Patch payloads stay inline** in `Patch.hunks` — patches are small by
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a"] }),
|
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_translate"] }),
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
DEFAULT_PORT = 8100
|
DEFAULT_PORT = 8100
|
||||||
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
||||||
@@ -96,6 +97,12 @@ def main() -> None:
|
|||||||
# Export the hostname before pagerite.app is imported: it derives the
|
# Export the hostname before pagerite.app is imported: it derives the
|
||||||
# data directory and public origin from it at import time.
|
# data directory and public origin from it at import time.
|
||||||
os.environ["PAGERITE_HOSTNAME"] = args.hostname
|
os.environ["PAGERITE_HOSTNAME"] = args.hostname
|
||||||
|
# And the listen port: the app prints the translator WS URL at startup,
|
||||||
|
# which for localhost includes the actual port.
|
||||||
|
for endpoint in parse_endpoints(args.listen, DEFAULT_PORT):
|
||||||
|
if "port" in endpoint:
|
||||||
|
os.environ["PAGERITE_PORT"] = str(endpoint["port"])
|
||||||
|
break
|
||||||
if args.dbip:
|
if args.dbip:
|
||||||
_download_dbip()
|
_download_dbip()
|
||||||
server.run(
|
server.run(
|
||||||
|
|||||||
+58
-20
@@ -15,6 +15,7 @@ walking the tree (``resolve``), moves are slot detach/attach
|
|||||||
import asyncio
|
import asyncio
|
||||||
import gzip
|
import gzip
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -48,7 +49,7 @@ from mediapreview import dispatch
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from pagerite import analytics, i18n, seed, translate, views
|
from pagerite import analytics, i18n, masking, seed, translate, views
|
||||||
from pagerite.__main__ import DEVMODE
|
from pagerite.__main__ import DEVMODE
|
||||||
from pagerite.chunks import store_chunks
|
from pagerite.chunks import store_chunks
|
||||||
from pagerite.data import (
|
from pagerite.data import (
|
||||||
@@ -63,6 +64,8 @@ from pagerite.data import (
|
|||||||
)
|
)
|
||||||
from pagerite.markdown import render, toggle_task
|
from pagerite.markdown import render, toggle_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Site identity: the hostname comes from the CLI (first positional argument,
|
# Site identity: the hostname comes from the CLI (first positional argument,
|
||||||
# exported as PAGERITE_HOSTNAME) and names the per-site data directory
|
# exported as PAGERITE_HOSTNAME) and names the per-site data directory
|
||||||
# ``<hostname>/{content.kantadb, analytics.json, files}`` under the cwd.
|
# ``<hostname>/{content.kantadb, analytics.json, files}`` under the cwd.
|
||||||
@@ -283,14 +286,26 @@ def _seed(data: Data) -> None:
|
|||||||
node.order = order
|
node.order = order
|
||||||
|
|
||||||
|
|
||||||
|
#: Translator key format: 12 lowercase alphanumeric characters — not
|
||||||
|
#: brute-forceable over a WebSocket handshake, still human-manageable.
|
||||||
|
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.bootstrap
|
||||||
|
def _translate_key(data: Data) -> None:
|
||||||
|
"""Generate the first translator service key on database creation.
|
||||||
|
|
||||||
|
Keys are a dict (key -> display name) with the future reservation that
|
||||||
|
multiple keys could be managed (e.g. via a web interface)."""
|
||||||
|
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
|
||||||
|
data.translate_keys[key] = "default"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
|
"""Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
|
||||||
await kanta.open()
|
await kanta.open()
|
||||||
# The translator service key is persisted; generated once on first boot.
|
translate.log_service_urls(data.translate_keys, HOSTNAME)
|
||||||
if not data.translate_key:
|
|
||||||
with kanta.transaction("generate translate key"):
|
|
||||||
data.translate_key = secrets.token_urlsafe(24)
|
|
||||||
await asyncio.to_thread(file_store.load)
|
await asyncio.to_thread(file_store.load)
|
||||||
await frontend.load()
|
await frontend.load()
|
||||||
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
|
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
|
||||||
@@ -633,7 +648,7 @@ async def update_structure(op: StructureOp) -> None:
|
|||||||
async def get_settings() -> dict:
|
async def get_settings() -> dict:
|
||||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||||
the themes, banner designs and user fonts available on disk for the
|
the themes, banner designs and user fonts available on disk for the
|
||||||
selectors, the translator service key and the wanted translation
|
selectors, the translator service keys and the wanted translation
|
||||||
languages (for the /_translate socket)."""
|
languages (for the /_translate socket)."""
|
||||||
return {
|
return {
|
||||||
"brand": data.brand,
|
"brand": data.brand,
|
||||||
@@ -646,7 +661,7 @@ async def get_settings() -> dict:
|
|||||||
"fonts": views._user_fonts(),
|
"fonts": views._user_fonts(),
|
||||||
"transition": data.transition,
|
"transition": data.transition,
|
||||||
"transitions": views._transition_names(),
|
"transitions": views._transition_names(),
|
||||||
"translate_key": data.translate_key,
|
"translate_keys": data.translate_keys,
|
||||||
"translate_langs": sorted(data.translate_langs),
|
"translate_langs": sorted(data.translate_langs),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,7 +1009,7 @@ async def delete_page(path: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# WebSocket API for external translation services (not under /_api: it is keyed
|
# WebSocket API for external translation services (not under /_api: it is keyed
|
||||||
# with Data.translate_key instead of the SSO forward-auth). The server is a
|
# with Data.translate_keys instead of the SSO forward-auth). The server is a
|
||||||
# dispatcher: one single-item job at a time per connection, offered in the
|
# dispatcher: one single-item job at a time per connection, offered in the
|
||||||
# intersection of the wanted languages (Data.translate_langs) and the
|
# intersection of the wanted languages (Data.translate_langs) and the
|
||||||
# connection's announced capabilities. Results are matched to content by
|
# connection's announced capabilities. Results are matched to content by
|
||||||
@@ -1002,7 +1017,9 @@ async def delete_page(path: str) -> None:
|
|||||||
class _TranslatorState:
|
class _TranslatorState:
|
||||||
"""One connected translator socket: the language codes it announced as
|
"""One connected translator socket: the language codes it announced as
|
||||||
capabilities (Hello) and the (lang, chunk-key) job currently in flight
|
capabilities (Hello) and the (lang, chunk-key) job currently in flight
|
||||||
on it — one at a time, the next is sent only after its Result.
|
on it, with the mask spans to restore into its Result
|
||||||
|
(pagerite/masking.py) — one at a time, the next is sent only after its
|
||||||
|
Result.
|
||||||
|
|
||||||
Per-connection only: in-flight lives solely here, so on disconnect the
|
Per-connection only: in-flight lives solely here, so on disconnect the
|
||||||
item simply becomes pending again and is re-offered to any free capable
|
item simply becomes pending again and is re-offered to any free capable
|
||||||
@@ -1011,11 +1028,17 @@ class _TranslatorState:
|
|||||||
def __init__(self, capable: set[str]) -> None:
|
def __init__(self, capable: set[str]) -> None:
|
||||||
self.capable = capable
|
self.capable = capable
|
||||||
self.inflight: tuple[str, bytes] | None = None
|
self.inflight: tuple[str, bytes] | None = None
|
||||||
|
self.spans: list[str] = [] # mask spans of the in-flight job
|
||||||
|
|
||||||
|
|
||||||
#: Connected translator sockets and their per-connection state.
|
#: Connected translator sockets and their per-connection state.
|
||||||
_translator_clients: dict[WebSocket, _TranslatorState] = {}
|
_translator_clients: dict[WebSocket, _TranslatorState] = {}
|
||||||
|
|
||||||
|
#: (lang, chunk key) of fragments whose result failed sentinel validation
|
||||||
|
#: (masking.unmask): skipped on later dispatches this run — generation is
|
||||||
|
#: near-deterministic, so an immediate retry would just re-fail.
|
||||||
|
_mask_failures: set[tuple[str, bytes]] = set()
|
||||||
|
|
||||||
|
|
||||||
def _schedule_translation_dispatch() -> None:
|
def _schedule_translation_dispatch() -> None:
|
||||||
"""Schedule a dispatch pass, if any translator is connected.
|
"""Schedule a dispatch pass, if any translator is connected.
|
||||||
@@ -1057,19 +1080,23 @@ async def _dispatch_translations() -> None:
|
|||||||
continue
|
continue
|
||||||
inflight = {s.inflight for s in _translator_clients.values() if s.inflight}
|
inflight = {s.inflight for s in _translator_clients.values() if s.inflight}
|
||||||
job = None
|
job = None
|
||||||
|
spans: list[str] = []
|
||||||
for lang in sorted(langs):
|
for lang in sorted(langs):
|
||||||
for item in translate.pending_items(data, lang):
|
for item in translate.pending_items(data, lang):
|
||||||
if (lang, item.key) not in inflight:
|
if (lang, item.key) in inflight or (lang, item.key) in _mask_failures:
|
||||||
job = translate.Job(
|
continue
|
||||||
lang=lang, key=item.key, text=item.text,
|
masked, spans = masking.mask(item.text)
|
||||||
path=item.path, kind=item.kind,
|
job = translate.Job(
|
||||||
)
|
lang=lang, key=item.key, text=masked,
|
||||||
break
|
path=item.path, kind=item.kind,
|
||||||
|
)
|
||||||
|
break
|
||||||
if job is not None:
|
if job is not None:
|
||||||
break
|
break
|
||||||
if job is None:
|
if job is None:
|
||||||
continue
|
continue
|
||||||
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
||||||
|
state.spans = spans
|
||||||
try:
|
try:
|
||||||
await ws.send_text(msgspec.json.encode(job).decode())
|
await ws.send_text(msgspec.json.encode(job).decode())
|
||||||
except Exception: # send failed: the receive loop cleans up
|
except Exception: # send failed: the receive loop cleans up
|
||||||
@@ -1082,7 +1109,8 @@ async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|||||||
|
|
||||||
Deliberately NOT under /_api/: the external forward-auth is skipped;
|
Deliberately NOT under /_api/: the external forward-auth is skipped;
|
||||||
the server-generated client key in the path is the access control
|
the server-generated client key in the path is the access control
|
||||||
(``Data.translate_key``, generated at startup, shown in the admin's
|
(``Data.translate_keys``: key -> display name; the first is generated
|
||||||
|
at bootstrap, all are shown in the admin's
|
||||||
/_api/settings). A wrong/empty key rejects the handshake — closing
|
/_api/settings). A wrong/empty key rejects the handshake — closing
|
||||||
before accept makes Starlette answer HTTP 403.
|
before accept makes Starlette answer HTTP 403.
|
||||||
|
|
||||||
@@ -1095,7 +1123,7 @@ async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|||||||
different (lang, key), a duplicate Hello, or any malformed frame
|
different (lang, key), a duplicate Hello, or any malformed frame
|
||||||
closes the socket with a protocol error.
|
closes the socket with a protocol error.
|
||||||
"""
|
"""
|
||||||
if not data.translate_key or clientkey != data.translate_key:
|
if clientkey not in data.translate_keys:
|
||||||
await ws.close(code=1008) # policy violation; pre-accept = HTTP 403
|
await ws.close(code=1008) # policy violation; pre-accept = HTTP 403
|
||||||
return
|
return
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
@@ -1128,12 +1156,22 @@ async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|||||||
):
|
):
|
||||||
await ws.close(code=1002)
|
await ws.close(code=1002)
|
||||||
return
|
return
|
||||||
with kanta.transaction("translator results", extra=lang):
|
text = masking.unmask(msg.text, state.spans)
|
||||||
|
state.inflight = None
|
||||||
|
state.spans = []
|
||||||
|
if text is None:
|
||||||
|
# The model mangled the sentinels: drop the result and
|
||||||
|
# skip the fragment for this run (it stays pending; a
|
||||||
|
# restart or a masking/prompt change gets another chance).
|
||||||
|
_mask_failures.add((lang, msg.key))
|
||||||
|
print(f"[{lang}] result for chunk {msg.key.hex()} rejected: sentinels mangled")
|
||||||
|
_schedule_translation_dispatch()
|
||||||
|
continue
|
||||||
|
with kanta.transaction("translator results", user=clientkey, extra=lang):
|
||||||
paths = translate.store_results(
|
paths = translate.store_results(
|
||||||
data, lang, [translate.TransResult(key=msg.key, text=msg.text)]
|
data, lang, [translate.TransResult(key=msg.key, text=text)]
|
||||||
)
|
)
|
||||||
_invalidate_pages() # schedules the next dispatch
|
_invalidate_pages() # schedules the next dispatch
|
||||||
state.inflight = None
|
|
||||||
if paths:
|
if paths:
|
||||||
print(f"[{lang}] now available for {len(paths)} page(s): {', '.join(sorted(paths))}")
|
print(f"[{lang}] now available for {len(paths)} page(s): {', '.join(sorted(paths))}")
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
+10
-3
@@ -11,6 +11,8 @@ import re
|
|||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
|
|
||||||
|
from pagerite.masking import has_prose
|
||||||
|
|
||||||
#: Fenced code block opener/closer: up to 3 spaces indent, then 3+
|
#: Fenced code block opener/closer: up to 3 spaces indent, then 3+
|
||||||
#: backticks or tildes (CommonMark).
|
#: backticks or tildes (CommonMark).
|
||||||
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||||
@@ -123,17 +125,22 @@ def chunk_key(text: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def needs_translation(chunk: str) -> bool:
|
def needs_translation(chunk: str) -> bool:
|
||||||
"""False for chunks without prose: pure code fences and HTML blocks.
|
"""False for chunks without prose: pure code fences, HTML blocks, and
|
||||||
|
anything whose masked form (pagerite/masking.py) has no letters left —
|
||||||
|
container fences, lone {placeholders}, reference definitions.
|
||||||
|
|
||||||
These are inherently no-translate (docs/migrate.md): derived from the
|
These are inherently no-translate (docs/migrate.md): derived from the
|
||||||
chunk text itself, nothing is stored.
|
chunk text itself, nothing is stored. Every language renders them from
|
||||||
|
the original chunk via the hybrid fallback.
|
||||||
"""
|
"""
|
||||||
if _FENCE_OPEN.match(chunk):
|
if _FENCE_OPEN.match(chunk):
|
||||||
return False
|
return False
|
||||||
first = chunk.split("\n", 1)[0]
|
first = chunk.split("\n", 1)[0]
|
||||||
if any(open_re.match(first) for open_re, _ in _HTML_ATOMIC):
|
if any(open_re.match(first) for open_re, _ in _HTML_ATOMIC):
|
||||||
return False
|
return False
|
||||||
return not _HTML_TAG.match(first)
|
if _HTML_TAG.match(first):
|
||||||
|
return False
|
||||||
|
return has_prose(chunk)
|
||||||
|
|
||||||
|
|
||||||
def join_chunks(chunks: list[str]) -> str:
|
def join_chunks(chunks: list[str]) -> str:
|
||||||
|
|||||||
+6
-4
@@ -101,10 +101,12 @@ class Data(msgspec.Struct):
|
|||||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
#: linked as <link rel="icon"> on every page. Empty = the build's
|
||||||
#: /favicon.ico.
|
#: /favicon.ico.
|
||||||
favicon: str = ""
|
favicon: str = ""
|
||||||
#: API key gating the translator service WebSocket (/_translate/{key};
|
#: API keys gating the translator service WebSocket (/_translate/{key};
|
||||||
#: the external forward-auth does not cover that route). Generated
|
#: the external forward-auth does not cover that route): key -> display
|
||||||
#: lazily at startup when empty (see lifespan in app.py).
|
#: name. Keys are 12 lowercase alphanumeric characters; the first is
|
||||||
translate_key: str = ""
|
#: generated at database bootstrap (see app.py), multiple keys are a
|
||||||
|
#: future reservation (e.g. managed via a web interface).
|
||||||
|
translate_keys: dict[str, str] = {}
|
||||||
#: Wanted target languages for the translator service (presence-keys,
|
#: Wanted target languages for the translator service (presence-keys,
|
||||||
#: value always True). The dispatcher offers jobs only in the
|
#: value always True). The dispatcher offers jobs only in the
|
||||||
#: intersection of these and a connection's announced capabilities.
|
#: intersection of these and a connection's announced capabilities.
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Masking of non-translatable spans for the machine-translation round trip.
|
||||||
|
|
||||||
|
A translator model must copy technical spans (code, URLs, {placeholders},
|
||||||
|
attrs, footnote and link labels, container names, HTML tags) byte-identically
|
||||||
|
while translating the prose around them — and small models translate anything
|
||||||
|
that looks like a word (a {dates} placeholder once came back as
|
||||||
|
{päivämäärät}). So before a fragment is dispatched, each such span is
|
||||||
|
replaced with a numbered sentinel (``⟦1⟧``, ``⟦2⟧``, ...) — the model only
|
||||||
|
ever sees prose — and on the way back the sentinels are restored by number
|
||||||
|
(``unmask``). A result whose sentinels did not all survive — missing,
|
||||||
|
duplicated or out of range — is rejected and the fragment stays pending.
|
||||||
|
|
||||||
|
Punctuation structure (*, |, [], (), :::) is not masked: it carries no
|
||||||
|
lexical content and models preserve it. Link and image text — including alt
|
||||||
|
text and captions — stays visible for translation; only the destination is
|
||||||
|
masked. Rule order matters: earlier rules consume syntax later ones would
|
||||||
|
misread, and no rule may match across or inside an already emitted sentinel
|
||||||
|
(the container-fence rule runs before the brace rule for that reason).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: A masked span marker: the span's 1-based number in brackets that never
|
||||||
|
#: appear in content and are atomic enough for a model to copy verbatim.
|
||||||
|
#: unmask() validates survival, so a model that mangles them only loses its
|
||||||
|
#: own result.
|
||||||
|
_SENTINEL = re.compile(r"⟦(\d+)⟧")
|
||||||
|
|
||||||
|
#: URL-ish span: an <angle-bracketed> destination, or a whitespace-free run
|
||||||
|
#: allowing one level of balanced parens (Wikipedia-style).
|
||||||
|
_URLISH = r"<[^<>\n]*>|[^\s()]*(?:\([^()\n]*\)[^\s()]*)*"
|
||||||
|
|
||||||
|
#: Inline code: matching backtick runs, whole span masked. The content may
|
||||||
|
#: not cross a paragraph break, so a stray backtick cannot swallow the rest
|
||||||
|
#: of the chunk.
|
||||||
|
_CODE = re.compile(r"(`+)((?:(?!\n\n).)+?)\1(?!`)", re.DOTALL)
|
||||||
|
|
||||||
|
#: Container fence line (`:::: aside {.x}`): the name-and-attrs tail is
|
||||||
|
#: masked; a bare `:::` has nothing to mask. Runs before the brace rule so
|
||||||
|
#: fence-line attrs are masked together with the name.
|
||||||
|
_FENCE = re.compile(r"^( {0,3}:{3,})[ \t]*(\S[^\n]*)", re.MULTILINE)
|
||||||
|
|
||||||
|
#: Link/image destination: `[text](url "title")` -> `[text](⟦N⟧ "title")`.
|
||||||
|
_DEST = re.compile(r"(\]\(\s*)(" + _URLISH + r")")
|
||||||
|
|
||||||
|
#: Autolinks and inline HTML (<http://...>, <b>, <!-- ... -->, <? ... ?>).
|
||||||
|
#: A `<` followed by whitespace (a prose "a < b") is not matched.
|
||||||
|
_TAG = re.compile(r"<[A-Za-z/!?][^<>\n]*>")
|
||||||
|
|
||||||
|
#: Footnote definition `[^label]: text...` — label masked; the text after
|
||||||
|
#: the colon is prose.
|
||||||
|
_FOOTDEF = re.compile(r"^( {0,3}\[\^)([^\]\n]+)(\]:)", re.MULTILINE)
|
||||||
|
|
||||||
|
#: Reference-style link definition `[label]: url "title"` — label and
|
||||||
|
#: destination masked, title stays visible.
|
||||||
|
_LINKDEF = re.compile(r"^( {0,3}\[)(?!\^)([^\]\n]+)(\]:[ \t]*)(" + _URLISH + r")", re.MULTILINE)
|
||||||
|
|
||||||
|
#: Footnote reference `[^label]` ((?!:) — definitions are _FOOTDEF's).
|
||||||
|
_FOOTREF = re.compile(r"\[\^([^\]\n]+)\](?!:)")
|
||||||
|
|
||||||
|
#: Reference-style link usage `[text][label]` — the label.
|
||||||
|
_REFPAIR = re.compile(r"(\][ \t]?\[)([^\]\n]+)(\])")
|
||||||
|
|
||||||
|
#: Any {...} span: {placeholders} and {#id .class} attrs alike.
|
||||||
|
_BRACES = re.compile(r"\{[^{}\n]*\}")
|
||||||
|
|
||||||
|
#: Bare URLs in prose (GFM autolinks); trailing sentence punctuation stays
|
||||||
|
#: outside the mask.
|
||||||
|
_BARE_URL = re.compile(r"(?<![\w/])(?:https?://|www\.)[^\s<>()\[\]]+")
|
||||||
|
|
||||||
|
#: Any Unicode letter (digits and underscore are not prose).
|
||||||
|
_LETTER = re.compile(r"[^\W\d_]")
|
||||||
|
|
||||||
|
|
||||||
|
def mask(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Replace every non-translatable span with a ⟦N⟧ sentinel; return the
|
||||||
|
masked text and the original spans in sentinel order."""
|
||||||
|
spans: list[str] = []
|
||||||
|
|
||||||
|
def emit(original: str) -> str:
|
||||||
|
if not original:
|
||||||
|
return original
|
||||||
|
spans.append(original)
|
||||||
|
return f"⟦{len(spans)}⟧"
|
||||||
|
|
||||||
|
for sub in (_code, _fence, _dest, _tag, _footdef, _linkdef, _footref,
|
||||||
|
_refpair, _braces, _bare_url):
|
||||||
|
text = sub(text, emit)
|
||||||
|
return text, spans
|
||||||
|
|
||||||
|
|
||||||
|
def unmask(text: str, spans: list[str]) -> str | None:
|
||||||
|
"""Restore the masked spans into a translated fragment; None when the
|
||||||
|
sentinels did not all survive intact (missing, duplicated or out of
|
||||||
|
range) — the caller drops the result and the fragment stays pending.
|
||||||
|
|
||||||
|
Order is not checked: translations legitimately reorder spans.
|
||||||
|
"""
|
||||||
|
if not spans:
|
||||||
|
return text
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
n = int(m.group(1))
|
||||||
|
counts[n] = counts.get(n, 0) + 1
|
||||||
|
return spans[n - 1] if 0 < n <= len(spans) else m.group(0)
|
||||||
|
|
||||||
|
restored = _SENTINEL.sub(repl, text)
|
||||||
|
if counts != dict.fromkeys(range(1, len(spans) + 1), 1):
|
||||||
|
return None
|
||||||
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
def has_prose(text: str) -> bool:
|
||||||
|
"""True when the masked form still contains a letter — i.e. there is
|
||||||
|
something for a translator to translate. Chunks that are all markup,
|
||||||
|
placeholders or code (a lone {dates}, container fences, reference
|
||||||
|
definitions) have no business reaching the model: every language
|
||||||
|
renders them from the original chunk."""
|
||||||
|
return bool(_LETTER.search(mask(text)[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _code(text: str, emit) -> str:
|
||||||
|
return _CODE.sub(lambda m: emit(m.group(0)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _fence(text: str, emit) -> str:
|
||||||
|
return _FENCE.sub(lambda m: m.group(1) + emit(m.group(2)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _dest(text: str, emit) -> str:
|
||||||
|
return _DEST.sub(lambda m: m.group(1) + emit(m.group(2)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag(text: str, emit) -> str:
|
||||||
|
return _TAG.sub(lambda m: emit(m.group(0)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _footdef(text: str, emit) -> str:
|
||||||
|
return _FOOTDEF.sub(lambda m: m.group(1) + emit(m.group(2)) + m.group(3), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _linkdef(text: str, emit) -> str:
|
||||||
|
return _LINKDEF.sub(
|
||||||
|
lambda m: m.group(1) + emit(m.group(2)) + m.group(3) + emit(m.group(4)), text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _footref(text: str, emit) -> str:
|
||||||
|
return _FOOTREF.sub(lambda m: "[^" + emit(m.group(1)) + "]", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _refpair(text: str, emit) -> str:
|
||||||
|
return _REFPAIR.sub(lambda m: m.group(1) + emit(m.group(2)) + m.group(3), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _braces(text: str, emit) -> str:
|
||||||
|
return _BRACES.sub(lambda m: emit(m.group(0)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_url(text: str, emit) -> str:
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
core = m.group(0)
|
||||||
|
tail = ""
|
||||||
|
while core and core[-1] in ".,;:!?":
|
||||||
|
tail = core[-1] + tail
|
||||||
|
core = core[:-1]
|
||||||
|
return emit(core) + tail
|
||||||
|
|
||||||
|
return _BARE_URL.sub(repl, text)
|
||||||
+27
-3
@@ -4,17 +4,28 @@ The external machine-translation service connects over WebSocket
|
|||||||
(``/_translate/<key>``, see app.py) and exchanges JSON frames decoded into
|
(``/_translate/<key>``, see app.py) and exchanges JSON frames decoded into
|
||||||
the tagged msgspec structs below (``bytes`` fields ride as base64 — no
|
the tagged msgspec structs below (``bytes`` fields ride as base64 — no
|
||||||
manual encoding anywhere). This module holds the message structs plus the
|
manual encoding anywhere). This module holds the message structs plus the
|
||||||
two computations shared by the WS handler: which fragments are pending for
|
shared computations around the WS handler: which fragments are pending for
|
||||||
a language (``pending_items``) and storing a result (``store_results``).
|
a language (``pending_items``), storing a result (``store_results``) and
|
||||||
|
the startup URL listing (``log_service_urls``).
|
||||||
The dispatcher itself (one job at a time per connection, wanted ∩ capable
|
The dispatcher itself (one job at a time per connection, wanted ∩ capable
|
||||||
language matching, requeue on disconnect) lives in app.py.
|
language matching, requeue on disconnect) lives in app.py.
|
||||||
|
|
||||||
|
Fragments cross the wire masked: non-translatable spans (code, URLs,
|
||||||
|
{placeholders}, tags, ...) are numbered ⟦N⟧ sentinels in ``Job.text``,
|
||||||
|
restored and validated before storage (``pagerite/masking.py``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
|
from pagerite.__main__ import DEFAULT_PORT
|
||||||
from pagerite.chunks import chunk_key, needs_translation
|
from pagerite.chunks import chunk_key, needs_translation
|
||||||
from pagerite.data import Data, Node, sorted_nodes
|
from pagerite.data import Data, Node, sorted_nodes
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Hello(msgspec.Struct, tag="hello"):
|
class Hello(msgspec.Struct, tag="hello"):
|
||||||
"""Client greeting on connect: the language codes its model CAN produce
|
"""Client greeting on connect: the language codes its model CAN produce
|
||||||
@@ -42,7 +53,7 @@ class Job(msgspec.Struct, tag="job"):
|
|||||||
|
|
||||||
lang: str
|
lang: str
|
||||||
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||||
text: str
|
text: str #: masked Markdown (pagerite/masking.py)
|
||||||
path: str #: article it came from ("" = front page), no leading slash
|
path: str #: article it came from ("" = front page), no leading slash
|
||||||
kind: str #: "chunk" | "title"
|
kind: str #: "chunk" | "title"
|
||||||
|
|
||||||
@@ -136,3 +147,16 @@ def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
|
|||||||
|
|
||||||
walk(data.menu, "")
|
walk(data.menu, "")
|
||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def log_service_urls(keys: dict[str, str], hostname: str) -> None:
|
||||||
|
"""Log the translator WebSocket URL(s) for the admin at startup, one
|
||||||
|
line: "…/_translate/<key> (<name>)", comma-joined — ws:// with the port
|
||||||
|
on localhost, wss:// without a port on a public hostname (the port comes
|
||||||
|
from the CLI via the PAGERITE_PORT env). No-op without keys."""
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
port = os.getenv("PAGERITE_PORT", DEFAULT_PORT)
|
||||||
|
base = f"ws://localhost:{port}" if hostname == "localhost" else f"wss://{hostname}"
|
||||||
|
urls = ", ".join(f"{base}/_translate/{key} ({name})" for key, name in keys.items())
|
||||||
|
logger.info("Translator %s", urls)
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
"""Pagerite translator service: translate site content with Seed-X-PPO-7B.
|
"""Pagerite translator service: translate site content with Seed-X-PPO-7B.
|
||||||
|
|
||||||
Connects to a Pagerite server's translator WebSocket — the full URL
|
Connects to a Pagerite server's translator WebSocket — the full URL
|
||||||
including the access key (the admin finds it in the site settings,
|
including the access key (printed at server startup; the admin also finds
|
||||||
GET /_api/settings -> ``translate_key``) — and announces the languages the
|
the key in the site settings, GET /_api/settings -> ``translate_keys``) —
|
||||||
|
and announces the languages the
|
||||||
model CAN translate (capabilities). The server dispatches one single-item
|
model CAN translate (capabilities). The server dispatches one single-item
|
||||||
job at a time per connection, offered only in its configured target
|
job at a time per connection, offered only in its configured target
|
||||||
languages (``Data.translate_langs``) ∩ the announced capabilities; a
|
languages (``Data.translate_langs``) ∩ the announced capabilities; a
|
||||||
@@ -55,8 +56,10 @@ SEED_X_TAGS = {
|
|||||||
}
|
}
|
||||||
SEED_X_NAMES = {v: k for k, v in SEED_X_TAGS.items()}
|
SEED_X_NAMES = {v: k for k, v in SEED_X_TAGS.items()}
|
||||||
|
|
||||||
#: The fragments are Markdown; Seed-X has no system prompt, so it goes in-line.
|
#: The fragments are masked Markdown (pagerite/masking.py: ⟦N⟧ sentinels
|
||||||
NOTE = ", preserving all Markdown formatting, URLs and code exactly unchanged"
|
#: stand in for code, URLs, placeholders...); Seed-X has no system prompt,
|
||||||
|
#: so the instruction goes in-line.
|
||||||
|
NOTE = ", preserving all Markdown formatting and keeping every ⟦N⟧ token exactly unchanged"
|
||||||
|
|
||||||
|
|
||||||
# The wire structs below duplicate pagerite/translate.py: this script runs
|
# The wire structs below duplicate pagerite/translate.py: this script runs
|
||||||
@@ -76,7 +79,7 @@ class Job(msgspec.Struct, tag="job"):
|
|||||||
|
|
||||||
lang: str
|
lang: str
|
||||||
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||||
text: str
|
text: str #: masked (pagerite/masking.py): the ⟦N⟧ tokens must survive verbatim
|
||||||
path: str #: article it came from ("" = front page), no leading slash
|
path: str #: article it came from ("" = front page), no leading slash
|
||||||
kind: str #: "chunk" | "title"
|
kind: str #: "chunk" | "title"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user