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,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
|
+
# `<` 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 = { "&" => "&", "<" => "<" }.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 == "&" ? "&" : "<" }
|
|
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)
|