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,205 @@
1
+ # Configuration
2
+
3
+ Everything here is configured through the one `Configuration` object yielded by `TranslationDiff.configure`.
4
+
5
+ ## Dependencies
6
+
7
+ This gem loads four at require time: [`ox`](https://github.com/ohler55/ox) to
8
+ walk the HTML; [`pragmatic_segmenter`](https://github.com/diasks2/pragmatic_segmenter),
9
+ which backs the default sentence segmenter and has zero dependencies of its
10
+ own (see [The segmenter contract](contracts.md#the-segmenter-contract)
11
+ below if you want to avoid it); and [`faraday`](https://github.com/lostisland/faraday)
12
+ with [`faraday-retry`](https://github.com/lostisland/faraday-retry), the HTTP
13
+ transport every REST-backed provider (DeepL, Google, Azure, ModernMT,
14
+ LibreTranslate) inherits and owns directly -- none of them wraps a
15
+ vendor-supplied SDK any more.
16
+
17
+ Everything else is duck typed and supplied by you: `aws-sigv4`, required
18
+ lazily the first time the Amazon provider signs a request, with a clear
19
+ error if it is missing. The same is true of `redis` and `connection_pool`
20
+ once you configure `redis_url`, and of `ratelimit` on the first check once
21
+ you configure `rate_limit`. `redis-namespace` (for the Redis cache store)
22
+ goes one step further: this gem never requires it at all, so your
23
+ application must `require` it itself before using the Redis-backed store.
24
+ See [Getting started](#getting-started) below.
25
+
26
+ ## Getting started
27
+
28
+ `deepl_api_key` is required -- the provider checks for it at build time and
29
+ raises `TranslationDiff::ConfigurationError` naming what is missing, rather
30
+ than failing on the first real request. Leave it unset and `DEEPL_AUTH_KEY`
31
+ is read instead, on use rather than at load, so the variable may be exported
32
+ after this gem is required. `redis_url` is optional: without it
33
+ the cache lives in the process, which means the library runs before any
34
+ infrastructure does.
35
+
36
+ Every extension point takes **either a symbol naming a built-in, or an object
37
+ of your own**:
38
+
39
+ ```ruby
40
+ TranslationDiff.configure do |config|
41
+ config.provider = :deepl # or any TranslationDiff::Provider of your own -- see Providers
42
+ config.cache = :redis # or any object satisfying the cache store contract
43
+ config.segmenter = :pragmatic # or any object satisfying the segmenter contract
44
+ end
45
+ ```
46
+
47
+ ## Configuration options
48
+
49
+ Every setting lives on the one `Configuration` object yielded by
50
+ `TranslationDiff.configure`. An option not assigned falls back to its
51
+ default; assigning it a blank string behaves as though it was never touched
52
+ at all, so an unset environment variable never has to be special-cased.
53
+
54
+ | Option | Default | Meaning |
55
+ | --- | --- | --- |
56
+ | `provider` | `:deepl` | The translation provider: a registered name or a `TranslationDiff::Provider` of your own. See [Providers](providers.md). |
57
+ | `cache` | `nil` | The cache store: a registered name or an object satisfying the [cache store contract](caching.md#the-cache-store-contract). `nil` means "choose for me" -- see below. |
58
+ | `cache_ttl` | `604_800` (one week) | Seconds an entry is kept before it expires. Read by `Stores::Redis` (a `SETEX`) and by `Stores::ActiveRecord` (written into each row's `expires_at`); `Stores::Memory` evicts by size instead and ignores it. A non-positive value (`0` or less, or `nil`) means never expires. A String is coerced, so an environment variable works; a value that is not a number is refused at `configure` time rather than mid-translation. See [SQL cache](sql-cache.md#cache_ttl-becomes-expires_at). |
59
+ | `cache_namespace` | `"translation-diff"` | Prefix applied to every Redis key this gem writes -- both cache entries and the rate limiter's own bookkeeping. Also the `namespace` column both SQL tables share and the unit `Stores::ActiveRecord#prune` operates on. At most 64 characters -- longer is refused at `configure` time. See [SQL cache](sql-cache.md#the-tables). |
60
+ | `cache_max_size` | `1_000` | Maximum number of entries `Stores::Memory` keeps before evicting the least recently used one. |
61
+ | `cache_table_name` | `"translation_diff_translations"` | Table `Stores::ActiveRecord` reads and writes. For a host with its own table-naming convention. See [SQL cache](sql-cache.md). |
62
+ | `rate_limit_table_name` | `"translation_diff_rate_limits"` | Table `RateLimiters::ActiveRecord` reads and writes. As above. |
63
+ | `active_record_base` | `nil` (`::ActiveRecord::Base`) | The class `Stores::ActiveRecord` and `RateLimiters::ActiveRecord` build their model from -- point this at a second database. It does not exempt this store from a Rails application's own read-replica routing; see [Rails replica routing](sql-cache.md#rails-replica-routing). See [SQL cache](sql-cache.md#active_record_base-a-second-database). |
64
+ | `cache_prune_probability` | `0.0` | Chance, per write, that `Stores::ActiveRecord` prunes expired rows before returning. `0.0` is off, and a value outside `0.0..1.0` is refused at `configure` time; `rake translation_diff:prune` is the other way to prune. See [SQL cache](sql-cache.md#pruning-three-answers-none-imposed). |
65
+ | `redis_url` | `ENV["REDIS_URL"]` | Where to connect for the Redis-backed cache store and rate limiter. Setting this is what makes `cache` default to `:redis` instead of `:memory`. |
66
+ | `redis_pool_size` | `5` | Size of the connection pool built from `redis_url`. |
67
+ | `redis_pool_timeout` | `5` | Seconds to wait for a connection from that pool before raising. |
68
+ | `rate_limit` | `nil` | Character threshold per `rate_interval`. Unset with `rate_limiter` also unset means no rate limiting at all. Unset with `rate_limiter` set turns rate limiting on anyway, at that limiter's own default threshold -- 8,000 characters per `rate_interval`, the same default for both shipped limiters -- rather than the threshold you never set. |
69
+ | `rate_interval` | `60` | Seconds over which `rate_limit` (or a limiter's own default threshold) is measured. **Actually enforced over roughly 5-600 seconds** -- see [The rate limiter contract](contracts.md#the-rate-limiter-contract). |
70
+ | `rate_limiter` | `nil` | A registered name (`:redis`, `:active_record`) or an object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract). `nil` with `rate_limit` also `nil` means no rate limiting; `nil` with `rate_limit` set resolves to `:redis`. Setting `rate_limiter` alone -- with `rate_limit` left unset -- is enough to turn rate limiting on, at the limiter's own default threshold; it no longer needs `rate_limit` set to avoid crashing. |
71
+ | `segmenter` | `:pragmatic` | The sentence segmenter: a registered name or an object satisfying the [segmenter contract](contracts.md#the-segmenter-contract). |
72
+ | `opaque_elements` | `%i[script style pre code]` | Element names `TranslationDiff::Passage` never treats as prose, whatever they contain, matched case-insensitively so `<PRE>` and `<STYLE>` count too. Read fresh on every passage rather than memoised, so a runtime change applies immediately, to the next translation -- and, since a call reads this from the configuration it is actually using, a context's own `opaque_elements` is honoured too, not the global value it was copied from. See [How it works](how-it-works.md#html). |
73
+ | `instrumenter` | `nil` | Anything satisfying `ActiveSupport::Notifications`' `#instrument(name, payload) { }` interface. See [Instrumentation and logging](instrumentation.md). |
74
+ | `logger` | `nil` | A standard `Logger` -- anything answering to `debug` and `warn` with a block. Receives one `debug` line per provider resolution, naming the provider class, and a `warn` line when a cache write fails; never content and never a credential. Note that `warn` must be a public method: a bare object inherits a private `Kernel#warn` and would raise instead of logging. See [Instrumentation and logging](instrumentation.md). |
75
+ | `open_timeout` | `5` | Seconds an HTTP-backed provider waits to open a connection before raising `TranslationDiff::TransportError`. |
76
+ | `timeout` | `30` | Seconds an HTTP-backed provider waits for a response before raising `TranslationDiff::TransportError`. |
77
+ | `max_retries` | `3` | Retries `faraday-retry` attempts on a transport failure or a `429`/`500`/`502`/`503`/`504` response, with exponential backoff. `faraday-retry` honours a `Retry-After` header itself, so a `429` usually exhausts its retries before `TranslationDiff::RateLimitError` is ever raised. |
78
+ | `validate_languages` | `true` | Whether `translate` refuses a source/target pair the shipped data doesn't list, before making a request. See [Languages](languages.md). |
79
+
80
+ ## Provider options
81
+
82
+ Every provider declares its own configuration options, registered the moment
83
+ `translation_diff` is required:
84
+
85
+ | Provider | Options | Meaning |
86
+ | --- | --- | --- |
87
+ | `:deepl` | `deepl_api_key` (required) | Sent as `DeepL-Auth-Key`. Falls back to `ENV["DEEPL_AUTH_KEY"]`. |
88
+ | | `deepl_api_base` | Overrides the automatic free/paid host selection (from the `:fx` suffix on the key). Rarely needed. |
89
+ | `:google` | `google_api_key` (required) | Sent as the `key` query parameter. Falls back to `ENV["TRANSLATE_KEY"]`, then `ENV["GOOGLE_CLOUD_KEY"]`. |
90
+ | | `google_project_id` | Declared for a future credentials path; not currently read -- an API key needs no project. Falls back to `ENV["TRANSLATE_PROJECT"]`. |
91
+ | | `google_api_base` | Overrides the default `https://translation.googleapis.com`. |
92
+ | `:azure` | `azure_api_key` (required) | Sent as `Ocp-Apim-Subscription-Key`. |
93
+ | | `azure_region` | Sent as `Ocp-Apim-Subscription-Region`. Required by a multi-service Azure resource; a single-service resource needs no region. |
94
+ | | `azure_api_base` | Overrides the default `https://api.cognitive.microsofttranslator.com`. |
95
+ | `:modernmt` | `modernmt_api_key` (required) | Sent as `MMT-ApiKey`. |
96
+ | | `modernmt_api_base` | Overrides the default `https://api.modernmt.com`. |
97
+ | `:libretranslate` | `libretranslate_api_base` (required) | Every instance is self-hosted; there is no default to fall back to. |
98
+ | | `libretranslate_api_key` | Sent as `api_key` in the request body. Most instances do not require one. |
99
+ | `:amazon` | `amazon_access_key_id`, `amazon_secret_access_key`, `amazon_region` (all required) | Used to sign each request with `aws-sigv4`. No environment fallback: this library does not implement the AWS credential chain, so `AWS_ACCESS_KEY_ID` and friends are not read. |
100
+ | | `amazon_session_token` | For temporary credentials. |
101
+ | | `amazon_api_base` | Overrides the default `https://translate.<amazon_region>.amazonaws.com`. |
102
+
103
+ A provider you register yourself can declare its own options the same way --
104
+ see [Writing a provider](providers.md#writing-a-provider) below.
105
+
106
+ ## Changing configuration at runtime
107
+
108
+ `provider`, `cache`, `segmenter` and `rate_limiter` each resolve to a
109
+ collaborator on first use, and that collaborator is memoised. Writing an
110
+ option afterwards rebuilds only the memoised collaborator(s) that option
111
+ actually feeds, not the whole configuration:
112
+
113
+ - `provider`, any option a provider declares for itself (`deepl_api_key`
114
+ and the like), and the timeouts (`open_timeout`, `timeout`,
115
+ `max_retries`) rebuild the provider. An HTTP-backed provider memoises a
116
+ Faraday connection built from the three timeouts, so a change to any of
117
+ them has to reach the provider or it never reaches the connection.
118
+ - The cache options (`cache`, `cache_ttl`, `cache_max_size`,
119
+ `cache_table_name`, `active_record_base`, `cache_prune_probability`,
120
+ `cache_namespace`) rebuild the cache store.
121
+ - `redis_url`, `redis_pool_size` and `redis_pool_timeout` rebuild the
122
+ connection pool and everything holding it -- the cache store and the rate
123
+ limiter both.
124
+ - The rate options (`rate_limit`, `rate_interval`, `rate_limiter`,
125
+ `rate_limit_table_name`), `cache_namespace` and `active_record_base`
126
+ rebuild the rate limiter.
127
+ - `segmenter` rebuilds the segmenter.
128
+ - `logger` and `instrumenter` rebuild nothing -- both are read live, on
129
+ every use, and nothing memoised reads either one.
130
+
131
+ Before this, nothing was ever rebuilt: an application wanting to switch
132
+ `provider` at runtime had no way to do it short of `TranslationDiff.reset!`
133
+ and reconfiguring from scratch, which also threw away a Redis pool, and
134
+ everything built from it, that had no reason to go.
135
+
136
+ Two options carry further than their names suggest, and both rebuild the
137
+ rate limiter as well as the store. `cache_namespace` names the limiter's own
138
+ bookkeeping namespace, so changing it at runtime moves the limiter too --
139
+ it counts under the new namespace from the next check on, rather than
140
+ continuing silently under the old one. `active_record_base` is the class the
141
+ SQL-backed limiter builds its model from just as the SQL-backed store does,
142
+ so pointing it at another database moves both.
143
+
144
+ **A write that leaves an option at the value it already holds rebuilds
145
+ nothing.** Only a value that actually changes invalidates a memoised
146
+ collaborator, even one the option is declared to invalidate. This is what
147
+ makes a per-request `TranslationDiff.configure { |c| c.cache_namespace =
148
+ current_tenant }` safe: writing the same tenant on every request no longer
149
+ rebuilds the cache store on every request. Writing a genuinely *different*
150
+ value still rebuilds the store exactly as before, though, and if that store
151
+ is the default `Stores::Memory`, a rebuilt store is a fresh, empty Hash --
152
+ its contents are gone, and whatever it held has to be paid for again at the
153
+ provider.
154
+
155
+ `TranslationDiff.context` -- or `config.copy`, which it is built on -- is
156
+ still the way to get a configuration that resolves everything afresh from
157
+ its own values, independently of whatever the configuration it was copied
158
+ from has already built.
159
+
160
+ ## Choosing the cache store
161
+
162
+ `cache` unset means "choose for me": `Stores::Redis` when `redis_url` is
163
+ configured, `Stores::Memory` otherwise, so the library works before any
164
+ infrastructure does. Set `cache` explicitly (`:redis`, `:memory`, or your own
165
+ object) to override that choice.
166
+
167
+ ## Contexts
168
+
169
+ `TranslationDiff.context` returns a `TranslationDiff::Context`: an isolated
170
+ configuration scope with the same `#translate` and `#preview` entry points
171
+ as the `TranslationDiff` module itself, for multi-tenant applications and
172
+ per-request overrides. It starts from a copy of the global configuration, so
173
+ it inherits every value already set, and changes made inside it never touch
174
+ the global configuration:
175
+
176
+ ```ruby
177
+ tenant = TranslationDiff.context { |config| config.deepl_api_key = tenant_key }
178
+ tenant.translate("Hello.", from: "en", to: "ru")
179
+
180
+ TranslationDiff.config.deepl_api_key # unchanged
181
+ ```
182
+
183
+ **The block is required.** `TranslationDiff.context` yields the copy for you
184
+ to configure and there is nothing useful to hand back without one -- a copy
185
+ nobody configured is just the global configuration. Called without a block it
186
+ raises `LocalJumpError`.
187
+
188
+ **A context starts with a cold cache.** Copying a configuration carries its
189
+ option *values* over, but deliberately not the collaborators already built
190
+ from them -- each context resolves its own provider, cache store, segmenter
191
+ and rate limiter from its own values, independently of whatever the
192
+ configuration it was copied from had already built. When `cache` is left
193
+ unset, that resolves to `Stores::Memory`, an in-process store, so a freshly
194
+ built context's store starts empty every time -- a short-lived, per-request
195
+ context therefore caches nothing across requests. Configure `redis_url` (or
196
+ assign one shared cache object explicitly) if contexts need to share a
197
+ cache.
198
+
199
+ **An object assigned directly is shared; a name is not.** Assign
200
+ `config.cache`, `config.provider`, `config.segmenter` or `config.rate_limiter`
201
+ a symbol and every context builds its own instance from it. Assign an
202
+ *object* instead and that exact object -- the same connection pool, the same
203
+ client -- is carried into every context copied from that configuration:
204
+ someone who hands this library one connection pool means one connection
205
+ pool.
data/docs/contracts.md ADDED
@@ -0,0 +1,187 @@
1
+ # Contracts
2
+
3
+ ## The rate limiter contract
4
+
5
+ Like `provider`, `cache` and `segmenter`, `rate_limiter` resolves a symbol
6
+ through its own registry, `TranslationDiff::RateLimiters` -- `:redis` and
7
+ `:active_record` are registered there. `config.rate_limiter_instance` is:
8
+
9
+ - the object assigned to `config.rate_limiter`, if any -- an object still
10
+ bypasses the registry entirely, the same way it does for `cache`;
11
+ - otherwise `nil` if both `rate_limiter` and `rate_limit` were never set --
12
+ and `Dispatcher#throttle` checks for that `nil` and skips rate limiting
13
+ entirely, so the common case costs nothing;
14
+ - otherwise the registered limiter named by `config.rate_limiter`, or
15
+ `TranslationDiff::RateLimiters::Redis` when `rate_limiter` is left unset but
16
+ `rate_limit` is set -- built from `rate_interval`, `cache_namespace`, and
17
+ either `redis_url` (`:redis`) or `active_record_base` and
18
+ `rate_limit_table_name` (`:active_record`; see [SQL cache](sql-cache.md)).
19
+ `rate_limit` supplies the threshold when it is set; left unset, the
20
+ limiter falls back to its own default -- 8,000 characters per
21
+ `rate_interval` for both shipped limiters -- instead of crashing, so
22
+ setting `rate_limiter` alone is enough to turn a limiter on.
23
+
24
+ An object assigned to `rate_limiter` must implement:
25
+
26
+ ```ruby
27
+ # Called with the number of characters about to be sent to the provider.
28
+ # Raises when the caller-defined threshold is exceeded.
29
+ def check(size); end
30
+ ```
31
+
32
+ Both shipped limiters raise `TranslationDiff::RateLimitExceeded` when the
33
+ threshold is exceeded within the interval -- one class whichever limiter is
34
+ configured, so switching from `:redis` to `:active_record` does not quietly
35
+ stop a `rescue` from matching. They raise with a
36
+ message naming the namespace, the threshold and the interval that were hit
37
+ (`"rate limit reached for translation-diff: 8000 characters per 60
38
+ seconds"`) -- never the text that tripped it. Neither `redis`
39
+ nor `connection_pool` nor `ratelimit` is a dependency of this gem:
40
+ `ratelimit` is required on the first check, so an application that
41
+ configures no `rate_limit` never needs it, and its absence raises
42
+ `TranslationDiff::Error` naming the gem to add. `activerecord` is never a
43
+ dependency either -- see [SQL cache](sql-cache.md#the-activerecord-version-floor).
44
+
45
+ **Upgrading to translation_diff 1.0.0: re-validate your `rate_limit` threshold.** Before this
46
+ release, `RateLimiters::Redis` never actually limited anything -- a signature
47
+ mismatch with the `ratelimit` gem meant it recorded hits under a subject
48
+ `exceeded?` never read, so the threshold could never be reached. That bug
49
+ shipped in every release since `v1.0.2` (2023-02-16). If you have
50
+ `rate_limit` configured, your traffic has never actually been throttled by
51
+ it; upgrading makes the limiter fire for the first time, against a value you
52
+ may have set once and never seen exercised. Re-check that the threshold
53
+ still reflects the traffic you actually want to allow before you upgrade.
54
+
55
+ **`rate_interval` is silently clamped to roughly 5-600 seconds.** The
56
+ built-in limiter constructs `Ratelimit.new` with no bucket options, so the
57
+ gem's own fixed bucket span applies regardless of what you configure --
58
+ measured: `rate_interval: 3600` behaves as `600`, and `rate_interval: 1`
59
+ behaves as `5`. Combined with the fix above, an interval configured above
60
+ 600 seconds is now enforced over 600 seconds instead, which trips the
61
+ limiter up to six times more eagerly than the configured value suggests.
62
+ Keep `rate_interval` within 5-600 seconds if you want the configured number
63
+ to be the enforced one.
64
+
65
+ Both the clamp above and the upgrade note before it are about
66
+ `RateLimiters::Redis`, which delegates its bucketing to the `ratelimit` gem.
67
+ `RateLimiters::ActiveRecord` owns its own bucketing instead, and its window is
68
+ sliding rather than tumbling: buckets are `rate_interval / 12` seconds wide
69
+ (floored at 1 second), and a check sums every bucket touching the trailing
70
+ `rate_interval` seconds -- including the oldest one, which is only ever
71
+ partially inside that window, summed in full rather than pro-rated. So the
72
+ window actually enforced is `rate_interval` to `rate_interval +
73
+ rate_interval / 12` seconds: slightly stricter than configured, never
74
+ looser, and with no external clamp. See
75
+ [SQL cache](sql-cache.md#the-rate-limiter).
76
+
77
+ ## The segmenter contract
78
+
79
+ `config.segmenter` decides where a text node is cut into sentence-sized
80
+ cache units, the same way `config.provider` decides how a sentence gets
81
+ translated. It defaults to `:pragmatic` and can be swapped for `:simple` or
82
+ for any object implementing:
83
+
84
+ ```ruby
85
+ # Returns the offsets at which a new sentence begins, always starting with 0
86
+ # and strictly increasing. Slicing the source between consecutive offsets, and
87
+ # from the last offset to the end, reconstructs the source exactly -- a wrong
88
+ # boundary never corrupts the document, it only changes how the text is
89
+ # grouped into cache units.
90
+ #
91
+ # language: is an ISO 639-1 code such as "en" or "ru" when the caller already
92
+ # knows the source language, and nil when it does not -- see below.
93
+ def split_offsets(text, language: nil); end
94
+ ```
95
+
96
+ Two segmenters ship with this gem:
97
+
98
+ - **`TranslationDiff::Segmenters::Pragmatic`** (the default) wraps the
99
+ [`pragmatic_segmenter`](https://github.com/diasks2/pragmatic_segmenter) gem,
100
+ which ships per-language rule sets rather than one rule set applied to every
101
+ script. Measured against the Golden Rules corpus, the de-facto benchmark for
102
+ sentence segmentation -- the `context "Golden Rules" do` block of each of
103
+ the 10 per-language spec files on `diasks2/pragmatic_segmenter`, 80
104
+ exemplars in total; a sample of the same corpus is in
105
+ `test/translation_diff/golden_rules_test.rb` -- it scores 76/80 against
106
+ `Simple`'s 47/80, and the gap is largest on languages that have no letter
107
+ case at all -- Arabic, Hindi, Armenian, Greek -- which `Simple` cannot
108
+ reason about by design.
109
+
110
+ Of the 4 exemplars `Pragmatic` misses, 3 are not boundary disagreements at
111
+ all: `pragmatic_segmenter`'s own expected value rewrites an incidental
112
+ newline into a space before comparing --
113
+
114
+ "This is a sentence\ncut off in the middle because pdf."
115
+ expected ["This is a sentence cut off in the middle because pdf."]
116
+ ours ["This is a sentence\ncut off in the middle because pdf."]
117
+
118
+ "It was a cold \nnight in the city."
119
+ expected ["It was a cold night in the city."]
120
+ ours ["It was a cold \nnight in the city."]
121
+
122
+ -- and the same shape recurs once in Japanese (`"これは父の\n家です。"`, expected
123
+ with the newline gone). In all three, `Pragmatic` finds exactly one
124
+ sentence, agrees on where it ends, and is scored wrong only because it
125
+ will not rewrite the source to match. Rewriting the source is exactly what
126
+ this gem's reconstruction invariant forbids, so this is a deliberate
127
+ choice, not a defect the score is hiding. The 1 remaining miss is a real
128
+ boundary disagreement, in English -- see the shadowing paragraph below.
129
+
130
+ Before segmenting, `Pragmatic` replaces every single newline (one with no
131
+ adjoining newline) with a space in a shadow copy of the text, segments the
132
+ shadow, and slices the *original* text at the recovered offsets --
133
+ `pragmatic_segmenter` otherwise treats almost any single newline as a
134
+ sentence boundary candidate even with no punctuation at all, which is a
135
+ false split (the harmful kind) on the incidental newlines that HTML text
136
+ nodes routinely carry from source formatting. A run of two or more
137
+ newlines (a real paragraph break) is left alone. This costs one Golden
138
+ Rules point (77 -> 76): one exemplar shaped like a bare list of items
139
+ separated by single newlines, with no punctuation, now segments as one
140
+ unit instead of three. That shape does not arise in this gem's actual
141
+ input -- HTML list items are separated by markup into distinct text nodes
142
+ already -- so the point is a deliberate trade, not a regression to chase.
143
+
144
+ Language codes are normalised before reaching `pragmatic_segmenter`:
145
+ downcased, with any region subtag after `-` or `_` dropped, and checked
146
+ against the codes `pragmatic_segmenter` actually has rules for, falling
147
+ back to English otherwise. DeepL -- this gem's own flagship provider --
148
+ sends codes exactly like `"RU"` and `"EN-GB"`; without normalising,
149
+ `pragmatic_segmenter`'s own lookup is case-sensitive and region-blind, so
150
+ those would silently miss their rule set entirely.
151
+ - **`TranslationDiff::Segmenters::Simple`** is a zero-dependency, in-house
152
+ segmenter. It splits conservatively on punctuation followed by whitespace,
153
+ guarded by a handful of signals (a known abbreviation, an initial, digits on
154
+ both sides, a URL or email, or a lowercase letter immediately following --
155
+ the guard that gives it away as built for cased scripts). Reach for it if
156
+ you want no extra dependency and you only ever translate from languages
157
+ written in a cased script (Latin, Cyrillic, Greek's own script aside,
158
+ Armenian, and similar).
159
+
160
+ Passing `from:` to `::translate` does more than skip a detection call (see
161
+ [How it works](how-it-works.md) below): it is also the only way a segmenter sees
162
+ the source language. When `from:` is omitted, the language is genuinely
163
+ unknown at the time the text is segmented -- language detection needs the
164
+ segmented text to build its sample, so segmentation cannot wait for it -- and
165
+ `Pragmatic` falls back to English rules, which can mis-segment other
166
+ languages (Russian abbreviations, for one). `Simple` ignores the argument
167
+ entirely; its rules are language-neutral.
168
+
169
+ `pragmatic_segmenter`'s cleaner rewrites the sentences it hands back in ways
170
+ shadowing does not cover -- it collapses runs of three or more spaces, and it
171
+ respaces abbreviations like `"Ph.D."` into `"Ph. D."`, among other things --
172
+ so the sentence `Pragmatic` gets back does not always appear verbatim in the
173
+ source any more. `Pragmatic` never guesses at an offset it cannot verify: it
174
+ walks the returned sentences in order, keeps every offset it locates, and
175
+ stops at the first one it cannot -- but the boundary at the end of the last
176
+ sentence it did locate is not thrown away with the rest, since it was
177
+ matched character for character too. Only the genuinely unrecoverable
178
+ remainder is coarsened into one final unit; the verified prefix before it is
179
+ still sliced off. This is a *coarsening*, not a failure -- the text still
180
+ translates correctly, the cache unit is just larger than it could have
181
+ been -- and it is silent by design,
182
+ the same way a segmenter simply not splitting a node has always been
183
+ acceptable. `TranslationDiff::Segmenters::Pragmatic::Error` (a
184
+ `TranslationDiff::Error`) still exists and is still raised, but only if
185
+ `Pragmatic` itself computes offsets that violate its own postcondition
186
+ (starting at 0, strictly increasing, all within the text) -- not by ordinary
187
+ use of `pragmatic_segmenter`, however it rewrites a sentence.
@@ -0,0 +1,34 @@
1
+ # Former name, upgrading, and development
2
+
3
+ ## Former name and upgrading
4
+
5
+ This gem was published as `deepl_diff` through 2.2.0. `deepl_diff` is
6
+ deprecated in favor of `translation_diff`, which is functionally the same
7
+ gem under a name that no longer implies a dependency on DeepL specifically.
8
+
9
+ **Upgrading from `deepl_diff`:** every cache key changed in translation_diff 1.0.0 -- the
10
+ provider, the provider options and normalised language codes are now part of
11
+ the key. Nothing cached previously is reused; the next translation of every
12
+ sentence is a cache miss, once, everywhere. `TranslationDiff.api`, `.cache_store`,
13
+ `.segmenter` and `.rate_limiter` -- the four module-level accessors earlier
14
+ versions configured directly -- are gone; configure `TranslationDiff.config`
15
+ (or use `TranslationDiff.configure`) instead. If you have `rate_limit`
16
+ configured, also read the upgrading note in
17
+ [The rate limiter contract](contracts.md#the-rate-limiter-contract): the limiter was
18
+ never actually enforcing your threshold before translation_diff 1.0.0, and it starts doing so
19
+ now. See [CHANGELOG.md](../CHANGELOG.md) for the full list of breaking changes.
20
+
21
+ **If you registered a custom provider,** it must now subclass
22
+ `TranslationDiff::Provider` (or `TranslationDiff::HTTPProvider`), declare
23
+ `self.capabilities`, and implement `#translate(request)` taking a
24
+ `TranslationDiff::Translation::Request` and returning a
25
+ `TranslationDiff::Translation::Response` -- the duck-typed
26
+ `#translate(texts, from:, to:, **options)` plus `#max_request_size` and
27
+ `#max_batch_size` methods are no longer read at all. See [Writing a
28
+ provider](providers.md#writing-a-provider).
29
+
30
+ ## Development
31
+
32
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
33
+
34
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
data/docs/errors.md ADDED
@@ -0,0 +1,92 @@
1
+ # Errors
2
+
3
+ Every error this gem raises inherits from `TranslationDiff::Error < StandardError`,
4
+ so rescuing the gem's failures in one clause is a single `rescue TranslationDiff::Error`:
5
+
6
+ ```
7
+ TranslationDiff::Error
8
+ ├── TranslationDiff::ConfigurationError # a provider is missing a required option
9
+ ├── TranslationDiff::ProviderError # the service answered and said no
10
+ │ ├── AuthenticationError # 401/403
11
+ │ ├── RateLimitError # 429, once faraday-retry's own retries
12
+ │ │ # are exhausted -- carries #retry_after
13
+ │ │ # when the service sent one
14
+ │ ├── QuotaExceededError # 456
15
+ │ ├── InvalidRequestError # any other 4xx
16
+ │ └── ServiceError # 5xx, or anything else
17
+ ├── TranslationDiff::TransportError # nobody answered: connection failed,
18
+ │ # timed out, or TLS failed
19
+ ├── TranslationDiff::ResponseError # the answer was well-formed HTTP but broke
20
+ │ # this library's contract -- a body that
21
+ │ # is not JSON, a provider that returned
22
+ │ # the wrong number of translations, or one
23
+ │ # that returned no translation for an input
24
+ ├── TranslationDiff::InvalidProviderError # a class registered without inheriting
25
+ │ # TranslationDiff::Provider, or any
26
+ │ # provider -- registered or assigned --
27
+ │ # whose cache_key is blank
28
+ ├── TranslationDiff::UnsupportedLanguageError # the shipped language data doesn't list
29
+ │ # this source/target pair for this
30
+ │ # provider -- see docs/languages.md
31
+ ├── TranslationDiff::Translator::Error # from: missing and the provider cannot
32
+ │ # detect
33
+ ├── TranslationDiff::Previewer::Error # the same, asked of a preview: detecting
34
+ │ # a language is a paid request, and a
35
+ │ # preview never makes one
36
+ ├── TranslationDiff::SentenceCache::Error # provider options have no stable
37
+ │ # serialisation for the cache key
38
+ ├── TranslationDiff::Batch::Error # one sentence, once escaped, is larger
39
+ │ # than the provider's declared limit
40
+ ├── TranslationDiff::Segmenters::Pragmatic::Error
41
+ │ # Pragmatic computed offsets that
42
+ │ # violate its own postcondition --
43
+ │ # not raised by ordinary use
44
+ └── TranslationDiff::RateLimitExceeded # the configured rate_limit was exceeded --
45
+ # one class whichever limiter noticed, so
46
+ # switching `rate_limiter` between `:redis`
47
+ # and `:active_record` cannot quietly stop a
48
+ # rescue from matching
49
+ ```
50
+
51
+ `RateLimitExceeded` carries a message naming the namespace,
52
+ the threshold and the interval that were exceeded (`"rate limit reached for
53
+ translation-diff: 8000 characters per 60 seconds"`) -- never the text that
54
+ tripped it.
55
+
56
+ Both SQL-backed collaborators report a database failure the same way. A
57
+ cache write that the database refuses -- including under Rails'
58
+ `prevent_writes` (a read-replica request, see
59
+ [Rails replica routing](sql-cache.md#rails-replica-routing)) -- is rescued,
60
+ redacted and swallowed, and the translation is returned anyway. The rate
61
+ limiter's own write raises a redacted `TranslationDiff::Error` instead of
62
+ continuing, because it runs before the provider does and a limiter that
63
+ cannot count is not a limiter. Either way `rescue TranslationDiff::Error`
64
+ around `translate` catches what a caller can catch, and no raw
65
+ `ActiveRecord::ReadOnlyError` reaches it.
66
+
67
+ `ProviderError` and its subclasses carry `#provider` (the registered name)
68
+ and `#status` (the HTTP status code), so a caller can log or branch on which
69
+ service and which response caused the failure without parsing the message.
70
+
71
+ `TranslationDiff::Registry` -- which backs the provider, cache store and
72
+ segmenter registries -- raises `TranslationDiff::Error` directly (not a
73
+ dedicated subclass) for an unknown name, listing what is actually
74
+ registered. `TranslationDiff::Batch::Error` is its own class rather than a
75
+ direct `TranslationDiff::Error`, so a caller can catch "this sentence is too
76
+ long for this provider" without also catching an unrelated registry miss; it
77
+ is raised when one sentence is larger once escaped than the provider's
78
+ declared `max_request_size` or `max_text_size` and so could never be sent
79
+ even in a batch of its own. The message names a short prefix of the
80
+ offending text and both numbers.
81
+
82
+ `ArgumentError`, not a `TranslationDiff::Error`, is what
83
+ `TranslationDiff.translate`, `TranslationDiff.preview` and their `Context`
84
+ counterparts raise when `to:` is missing or `nil`. It is a caller's mistake
85
+ before it is a translation, and the message names the keyword.
86
+
87
+ **Renamed in translation_diff 1.0.0.** `TranslationDiff::Request::Error` is now
88
+ `TranslationDiff::Translator::Error` and `TranslationDiff::Cache::Error` is
89
+ now `TranslationDiff::SentenceCache::Error`; both classes they hung off are
90
+ gone. `TranslationDiff::Chunker::Error` is gone with no replacement -- the
91
+ condition it named now raises `TranslationDiff::Batch::Error`. A
92
+ `rescue TranslationDiff::Error` catches all three exactly as before.