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,145 @@
1
+ # How it works
2
+
3
+ A call to `TranslationDiff.translate` walks a value, cuts the prose in it
4
+ into sentences, translates only the sentences no cache already holds, and
5
+ puts the value back together in the shape it arrived in.
6
+
7
+ `TranslationDiff::Translator` coordinates document assembly, provider
8
+ resolution, settling the source language, language validation and the
9
+ sentence cache. `TranslationDiff::Dispatcher` takes over once there are cache
10
+ misses to send: it packs them into batches, throttles each one, makes the
11
+ wire call, and fires the `request`, `rate_limit` and `usage` events.
12
+ Everything below is a collaborator one of the two drives.
13
+
14
+ ## The steps
15
+
16
+ 1. **The caller's structure is walked, not flattened.**
17
+ `TranslationDiff::Document` visits every leaf `String` of a String, Array
18
+ or deep Hash and can rebuild the same shape from new leaves.
19
+ `TranslationDiff::Leaves` holds the two promises the structure itself does
20
+ not: a nested `nil` comes back as `""`, and the `values` count in an event
21
+ payload is every leaf the caller wrote, translatable or not.
22
+
23
+ 2. **Each leaf becomes a passage of markup and prose.**
24
+ `TranslationDiff::Passage` parses the string with `ox` and records where
25
+ every construct begins, so each run of the source is either markup --
26
+ tags, comments, CDATA, doctypes, processing instructions, the bodies of
27
+ `config.opaque_elements` (`script`, `style`, `pre` and `code` by
28
+ default, matched case-insensitively), and anything inside
29
+ `class="notranslate"` -- or prose.
30
+ Each run becomes a `TranslationDiff::Fragment`, and a fragment is always a
31
+ slice of the source, never a rebuilt string.
32
+
33
+ 3. **Prose is cut into sentences.**
34
+ A prose fragment is split by `config.segmenter` (see [The segmenter
35
+ contract](contracts.md#the-segmenter-contract)) into
36
+ `TranslationDiff::Segment`s. A segment keeps the whitespace it was found
37
+ in: its `#core` is the text a provider sees, with entity references
38
+ decoded to the characters they mean, and its `#render` is markup again.
39
+
40
+ 4. **The cache is consulted once, for every sentence at once.**
41
+ `TranslationDiff::SentenceCache` builds one key per sentence and reads
42
+ them all in a single `read_multi`. Sentences it answers for are already
43
+ done; the rest are misses. See [Caching](caching.md).
44
+
45
+ 5. **The misses are handed to `TranslationDiff::Dispatcher`.**
46
+ `TranslationDiff::Batch.pack` groups them into batches that fit inside the
47
+ provider's declared `max_batch_size` and `max_request_size` -- its
48
+ `TranslationDiff::Capabilities` (see [Providers](providers.md)).
49
+ `Dispatcher` throttles each batch through `config.rate_limiter_instance`
50
+ when one is configured, sends it to the provider, and applies the reply
51
+ back onto the very segments that produced it -- no step ever correlates a
52
+ translation to a sentence by position after the fact.
53
+
54
+ 6. **What came back is written home, and the value is rebuilt.**
55
+ Only sentences that actually got a translation are cached, through
56
+ `TranslationDiff::SentenceCache#store` -- see [`write_multi` is
57
+ optional](caching.md#write_multi-is-optional) for what happens when the
58
+ store's write fails partway through a batch. Then each passage renders
59
+ itself -- markup fragments byte-exact, translated sentences re-encoded as
60
+ HTML text -- and `Document` puts the renders back into the caller's
61
+ shape.
62
+
63
+ `TranslationDiff::Markup` is the small module underneath steps 2, 3 and 6: it
64
+ decodes entity references on the way to a provider and, in
65
+ `TranslationDiff::Translation::Response.build`, on the way back too, for
66
+ every provider -- Google and DeepL both return HTML-escaped text, and
67
+ without the second decode a vendor's own `&` was escaped a second time, so
68
+ `didn't` came back as `didn't`. Named entities, and both the decimal
69
+ (`'`) and hex (`'`) numeric forms, are decoded; an entity neither
70
+ decoder recognizes, or one that would decode to invalid UTF-8, is left
71
+ exactly as it arrived.
72
+
73
+ Decoding a reply raw would make `&lt;` a bare `<`, and `ox` reads a bare `<`
74
+ in front of a letter as an opening tag -- a provider's own `&lt;b attack`
75
+ would become a real `<b attack>` element. So a reply is escaped the same way
76
+ a source document's own bare angles already are, before it is decoded, and
77
+ `Segment#render` re-encodes a translated sentence with
78
+ `Markup.encode_translation`: `&` is always escaped, and so is a `<` that is
79
+ not shaped like a tag -- a source document's own bare `<` is untouched by
80
+ this. **This is a behaviour change:** `if a < b then stop.` used to come
81
+ back with the bare `<` exactly as written; it now comes back
82
+ `if a &lt; b then stop.`, the correct HTML encoding of that character,
83
+ rendering identically in a browser but visible to anything comparing output
84
+ byte-for-byte against an earlier release. `>` is left alone -- a stray `>`
85
+ never opens anything a parser would honour, so there is nothing to protect
86
+ it from.
87
+
88
+ *NOTE:* if `:from` is not specified or equal to nil, then the provider's `#detect` will be called once with a sample of text up to 100 characters long to determine the language, and `#translate` will be called separately with the entire text.
89
+ Try to specify `:from` explicitly to save the extra call -- it also improves segmentation, since the segmenter only sees a language when `:from` is given (see [The segmenter contract](contracts.md#the-segmenter-contract)).
90
+
91
+ ## Input
92
+
93
+ `TranslationDiff.translate` can receive string, array or deep hash and will return the same, but translated.
94
+
95
+ ```ruby
96
+ TranslationDiff.translate("test", from: "en", to: "es")
97
+ TranslationDiff.translate(%w[test language], from: "en", to: "es")
98
+ TranslationDiff.translate(
99
+ { title: "test", values: { type: "frequent" } }, from: "en", to: "es"
100
+ )
101
+ ```
102
+
103
+ A leaf that is not a `String` -- a number, a symbol, `true` -- is handed back
104
+ untouched. A nested `nil` comes back as `""`. See
105
+ `TranslationDiff::Document` and `TranslationDiff::Leaves` for details.
106
+
107
+ `to:` is not optional. It defaults to `nil` in the signature and a `nil`
108
+ target raises `ArgumentError` naming the keyword, rather than silently
109
+ handing your values back untranslated.
110
+
111
+ ## HTML
112
+
113
+ You can pass HTML as like as plain text:
114
+
115
+ ```ruby
116
+ TranslationDiff.translate("<b>Black</b>", from: "en", to: "es")
117
+ ```
118
+
119
+ A `<pre>` or `<code>` block is not prose to this gem, so it is left alone.
120
+ `config.opaque_elements` names the set treated this way -- `script`,
121
+ `style`, `pre` and `code` by default -- matched case-insensitively, so
122
+ `<PRE><CODE>` and `<STYLE>` (the shape Word, Outlook and older CMSes emit)
123
+ are opaque too. An application can widen or narrow the set. Measured
124
+ against the live Google API:
125
+
126
+ ```ruby
127
+ TranslationDiff.translate(
128
+ "<pre><code>curl -s https://example.com/level | jq '.meters'</code></pre>",
129
+ from: "en", to: "es"
130
+ )
131
+ # => "<pre><code>curl -s https://example.com/level | jq '.meters'</code></pre>"
132
+ ```
133
+
134
+ Before `pre` and `code` joined the opaque set, nothing told the pipeline
135
+ that code holds language, not prose: the segmenter cut the block above at
136
+ the quote and handed `meters'` to the provider as a sentence of its own,
137
+ and Google translated it -- `jq '.meters'` came back as `jq '.metros'`,
138
+ inside the quoted filter. Wrap a block you don't want touched in
139
+ `class="notranslate"` instead, for protection finer than an element, or for
140
+ an element outside `config.opaque_elements`; the providers that honour it
141
+ (see [Providers](providers.md)) leave it exactly as written.
142
+
143
+ **Upgrading:** widening what counts as markup changes what gets sent to the
144
+ provider, so it changes cache keys for any document containing a `pre` or
145
+ `code` element -- see [Caching](caching.md#what-a-cache-key-is-made-of).
@@ -0,0 +1,96 @@
1
+ # Instrumentation and logging
2
+
3
+ `config.instrumenter` accepts anything satisfying
4
+ `ActiveSupport::Notifications`' interface -- `#instrument(name, payload) { }` --
5
+ and `config.logger` accepts a standard `Logger`. Neither is required: with
6
+ both unset, `TranslationDiff.translate` runs exactly the same, at no extra
7
+ cost.
8
+
9
+ A translation emits up to six events, each named `<name>.translation_diff`:
10
+
11
+ | Event | Fired | Payload |
12
+ | --- | --- | --- |
13
+ | `translate` | Once per `translate` call that has something to translate, wrapping the whole thing -- including a call served entirely from cache, which never reaches the provider. A call whose source and target languages are the same, or whose values hold no translatable text at all, returns early and emits no events. | `call_id`, `from`, `to`, `provider`, `values` (number of texts), `characters` (total considered by this call) |
14
+ | `cache` | Once per such call, after checking the cache for every sentence at once -- whether or not anything is left to send the provider. | `call_id`, `provider`, `hits`, `misses` |
15
+ | `request` | Once per batch actually sent to the provider (skipped entirely on a full cache hit). | `call_id`, `provider`, `batch` (values sent), `characters` (sent by this batch) |
16
+ | `rate_limit` | Once per batch sent to the provider, only when a rate limiter is configured. | `call_id`, `provider`, `characters` |
17
+ | `usage` | Once per batch actually sent to the provider, right after `request`. | `call_id`, `provider`, `characters`, `billed_characters`, `reported`, `model` |
18
+ | `cache_error` | Only when writing the translation back to the cache fails -- after the provider has already answered. Never fires on a successful write, so it is not part of every call the way the other five are. | `call_id`, `provider`, `error` (the failed write's error class, as a string) |
19
+
20
+ **Every event above carries `call_id`.** It is generated once per
21
+ `translate` call, opaque, and never derived from the text. Before it, a
22
+ subscriber receiving `cache`, `request`, `rate_limit`, `usage` or
23
+ `cache_error` events had no way to tell which `translate` call any of them
24
+ belonged to, short of tagging `Thread.current` itself -- a workaround that
25
+ breaks the moment two translations share a thread.
26
+
27
+ **`translate`'s `characters` and `request`'s `characters` measure different
28
+ things.** `translate`'s is the total this call considered -- every
29
+ non-blank sentence, hit or miss, whether or not any of it was sent to the
30
+ provider -- so a call served entirely from cache still reports a number
31
+ instead of nothing, even though no `request` event fires for it at all.
32
+ `request`'s keeps its narrower meaning: what this one batch actually sent.
33
+ `usage`'s `characters` carries that same narrower meaning too, per batch,
34
+ like `request`'s. Same name, three events, two meanings -- a subscriber
35
+ summing the wrong one gets a wrong bill.
36
+
37
+ `cache_error` is what a failing cache write looks like from the outside:
38
+ the write itself is rescued, not the translation, which still reaches the
39
+ caller -- see
40
+ [The three write paths fail differently](caching.md#the-three-write-paths-fail-differently).
41
+ `error` is the exception's class name, never its message, which could echo
42
+ the row it failed to write. Which class you see depends on the store: a
43
+ store that redacts its own failures reports that redaction, so
44
+ `Stores::ActiveRecord` always gives `"TranslationDiff::Error"` -- the
45
+ adapter's own class is named inside that error's (content-free) message,
46
+ not in this payload. `Stores::Redis` does not wrap, so it gives the
47
+ driver's class, `"Redis::CannotConnectError"` and the like. Alert on the
48
+ event, not on a particular class name.
49
+
50
+ The same failure is logged at **warn**, not debug: an application whose
51
+ cache has quietly stopped accepting writes pays the provider for every
52
+ sentence, every time, and a signal only visible at debug level is one
53
+ nobody sees in production.
54
+
55
+ `usage`'s `characters` is what this library sent, counted locally -- the same
56
+ number `request` carries. `billed_characters` is what the provider said it
57
+ charged for, or `nil` when it said nothing. **`reported` means the provider
58
+ reports billing at all -- not that this particular response was billed.**
59
+ `billed_characters: nil` alone cannot tell "this provider never says" apart
60
+ from "this response omitted it"; `reported` is what makes the `nil` honest.
61
+ Summing `billed_characters` across providers without checking `reported`
62
+ first produces a total that is quietly too low, since only three of the six
63
+ built-in providers (DeepL, Azure, ModernMT) report billing at all -- the
64
+ other three always answer `nil`. `model` is the model the provider used,
65
+ when it names one, and `nil` otherwise.
66
+
67
+ **`cache` fires once per call as of translation_diff 1.0.0, not once per chunk.** The cache is
68
+ now consulted for every sentence in one `read_multi` before anything is
69
+ batched, so there is one event where there used to be one per chunk. `hits`
70
+ and `misses` still sum to the same totals over a call, so a counter that adds
71
+ them up is unaffected; a counter of *events*, or a histogram of per-chunk hit
72
+ ratios, will see the cardinality drop.
73
+
74
+ **Instrumentation payloads never contain the text being translated, its
75
+ translation, or a credential.** This is a guarantee, not an implementation
76
+ detail: this library handles other people's content, and an instrumenter
77
+ usually writes somewhere that content must not go. Only counts, language
78
+ codes and provider names cross that boundary.
79
+
80
+ `config.logger` receives one `debug` line per provider resolution, naming
81
+ the provider class in use -- nothing about the content being translated. The
82
+ same guarantee applies to it as to instrumentation payloads: no log line this
83
+ library writes carries the text being translated, its translation, or a
84
+ credential.
85
+
86
+ **No HTTP-backed provider ever receives `config.logger`, and there is no way
87
+ to opt one in.** `TranslationDiff::HTTPProvider` installs no logging
88
+ middleware on its Faraday connection and never passes a logger to it -- this
89
+ is enforced by `test/support/http_provider_contract.rb`, not merely
90
+ documented. Earlier versions wrapped `deepl-rb`, which logged a
91
+ `Request details:` line at DEBUG holding the full
92
+ `Authorization: DeepL-Auth-Key ...` header and the request payload -- your
93
+ API key and the text being translated -- if you gave it a logger of its own.
94
+ Owning the transport directly closed that door rather than working around
95
+ it: nothing this library builds writes source text, a translation, or a
96
+ credential anywhere, and no configuration option reopens that.
data/docs/languages.md ADDED
@@ -0,0 +1,94 @@
1
+ # Languages
2
+
3
+ `TranslationDiff::Languages` answers, without making a request, whether a
4
+ provider translates a given source into a given target. `Translator` calls it
5
+ before every translation and refuses a pair it says no to.
6
+
7
+ ```ruby
8
+ TranslationDiff::Languages.supports?(:deepl, from: "en", to: "ru") # => true
9
+ TranslationDiff::Languages.supports?(:amazon, from: "en", to: "ru") # => nil
10
+ ```
11
+
12
+ Three answers, not two: `true`, `false`, and `nil` for "no opinion". `nil` is
13
+ not a refusal -- a provider this registry knows nothing about is never
14
+ blocked from translating anything.
15
+
16
+ ## What ships
17
+
18
+ One JSON file per provider under `data/languages/`, each naming the date it
19
+ was captured and the endpoint it came from:
20
+
21
+ ```json
22
+ {
23
+ "provider": "deepl",
24
+ "captured_at": "2026-09-10",
25
+ "endpoint": "https://api.deepl.com/v2/languages",
26
+ "source": ["ar", "bg", "cs", "..."],
27
+ "target": ["ar", "bg", "cs", "en-gb", "en-us", "..."]
28
+ }
29
+ ```
30
+
31
+ `captured_at` is not decoration: it is the difference between "these are the
32
+ languages" and "these were the languages on 10 September 2026", and only the
33
+ second is true.
34
+
35
+ Data ships for **DeepL, Google, Azure and ModernMT** only. **Amazon** ships
36
+ none -- its language list needs signed credentials, so nothing can be fetched
37
+ without them. **LibreTranslate** ships none either, for a different reason:
38
+ it is self-hosted, so the language set belongs to whichever instance you
39
+ point this gem at, not to a vendor this gem can capture once and ship.
40
+
41
+ A provider with no shipped data -- Amazon, LibreTranslate, or any provider of
42
+ your own -- refuses nothing. `Languages.supports?` returns `nil` for it,
43
+ every time, and `Translator` treats `nil` the same as `true`.
44
+
45
+ ## Matching a code
46
+
47
+ A code is matched downcased, on its primary subtag, so `en-GB` and `en`
48
+ match each other in both directions.
49
+
50
+ This is deliberately permissive. The registry exists to catch a wrong
51
+ language -- `to: "klingon"`, a swapped pair, a typo -- not to adjudicate a
52
+ wrong regional variant. `Languages.supports?(:azure, from: "en", to: "zh")`
53
+ answers `true`, even though Azure itself wants `zh-Hans` or `zh-Hant` as a
54
+ target and would reject a bare `zh`. The rule can over-allow; it must never
55
+ wrongly refuse a pair the provider would actually have accepted.
56
+
57
+ One more rule, for a mismatch the vendors themselves create. ModernMT
58
+ publishes ISO 639-3 individual codes where applications write the ISO 639-1
59
+ macrolanguage: it lists `pes`, not `fa`, and `uzn`, not `uz`. A vendor that
60
+ translates the individual language translates the macrolanguage it belongs
61
+ to, so `to: "fa"` is accepted against a list carrying `pes`.
62
+
63
+ That holds in one direction only. A vendor publishing `zh` has not promised
64
+ to accept `cmn`, so `Languages.supports?(:deepl, from: "en", to: "cmn")`
65
+ answers `false` -- DeepL would reject the code it was sent.
66
+
67
+ ## The two escapes
68
+
69
+ Shipped data goes stale, and a vendor adding a language must not make this
70
+ gem refuse work that would now succeed. Two escapes exist for that:
71
+
72
+ ```ruby
73
+ # Per call
74
+ TranslationDiff.translate(text, from: "en", to: "yue", assume_supported: true)
75
+
76
+ # Globally
77
+ TranslationDiff.configure { |config| config.validate_languages = false }
78
+ ```
79
+
80
+ `assume_supported:` is a reserved keyword, like `provider:` and `config:` --
81
+ it is read by `Translator` and never forwarded to the provider itself.
82
+
83
+ ## `rake languages:refresh`
84
+
85
+ A maintainer's tool, not something an application runs: it re-fetches every
86
+ provider's language lists from its vendor and rewrites the shipped files,
87
+ which is why it needs each vendor's own credentials configured to do
88
+ anything.
89
+
90
+ A provider whose fetch fails keeps its previous file -- an emptied list from
91
+ a timed-out request would be worse than a stale one -- and the task reports
92
+ which providers failed and why, rather than failing silently. Its output is
93
+ meant to be reviewed as a diff, the way any change to shipped data should be,
94
+ before it is committed.