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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +84 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +39 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +723 -0
- data/Gemfile +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +41 -0
- data/data/languages/azure.json +285 -0
- data/data/languages/deepl.json +220 -0
- data/data/languages/google.json +399 -0
- data/data/languages/modernmt.json +413 -0
- data/docs/caching.md +185 -0
- data/docs/configuration.md +205 -0
- data/docs/contracts.md +187 -0
- data/docs/development.md +34 -0
- data/docs/errors.md +92 -0
- data/docs/how-it-works.md +145 -0
- data/docs/instrumentation.md +96 -0
- data/docs/languages.md +94 -0
- data/docs/providers.md +379 -0
- data/docs/sql-cache.md +366 -0
- data/lib/generators/translation_diff/install_generator.rb +15 -0
- data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
- data/lib/translation_diff/active_record/support.rb +34 -0
- data/lib/translation_diff/active_record.rb +3 -0
- data/lib/translation_diff/batch.rb +98 -0
- data/lib/translation_diff/call_preparation.rb +47 -0
- data/lib/translation_diff/capabilities.rb +9 -0
- data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
- data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
- data/lib/translation_diff/configuration/option_table.rb +38 -0
- data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
- data/lib/translation_diff/configuration.rb +135 -0
- data/lib/translation_diff/context.rb +23 -0
- data/lib/translation_diff/dispatcher.rb +57 -0
- data/lib/translation_diff/document.rb +23 -0
- data/lib/translation_diff/errors.rb +44 -0
- data/lib/translation_diff/fragment.rb +33 -0
- data/lib/translation_diff/http_provider.rb +128 -0
- data/lib/translation_diff/instrumentation.rb +25 -0
- data/lib/translation_diff/languages/refresh.rb +70 -0
- data/lib/translation_diff/languages/set.rb +53 -0
- data/lib/translation_diff/languages.rb +30 -0
- data/lib/translation_diff/leaves.rb +22 -0
- data/lib/translation_diff/markup.rb +85 -0
- data/lib/translation_diff/passage.rb +149 -0
- data/lib/translation_diff/preview.rb +3 -0
- data/lib/translation_diff/previewer.rb +78 -0
- data/lib/translation_diff/provider.rb +91 -0
- data/lib/translation_diff/providers/amazon.rb +126 -0
- data/lib/translation_diff/providers/azure.rb +78 -0
- data/lib/translation_diff/providers/deepl.rb +88 -0
- data/lib/translation_diff/providers/google.rb +64 -0
- data/lib/translation_diff/providers/libretranslate.rb +65 -0
- data/lib/translation_diff/providers/modernmt.rb +74 -0
- data/lib/translation_diff/providers/null.rb +20 -0
- data/lib/translation_diff/providers.rb +69 -0
- data/lib/translation_diff/railtie.rb +12 -0
- data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
- data/lib/translation_diff/rate_limiters/redis.rb +59 -0
- data/lib/translation_diff/rate_limiters.rb +9 -0
- data/lib/translation_diff/redaction.rb +45 -0
- data/lib/translation_diff/registry.rb +31 -0
- data/lib/translation_diff/segment.rb +32 -0
- data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
- data/lib/translation_diff/segmenters/simple.rb +122 -0
- data/lib/translation_diff/segmenters.rb +4 -0
- data/lib/translation_diff/sentence_cache.rb +76 -0
- data/lib/translation_diff/stores/active_record.rb +106 -0
- data/lib/translation_diff/stores/memory.rb +34 -0
- data/lib/translation_diff/stores/redis.rb +49 -0
- data/lib/translation_diff/stores.rb +9 -0
- data/lib/translation_diff/tasks/translation_diff.rake +21 -0
- data/lib/translation_diff/translation/request.rb +8 -0
- data/lib/translation_diff/translation/response.rb +35 -0
- data/lib/translation_diff/translation/usage.rb +8 -0
- data/lib/translation_diff/translator.rb +103 -0
- data/lib/translation_diff/version.rb +3 -0
- data/lib/translation_diff.rb +93 -0
- data/translation_diff.gemspec +56 -0
- 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.
|
data/docs/development.md
ADDED
|
@@ -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.
|