claude-translator 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "claude-translator",
3
+ "version": "1.3.0",
4
+ "description": "Translate a static website into dozens of languages as real static pages, by substituting into built HTML instead of re-rendering.",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-or-later",
7
+ "author": "ConveyThis (https://conveythis.com)",
8
+ "homepage": "https://github.com/ConveyThis/claude-translator",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ConveyThis/claude-translator.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ConveyThis/claude-translator/issues"
15
+ },
16
+ "keywords": [
17
+ "i18n",
18
+ "l10n",
19
+ "localization",
20
+ "translation",
21
+ "static-site",
22
+ "hreflang",
23
+ "seo",
24
+ "astro",
25
+ "claude",
26
+ "anthropic",
27
+ "gemini",
28
+ "openai",
29
+ "ollama",
30
+ "multilingual"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "bin": {
36
+ "claude-translator": "bin/claude-translator.mjs"
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "scripts",
41
+ "references",
42
+ "SKILL.md",
43
+ "LICENSING.md",
44
+ "CHANGELOG.md",
45
+ "i18n.config.example.json"
46
+ ],
47
+ "devDependencies": {
48
+ "parse5": "^7.3.0"
49
+ },
50
+ "scripts": {
51
+ "i18n:extract": "node scripts/extract.mjs",
52
+ "i18n:translate": "node scripts/translate.mjs",
53
+ "i18n:review": "node scripts/review.mjs",
54
+ "i18n:build": "node scripts/build-locales.mjs",
55
+ "i18n:verify": "node scripts/verify.mjs",
56
+ "i18n:audit": "node scripts/audit-seo.mjs",
57
+ "check": "for f in scripts/*.mjs scripts/providers/*.mjs bin/*.mjs; do node --check \"$f\" || exit 1; done && echo 'all scripts parse'",
58
+ "test": "node --test scripts/providers/providers.test.mjs bin/cli.test.mjs"
59
+ }
60
+ }
@@ -0,0 +1,76 @@
1
+ # Adapting to different generators
2
+
3
+ The method needs only three things:
4
+
5
+ 1. A **directory of built HTML** where each page is `<slug>/index.html` (or `<slug>.html`)
6
+ 2. A **list of pages** to localize
7
+ 3. A **list of locales**
8
+
9
+ Nothing else is framework-specific. `config.mjs` is the only file that knows about a
10
+ given site.
11
+
12
+ ---
13
+
14
+ ## Per generator
15
+
16
+ | Generator | `buildDir` | Page list | Notes |
17
+ | --- | --- | --- | --- |
18
+ | **Astro** (`output: 'static'`) | `dist` | `"build"`, or a slug array | Reference implementation. Clean-URL output already matches. |
19
+ | **Next.js** (`next export` / `output: 'export'`) | `out` | `"build"` | Check trailing-slash config — canonical rules must match what the server serves. |
20
+ | **Hugo** | `public` | `"build"` | Hugo has native i18n; use it if content lives in Markdown front matter. This skill wins when translating *rendered* pages. |
21
+ | **Eleventy** | `_site` | `"build"` | Straightforward. |
22
+ | **Jekyll** | `_site` | `"build"` | Straightforward. |
23
+ | **Gatsby** | `public` | `"build"` | Exclude `page-data/` and the JSON payloads. |
24
+ | **Plain HTML** | wherever | `"build"` | Works as-is. |
25
+
26
+ ```json
27
+ { "buildDir": "out", "pages": { "source": "build", "exclude": ["404", "admin"] } }
28
+ ```
29
+
30
+ `pages.source: "build"` derives the list from the output directory. Point it at a file
31
+ instead when an explicit list exists (a sitemap slug array). For a `.ts`/`.js` file, set
32
+ `pages.export` to the exported const name — **scoping matters**: scanning the whole file
33
+ sweeps up every other quoted string in it. In one case that silently added the
34
+ language-code array to the page-slug array and inflated the page count.
35
+
36
+ ---
37
+
38
+ ## What to check on a new generator
39
+
40
+ **Clean URLs vs `.html`.** The scripts assume `<slug>/index.html`. If output is
41
+ `about.html`, adjust the path construction in `build-locales.mjs` and `verify.mjs`
42
+ together, and make canonicals match what the server actually serves.
43
+
44
+ **Trailing slashes.** Canonical, hreflang and sitemap must agree with the server's
45
+ redirect behaviour. A canonical pointing at a URL that 301-redirects is a real SEO defect
46
+ (a real defect found in production).
47
+
48
+ **Hydration payloads.** Frameworks that embed serialized state (`__NEXT_DATA__`,
49
+ Gatsby's `page-data`) duplicate visible text inside `<script>`. The extractor already skips
50
+ `<script>`, so those copies stay in the source language — **and the client may re-render
51
+ from them**, overwriting your translations. Test one interactive page early. If it happens,
52
+ either translate the payload too or exclude that route.
53
+
54
+ **Framework-native i18n.** If the source content is structured (Markdown + front matter)
55
+ and the generator has real i18n, prefer it — you get translated slugs and proper routing.
56
+ This skill is for the case where content is *already rendered*, or where re-rendering every
57
+ page is unacceptable (large sites, critical-CSS pipelines, proxy replacement).
58
+
59
+ ---
60
+
61
+ ## Locale identity to emit per page
62
+
63
+ Whatever the generator, each localized page needs:
64
+
65
+ | Element | Value |
66
+ | --- | --- |
67
+ | `<html lang>` | the locale's `hreflang` |
68
+ | `<html dir>` | `rtl` for ar/fa/he/ur — **replace** any existing `dir`, don't append |
69
+ | canonical | the page's own URL, `https`, no trailing slash |
70
+ | `hreflang` self | identical to the canonical |
71
+ | `hreflang` en / x-default | the **source** page, not self |
72
+ | `og:url` / `og:locale` | page URL / locale code |
73
+ | JSON-LD | `@id` = `<page-url>#webpage`, `url` per type, `inLanguage` = locale |
74
+
75
+ `build-locales.mjs` does all of this and reports any rule that matched nothing — which is
76
+ how attribute-order bugs surface. `audit-seo.mjs` then verifies it across every page.
@@ -0,0 +1,255 @@
1
+ # Failure modes
2
+
3
+ Every one of these was hit in production on a 55-locale rollout. They are listed because
4
+ each **looked like working code**, and several produced wrong output that reported success.
5
+
6
+ Read this before modifying any script.
7
+
8
+ ---
9
+
10
+ ## Extraction
11
+
12
+ ### A skipped element inside a block silently drops the whole block
13
+
14
+ **Symptom:** coverage 100%, gates green, yet "Translate a Document", "Sign Up Free" and
15
+ every `✓ feature` bullet render in the source language.
16
+
17
+ **Cause:** the tokenizer treated `<svg>` as "skip", and skipping aborted the *entire*
18
+ containing block. Every icon+label pair on the site — buttons, feature bullets, badges —
19
+ was never offered for translation. **Thousands of segments, invisible.**
20
+
21
+ **Fix:** an opaque element becomes **one void placeholder** carrying the element verbatim.
22
+ Never abandon a unit because of a child.
23
+
24
+ **Detection:** gate 6 in `verify.mjs`. Coverage cannot see this class of bug at all.
25
+
26
+ ### The extractor scans its own output
27
+
28
+ **Symptom:** unit and segment counts roughly double; the memory starts filling with
29
+ target-language strings keyed as source.
30
+
31
+ **Cause:** locale directories live inside the build directory, so a naive recursive scan
32
+ reads `dist/es/**` as if it were source.
33
+
34
+ **Fix:** exclude every configured `pathCode` from the scan (`config.mjs` does this).
35
+
36
+ ### JSON-LD bare-value search matches substrings
37
+
38
+ **Symptom:** `Overlapping segments … Aborting.`
39
+
40
+ **Cause:** searching for a bare value finds it inside a longer sibling —
41
+ `"name":"PDF Translator"` sits within `"name":"AI PDF Translator"` — producing overlapping
42
+ byte ranges that would corrupt output.
43
+
44
+ **Fix:** match the full `"key"\s*:\s*"value"` pair, never the value alone.
45
+
46
+ ### Language-switcher labels drift per locale
47
+
48
+ **Symptom:** the picker reads differently on every locale — `English (English)` becomes
49
+ `Inglés (English)` on Spanish while `Español (Spanish)` stays put.
50
+
51
+ **Cause:** a language picker shows every language in **its own** language. These are data,
52
+ not copy.
53
+
54
+ **Fix:** treat `Native (EnglishName)` strings and bare native labels as do-not-translate.
55
+ Apply the same rule in the verifier, or gate 6 will report them as holes.
56
+
57
+ ---
58
+
59
+ ## Translation API
60
+
61
+ ### Network exceptions bypass status-code retry
62
+
63
+ **Symptom:** a run finishes "successfully" with several hundred units missing.
64
+
65
+ **Cause:** `TypeError: fetch failed` (DNS, reset, socket timeout) throws **before** any
66
+ response exists, so `if (!res.ok)` never sees it. Cost: **hundreds of units** lost silently in a single run.
67
+
68
+ **Fix:** wrap the `fetch` itself and retry on the same backoff as 429/5xx.
69
+
70
+ ### A batch that exceeds the output limit returns truncated JSON
71
+
72
+ **Symptom:** `SyntaxError: Unterminated string in JSON at position …`, 40 units gone.
73
+
74
+ **Fix:** catch the parse error, split the batch in half, recurse. Only a single oversized
75
+ unit can then be unrecoverable.
76
+
77
+ ### The safety filter blocks the whole request
78
+
79
+ **Symptom:** `promptFeedback.blockReason: PROHIBITED_CONTENT`, no candidates, 40 units gone.
80
+
81
+ **Cause:** one string the filter dislikes takes the other 39 with it.
82
+
83
+ **Fix:** same split-and-recurse path.
84
+
85
+ ### Memory written only at the end
86
+
87
+ **Symptom:** a 40-minute run is interrupted and *everything* is lost.
88
+
89
+ **Fix:** checkpoint every ~10 batches. This later saved thousands of units when a long run
90
+ had to be killed and restarted.
91
+
92
+ ### The model silently returns the source language
93
+
94
+ Not an API error — a **quality** failure that every structural gate passes. See
95
+ `quality-review.md`; word-overlap detection is what catches it.
96
+
97
+ ---
98
+
99
+ ### `output_config.effort` is rejected on Haiku
100
+
101
+ **Symptom.** Every Anthropic request fails with a 400 naming `effort`, on a model that
102
+ otherwise works.
103
+
104
+ **Cause.** Claude tiers do not take the same parameters. `claude-haiku-4-5` rejects
105
+ `output_config.effort` outright; the Opus and Sonnet tiers accept it.
106
+
107
+ **Fix.** `providers/anthropic.mjs` keeps a capability table and sends the parameter only
108
+ where it is accepted. Adding a model means adding it to `ACCEPTS_EFFORT`, not assuming.
109
+
110
+ ### Adaptive thinking is on by default, and translation pays for it
111
+
112
+ **Symptom.** A run on Opus or Sonnet costs several times the estimate, with output token
113
+ counts far above what the translated text can account for.
114
+
115
+ **Cause.** Those models run adaptive thinking unless told otherwise. Segment translation
116
+ has no use for it, but it is billed all the same.
117
+
118
+ **Fix.** The adapter pins `output_config.effort: 'low'` on the tiers that support it.
119
+ Do **not** "fix" this by setting `thinking: {type: 'disabled'}` — on Opus 5 that has its
120
+ own failure modes, including writing a tool call into visible text.
121
+
122
+ ### Local models wrap JSON in a code fence
123
+
124
+ **Symptom.** Works on a hosted provider, fails on Ollama or LM Studio with
125
+ `Unexpected token \`` on every batch.
126
+
127
+ **Cause.** Smaller models emit ` ```json … ``` ` regardless of instructions, and some
128
+ prepend a sentence of preamble.
129
+
130
+ **Fix.** `extractJson()` in `providers/index.mjs` strips fences and falls back to the
131
+ outermost bracket pair. Never assume the response body starts with `[`.
132
+
133
+ ### A server rejects `response_format` and the run dies
134
+
135
+ **Symptom.** An OpenAI-compatible endpoint returns 400 mentioning `response_format` or
136
+ `json_schema`, and nothing translates.
137
+
138
+ **Cause.** The structured-output field is not universal across "OpenAI-compatible"
139
+ servers. Many implement `json_object` only; some implement neither.
140
+
141
+ **Fix.** `unsupportedJsonMode()` distinguishes that rejection from a genuine bad request,
142
+ and `translate.mjs` drops one rung — `json_schema` → `json_object` → prompt only — rather
143
+ than failing. Placeholder validation still guards the output, so the weaker mode costs
144
+ retries and not correctness. A real error (bad key, unknown model) must **not** match this
145
+ path, which is why the pattern is narrow.
146
+
147
+ ### Locale pages built from a stale source build
148
+
149
+ **Symptom.** The locale pages' inlined critical CSS no longer matches the source page's, and
150
+ each carries rules for classes the other one uses. Tag sequence and `class` attributes still
151
+ match perfectly, so substitution clearly worked — but the two pages disagree about their CSS.
152
+
153
+ **Cause.** The source site was rebuilt *after* the locale pages were generated, and
154
+ critical-CSS extraction is not stable across builds. Observed live on a production site: two
155
+ builds three hours apart, identical markup, and the later build's critical CSS gained
156
+ `.bg-sky-500`, `.bg-slate-100`, `.object-cover` and `.scale-150` while losing `.aspect-video`.
157
+ Neither build's CSS was a correct match for its own markup.
158
+
159
+ This is the same instability that makes per-locale re-rendering a bad idea, showing up in a
160
+ different place — and it is a useful reminder that the tooling is the unreliable part, not the
161
+ substitution.
162
+
163
+ **Fix.** Re-run `build-locales.mjs` after any rebuild that changes markup. It is nearly free:
164
+ the translation memory is keyed by source text, so nothing is re-translated and no API call is
165
+ made. Cheap enough to put in the same CI step as the build itself.
166
+
167
+ **How to spot it.** Compare an inlined `<style>` block between a source page and its locale
168
+ twin. Substitution never touches `<style>` contents, so if they differ, the locale page was
169
+ built from a different snapshot than the one you are looking at.
170
+
171
+ ### A model id sent to the wrong provider
172
+
173
+ **Symptom.** After upgrading to 1.2, an existing project fails with an unhelpful
174
+ "unknown model" from an API it never used before.
175
+
176
+ **Cause.** 1.2 changed the default provider to Anthropic. A config from 1.0/1.1 pins a
177
+ Gemini model id and has no `provider` key.
178
+
179
+ **Fix.** `inferProvider()` derives the provider from the model id when `provider` is
180
+ absent, so those configs keep working. Only a config with neither key gets the default.
181
+ The run prints which provider it resolved and whether it was inferred — read that line
182
+ before debugging anything else.
183
+
184
+ ## Building locale pages
185
+
186
+ ### Attribute order is not guaranteed
187
+
188
+ **Symptom:** canonical/hreflang/og rewrites do nothing, build reports success.
189
+
190
+ **Cause:** a builder emitted `<link href="…" rel="canonical">`; the regex assumed `rel`
191
+ first. Every identity rule silently no-opped.
192
+
193
+ **Fix:** parse attributes into a map rather than matching positionally, **and report any
194
+ rule that matched zero occurrences**. That report is what surfaced it.
195
+
196
+ ### Not every schema `url` is the page URL
197
+
198
+ **Symptom:** `Organization.url` becomes `/es/about` — the company relocated to a subpage.
199
+
200
+ **Cause:** blanket rewriting of every `url` in JSON-LD.
201
+
202
+ **Fix:** only page-level types (`WebPage`, `AboutPage`, `FAQPage`, `ContactPage`, …) get
203
+ the page URL. `WebSite.url` gets the **locale root**. `Organization` is one entity across
204
+ all languages — leave it alone.
205
+
206
+ ### The home link gains a trailing slash
207
+
208
+ `href="/"` naively prefixed becomes `/es/`, which 301-redirects back to `/es` — a
209
+ needless redirect on the logo of every page, contradicting the canonical. Special-case the
210
+ empty path.
211
+
212
+ ### One component, two instances
213
+
214
+ A non-global regex patches only the first. If a component is mounted twice (mobile +
215
+ desktop header), every per-locale rewrite needs the `g` flag.
216
+
217
+ ### Concurrent builds of the same locale race
218
+
219
+ Each build starts by deleting the locale directory, so two overlapping runs leave a
220
+ half-written tree — pages missing while both runs report success. Caught by URL parity.
221
+ Never build the same locale twice concurrently.
222
+
223
+ ---
224
+
225
+ ## Verification
226
+
227
+ ### Coverage above 100%
228
+
229
+ Numerator and denominator used different page sets (one counted every segment file,
230
+ the other only sitemap pages). A percentage that can exceed 100 is a broken measurement,
231
+ not a good result.
232
+
233
+ ### Coverage is not completeness
234
+
235
+ The single most important lesson. Coverage answers *"how much of what I extracted did I
236
+ translate?"* — it is structurally incapable of seeing text the extractor never offered.
237
+
238
+ Gate 6 answers the real question by comparing built output against the source page and
239
+ failing on strings that were never translation candidates. It must **not** fail merely
240
+ because a string is identical in both languages: `e-Learning`, `PowerPoint (.PPT)` and
241
+ `Google Translate PDF` are correct unchanged. Report those separately as "identical but
242
+ offered".
243
+
244
+ ---
245
+
246
+ ## Operating the pipeline
247
+
248
+ - **`nohup … &` does not survive a tool-call shell.** Use the harness's background
249
+ mechanism. A killed run loses everything since the last checkpoint.
250
+ - **macOS ships `openrsync`**, which rejects `--info=progress2` by printing usage and
251
+ **exiting 0**. The sync silently transfers nothing. Verify destination state.
252
+ - **`pgrep -f 'script.sh'` matches the polling shell itself** → the waiter waits forever.
253
+ Poll a state file, or match interpreter + script path.
254
+ - **Running the same expensive command twice in one line** (once to count, once to grep)
255
+ doubles a 20-minute build. Redirect to a file and inspect the file.
@@ -0,0 +1,157 @@
1
+ # Providers
2
+
3
+ The translation step is the only part of this pipeline that talks to a model, and it
4
+ talks through a small adapter. Three ship with the project; anything else is one file.
5
+
6
+ ---
7
+
8
+ ## Choosing one
9
+
10
+ ```json
11
+ {
12
+ "provider": "anthropic",
13
+ "model": "claude-haiku-4-5"
14
+ }
15
+ ```
16
+
17
+ | `provider` | Default model | Key | Notes |
18
+ | --- | --- | --- | --- |
19
+ | `anthropic` | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | The default. |
20
+ | `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Roughly a tenth the cost — see [throughput-and-cost.md](throughput-and-cost.md). |
21
+ | `openai` | `gpt-4o-mini` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
22
+ | `./my-provider.mjs` | — | yours | A path is imported directly. See below. |
23
+
24
+ `--provider` and `--model` on the command line override the config for one run.
25
+
26
+ **If you omit `provider`, it is inferred from the model id** — `gemini-*` → `gemini`,
27
+ `claude-*` → `anthropic`, `gpt-*` → `openai`. This is what keeps configs written for
28
+ 1.0 and 1.1 working after the 1.2 default changed to Claude. Omit both and you get
29
+ `claude-haiku-4-5`. The run prints which provider it resolved and whether it guessed.
30
+
31
+ ---
32
+
33
+ ## Running on a local model
34
+
35
+ The `openai` adapter speaks `/v1/chat/completions`, which Ollama, LM Studio and vLLM
36
+ all implement. Point it at one and the whole pipeline runs on your own hardware for
37
+ nothing:
38
+
39
+ ```json
40
+ {
41
+ "provider": "openai",
42
+ "apiBaseUrl": "http://localhost:11434/v1",
43
+ "model": "qwen2.5:14b"
44
+ }
45
+ ```
46
+
47
+ No key is needed — the adapter omits the `Authorization` header when there isn't one.
48
+
49
+ > `apiBaseUrl` is the **model** host. It is deliberately a different key from `baseUrl`,
50
+ > which is your **site's** canonical origin. Confusing them would point your canonical
51
+ > tags at a language model.
52
+
53
+ Two things to expect from smaller local models. They emit JSON wrapped in a
54
+ ` ```json ` fence, which the parser strips. And they often do not implement
55
+ `response_format`; when a server rejects it, the adapter drops to the next rung —
56
+ `json_schema` → `json_object` → prompt only — and says so. Placeholder validation runs
57
+ regardless, so a weaker guarantee costs retries rather than correctness. Force a rung
58
+ with `"jsonMode": "object"` if you already know what your server supports.
59
+
60
+ The same adapter covers OpenAI, Azure OpenAI, Groq, DeepSeek, Mistral, OpenRouter,
61
+ Together and Fireworks. Set `apiBaseUrl` and `model`; some need `apiKeyEnv` to point at
62
+ a differently-named variable.
63
+
64
+ ---
65
+
66
+ ## Which Claude model
67
+
68
+ `claude-haiku-4-5` is the default because bulk segment translation is a high-volume,
69
+ low-reasoning task and Haiku is the tier priced for it. Moving up is one line:
70
+
71
+ | Model | $/Mtok in/out | Relative cost |
72
+ | --- | --- | --- |
73
+ | `claude-haiku-4-5` | 1 / 5 | baseline |
74
+ | `claude-sonnet-5` | 3 / 15 | ~3x |
75
+ | `claude-opus-5` | 5 / 25 | ~5x |
76
+
77
+ Rates checked 2026-08-25. The larger models are better on idiom and register, which
78
+ matters for marketing copy and much less for UI strings.
79
+
80
+ One implementation detail worth knowing: the models that support adaptive thinking have
81
+ it **on by default**, and a translation run has no use for it. The adapter pins
82
+ `output_config.effort: "low"` on those tiers so you are not billed for reasoning you did
83
+ not ask for. Haiku takes no effort parameter at all — the API rejects it — so it is sent
84
+ nothing.
85
+
86
+ ---
87
+
88
+ ## Writing your own
89
+
90
+ A provider is a module with two required exports.
91
+
92
+ ```js
93
+ // my-provider.mjs
94
+ export const id = 'my-provider';
95
+ export const label = 'My Provider'; // shown in logs and errors
96
+ export const defaultModel = 'my-model-v1';
97
+ export const envKeys = ['MY_PROVIDER_KEY']; // checked in order, env then .env
98
+ export const keyOptional = false; // true for local servers
99
+
100
+ /** Build one HTTP request. */
101
+ export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
102
+ return {
103
+ url: `${baseUrl ?? 'https://api.example.com/v1'}/translate`,
104
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
105
+ body: { model, system, items, temperature },
106
+ };
107
+ }
108
+
109
+ /** Read one response. */
110
+ export function parse(data) {
111
+ return {
112
+ text: data.output, // the raw JSON string, or null
113
+ usage: { inTok: data.in ?? 0, outTok: data.out ?? 0 },
114
+ retryable: null, // 'safety' | 'truncated' | null
115
+ detail: data.reason, // shown when splitting
116
+ };
117
+ }
118
+
119
+ /** Optional: the units are nested in your response envelope. */
120
+ export const unwrap = (parsed) => parsed.translations ?? parsed;
121
+
122
+ /** Optional: USD per million tokens, [in, out]. Omit if unknown. */
123
+ export function pricing(model) { return [0.5, 1.5]; }
124
+ ```
125
+
126
+ Then:
127
+
128
+ ```json
129
+ { "provider": "./scripts/my-provider.mjs", "model": "my-model-v1" }
130
+ ```
131
+
132
+ `request` returns what to send; `parse` says what came back. Everything else —
133
+ exponential backoff, retrying network errors, halving a batch that was refused or
134
+ truncated, validating that placeholders survived, checkpointing the memory — is handled
135
+ by `translate.mjs` and applies to your adapter automatically.
136
+
137
+ **`retryable` is the important field.** Return `'safety'` when the provider refused the
138
+ whole request because of one string in it, and `'truncated'` when the output hit a
139
+ length limit. Both cause the batch to be halved and retried, which is what turns a lost
140
+ batch of forty units into one unusable unit. Return `null` and a failure takes all forty
141
+ with it.
142
+
143
+ Run `node --test scripts/providers/providers.test.mjs` — the contract tests there are
144
+ the clearest specification of what an adapter must do.
145
+
146
+ ---
147
+
148
+ ## Switching providers mid-project
149
+
150
+ The translation memory is keyed by a hash of the **source** text, not by model. So
151
+ changing provider translates only what is missing and reuses everything already there.
152
+ That makes a cheap first pass and a selective re-run practical: delete the units you
153
+ want redone from `i18n/tm/{lang}.json` and translate again on a better model.
154
+
155
+ The flip side is that one locale can end up part-translated by two models, with a
156
+ visible seam in voice. If that matters, delete the whole memory for that locale rather
157
+ than topping it up.
@@ -0,0 +1,91 @@
1
+ # Quality review
2
+
3
+ Structural gates prove the *plumbing* is correct — right pages, right markup, no broken
4
+ placeholders. They say nothing about whether the text is actually translated, or
5
+ translated into the right language. That is what `review.mjs` is for.
6
+
7
+ **The governing lesson: a heuristic that over-flags is worse than none.** The remedy for a
8
+ flag is purge-and-retranslate, which costs money and time. A noisy check either burns both
9
+ or trains you to ignore it.
10
+
11
+ ---
12
+
13
+ ## Verdicts
14
+
15
+ | Check | Reliable? | Notes |
16
+ | --- | --- | --- |
17
+ | Placeholder multiset mismatch | **Yes** — hard gate | Would corrupt HTML; validated at translate time and re-checked |
18
+ | Word overlap vs source | **Yes** | Catches wholesale-source returns *and* wrong-target-language |
19
+ | Length ratio | **Only script-aware** | See calibration below |
20
+ | Latin-script ratio | **Only brand-stripped** | Non-Latin locales only |
21
+ | Identical to source | Yes, with a content-word floor | Brand-only strings are correct unchanged |
22
+ | Adjacent word repetition | **No — disabled** | Three narrowing attempts, still mostly false positives |
23
+
24
+ ---
25
+
26
+ ## Calibration failures worth remembering
27
+
28
+ **Length ratio, flat floor → mass false positives on CJK.** A 0.4× floor flagged roughly
29
+ half of a Traditional Chinese locale, because CJK encodes far more meaning per character:
30
+ a 100-character English sentence is routinely ~30 characters of Chinese. Those units were
31
+ purged and re-translated for nothing.
32
+
33
+ Use script-aware bounds:
34
+
35
+ | Script group | Floor | Ceiling |
36
+ | --- | --- | --- |
37
+ | CJK, Thai, Lao, Khmer, Burmese | 0.12 | 1.2 |
38
+ | Everything else | 0.4 | 2.5 |
39
+
40
+ **Latin-ratio counting brand names → dozens of false positives per non-Latin locale.**
41
+ A heading like `"BrandName: лучше, чем Competitor?"` is mostly Latin characters and
42
+ perfectly translated. Strip brand and format tokens *before* computing the ratio, require ≥6 content
43
+ words, and set the threshold at 0.8. The check is aimed at "the model returned the source
44
+ language wholesale", not at short brand-heavy labels.
45
+
46
+ **Tautology detection — abandoned.** The real defect looks like `prueba una prueba`
47
+ ("try a try", from "try a 7-day trial"). Three successive narrowings were tried:
48
+ distance-1 only; require the source not to repeat the word; restrict to within a sentence.
49
+ It still could not separate that from `de inglés y de inglés a armenio`, which is correct
50
+ Spanish with an identical shape — the connectors between the repeats are shorter than the
51
+ token filter. Distinguishing them needs meaning, not shape. Shipped behind `--tautology`,
52
+ never gating.
53
+
54
+ ---
55
+
56
+ ## What review actually caught
57
+
58
+ These are real defects that **no structural gate could see**, because the text *was*
59
+ offered for translation and *was* returned:
60
+
61
+ - **Whole paragraphs returned in the source language**, with one word changed — a paragraph came back with only the language
62
+ name changed. Not byte-identical, so an equality check missed it; word overlap caught it.
63
+ - **Wrong target language.** `"Is Bahasa Melayu the same as Bahasa Indonesia?"` came back
64
+ in **Malay** on the Spanish locale — the model latched onto "Bahasa". One FAQ heading
65
+ among hundreds of pages; visual review would never have reached it.
66
+ - **Truncated output.** A 713-character Hebrew passage came back at 267 characters, cut
67
+ mid-word, because the batch exceeded the output limit. Coverage counted it as done.
68
+
69
+ Typical healthy flag rate after correction: **0.05–0.15% per locale**, and the remainder
70
+ are legitimate — postal addresses, image filenames used as alt text, proper nouns.
71
+
72
+ ---
73
+
74
+ ## Workflow
75
+
76
+ ```bash
77
+ node scripts/review.mjs --lang es # inspect first
78
+ node scripts/review.mjs --lang es --purge # only once you believe the flags
79
+ node scripts/translate.mjs --lang es --batch 6 # smaller batches for the awkward units
80
+ node scripts/review.mjs --lang es # confirm the rate dropped
81
+ ```
82
+
83
+ Always run the inspect step and **read the samples** before purging. Both known
84
+ purge disasters happened because purge followed detection automatically — which is why
85
+ `finalize.sh` requires an explicit `--purge` flag rather than doing it for you.
86
+
87
+ A concrete example of the danger: a set of postal-address blocks was flagged
88
+ "untranslated / high-overlap" (false positives — addresses are mostly proper nouns).
89
+ Purging and re-translating them replaced *correct* localised text with the source
90
+ language: `"Correo electrónico"` came back as `"Email:"`. The flags were right that the
91
+ strings looked unusual, and wrong that they needed fixing.