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,30 @@
1
+ # What each provider translates, captured from the vendor and shipped; unknown means no opinion, never a refusal.
2
+ module TranslationDiff::Languages
3
+ DIRECTORY = File.expand_path("../../data/languages", __dir__).freeze
4
+ # Derived from the directory itself: shipping a new file makes it load without editing this list too.
5
+ SHIPPED = Dir.children(DIRECTORY).grep(/\.json\z/).map { |file| File.basename(file, ".json").to_sym }.sort.freeze
6
+ # Amazon needs AWS credentials most maintainers lack; LibreTranslate's list is one private instance's own.
7
+ NOT_SHIPPED = %i[amazon libretranslate].freeze
8
+
9
+ class << self
10
+ # nil, not false: a provider we ship no data for must never refuse a pair.
11
+ def supports?(provider, from:, to:)
12
+ set = self.for(provider)
13
+ return nil if set.nil? # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
14
+
15
+ set.supports_source?(from) && set.supports_target?(to)
16
+ end
17
+
18
+ def for(provider)
19
+ name = provider.to_s
20
+ return nil unless SHIPPED.include?(name.to_sym)
21
+
22
+ sets[name] ||= Set.load(File.join(DIRECTORY, "#{name}.json"))
23
+ end
24
+
25
+ private
26
+
27
+ # Loaded on first use: six JSON files read at require time would slow every application that never validates.
28
+ def sets = @sets ||= {}
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # The two things this pipeline has always promised about a caller's structure that Document deliberately does not:
2
+ # a nested nil comes back as the empty string, and the size of a document is every leaf in it, translatable or not.
3
+ module TranslationDiff::Leaves
4
+ # Document hands every non-String leaf straight back, which is the honest behaviour for a general structural map.
5
+ # Answering a nested nil with "" is the pipeline's own promise, so it is made here rather than there.
6
+ def self.collapse_nils(node)
7
+ case node
8
+ when Hash then node.to_h { |key, value| [key, collapse_nils(value)] }
9
+ when Array then node.map { |value| collapse_nils(value) }
10
+ when nil then ""
11
+ else node
12
+ end
13
+ end
14
+
15
+ # Every leaf the caller wrote, whatever its type: the number a `translate` payload reports as `values`.
16
+ def self.count(node)
17
+ return node.each_value.sum { |value| count(value) } if node.is_a?(Hash)
18
+ return node.sum { |value| count(value) } if node.is_a?(Array)
19
+
20
+ 1
21
+ end
22
+ end
@@ -0,0 +1,85 @@
1
+ # Entity references, and a `<` that opens no tag: the two places a document's text is not the text `ox` reports.
2
+ module TranslationDiff::Markup
3
+ # Escaping a lone `<` is a workaround around `ox`, not a fix of it: the real fix is our own lexer, and out of scope.
4
+
5
+ # A `<` opens a tag only when an element name, a closing name, a declaration or an instruction follows it.
6
+ TAG_OPENER = %r{[A-Za-z!?]|/[A-Za-z]}
7
+
8
+ # The two characters escaping has to move: a lone `<`, and an `&` that would read as an escape this module wrote.
9
+ AMBIGUOUS = /&(?=(?:amp;)*lt;)|<(?!#{TAG_OPENER})/
10
+
11
+ # `&lt;` was a lone `<`; every further `amp;` is a level the source itself wrote and escaping pushed up by one.
12
+ ESCAPED_ANGLE = /&((?:amp;)*)lt;/
13
+
14
+ # The bargain: an untranslated segment renders byte-exact, a translated one renders equivalent HTML, not equal bytes.
15
+
16
+ # Named and numeric alike, so nothing an `&` opens survives to be escaped again and rendered as its own spelling.
17
+ DECODABLE = /&(?:[A-Za-z][A-Za-z0-9]*|#\d+|#[xX]\h+);/
18
+
19
+ # Which of the two decoders an entity belongs to: Ox knows every HTML5 name, CGI knows both numeric forms.
20
+ NAMED = /\A&([A-Za-z][A-Za-z0-9]*);\z/
21
+
22
+ # Only the two characters that are unsafe in HTML text; every other decoded character is left as the character it is.
23
+ ENCODED = { "&" => "&amp;", "<" => "&lt;" }.freeze
24
+
25
+ ENCODABLE = /[&<]/
26
+
27
+ # Hands back markup `ox` can parse: same document, with every lone `<` written as the entity it should have been.
28
+ def self.escape_bare_angles(source)
29
+ source.gsub(AMBIGUOUS) { |ambiguous| ambiguous == "&" ? "&amp;" : "&lt;" }
30
+ end
31
+
32
+ # The exact inverse: one `amp;` off every escaped angle, and the angles with none left were the lone ones.
33
+ def self.restore_bare_angles(rendered)
34
+ rendered.gsub(ESCAPED_ANGLE) do
35
+ levels = Regexp.last_match(1)
36
+ levels.empty? ? "<" : "&#{levels.delete_prefix('amp;')}lt;"
37
+ end
38
+ end
39
+
40
+ # What a provider is sent is text, so it gets the characters; one left-to-right pass, so nothing is decoded twice.
41
+ def self.decode_entities(text) = text.gsub(DECODABLE) { |entity| decoded(entity) }
42
+
43
+ # An entity neither decoder knows stays as it arrived, and so does a surrogate: that decodes to invalid UTF-8.
44
+ def self.decoded(entity)
45
+ name = entity[NAMED, 1]
46
+ plain = name ? named(name) : CGI.unescapeHTML(entity)
47
+ plain.valid_encoding? ? plain : entity
48
+ end
49
+
50
+ # A document repeats the same handful of names, and only a name that resolved is kept, so the table cannot be grown.
51
+ def self.named(name) = resolved[name] || resolve(name)
52
+
53
+ def self.resolved = @resolved ||= {}
54
+
55
+ # One well-formed entity alone in an element is the only input Ox decodes safely -- prose with a lone `&` raises.
56
+ def self.resolve(name)
57
+ entity = "&#{name};"
58
+ resolver = Resolver.new
59
+ Ox.sax_html(resolver, StringIO.new("<e>#{entity}</e>"))
60
+ return entity if resolver.text.nil? || resolver.text == entity
61
+
62
+ resolved[name] = resolver.text
63
+ rescue StandardError
64
+ entity
65
+ end
66
+
67
+ # What a document renders is markup, so text that changed is made safe again -- and only where it is unsafe.
68
+ def self.encode_entities(text) = text.gsub(ENCODABLE, ENCODED)
69
+
70
+ # An entity the round trip already produced must not be escaped a second time, and a `<` shaped like a tag is
71
+ # trusted the same way a source tag already is -- everything else a provider sent back is untrusted new text.
72
+ TRANSLATED_ENCODABLE = /&(?:amp|lt|gt);|&|<(?!#{TAG_OPENER})/
73
+
74
+ # What a translation renders as: unlike #encode_entities, this leaves a provider's own reproduced tags alone.
75
+ def self.encode_translation(text)
76
+ text.gsub(TRANSLATED_ENCODABLE) { |match| match.length == 1 ? ENCODED[match] : match }
77
+ end
78
+
79
+ # Ox hands back the decoded text of the one element it was given; a name it does not know arrives as the text it was.
80
+ class Resolver < Ox::Sax
81
+ attr_reader :text
82
+
83
+ def value(value) = @text = value.as_s
84
+ end
85
+ end
@@ -0,0 +1,149 @@
1
+ # A source document as markup and prose: the markup kept as found, the prose cut into segments a provider can take.
2
+ class TranslationDiff::Passage
3
+ attr_reader :fragments
4
+
5
+ # The source is scanned with every lone `<` escaped, so the offsets, the slices and the render all agree on it.
6
+ # opaque_elements defaults to the live config, read here rather than memoised, so a runtime change takes effect
7
+ # without a caller that only ever passes segmenter and language having to be touched.
8
+ def initialize(source, segmenter:, language: nil, opaque_elements: TranslationDiff.config.opaque_elements)
9
+ @source = TranslationDiff::Markup.escape_bare_angles(source)
10
+ @segmenter = segmenter
11
+ @language = language
12
+ @fragments = Scanner.new(@source, opaque_elements: opaque_elements).runs.map { |run| fragment(run) }
13
+ end
14
+
15
+ # The translatable sentences, in document order; the empty ones are whitespace a provider has no use for.
16
+ def segments = fragments.flat_map(&:segments)
17
+
18
+ # Entities are a segment's business, so the only thing left to undo here is the escape this class put in.
19
+ def render
20
+ TranslationDiff::Markup.restore_bare_angles(fragments.map(&:render).join)
21
+ end
22
+
23
+ private
24
+
25
+ # Every fragment is a slice of the source, never a rebuilt string; that is what makes an untranslated render exact.
26
+ def fragment(run)
27
+ from, to, prose = run
28
+ slice = @source.byteslice(from, to - from)
29
+ return TranslationDiff::Fragment.markup(slice) unless prose
30
+
31
+ TranslationDiff::Fragment.prose(slice, segmenter: @segmenter, language: @language)
32
+ end
33
+
34
+ # Ox reports a byte position for every construct it sees; recording those is what lets rendering slice the source.
35
+ class Scanner < Ox::Sax
36
+ # Providers honour this class themselves under the HTML mode this gem sends, so the element must reach them whole.
37
+ PROTECTED = "notranslate".freeze
38
+
39
+ OPENING_ANGLE = "<".ord
40
+
41
+ # Where a run begins and whether it is prose; prose is set after the fact when an element claims protection.
42
+ Mark = Struct.new(:offset, :prose)
43
+
44
+ # Ox reports positions only to a handler that already has the ivar, so @pos exists before parsing starts.
45
+ # opaque_elements is a caller-supplied set of element names, so it is normalised here rather than trusted as given.
46
+ def initialize(source, opaque_elements:)
47
+ super()
48
+ @source = source
49
+ @opaque = opaque_elements.map { |element| element.to_s.downcase.to_sym }
50
+ @pos = 0
51
+ @marks = []
52
+ @protected_depth = 0
53
+ @opaque_depth = 0
54
+ @opaque_bump = false
55
+ @pending = nil
56
+ end
57
+
58
+ # Triples of [first byte, last byte + 1, prose?], contiguous, covering the source exactly once.
59
+ def runs
60
+ Ox.sax_html(self, StringIO.new(@source))
61
+ merge(bounds)
62
+ end
63
+
64
+ # Protection beats opacity on purpose: a caller wrapping a subtree asked for it to be passed through as it is.
65
+ # @opaque_bump remembers whether *this* element is the one that raised @opaque_depth, so attr can undo exactly
66
+ # that increment if the element turns out to be protected -- its own end_element never gets the chance to.
67
+ def start_element(name)
68
+ return @protected_depth += 1 if @protected_depth.positive?
69
+
70
+ @opaque_bump = @opaque_depth.positive? || @opaque.include?(name.to_s.downcase.to_sym)
71
+ @opaque_depth += 1 if @opaque_bump
72
+ @pending = mark(prose: false)
73
+ end
74
+
75
+ # Attributes arrive straight after their own start element, so @pending is that element and never another.
76
+ # A protected element's own end_element takes the protected branch and never reaches the opaque decrement, so
77
+ # an opaque_depth this element raised has to be given back here or it would outlive the element that raised it.
78
+ def attr(name, value)
79
+ return unless @pending && protection?(name, value)
80
+
81
+ @pending.prose = true
82
+ @opaque_depth -= 1 if @opaque_bump
83
+ @protected_depth = 1
84
+ @pending = nil
85
+ end
86
+
87
+ def end_element(_name)
88
+ return @protected_depth -= 1 if @protected_depth.positive?
89
+
90
+ @opaque_depth -= 1 if @opaque_depth.positive?
91
+ record(prose: false)
92
+ end
93
+
94
+ def value(_value) = record(prose: @opaque_depth.zero?)
95
+
96
+ def comment(_content) = record(prose: false)
97
+
98
+ def cdata(_content) = record(prose: false)
99
+
100
+ def doctype(_content) = record(prose: false)
101
+
102
+ def instruct(_target) = record(prose: false)
103
+
104
+ private
105
+
106
+ # Ox hands back names exactly as written, never lowercased, so both comparisons here are case-insensitive.
107
+ def protection?(name, value)
108
+ name.to_s.casecmp?("class") && value.split.include?(PROTECTED)
109
+ end
110
+
111
+ # Everything inside a protected element belongs to the run that element opened, so it records nothing of its own.
112
+ def record(prose:)
113
+ @pending = nil
114
+ return if @protected_depth.positive?
115
+
116
+ mark(prose: prose)
117
+ end
118
+
119
+ # Ox counts from one. A markup mark that does not land on a "<" is a tag Ox implied, not one the source holds.
120
+ def mark(prose:)
121
+ offset = @pos - 1
122
+ return nil if offset.negative? || (!prose && @source.getbyte(offset) != OPENING_ANGLE)
123
+
124
+ Mark.new(offset, prose).tap { |recorded| @marks << recorded }
125
+ end
126
+
127
+ # Each mark owns the source as far as the next one begins, and the last one owns whatever is left.
128
+ def bounds
129
+ marks = ordered
130
+ finishes = marks.drop(1).map(&:offset) << @source.bytesize
131
+ marks.zip(finishes).map { |mark, finish| [mark.offset, finish, mark.prose] }
132
+ end
133
+
134
+ # Ox skips the whitespace ahead of the first construct it reports, so a prose run is seeded where it starts.
135
+ def ordered
136
+ sorted = @marks.sort_by.with_index { |mark, index| [mark.offset, index] }
137
+ return sorted if sorted.first&.offset&.zero?
138
+
139
+ sorted.unshift(Mark.new(0, true))
140
+ end
141
+
142
+ # Adjacent prose has to become one run, or a notranslate element would be cut off from the sentence it sits in.
143
+ def merge(spans)
144
+ spans.reject { |from, to, _prose| from == to }
145
+ .chunk_while { |before, after| before.last == after.last }
146
+ .map { |chunk| [chunk.first[0], chunk.last[1], chunk.first[2]] }
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,3 @@
1
+ # What a #translate call would do without doing it: sentences it would send, sentences already cached, their
2
+ # size, and characters -- the total the translate event itself reports, the denominator sendable_characters needs.
3
+ TranslationDiff::Preview = Data.define(:sendable_sentences, :cached_sentences, :sendable_characters, :characters)
@@ -0,0 +1,78 @@
1
+ # Answers what #translate would send and find cached, using the same segmenter, cache key and provider
2
+ # resolution translate uses -- without calling the provider or writing anything. Detection is a paid request
3
+ # this method never makes, so a nil `from:` for a provider that must detect the language is refused, not guessed at.
4
+ class TranslationDiff::Previewer
5
+ # Its own class, so rescuing a preview that cannot be answered cannot also swallow a cache or provider failure.
6
+ class Error < TranslationDiff::Error; end
7
+
8
+ include TranslationDiff::CallPreparation
9
+
10
+ EMPTY = TranslationDiff::Preview.new(sendable_sentences: 0, cached_sentences: 0, sendable_characters: 0,
11
+ characters: 0).freeze
12
+
13
+ attr_reader :config
14
+
15
+ # `provider:`, `config:` and `assume_supported:` are reserved, exactly as they are for Translator#initialize.
16
+ def initialize(values, from: nil, to: nil, provider: nil, config: nil, assume_supported: false, **options)
17
+ raise ArgumentError, "a preview needs a target language: pass `to:` a language code." if to.nil?
18
+
19
+ @values = values
20
+ @from = from
21
+ @to = to
22
+ @options = options
23
+ @config = config || TranslationDiff.config
24
+ @requested_provider = provider
25
+ @assume_supported = assume_supported
26
+ end
27
+
28
+ def call
29
+ segments = document_segments
30
+ return EMPTY if segments.empty? || same_language?(@from)
31
+
32
+ provider = resolve_provider
33
+ from = resolve_source_language(provider)
34
+ return EMPTY if same_language?(from)
35
+
36
+ preview_for(provider, from, segments)
37
+ end
38
+
39
+ private
40
+
41
+ def document_segments
42
+ document = TranslationDiff::Document.new(TranslationDiff::Leaves.collapse_nils(@values))
43
+ document.strings.flat_map { |string| passage(string).segments }.reject(&:empty?)
44
+ end
45
+
46
+ # Same resolution Translator#call uses: a name to build, an object to use as it is, or the configured one.
47
+ def resolve_provider = TranslationDiff::Providers.resolve(@requested_provider, config)
48
+
49
+ # `from:` given means the pair is already known, so it is validated once; `from:` nil needs a detection this
50
+ # method never pays for, so it stops here instead of guessing what a paid request would have answered.
51
+ def resolve_source_language(provider)
52
+ return @from.tap { |from| ensure_supported!(provider, from) } unless @from.nil?
53
+
54
+ ensure_supported!(provider, nil)
55
+ raise_undetectable!(provider)
56
+ end
57
+
58
+ # A provider that cannot detect at all is refused with the same message translate uses; one that could but
59
+ # would cost a paid request is refused too -- preview never spends money to answer what it would send.
60
+ def raise_undetectable!(provider)
61
+ ensure_detects_language!(provider, Error, "previewing")
62
+
63
+ raise Error, "TranslationDiff.preview cannot detect the source language for #{provider.cache_key} " \
64
+ "without a paid request: pass `from:` explicitly."
65
+ end
66
+
67
+ # characters is the denominator: the same total the translate event itself reports, present even when every
68
+ # segment is already cached and sendable_characters alone would leave nothing to divide by.
69
+ def preview_for(provider, from, segments)
70
+ misses = fill(provider, from, segments)
71
+ TranslationDiff::Preview.new(sendable_sentences: misses.size, cached_sentences: segments.size - misses.size,
72
+ sendable_characters: misses.sum { |segment| segment.core.size },
73
+ characters: segments.sum { |segment| segment.core.size })
74
+ end
75
+
76
+ # Reads the store through the same SentenceCache#fill translate uses; nothing here ever calls #store.
77
+ def fill(provider, from, segments) = cache_for(provider, from).fill(segments)
78
+ end
@@ -0,0 +1,91 @@
1
+ # Connects this library to one translation service; knows nothing about HTTP itself -- that's HTTPProvider.
2
+ class TranslationDiff::Provider
3
+ # A subclass that forgets to declare capabilities under-promises, not over-promises: smaller batches, not silent risk.
4
+ DEFAULT_CAPABILITIES = TranslationDiff::Capabilities.new(
5
+ max_request_size: 1_000, max_batch_size: 1, max_text_size: nil,
6
+ html: :none, notranslate: false, detects_language: false, reports_billing: false
7
+ ).freeze
8
+
9
+ # A bare alphabetic code is cased the way the vendor documents; anything with a subtag is left alone.
10
+ BARE_LANGUAGE_CODE = /\A[A-Za-z]{2,3}\z/
11
+
12
+ # Stamped by the registry at build time. See #cache_key.
13
+ attr_accessor :name
14
+
15
+ attr_reader :config
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ ensure_configured!
20
+ end
21
+
22
+ # Callers write whichever casing their old configuration used; the vendor gets the one it documents.
23
+ def language(value)
24
+ code = value.to_s
25
+ return nil if code.empty?
26
+ return code unless code.match?(BARE_LANGUAGE_CODE)
27
+
28
+ self.class.language_case == :upcase ? code.upcase : code.downcase
29
+ end
30
+
31
+ # nil means the provider reported no billing at all; 0 means it reported zero. Both are claims.
32
+ def billed_characters(reported)
33
+ values = reported.compact
34
+ values.empty? ? nil : values.sum
35
+ end
36
+
37
+ # Never the default: a provider holds the configuration, so the default renders every key it holds.
38
+ def inspect = "#<#{self.class.name} name=#{name.inspect} config=#{config.inspect}>"
39
+
40
+ def translate(_request) = raise NotImplementedError, "#{self.class} must implement #translate"
41
+
42
+ # Only called when `capabilities.detects_language?`.
43
+ def detect(_text) = raise NotImplementedError, "#{self.class} must implement #detect"
44
+
45
+ # Only `rake languages:refresh` calls this; a provider that cannot answer is skipped, not failed.
46
+ def languages = raise NotImplementedError, "#{self.class} cannot fetch its languages"
47
+
48
+ # The full URL #languages fetches; a provider whose fetch has more than one shape narrows this further.
49
+ def languages_endpoint = respond_to?(:api_base) ? api_base.to_s : ""
50
+
51
+ # Raising when never stamped, rather than falling back to "", is deliberate: "" would merge namespaces silently.
52
+ def cache_key
53
+ return name.to_s unless name.nil?
54
+
55
+ raise TranslationDiff::Error,
56
+ "#{self.class} has no cache key: it was instantiated directly instead of being " \
57
+ "built through the registry. Build it through TranslationDiff::Providers.build, " \
58
+ "or give #{self.class} its own #cache_key."
59
+ end
60
+
61
+ class << self
62
+ # The casing this vendor documents for a bare code. DeepL upcases; everyone else takes lower case.
63
+ def language_case = :downcase
64
+
65
+ def configuration_options = []
66
+
67
+ # Overridable: a provider whose credential is named unusually says so rather than leaking it.
68
+ def sensitive_options
69
+ configuration_options.flat_map { |o| o.is_a?(Hash) ? o.keys : [o] }
70
+ .select { |key| TranslationDiff::Redaction.sensitive?(key) }
71
+ end
72
+
73
+ # Checked once, at build time, so a caller learns what to set before a vendor's own exception does.
74
+ def configuration_requirements = []
75
+
76
+ def capabilities = DEFAULT_CAPABILITIES
77
+
78
+ def build(config) = new(config)
79
+ end
80
+
81
+ private
82
+
83
+ def ensure_configured!
84
+ missing = self.class.configuration_requirements.reject { |key| config.public_send(key) }
85
+ return if missing.empty?
86
+
87
+ raise TranslationDiff::ConfigurationError,
88
+ "Provider #{self.class} is missing #{missing.join(', ')}. " \
89
+ "Set #{missing.size == 1 ? 'it' : 'them'} in TranslationDiff.configure."
90
+ end
91
+ end
@@ -0,0 +1,126 @@
1
+ # Amazon Translate: no batch API (one text per call), no HTML mode, and requests are signed, not just headed.
2
+ class TranslationDiff::Providers::Amazon < TranslationDiff::HTTPProvider
3
+ SERVICE = "translate".freeze
4
+ TARGET = "AWSShineFrontendService_20170701.TranslateText".freeze
5
+ LIST_LANGUAGES = "AWSShineFrontendService_20170701.ListLanguages".freeze
6
+ CONTENT_TYPE = "application/x-amz-json-1.1".freeze
7
+
8
+ # Amazon's own way of asking for detection; reaches Comprehend under the hood, in regions that have it.
9
+ AUTO = "auto".freeze
10
+
11
+ def self.capabilities
12
+ TranslationDiff::Capabilities.new(
13
+ max_request_size: 10_000, max_batch_size: 1, max_text_size: 10_000,
14
+ html: :none, notranslate: false, detects_language: true, reports_billing: false
15
+ )
16
+ end
17
+
18
+ # Deliberately no environment fallback: this library does not implement the AWS credential chain.
19
+ def self.configuration_options
20
+ %i[amazon_access_key_id amazon_secret_access_key amazon_session_token
21
+ amazon_region amazon_api_base]
22
+ end
23
+
24
+ def self.configuration_requirements
25
+ %i[amazon_access_key_id amazon_secret_access_key amazon_region]
26
+ end
27
+
28
+ def api_base = config.amazon_api_base || "https://#{SERVICE}.#{config.amazon_region}.amazonaws.com"
29
+
30
+ # Detected language is the first one Amazon reported: every text in a chunk shares a source language.
31
+ def translate(request)
32
+ detected = nil
33
+ texts = request.texts.map do |text|
34
+ body = call(text, request)
35
+ detected ||= body["SourceLanguageCode"]&.downcase
36
+ body["TranslatedText"]
37
+ end
38
+
39
+ TranslationDiff::Translation::Response.build(
40
+ request: request, texts: texts, detected_source: detected,
41
+ usage: TranslationDiff::Translation::Usage.new(characters: request.texts.sum(&:size))
42
+ )
43
+ end
44
+
45
+ def detect(text)
46
+ call(text, TranslationDiff::Translation::Request.new(texts: [text], from: nil, to: "en"))
47
+ .fetch("SourceLanguageCode", nil)&.downcase
48
+ end
49
+
50
+ # ListLanguages is a signed POST like every other Amazon call; the list serves both directions.
51
+ def languages
52
+ body = post_signed(JSON.generate({}), target: LIST_LANGUAGES).body
53
+ codes = Array(body["Languages"]).map { |entry| entry["LanguageCode"] }
54
+
55
+ { source: codes, target: codes }
56
+ end
57
+
58
+ # There is no distinct languages URL: every Amazon call, ListLanguages included, is a signed POST to the root.
59
+ def languages_endpoint = "#{api_base}/"
60
+
61
+ private
62
+
63
+ def call(text, request)
64
+ payload = request.options.transform_keys(&:to_s).merge(
65
+ "Text" => text,
66
+ "SourceLanguageCode" => request.from.nil? ? AUTO : language(request.from),
67
+ "TargetLanguageCode" => language(request.to)
68
+ )
69
+
70
+ post_signed(JSON.generate(payload)).body
71
+ end
72
+
73
+ def post_signed(body, target: TARGET)
74
+ raw = connection.post("/", body, signed_headers(body, target: target))
75
+ response = decoded_response(raw)
76
+ raise_for_status!(response)
77
+ response
78
+ rescue *TRANSPORT_FAILURES => e
79
+ # The message is the transport's, never the payload's: the payload is the customer's text.
80
+ raise TranslationDiff::TransportError, "#{self.class}: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ # Reuses the base's own decoding rather than a second, Faraday-middleware-based path (see HTTPProvider#decode).
84
+ def decoded_response(raw) = Decoded.new(status: raw.status, headers: raw.headers, body: decode(raw))
85
+
86
+ # Required here, not at load time, so an application using another provider never needs it installed.
87
+ def signer
88
+ @signer ||= begin
89
+ require_sigv4
90
+ Aws::Sigv4::Signer.new(
91
+ service: SERVICE, region: config.amazon_region,
92
+ access_key_id: config.amazon_access_key_id,
93
+ secret_access_key: config.amazon_secret_access_key,
94
+ session_token: config.amazon_session_token
95
+ )
96
+ end
97
+ end
98
+
99
+ def require_sigv4
100
+ require "aws-sigv4"
101
+ rescue LoadError
102
+ raise TranslationDiff::Error,
103
+ "provider is :amazon but the `aws-sigv4` gem is not available. " \
104
+ 'Add `gem "aws-sigv4"` to your Gemfile.'
105
+ end
106
+
107
+ def signed_headers(body, target: TARGET)
108
+ signature = signer.sign_request(
109
+ http_method: "POST", url: "#{api_base}/", body: body,
110
+ headers: { "Content-Type" => CONTENT_TYPE, "X-Amz-Target" => target }
111
+ )
112
+
113
+ signature.headers.merge("Content-Type" => CONTENT_TYPE, "X-Amz-Target" => target)
114
+ end
115
+
116
+ # The signature covers the body exactly as sent, so this omits `faraday.request :json` unlike the base class.
117
+ def build_connection(&)
118
+ Faraday.new(url: api_base, headers: headers) do |faraday|
119
+ faraday.request :retry, retry_options
120
+ adapt(faraday, &)
121
+ apply_timeouts(faraday)
122
+ end
123
+ end
124
+ end
125
+
126
+ TranslationDiff::Providers.register(:amazon, TranslationDiff::Providers::Amazon)
@@ -0,0 +1,78 @@
1
+ # Azure AI Translator, REST v3.0: cheapest per character, most generous per request (1,000 strings/50,000 chars).
2
+ class TranslationDiff::Providers::Azure < TranslationDiff::HTTPProvider
3
+ HOST = "https://api.cognitive.microsofttranslator.com".freeze
4
+ API_VERSION = "3.0".freeze
5
+
6
+ # Azure spells HTML handling `textType`, and under it honours `class=notranslate` like DeepL and Google do.
7
+ DEFAULT_TEXT_TYPE = "html".freeze
8
+
9
+ def self.capabilities
10
+ TranslationDiff::Capabilities.new(
11
+ max_request_size: 50_000, max_batch_size: 1_000, max_text_size: 50_000,
12
+ html: :textType, notranslate: true, detects_language: true, reports_billing: true
13
+ )
14
+ end
15
+
16
+ def self.configuration_options = %i[azure_api_key azure_region azure_api_base]
17
+ def self.configuration_requirements = %i[azure_api_key]
18
+
19
+ def api_base = config.azure_api_base || HOST
20
+
21
+ # A multi-service resource needs the region header; a single-service one rejects nothing without it.
22
+ def headers
23
+ { "Ocp-Apim-Subscription-Key" => config.azure_api_key.to_s }
24
+ .tap { |h| h["Ocp-Apim-Subscription-Region"] = config.azure_region if config.azure_region }
25
+ end
26
+
27
+ # Azure takes the language pair in the query string, so the URL is built per request, not a constant.
28
+ def translate_url = "translate"
29
+
30
+ def translate(request)
31
+ response = post(url_for(request), render_translate_payload(request))
32
+ parse_translate_response(response.body, response.headers, request)
33
+ end
34
+
35
+ def render_translate_payload(request) = request.texts.map { |text| { Text: text } }
36
+
37
+ def parse_translate_response(body, headers, request)
38
+ results = Array(body)
39
+
40
+ TranslationDiff::Translation::Response.build(
41
+ request: request,
42
+ texts: results.map { |result| result.dig("translations", 0, "text") },
43
+ detected_source: results.first&.dig("detectedLanguage", "language")&.downcase,
44
+ usage: TranslationDiff::Translation::Usage.new(
45
+ characters: request.texts.sum(&:size),
46
+ billed_characters: headers["x-metered-usage"]&.to_i
47
+ )
48
+ )
49
+ end
50
+
51
+ def detect(text)
52
+ response = post("detect?api-version=#{API_VERSION}", [{ Text: text }])
53
+ response.body.dig(0, "language")&.downcase
54
+ end
55
+
56
+ # Azure's own docs mark this endpoint public, but this provider still requires a key to be built at all.
57
+ def languages
58
+ codes = Array(get("languages?api-version=#{API_VERSION}&scope=translation").body["translation"]&.keys)
59
+
60
+ { source: codes, target: codes }
61
+ end
62
+
63
+ def languages_endpoint = "#{api_base}/languages?api-version=#{API_VERSION}&scope=translation"
64
+
65
+ private
66
+
67
+ # Defaults, then caller options, then mandatory fields: a caller must not displace the language pair.
68
+ def url_for(request)
69
+ params = { "textType" => DEFAULT_TEXT_TYPE }
70
+ .merge(request.options.transform_keys(&:to_s))
71
+ .merge("api-version" => API_VERSION, "to" => language(request.to))
72
+ params["from"] = language(request.from) unless request.from.nil?
73
+
74
+ "#{translate_url}?#{URI.encode_www_form(params)}"
75
+ end
76
+ end
77
+
78
+ TranslationDiff::Providers.register(:azure, TranslationDiff::Providers::Azure)