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
data/docs/providers.md ADDED
@@ -0,0 +1,379 @@
1
+ # Providers
2
+
3
+ A translation cache that helps translate only changes between revisions of
4
+ long texts. It ships with six providers -- DeepL, Google Cloud Translation,
5
+ Azure AI Translator, ModernMT, LibreTranslate and Amazon Translate -- but any
6
+ translation service can be plugged in by subclassing a small base class.
7
+
8
+ See the provider table in the [README](../README.md#providers).
9
+
10
+ Language pairs are checked against shipped vendor data before every call, and
11
+ a provider with no shipped data refuses nothing. Data ships for **DeepL,
12
+ Google, Azure and ModernMT**; **Amazon** and **LibreTranslate** ship none --
13
+ see [Languages](languages.md).
14
+
15
+ `Configuration#inspect` and `Provider#inspect` print `[FILTERED]` in place of
16
+ every credential option below -- `deepl_api_key`, `azure_api_key`,
17
+ `amazon_secret_access_key`, and so on -- so a Rails error page and a stray
18
+ `p config` in a console cannot leak one; `pp` and `p` both go through the
19
+ overridden `inspect`. That guarantee stops at `inspect`, though: an error
20
+ reporter that serialises object state instead of calling `inspect` is not
21
+ covered, and neither is `p provider.connection`, which prints Faraday's own
22
+ headers, `Authorization` included, untouched.
23
+
24
+ Options that carry no credential at all, like `azure_region` or
25
+ `cache_namespace`, stay visible in full. Any option whose value parses as a
26
+ URI carrying userinfo -- `redis_url` among them -- has just that userinfo
27
+ redacted: `rediss://default:AbCdEf-TOKEN@cache.example.upstash.io:6379`
28
+ prints as `rediss://default:[FILTERED]@cache.example.upstash.io:6379`. The
29
+ scheme, host, port and path stay visible, since that's what you need to
30
+ debug against -- only the credential Heroku, Upstash, Redis Cloud and Aiven
31
+ all put in the userinfo is hidden. A value that isn't a URI, or a URI with no
32
+ userinfo, is left unchanged, and a malformed value never raises out of
33
+ `inspect`.
34
+
35
+ ## Configuring each provider
36
+
37
+ Every example below is complete: set the options shown and
38
+ `TranslationDiff.translate` works. The option names, and which of them are
39
+ required, come from the provider itself -- see [Configuration
40
+ options](configuration.md#configuration-options) for the full list and the environment
41
+ variables each one falls back to.
42
+
43
+ **DeepL** is the default, so `config.provider` may be omitted. A key ending
44
+ in `:fx` is a free-plan key and selects the free host on its own.
45
+
46
+ ```ruby
47
+ TranslationDiff.configure do |config|
48
+ config.provider = :deepl
49
+ config.deepl_api_key = ENV["DEEPL_AUTH_KEY"]
50
+ end
51
+
52
+ TranslationDiff.translate("Hello there. Second sentence.", from: "en", to: "ru")
53
+ ```
54
+
55
+ **Google Cloud Translation** needs an API key and nothing else -- no project,
56
+ no service account.
57
+
58
+ ```ruby
59
+ TranslationDiff.configure do |config|
60
+ config.provider = :google
61
+ config.google_api_key = ENV["TRANSLATE_KEY"]
62
+ end
63
+ ```
64
+
65
+ **Azure AI Translator** wants the region as well when the key belongs to a
66
+ multi-service Cognitive Services resource; a single-service Translator
67
+ resource needs no region.
68
+
69
+ ```ruby
70
+ TranslationDiff.configure do |config|
71
+ config.provider = :azure
72
+ config.azure_api_key = ENV["AZURE_TRANSLATOR_KEY"]
73
+ config.azure_region = "westeurope"
74
+ end
75
+ ```
76
+
77
+ **ModernMT** takes a key and can be pointed at an adaptive memory per call,
78
+ since every unrecognised keyword reaches the provider untouched.
79
+
80
+ ```ruby
81
+ TranslationDiff.configure do |config|
82
+ config.provider = :modernmt
83
+ config.modernmt_api_key = ENV["MMT_API_KEY"]
84
+ end
85
+
86
+ TranslationDiff.translate(text, from: "en", to: "ru", hints: "1234")
87
+ ```
88
+
89
+ **LibreTranslate** inverts the usual arrangement: the base URL is required
90
+ because every instance is someone's own, and the key is optional because most
91
+ instances ask for none. It is also the only provider here you can run
92
+ yourself, which makes it the one to develop against.
93
+
94
+ ```ruby
95
+ TranslationDiff.configure do |config|
96
+ config.provider = :libretranslate
97
+ config.libretranslate_api_base = "http://localhost:5000"
98
+ config.libretranslate_api_key = ENV["LIBRETRANSLATE_KEY"] # optional
99
+ end
100
+ ```
101
+
102
+ ```bash
103
+ docker run -d --rm -p 5000:5000 libretranslate/libretranslate --load-only en,ru
104
+ ```
105
+
106
+ **Amazon Translate** is signed rather than keyed, so it takes credentials and
107
+ a region. There is no environment fallback: this library does not implement
108
+ the AWS credential chain, so `AWS_ACCESS_KEY_ID` and friends are not read --
109
+ pass them explicitly.
110
+
111
+ ```ruby
112
+ TranslationDiff.configure do |config|
113
+ config.provider = :amazon
114
+ config.amazon_access_key_id = ENV.fetch("AWS_ACCESS_KEY_ID")
115
+ config.amazon_secret_access_key = ENV.fetch("AWS_SECRET_ACCESS_KEY")
116
+ config.amazon_region = "eu-central-1"
117
+ end
118
+ ```
119
+
120
+ Add `gem "aws-sigv4"` to your Gemfile for this one. It is Amazon's own
121
+ signing library and nothing more -- no clients, no service models -- and it
122
+ is required lazily, so an application on any other provider never installs it.
123
+
124
+ **Null** translates nothing and returns what it was given. It exists so a
125
+ pipeline can be wired up, and its cache and instrumentation exercised, before
126
+ anyone has paid for a key.
127
+
128
+ ```ruby
129
+ TranslationDiff.configure { |config| config.provider = :null }
130
+ ```
131
+
132
+ ## Switching providers
133
+
134
+ Different providers can be used side by side without disturbing the global
135
+ configuration -- `TranslationDiff.context` yields an isolated copy, and
136
+ `provider:` overrides one call:
137
+
138
+ ```ruby
139
+ formal = TranslationDiff.context do |config|
140
+ config.provider = :deepl
141
+ config.deepl_api_key = ENV["DEEPL_AUTH_KEY"]
142
+ end
143
+ formal.translate(contract, from: "en", to: "de", formality: :more)
144
+
145
+ TranslationDiff.translate(blog_post, from: "en", to: "de", provider: :google)
146
+ ```
147
+
148
+ Both read and write the same cache, keyed per provider, so switching one
149
+ never serves you the other's translations.
150
+
151
+ ## Capabilities, in full
152
+
153
+ **Every keyword other than `from:`, `to:`, `provider:`, `config:` and
154
+ `assume_supported:` is forwarded to the provider, and every provider applies
155
+ them the same way: its own defaults first, then your options, then the
156
+ fields the request cannot do without.** So `formality: :less` overrides a
157
+ default, and a keyword colliding with the language pair or the texts
158
+ themselves is overridden rather than obeyed. `assume_supported:` is reserved
159
+ because it is this library's own decision -- whether to skip language
160
+ validation for this call -- not a vendor's, and it must never reach a
161
+ payload.
162
+
163
+ **`usage.billed_characters` is `nil` when the provider said nothing about
164
+ billing and a number -- `0` included -- when it said something.** Three of
165
+ the six built-in providers report billing that way -- DeepL, Azure and
166
+ ModernMT; the other three, Google, LibreTranslate and Amazon, always answer
167
+ `nil`.
168
+
169
+ **Language codes are normalised per vendor, so switching provider needs no
170
+ other change.** A bare code (`"EN"`, `:ru`) is cased the way the vendor
171
+ documents it -- DeepL takes upper case, every other provider here takes lower
172
+ case -- whichever casing you wrote. A code carrying a script or region subtag
173
+ (`"zh-Hans"`, `"pt-BR"`) is passed through untouched, because the casing of a
174
+ subtag is its own. A provider of your own gets the same rule from
175
+ `TranslationDiff::Provider#language`; declare `def self.language_case =
176
+ :upcase` if your vendor wants upper case.
177
+
178
+ "Request size" is what `TranslationDiff::Batch` measures: the URL-escaped form of each
179
+ string (`CGI.escape(text).size`), which is never smaller than its UTF-8 byte
180
+ count. "HTML support" names the provider option that turns HTML handling on
181
+ -- every vendor spells it differently, which is exactly what
182
+ `Capabilities#html` is for. A provider whose "Detects language" column says
183
+ no makes `from:` required; passing it makes every provider's `#detect` call
184
+ unnecessary regardless of whether it has one.
185
+
186
+ ### Very long texts
187
+
188
+ Every provider limits how large a single request or a single batch can be,
189
+ declared through `TranslationDiff::Capabilities#max_request_size` and
190
+ `#max_batch_size`; if your text is longer than that, TranslationDiff splits
191
+ it into multiple requests automatically. See the [provider
192
+ table](../README.md#providers) for each built-in provider's actual numbers -- DeepL, for
193
+ example, caps requests at 1,700 escaped characters and batches at 50
194
+ sentences.
195
+
196
+ ### Provider caveats
197
+
198
+ **Amazon translates one text per call and honours no `notranslate`.** There
199
+ is no batch form of `TranslateText`, so a hundred sentences are a hundred
200
+ requests -- slow, but correct, and `Capabilities#max_batch_size` reflects
201
+ it. Amazon also has no HTML mode: a `notranslate` span reaches it as plain
202
+ text and is translated like everything else, tags and all. Both facts are
203
+ worth weighing before your bill and your brand names arrive, not after.
204
+
205
+ **LibreTranslate does not honour `notranslate` either -- measured, not
206
+ assumed.** Its HTML format preserves markup, but probing a real instance
207
+ (`docker run libretranslate/libretranslate --load-only en,ru`) with
208
+ `<span class="notranslate">Bold Mountain</span> is a good place.` came back
209
+ with the span tag intact and its content translated anyway -- "Bold
210
+ Mountain" became "Смелая гора". The tags survive; what they were meant to
211
+ protect does not.
212
+
213
+ ModernMT's `notranslate: false` is the conservative default rather than a
214
+ measurement: it documents an HTML format but says nothing about
215
+ `class="notranslate"`, and no key was available to probe it. A capability
216
+ that under-promises costs a warning; one that over-promises costs a
217
+ customer's protected content reaching a competitor's brand voice.
218
+
219
+ ### Writing a provider
220
+
221
+ Any translation service can be a provider -- no change to this gem's own
222
+ code is required. Subclass `TranslationDiff::HTTPProvider` for a REST
223
+ service; it owns the Faraday connection, retries, timeouts and turns HTTP
224
+ status codes into this library's error hierarchy, and asks only for three
225
+ seams per operation: the URL, how to render a request, how to parse a
226
+ reply. Subclass `TranslationDiff::Provider` directly for anything that
227
+ reaches its service some other way -- signed requests, another gem, an
228
+ LLM client -- and implement `#translate` outright, the way
229
+ `TranslationDiff::Providers::Amazon` does.
230
+
231
+ Registering a provider also declares the options it needs, so
232
+ `config.acme_api_key` below does not exist until `AcmeProvider` is
233
+ registered:
234
+
235
+ ```ruby
236
+ class AcmeProvider < TranslationDiff::HTTPProvider
237
+ # Declares this provider's own configuration options.
238
+ # TranslationDiff::Providers.register adds each one to
239
+ # TranslationDiff::Configuration as a side effect.
240
+ def self.configuration_options = %i[acme_api_key]
241
+ def self.configuration_requirements = %i[acme_api_key]
242
+
243
+ # What this provider can do, checked once by the pipeline for chunking,
244
+ # detection and cache-key safety.
245
+ def self.capabilities
246
+ TranslationDiff::Capabilities.new(
247
+ max_request_size: 10_000, max_batch_size: 100, max_text_size: nil,
248
+ html: :format, notranslate: true, detects_language: true,
249
+ reports_billing: false
250
+ )
251
+ end
252
+
253
+ def api_base = config.acme_api_base || "https://api.acme.example/v1"
254
+ def headers = { "Authorization" => "Bearer #{config.acme_api_key}" }
255
+ def translate_url = "translate"
256
+
257
+ # The three seams: build the request body, decode the reply.
258
+ def render_translate_payload(request)
259
+ { format: "HTML", texts: request.texts, targetLanguageCode: request.to.to_s }
260
+ .tap { |body| body[:sourceLanguageCode] = request.from.to_s unless request.from.nil? }
261
+ end
262
+
263
+ def parse_translate_response(body, _headers, request)
264
+ translations = Array(body["translations"])
265
+
266
+ TranslationDiff::Translation::Response.build(
267
+ request: request,
268
+ texts: translations.map { |t| t["text"] },
269
+ detected_source: translations.first&.dig("detectedLanguageCode")&.downcase
270
+ )
271
+ end
272
+
273
+ def detect_url = "translate/v2/detect"
274
+
275
+ def detect(text)
276
+ response = post(detect_url, { text: text })
277
+ response.body["languageCode"]&.downcase
278
+ end
279
+
280
+ # cache_key is optional: TranslationDiff::Providers.register stamps every
281
+ # instance built through the registry with its registered name, and
282
+ # Provider#cache_key falls back to that. Define it yourself only if this
283
+ # provider will also be instantiated and assigned directly, bypassing the
284
+ # registry -- see "Provider objects and cache_key" below.
285
+ end
286
+
287
+ TranslationDiff::Providers.register(:acme, AcmeProvider)
288
+
289
+ TranslationDiff.configure do |config|
290
+ config.provider = :acme
291
+ config.acme_api_key = ENV["ACME_API_KEY"]
292
+ end
293
+ ```
294
+
295
+ `translate_url`/`render_translate_payload`/`parse_translate_response` are
296
+ the three seams `HTTPProvider#translate` calls in order; `detect` is
297
+ entirely optional -- omit it (and leave `capabilities.detects_language:
298
+ false`) if the provider has no detection endpoint, or if callers of this
299
+ gem always pass `from:` explicitly.
300
+
301
+ **`self.sensitive_options` decides which of this provider's
302
+ `configuration_options` `Configuration#inspect` and `Provider#inspect`
303
+ filter.** By default it is every declared option whose name matches
304
+ `TranslationDiff::Redaction::SENSITIVE` (`key`, `secret`, `token`,
305
+ `password`, `auth`, `credential`) -- `acme_api_key` above is caught by that
306
+ pattern for free. Override it when a credential's name doesn't match: a
307
+ provider reading `config.acme_handshake` for its credential leaks it on
308
+ every `inspect` unless it says so itself:
309
+
310
+ ```ruby
311
+ def self.sensitive_options = %i[acme_handshake]
312
+ ```
313
+
314
+ **Provider names must be unique.** `TranslationDiff::Providers.register`
315
+ overwrites whatever was previously registered under that name, silently --
316
+ there is no error for registering `:deepl` twice. This is deliberate: a
317
+ raise would break Rails development-mode reloading and a defensive double
318
+ `require`. It also means a typo in a name collides with a real provider
319
+ without warning, so choose names as carefully as you would a constant.
320
+
321
+ What happens when they are not unique is worth being precise about. The last
322
+ class registered under the name wins, and it also inherits the cache entries
323
+ of the one it replaced: `cache_key` falls back to the registered name, so a
324
+ class registered over `:deepl` reads and writes exactly the entries the real
325
+ DeepL provider wrote. Callers are then served one service's translations from
326
+ another service's cache, for as long as those entries live, with nothing in
327
+ the log to say so. Registering over an existing name is not a way to
328
+ substitute a service -- give the replacement its own name, or clear the cache
329
+ (`cache_namespace` is the cheapest way to do that).
330
+
331
+ **An option can declare a default.** A bare symbol in
332
+ `configuration_options` declares an option with no default. Writing
333
+ `key => default` instead declares one, and a callable default is evaluated on
334
+ every read rather than at load time -- which is what lets an environment
335
+ variable work when the application exports it after requiring this gem:
336
+
337
+ ```ruby
338
+ def self.configuration_options
339
+ [:acme_api_base, { acme_api_key: -> { ENV.fetch("ACME_API_KEY", nil) } }]
340
+ end
341
+ ```
342
+
343
+ An explicitly configured value always wins over a default, and a default that
344
+ resolves to a blank string reads as unset -- the same rule assignment follows.
345
+
346
+ **Option names are unique too, and enforced.** Two providers declaring the
347
+ same `configuration_options` name would share one accessor on
348
+ `TranslationDiff::Configuration`, which would hand one service's credential
349
+ to the other, so registering the second one raises and names both providers
350
+ and the option. Prefix your options with your provider's name --
351
+ `deepl_api_key`, `google_api_key` -- the way the built-ins do. The same
352
+ provider redeclaring its own options is not a conflict: a double `require`
353
+ and a Rails reload both re-run registration.
354
+
355
+ `TranslationDiff::Providers.names` lists every registered provider;
356
+ `TranslationDiff::Providers.registered?(:acme)` checks one.
357
+
358
+ `test/support/provider_contract.rb` and `test/support/http_provider_contract.rb`
359
+ are the executable form of the provider contract: include `ProviderContract`
360
+ (and, for an `HTTPProvider` subclass, `HTTPProviderContract`) in a test class
361
+ that defines `#provider`, and they verify a provider inherits
362
+ `TranslationDiff::Provider`, that `#translate` preserves order and returns
363
+ one string per input, and that its declared capabilities are internally
364
+ consistent (a provider claiming `notranslate` must also claim an HTML mode).
365
+
366
+ ### Provider objects and cache_key
367
+
368
+ A provider built through `TranslationDiff::Providers.build` (which is what
369
+ happens when `config.provider` is a symbol) is stamped with its registered
370
+ name automatically, and never needs to define `cache_key` itself --
371
+ `Provider#cache_key` falls back to that stamped name.
372
+
373
+ A provider object assigned straight to `config.provider` never passes
374
+ through the registry, so it gets no name and **must define `cache_key`
375
+ itself**, or every call through it raises. This is not pedantry: `cache_key`
376
+ is a segment of every cache entry this provider ever reads or writes, so two
377
+ providers sharing one -- or both silently falling back to an empty one --
378
+ would let a caller be served another provider's cached translation.
379
+