translation_diff 1.0.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +84 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +11 -0
  5. data/.rubocop.yml +39 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +723 -0
  8. data/Gemfile +61 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +158 -0
  11. data/Rakefile +41 -0
  12. data/data/languages/azure.json +285 -0
  13. data/data/languages/deepl.json +220 -0
  14. data/data/languages/google.json +399 -0
  15. data/data/languages/modernmt.json +413 -0
  16. data/docs/caching.md +185 -0
  17. data/docs/configuration.md +205 -0
  18. data/docs/contracts.md +187 -0
  19. data/docs/development.md +34 -0
  20. data/docs/errors.md +92 -0
  21. data/docs/how-it-works.md +145 -0
  22. data/docs/instrumentation.md +96 -0
  23. data/docs/languages.md +94 -0
  24. data/docs/providers.md +379 -0
  25. data/docs/sql-cache.md +366 -0
  26. data/lib/generators/translation_diff/install_generator.rb +15 -0
  27. data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
  28. data/lib/translation_diff/active_record/support.rb +34 -0
  29. data/lib/translation_diff/active_record.rb +3 -0
  30. data/lib/translation_diff/batch.rb +98 -0
  31. data/lib/translation_diff/call_preparation.rb +47 -0
  32. data/lib/translation_diff/capabilities.rb +9 -0
  33. data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
  34. data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
  35. data/lib/translation_diff/configuration/option_table.rb +38 -0
  36. data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
  37. data/lib/translation_diff/configuration.rb +135 -0
  38. data/lib/translation_diff/context.rb +23 -0
  39. data/lib/translation_diff/dispatcher.rb +57 -0
  40. data/lib/translation_diff/document.rb +23 -0
  41. data/lib/translation_diff/errors.rb +44 -0
  42. data/lib/translation_diff/fragment.rb +33 -0
  43. data/lib/translation_diff/http_provider.rb +128 -0
  44. data/lib/translation_diff/instrumentation.rb +25 -0
  45. data/lib/translation_diff/languages/refresh.rb +70 -0
  46. data/lib/translation_diff/languages/set.rb +53 -0
  47. data/lib/translation_diff/languages.rb +30 -0
  48. data/lib/translation_diff/leaves.rb +22 -0
  49. data/lib/translation_diff/markup.rb +85 -0
  50. data/lib/translation_diff/passage.rb +149 -0
  51. data/lib/translation_diff/preview.rb +3 -0
  52. data/lib/translation_diff/previewer.rb +78 -0
  53. data/lib/translation_diff/provider.rb +91 -0
  54. data/lib/translation_diff/providers/amazon.rb +126 -0
  55. data/lib/translation_diff/providers/azure.rb +78 -0
  56. data/lib/translation_diff/providers/deepl.rb +88 -0
  57. data/lib/translation_diff/providers/google.rb +64 -0
  58. data/lib/translation_diff/providers/libretranslate.rb +65 -0
  59. data/lib/translation_diff/providers/modernmt.rb +74 -0
  60. data/lib/translation_diff/providers/null.rb +20 -0
  61. data/lib/translation_diff/providers.rb +69 -0
  62. data/lib/translation_diff/railtie.rb +12 -0
  63. data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
  64. data/lib/translation_diff/rate_limiters/redis.rb +59 -0
  65. data/lib/translation_diff/rate_limiters.rb +9 -0
  66. data/lib/translation_diff/redaction.rb +45 -0
  67. data/lib/translation_diff/registry.rb +31 -0
  68. data/lib/translation_diff/segment.rb +32 -0
  69. data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
  70. data/lib/translation_diff/segmenters/simple.rb +122 -0
  71. data/lib/translation_diff/segmenters.rb +4 -0
  72. data/lib/translation_diff/sentence_cache.rb +76 -0
  73. data/lib/translation_diff/stores/active_record.rb +106 -0
  74. data/lib/translation_diff/stores/memory.rb +34 -0
  75. data/lib/translation_diff/stores/redis.rb +49 -0
  76. data/lib/translation_diff/stores.rb +9 -0
  77. data/lib/translation_diff/tasks/translation_diff.rake +21 -0
  78. data/lib/translation_diff/translation/request.rb +8 -0
  79. data/lib/translation_diff/translation/response.rb +35 -0
  80. data/lib/translation_diff/translation/usage.rb +8 -0
  81. data/lib/translation_diff/translator.rb +103 -0
  82. data/lib/translation_diff/version.rb +3 -0
  83. data/lib/translation_diff.rb +93 -0
  84. data/translation_diff.gemspec +56 -0
  85. metadata +243 -0
@@ -0,0 +1,413 @@
1
+ {
2
+ "provider": "modernmt",
3
+ "captured_at": "2026-09-10",
4
+ "endpoint": "https://api.modernmt.com/translate/languages",
5
+ "source": [
6
+ "ace",
7
+ "af",
8
+ "ak",
9
+ "als",
10
+ "am",
11
+ "ar",
12
+ "as",
13
+ "ast",
14
+ "awa",
15
+ "ayr",
16
+ "az",
17
+ "azb",
18
+ "azj",
19
+ "ba",
20
+ "ban",
21
+ "be",
22
+ "bem",
23
+ "bg",
24
+ "bho",
25
+ "bjn",
26
+ "bm",
27
+ "bn",
28
+ "bo",
29
+ "bs",
30
+ "bug",
31
+ "ca",
32
+ "ceb",
33
+ "cjk",
34
+ "ckb",
35
+ "crh",
36
+ "cs",
37
+ "cy",
38
+ "da",
39
+ "de",
40
+ "dik",
41
+ "diq",
42
+ "dyu",
43
+ "dz",
44
+ "ee",
45
+ "el",
46
+ "en",
47
+ "eo",
48
+ "es",
49
+ "es-419",
50
+ "es-es",
51
+ "et",
52
+ "fi",
53
+ "fj",
54
+ "fo",
55
+ "fon",
56
+ "fr",
57
+ "fur",
58
+ "fuv",
59
+ "ga",
60
+ "gaz",
61
+ "gd",
62
+ "gl",
63
+ "gn",
64
+ "gu",
65
+ "ha",
66
+ "he",
67
+ "hi",
68
+ "hne",
69
+ "hr",
70
+ "ht",
71
+ "hu",
72
+ "hy",
73
+ "id",
74
+ "ig",
75
+ "ilo",
76
+ "is",
77
+ "it",
78
+ "ja",
79
+ "jv",
80
+ "ka",
81
+ "kab",
82
+ "kac",
83
+ "kam",
84
+ "kas",
85
+ "kbp",
86
+ "kea",
87
+ "kg",
88
+ "khk",
89
+ "ki",
90
+ "kk",
91
+ "km",
92
+ "kmb",
93
+ "kmr",
94
+ "kn",
95
+ "knc",
96
+ "ko",
97
+ "ks",
98
+ "ky",
99
+ "la",
100
+ "lb",
101
+ "lg",
102
+ "li",
103
+ "lij",
104
+ "lmo",
105
+ "ln",
106
+ "lo",
107
+ "lt",
108
+ "ltg",
109
+ "lua",
110
+ "luo",
111
+ "lus",
112
+ "lv",
113
+ "lvs",
114
+ "mag",
115
+ "mai",
116
+ "mg",
117
+ "mi",
118
+ "min",
119
+ "mk",
120
+ "ml",
121
+ "mn",
122
+ "mni",
123
+ "mos",
124
+ "mr",
125
+ "ms",
126
+ "mt",
127
+ "my",
128
+ "nb",
129
+ "ne",
130
+ "nl",
131
+ "nn",
132
+ "nso",
133
+ "nus",
134
+ "ny",
135
+ "oc",
136
+ "or",
137
+ "pa",
138
+ "pag",
139
+ "pap",
140
+ "pbt",
141
+ "pes",
142
+ "pl",
143
+ "plt",
144
+ "prs",
145
+ "ps",
146
+ "pt",
147
+ "pt-br",
148
+ "pt-pt",
149
+ "quy",
150
+ "rn",
151
+ "ro",
152
+ "ru",
153
+ "rw",
154
+ "sa",
155
+ "sat",
156
+ "sc",
157
+ "scn",
158
+ "sd",
159
+ "sg",
160
+ "shn",
161
+ "si",
162
+ "sk",
163
+ "sl",
164
+ "sm",
165
+ "sn",
166
+ "so",
167
+ "sq",
168
+ "sr",
169
+ "ss",
170
+ "st",
171
+ "su",
172
+ "sv",
173
+ "sw",
174
+ "szl",
175
+ "ta",
176
+ "taq",
177
+ "te",
178
+ "tg",
179
+ "th",
180
+ "ti",
181
+ "tk",
182
+ "tl",
183
+ "tn",
184
+ "tpi",
185
+ "tr",
186
+ "ts",
187
+ "tt",
188
+ "tum",
189
+ "tw",
190
+ "tzm",
191
+ "ug",
192
+ "uk",
193
+ "umb",
194
+ "ur",
195
+ "uzn",
196
+ "vec",
197
+ "vi",
198
+ "war",
199
+ "wo",
200
+ "xh",
201
+ "ydd",
202
+ "yo",
203
+ "zh",
204
+ "zh-cn",
205
+ "zh-tw",
206
+ "zsm",
207
+ "zu"
208
+ ],
209
+ "target": [
210
+ "ace",
211
+ "af",
212
+ "ak",
213
+ "als",
214
+ "am",
215
+ "ar",
216
+ "as",
217
+ "ast",
218
+ "awa",
219
+ "ayr",
220
+ "az",
221
+ "azb",
222
+ "azj",
223
+ "ba",
224
+ "ban",
225
+ "be",
226
+ "bem",
227
+ "bg",
228
+ "bho",
229
+ "bjn",
230
+ "bm",
231
+ "bn",
232
+ "bo",
233
+ "bs",
234
+ "bug",
235
+ "ca",
236
+ "ceb",
237
+ "cjk",
238
+ "ckb",
239
+ "crh",
240
+ "cs",
241
+ "cy",
242
+ "da",
243
+ "de",
244
+ "dik",
245
+ "diq",
246
+ "dyu",
247
+ "dz",
248
+ "ee",
249
+ "el",
250
+ "en",
251
+ "eo",
252
+ "es",
253
+ "es-419",
254
+ "es-es",
255
+ "et",
256
+ "fi",
257
+ "fj",
258
+ "fo",
259
+ "fon",
260
+ "fr",
261
+ "fur",
262
+ "fuv",
263
+ "ga",
264
+ "gaz",
265
+ "gd",
266
+ "gl",
267
+ "gn",
268
+ "gu",
269
+ "ha",
270
+ "he",
271
+ "hi",
272
+ "hne",
273
+ "hr",
274
+ "ht",
275
+ "hu",
276
+ "hy",
277
+ "id",
278
+ "ig",
279
+ "ilo",
280
+ "is",
281
+ "it",
282
+ "ja",
283
+ "jv",
284
+ "ka",
285
+ "kab",
286
+ "kac",
287
+ "kam",
288
+ "kas",
289
+ "kbp",
290
+ "kea",
291
+ "kg",
292
+ "khk",
293
+ "ki",
294
+ "kk",
295
+ "km",
296
+ "kmb",
297
+ "kmr",
298
+ "kn",
299
+ "knc",
300
+ "ko",
301
+ "ks",
302
+ "ky",
303
+ "la",
304
+ "lb",
305
+ "lg",
306
+ "li",
307
+ "lij",
308
+ "lmo",
309
+ "ln",
310
+ "lo",
311
+ "lt",
312
+ "ltg",
313
+ "lua",
314
+ "luo",
315
+ "lus",
316
+ "lv",
317
+ "lvs",
318
+ "mag",
319
+ "mai",
320
+ "mg",
321
+ "mi",
322
+ "min",
323
+ "mk",
324
+ "ml",
325
+ "mn",
326
+ "mni",
327
+ "mos",
328
+ "mr",
329
+ "ms",
330
+ "mt",
331
+ "my",
332
+ "nb",
333
+ "ne",
334
+ "nl",
335
+ "nn",
336
+ "nso",
337
+ "nus",
338
+ "ny",
339
+ "oc",
340
+ "or",
341
+ "pa",
342
+ "pag",
343
+ "pap",
344
+ "pbt",
345
+ "pes",
346
+ "pl",
347
+ "plt",
348
+ "prs",
349
+ "ps",
350
+ "pt",
351
+ "pt-br",
352
+ "pt-pt",
353
+ "quy",
354
+ "rn",
355
+ "ro",
356
+ "ru",
357
+ "rw",
358
+ "sa",
359
+ "sat",
360
+ "sc",
361
+ "scn",
362
+ "sd",
363
+ "sg",
364
+ "shn",
365
+ "si",
366
+ "sk",
367
+ "sl",
368
+ "sm",
369
+ "sn",
370
+ "so",
371
+ "sq",
372
+ "sr",
373
+ "ss",
374
+ "st",
375
+ "su",
376
+ "sv",
377
+ "sw",
378
+ "szl",
379
+ "ta",
380
+ "taq",
381
+ "te",
382
+ "tg",
383
+ "th",
384
+ "ti",
385
+ "tk",
386
+ "tl",
387
+ "tn",
388
+ "tpi",
389
+ "tr",
390
+ "ts",
391
+ "tt",
392
+ "tum",
393
+ "tw",
394
+ "tzm",
395
+ "ug",
396
+ "uk",
397
+ "umb",
398
+ "ur",
399
+ "uzn",
400
+ "vec",
401
+ "vi",
402
+ "war",
403
+ "wo",
404
+ "xh",
405
+ "ydd",
406
+ "yo",
407
+ "zh",
408
+ "zh-cn",
409
+ "zh-tw",
410
+ "zsm",
411
+ "zu"
412
+ ]
413
+ }
data/docs/caching.md ADDED
@@ -0,0 +1,185 @@
1
+ # Caching
2
+
3
+ ## What a cache key is made of
4
+
5
+ One entry per sentence, keyed by the provider's `cache_key`, the lowercased
6
+ source and target language codes, a digest of the provider options that call
7
+ passed (`formality:`, a glossary id, ...), and a digest of the sentence
8
+ itself. `Stores::Redis` prefixes all of that with `cache_namespace`.
9
+
10
+ **No provider's `*_api_base` option is part of the key.** Two configurations
11
+ pointing `deepl_api_base` (or any other provider's `_api_base`) at different
12
+ endpoints share cache entries. For DeepL's own free and paid hosts that is
13
+ correct -- they return the same translations -- but a self-hosted or proxied
14
+ endpoint may not, and it would be served, and would serve, the real
15
+ service's entries. Give such a configuration its own `cache_namespace` (or
16
+ its own Redis database). The key format is left alone here on purpose:
17
+ changing its shape invalidates every entry already cached, everywhere, at
18
+ once.
19
+
20
+ **A cache entry written before a bug fix keeps serving what the bug
21
+ produced.** The key above is built from the source sentence, never from the
22
+ value stored under it, so fixing what a provider's reply decodes to does not
23
+ invalidate what is already cached -- an entry written under the HTML-entity
24
+ double-escaping fixed in the Unreleased CHANGELOG entry is served exactly as
25
+ it was written until it expires. Give the configuration a new
26
+ `cache_namespace`, or let `cache_ttl` lapse, to force every sentence to be
27
+ retranslated under the fix.
28
+
29
+ Both read and write the same cache, keyed per provider, so switching one
30
+ never serves you the other's translations.
31
+
32
+ `TranslationDiff::SentenceCache` is the class that builds the key and does
33
+ both the read and the write.
34
+
35
+ ## The options digest is lossy, on purpose
36
+
37
+ The per-call options are canonicalised to a string before they are digested,
38
+ and that canonical form flattens more than it distinguishes. Nesting is not
39
+ recorded, so `["x", ["y", "z"]]` and `["x", "y", "z"]` canonicalise
40
+ identically; neither is emptiness typed, so `tags: []` and `tags: {}` do
41
+ too. Two calls whose options differ only in one of those ways share a cache
42
+ entry.
43
+
44
+ This is a known property, not an oversight. The pipeline this replaced
45
+ collides on exactly the same inputs -- that was checked, not assumed -- so
46
+ reproducing it was the choice that left every warm cache warm. Fixing it
47
+ would give those calls new keys and re-translate everything already cached
48
+ under the old ones, for a distinction no provider option this gem ships
49
+ actually makes. If you pass an option where that distinction matters, give
50
+ the configuration its own `cache_namespace`.
51
+
52
+ A value the canonical form cannot render at all -- anything that is not a
53
+ String, Symbol, Numeric, `true`, `false`, `nil`, or an Array or Hash of
54
+ those -- raises `TranslationDiff::SentenceCache::Error` rather than being
55
+ guessed at. A key that is silently wrong costs you the whole cache and tells
56
+ you nothing.
57
+
58
+ No options at all contributes no field to the key, which is the four-field
59
+ key every already-warm cache is keyed on.
60
+
61
+ ## Asking what a call would do, without doing it
62
+
63
+ `TranslationDiff.preview` answers what a `translate` call would send and
64
+ find cached, without calling a provider and without writing anything: how
65
+ many sentences it would send, how many the cache already has, and how many
66
+ characters that is. It reads the same store, through the same
67
+ `SentenceCache`, keyed the same way -- see [What a cache key is made
68
+ of](#what-a-cache-key-is-made-of) above -- so a preview and the call it
69
+ predicts always agree.
70
+
71
+ ```ruby
72
+ preview = TranslationDiff.preview(article_body, from: "en", to: "es")
73
+ preview.sendable_sentences # => 1, not yet cached
74
+ preview.cached_sentences # => 4, already cached
75
+ preview.sendable_characters # => 23
76
+ preview.characters # => 412, the total this call would consider
77
+ ```
78
+
79
+ `sendable_sentences` and `cached_sentences` are the same two counts the
80
+ `cache` event reports as `misses` and `hits`; `characters` is the same total
81
+ the `translate` event reports. A preview and the call it predicts are
82
+ answering the same question through the same numbers, so "this edit will
83
+ send 23 of 412 characters" and what the events for that call later report
84
+ should agree.
85
+
86
+ **`from:` is required wherever there is anything to preview.** `translate`
87
+ can leave `from:` unset and pay for one `#detect` request to find it; a
88
+ preview never calls the provider, so it cannot pay for that request either.
89
+ Passing `to:` alone raises `TranslationDiff::Previewer::Error`, naming the
90
+ provider and telling you to pass `from:` explicitly -- unless the document
91
+ holds nothing translatable, or the source and target already match, in
92
+ which case there is nothing to preview and an empty result comes back
93
+ regardless of `from:`.
94
+
95
+ This is the supported way to ask an editor's question before it becomes a
96
+ bill -- show "this edit will send 1 sentence" before the author saves:
97
+
98
+ ```ruby
99
+ preview = TranslationDiff.preview(edited_body, from: "en", to: "es")
100
+ "This edit will send #{preview.sendable_sentences} sentence#{'s' unless preview.sendable_sentences == 1}."
101
+ ```
102
+
103
+ ## The cache store contract
104
+
105
+ `config.cache` accepts either a registered name (`:redis`, `:memory`,
106
+ `:active_record`) or an object satisfying this contract directly:
107
+
108
+ ```ruby
109
+ # Reads several keys at once, returning an array the same length as keys,
110
+ # with nil in a missing key's position.
111
+ def read_multi(keys); end
112
+
113
+ # Writes one key. The second write of the same key replaces the first.
114
+ def write(key, value); end
115
+
116
+ # Writes several pairs at once. Optional -- see "write_multi is optional" below.
117
+ def write_multi(pairs); end
118
+ ```
119
+
120
+ `test/support/cache_store_contract.rb` is the executable form of this
121
+ contract: include `CacheStoreContract` in a test class that defines
122
+ `#store`. It only exercises `read_multi` and `write` -- the two required
123
+ methods -- so a store that implements only those two still passes it.
124
+ `test/support/batching_cache_store_contract.rb` holds the optional half:
125
+ include `BatchingCacheStoreContract` too, alongside `CacheStoreContract`,
126
+ once `#store` also implements `write_multi`.
127
+
128
+ Three stores ship with this gem: `TranslationDiff::Stores::Memory`, the
129
+ default -- a bounded, in-process LRU, not thread-safe by design, evicting by
130
+ `cache_max_size` rather than by time; `TranslationDiff::Stores::Redis`,
131
+ built from `redis_url` when that is set, expiring entries after `cache_ttl`
132
+ and namespacing every key under `cache_namespace`; and
133
+ `TranslationDiff::Stores::ActiveRecord`, opt-in, caching in the
134
+ application's own database -- see [SQL cache](sql-cache.md). Neither `redis`
135
+ nor `connection_pool` nor `redis-namespace` is a dependency of this gem --
136
+ `Stores::Redis` takes anything answering to `#with` the way
137
+ `ConnectionPool` does, and yields anything `Redis::Namespace` accepts.
138
+
139
+ ## `write_multi` is optional
140
+
141
+ A store need not implement `write_multi`. `SentenceCache#store` checks: a
142
+ store that answers to it gets one call carrying every translated sentence
143
+ from the batch; a store that does not is called once per sentence through
144
+ `write` instead, exactly as it always was. A custom cache store written
145
+ against the contract before `write_multi` existed keeps working unchanged
146
+ -- that is what "optional" means here.
147
+
148
+ All three shipped stores implement it: `Stores::Memory` loops over the
149
+ pairs (there is no round trip to save in-process); `Stores::Redis`
150
+ pipelines the writes; `Stores::ActiveRecord` upserts the whole batch in
151
+ one statement.
152
+
153
+ ### The three write paths fail differently
154
+
155
+ Nobody had written this down before: what a partial failure leaves cached
156
+ depends on which of these shapes wrote it.
157
+
158
+ - **No `write_multi` (the per-key path), and `Stores::Memory`'s loop.**
159
+ Sentences are written one at a time, in order. A failure at sentence N
160
+ leaves 1..N-1 written, N failed, and N+1.. never attempted.
161
+ - **`Stores::Redis#write_multi`.** A Redis pipeline is not a
162
+ transaction: each `SETEX` in it runs independently of the others, so a
163
+ failure in one does not stop its siblings from landing. Which of the
164
+ batch actually landed does not follow the sentence order the way the
165
+ per-key path's does.
166
+ - **`Stores::ActiveRecord#write_multi`.** One `upsert_all` statement for
167
+ the whole batch. It either lands as a whole or it does not -- there is no
168
+ partial batch to reason about.
169
+
170
+ A caller that needs to know which sentences got cached after a failure
171
+ needs to know which of these three shapes wrote them; the answer is not the
172
+ same for all three.
173
+
174
+ None of the three ever reaches the caller as an exception, though. The
175
+ cache is an optimisation on top of a translation that has already been
176
+ paid for at the provider: `Translator#fill` rescues whatever error surfaces
177
+ here, logs it, fires a `cache_error` event (provider and error class only,
178
+ never the text -- see [Instrumentation](instrumentation.md)), and returns
179
+ the translation regardless. This holds for all three shapes and every
180
+ store, not only `Stores::ActiveRecord` -- a `Stores::Memory` bug, a
181
+ dropped Redis connection, a SQL write blocked by a read-only replica (see
182
+ [Rails replica routing](sql-cache.md#rails-replica-routing)) all behave the
183
+ same way from the caller's side. What differs between the three shapes
184
+ above is only what ends up cached, never whether the translation comes
185
+ back.